harshisageek commited on
Commit
1cd56b6
·
0 Parent(s):

deploy: clean history for HuggingFace

Browse files
This view is limited to 50 files because it contains too many changes.   See raw diff
Files changed (50) hide show
  1. .dockerignore +14 -0
  2. .env.example +17 -0
  3. .gitattributes +42 -0
  4. .github/workflows/scraper.yml +35 -0
  5. .github/workflows/v30_trader.yml +45 -0
  6. .gitignore +56 -0
  7. .hf_space +1 -0
  8. .huggingfaceignore +12 -0
  9. DL_Class_Final_Presentation.pptx +3 -0
  10. Dockerfile +48 -0
  11. README.md +11 -0
  12. backend/__init__.py +0 -0
  13. backend/alpaca_executor.py +468 -0
  14. backend/alpaca_trade_log.json +119 -0
  15. backend/app.py +591 -0
  16. backend/database.py +110 -0
  17. backend/manager.py +94 -0
  18. backend/pairs_radar.json +1 -0
  19. backend/screener_data.json +1 -0
  20. backend/strategy_cache.json +1 -0
  21. backend/strategy_signals.py +183 -0
  22. backend/worker.py +512 -0
  23. backtesting/data_pipeline/bert_backtest.py +238 -0
  24. backtesting/data_pipeline/check_cache_dates.py +16 -0
  25. backtesting/data_pipeline/check_data_dates.py +9 -0
  26. backtesting/data_pipeline/download_fnspid.py +88 -0
  27. backtesting/data_pipeline/filter_fnspid.py +67 -0
  28. backtesting/data_pipeline/process_bert_sentiment.py +112 -0
  29. backtesting/data_pipeline/score_fnspid_bert.py +58 -0
  30. backtesting/engines/v11_causal_engine.py +190 -0
  31. backtesting/engines/v30_causal_engine.py +316 -0
  32. backtesting/engines/v36_research_engine.py +345 -0
  33. backtesting/engines/v36_research_engine_fixed.py +345 -0
  34. backtesting/engines/v36r2_research_engine.py +225 -0
  35. backtesting/engines/v36r3_engines.py +475 -0
  36. backtesting/engines/v36r4_engines.py +312 -0
  37. backtesting/engines/v36r5_engines.py +264 -0
  38. backtesting/engines/v53_final_average.py +55 -0
  39. backtesting/engines/v68_live_may2026.py +332 -0
  40. backtesting/framework/config.py +32 -0
  41. backtesting/framework/quant_framework.py +272 -0
  42. backtesting/framework/test_1_1_ic_analysis.py +59 -0
  43. backtesting/framework/test_1_2_regime_decay.py +69 -0
  44. backtesting/framework/test_1_3_monkey_test.py +161 -0
  45. backtesting/framework/test_2_1_train_test.py +42 -0
  46. backtesting/framework/test_2_2_start_date.py +63 -0
  47. backtesting/framework/test_2_3_poison.py +82 -0
  48. backtesting/framework/test_3_1_txn_costs.py +44 -0
  49. backtesting/framework/test_3_5_crash_autopsy.py +45 -0
  50. backtesting/framework/test_4_1_param_sweep.py +87 -0
.dockerignore ADDED
@@ -0,0 +1,14 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ brain/saved_models/
2
+ brain/models/
3
+ backtesting/
4
+ frontend/
5
+ ieee/
6
+ artifacts/
7
+ .git/
8
+ .gemini/
9
+ __pycache__/
10
+ *.pyc
11
+ node_modules/
12
+ *.npz
13
+ venv/
14
+ env/
.env.example ADDED
@@ -0,0 +1,17 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Stock Analysis App - Environment Variables
2
+ # Copy this to .env and fill in your actual keys
3
+
4
+ # Twelve Data API (for stock price data)
5
+ TWELVE_DATA_KEY=your_twelve_data_api_key_here
6
+
7
+ # Supabase (for auth & database)
8
+ SUPABASE_URL=https://your-project.supabase.co
9
+ SUPABASE_KEY=your_supabase_anon_key_here
10
+
11
+ # GNews API (for news articles - dual key rotation)
12
+ GNEWS_API_KEY1=your_gnews_api_key_1_here
13
+ GNEWS_API_KEY2=your_gnews_api_key_2_here
14
+
15
+ # Frontend Supabase (must match backend values)
16
+ VITE_SUPABASE_URL=https://your-project.supabase.co
17
+ VITE_SUPABASE_KEY=your_supabase_anon_key_here
.gitattributes ADDED
@@ -0,0 +1,42 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ *.7z filter=lfs diff=lfs merge=lfs -text
2
+ *.arrow filter=lfs diff=lfs merge=lfs -text
3
+ *.bin filter=lfs diff=lfs merge=lfs -text
4
+ *.bz2 filter=lfs diff=lfs merge=lfs -text
5
+ *.ckpt filter=lfs diff=lfs merge=lfs -text
6
+ *.ftz filter=lfs diff=lfs merge=lfs -text
7
+ *.gz filter=lfs diff=lfs merge=lfs -text
8
+ *.h5 filter=lfs diff=lfs merge=lfs -text
9
+ *.joblib filter=lfs diff=lfs merge=lfs -text
10
+ *.lfs.* filter=lfs diff=lfs merge=lfs -text
11
+ *.mlmodel filter=lfs diff=lfs merge=lfs -text
12
+ *.model filter=lfs diff=lfs merge=lfs -text
13
+ *.msgpack filter=lfs diff=lfs merge=lfs -text
14
+ *.npy filter=lfs diff=lfs merge=lfs -text
15
+ *.npz filter=lfs diff=lfs merge=lfs -text
16
+ *.onnx filter=lfs diff=lfs merge=lfs -text
17
+ *.ot filter=lfs diff=lfs merge=lfs -text
18
+ *.parquet filter=lfs diff=lfs merge=lfs -text
19
+ *.pb filter=lfs diff=lfs merge=lfs -text
20
+ *.pickle filter=lfs diff=lfs merge=lfs -text
21
+ *.pkl filter=lfs diff=lfs merge=lfs -text
22
+ *.pt filter=lfs diff=lfs merge=lfs -text
23
+ *.pth filter=lfs diff=lfs merge=lfs -text
24
+ *.rar filter=lfs diff=lfs merge=lfs -text
25
+ *.safetensors filter=lfs diff=lfs merge=lfs -text
26
+ saved_model/**/* filter=lfs diff=lfs merge=lfs -text
27
+ *.tar.* filter=lfs diff=lfs merge=lfs -text
28
+ *.tar filter=lfs diff=lfs merge=lfs -text
29
+ *.tflite filter=lfs diff=lfs merge=lfs -text
30
+ *.tgz filter=lfs diff=lfs merge=lfs -text
31
+ *.wasm filter=lfs diff=lfs merge=lfs -text
32
+ *.xz filter=lfs diff=lfs merge=lfs -text
33
+ *.zip filter=lfs diff=lfs merge=lfs -text
34
+ *.zst filter=lfs diff=lfs merge=lfs -text
35
+ *tfevents* filter=lfs diff=lfs merge=lfs -text
36
+ *.png filter=lfs diff=lfs merge=lfs -text
37
+ *.jpg filter=lfs diff=lfs merge=lfs -text
38
+ *.jpeg filter=lfs diff=lfs merge=lfs -text
39
+ *.csv filter=lfs diff=lfs merge=lfs -text
40
+ models/custom_sentiment/sentiment_model.pt filter=lfs diff=lfs merge=lfs -text
41
+ *.pptx filter=lfs diff=lfs merge=lfs -text
42
+ *.pdf filter=lfs diff=lfs merge=lfs -text
.github/workflows/scraper.yml ADDED
@@ -0,0 +1,35 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ name: Daily Screener & News
2
+
3
+ on:
4
+ schedule:
5
+ - cron: '0 0 * * *'
6
+ workflow_dispatch:
7
+
8
+ jobs:
9
+ scrape-and-analyze:
10
+ runs-on: ubuntu-latest
11
+
12
+ steps:
13
+ - name: Checkout Code
14
+ uses: actions/checkout@v4
15
+
16
+ - name: Set up Python (with pip cache)
17
+ uses: actions/setup-python@v5
18
+ with:
19
+ python-version: '3.11'
20
+ cache: 'pip'
21
+
22
+ - name: Install Dependencies
23
+ run: |
24
+ python -m pip install --upgrade pip
25
+ pip install -r requirements.txt
26
+
27
+ - name: Run Scraper Worker
28
+ env:
29
+ SUPABASE_URL: ${{ secrets.SUPABASE_URL }}
30
+ SUPABASE_KEY: ${{ secrets.SUPABASE_KEY }}
31
+ TWELVE_DATA_KEY: ${{ secrets.TWELVE_DATA_KEY }}
32
+ GNEWS_API_KEY1: ${{ secrets.GNEWS_API_KEY1 }}
33
+ GNEWS_API_KEY2: ${{ secrets.GNEWS_API_KEY2 }}
34
+ run: |
35
+ python -m backend.worker
.github/workflows/v30_trader.yml ADDED
@@ -0,0 +1,45 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ name: V30 Paper Trading Engine
2
+
3
+ on:
4
+ schedule:
5
+ # Run at 3:50 PM ET (19:50 UTC) every weekday — 10 min before market close
6
+ - cron: '50 19 * * 1-5'
7
+ workflow_dispatch: # Allow manual trigger from GitHub UI
8
+
9
+ jobs:
10
+ trade:
11
+ runs-on: ubuntu-latest
12
+ permissions:
13
+ contents: write
14
+
15
+ steps:
16
+ - name: Checkout Repository
17
+ uses: actions/checkout@v4
18
+
19
+ - name: Set up Python
20
+ uses: actions/setup-python@v5
21
+ with:
22
+ python-version: '3.11'
23
+ cache: 'pip'
24
+
25
+ - name: Install Dependencies
26
+ run: |
27
+ pip install alpaca-py yfinance numpy pandas python-dotenv scipy
28
+
29
+ - name: Run V30 Paper Trader
30
+ env:
31
+ ALPACA_API_KEY: ${{ secrets.ALPACA_API_KEY }}
32
+ ALPACA_SECRET_KEY: ${{ secrets.ALPACA_SECRET_KEY }}
33
+ SMTP_EMAIL: ${{ secrets.SMTP_EMAIL }}
34
+ SMTP_PASSWORD: ${{ secrets.SMTP_PASSWORD }}
35
+ PYTHONPATH: .
36
+ run: |
37
+ python backend/alpaca_executor.py --execute
38
+
39
+ - name: Commit Updated State
40
+ run: |
41
+ git config --local user.email "v30-bot@github.com"
42
+ git config --local user.name "V30 Trading Bot"
43
+ git add backend/alpaca_trade_log.json
44
+ git diff --cached --quiet || git commit -m "V30: Daily trading check $(date +'%Y-%m-%d')"
45
+ git push
.gitignore ADDED
@@ -0,0 +1,56 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # General
2
+ .DS_Store
3
+ Thumbs.db
4
+ .vscode/
5
+ .idea/
6
+
7
+ # Python / Backend
8
+ __pycache__/
9
+ *.pyc
10
+ *.pyo
11
+ *.pyd
12
+ venv/
13
+ env/
14
+ .env
15
+ .pytest_cache/
16
+ instance/
17
+ .coverage
18
+ htmlcov/
19
+
20
+ # Node / Frontend
21
+ node_modules/
22
+ dist/
23
+ build/
24
+ npm-debug.log*
25
+ yarn-debug.log*
26
+ yarn-error.log*
27
+ .env.local
28
+ .env.development.local
29
+ .env.test.local
30
+ .env.production.local
31
+
32
+ # Logs
33
+ logs/
34
+ *.log
35
+
36
+ # Training Data & Models
37
+ *.npz
38
+ *.pkl
39
+ tuning_results*.csv
40
+ brain/saved_models/
41
+ xgboost_training_data.npz
42
+
43
+ # Runtime Cache (regenerated automatically)
44
+ screener_data.json
45
+ pairs_radar.json
46
+
47
+ # Fine-tuning Data and Charts
48
+ data/
49
+ bert_*.png
50
+ feature_importance.png
51
+
52
+ # Research Artifacts (archived, not deployed)
53
+ artifacts/
54
+
55
+ # Gemini AI assistant
56
+ .gemini/
.hf_space ADDED
@@ -0,0 +1 @@
 
 
1
+ Subproject commit 4b3873f32c4a1be3000989013bc2c1574dce06c0
.huggingfaceignore ADDED
@@ -0,0 +1,12 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ frontend/
2
+ .git/
3
+ venv/
4
+ .venv/
5
+ artifacts/
6
+ __pycache__/
7
+ *.pkl
8
+ *.png
9
+ *.jpg
10
+ *.csv
11
+ .DS_Store
12
+ node_modules/
DL_Class_Final_Presentation.pptx ADDED
@@ -0,0 +1,3 @@
 
 
 
 
1
+ version https://git-lfs.github.com/spec/v1
2
+ oid sha256:3ad0b7c282f5d310ff19097918224561852cc5f458e8e1c2e3607ab506d19f79
3
+ size 588293
Dockerfile ADDED
@@ -0,0 +1,48 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Use Python 3.10 Slim image for a smaller footprint
2
+ FROM python:3.10-slim
3
+
4
+ # Set working directory
5
+ WORKDIR /app
6
+
7
+ # Install system dependencies (needed for some Python packages like lxml)
8
+ RUN apt-get update && apt-get install -y --no-install-recommends \
9
+ build-essential \
10
+ libxml2-dev \
11
+ libxslt-dev \
12
+ git-lfs \
13
+ && rm -rf /var/lib/apt/lists/*
14
+
15
+ # Copy requirements first to leverage Docker cache
16
+ COPY requirements-hf.txt requirements.txt
17
+
18
+ # Install Python dependencies
19
+ # Use --no-cache-dir to keep image size down
20
+ RUN pip install --no-cache-dir -r requirements.txt
21
+
22
+ # Copy the application code
23
+ # We copy the 'backend' and 'brain' folders to the root of the container
24
+ COPY backend/ backend/
25
+ COPY brain/ brain/
26
+ COPY models/ models/
27
+
28
+ # Create a non-root user for security (Required by Hugging Face Spaces)
29
+ RUN useradd -m -u 1000 user && chown -R user:user /app
30
+ USER user
31
+ ENV HOME=/home/user \
32
+ PATH=/home/user/.local/bin:$PATH
33
+
34
+ # Set PYTHONPATH so backend/app.py can import 'brain'
35
+ ENV PYTHONPATH=/app
36
+
37
+ # Expose the port that Hugging Face Spaces uses (7860)
38
+ EXPOSE 7860
39
+
40
+ # Run the application using Gunicorn (Production Server)
41
+ # We need to install gunicorn first if it's not in requirements.txt
42
+ RUN pip install gunicorn
43
+
44
+ # Command to run the app
45
+ # -w 1: 1 worker (limited resources on free tier)
46
+ # -b 0.0.0.0:7860: Bind to all interfaces on port 7860
47
+ # --timeout 120: Increase timeout for ML model loading
48
+ CMD ["gunicorn", "-w", "1", "-b", "0.0.0.0:7860", "--timeout", "300", "backend.app:app"]
README.md ADDED
@@ -0,0 +1,11 @@
 
 
 
 
 
 
 
 
 
 
 
 
1
+ ---
2
+ title: Stockproject
3
+ emoji: 🦀
4
+ colorFrom: red
5
+ colorTo: yellow
6
+ sdk: docker
7
+ pinned: false
8
+ license: mit
9
+ ---
10
+
11
+ Check out the configuration reference at https://huggingface.co/docs/hub/spaces-config-reference
backend/__init__.py ADDED
File without changes
backend/alpaca_executor.py ADDED
@@ -0,0 +1,468 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # -*- coding: utf-8 -*-
2
+ """
3
+ Strategy Alpaca Paper Trading Executor (Production-Hardened)
4
+ ========================================================
5
+ Connects to Alpaca Paper Trading and executes the active Strategy
6
+ momentum logic. Can be run manually or scheduled.
7
+
8
+ Usage:
9
+ python backend/alpaca_executor.py # Show current signals + positions
10
+ python backend/alpaca_executor.py --execute # Actually place trades
11
+ """
12
+
13
+ import os, sys, json, argparse, smtplib, traceback
14
+ from datetime import datetime, timedelta
15
+ from email.mime.text import MIMEText
16
+ from dotenv import load_dotenv
17
+
18
+ import numpy as np
19
+ import pandas as pd
20
+ import yfinance as yf
21
+
22
+ from alpaca.trading.client import TradingClient
23
+ from alpaca.trading.requests import MarketOrderRequest, GetAssetsRequest
24
+ from alpaca.trading.enums import OrderSide, TimeInForce, AssetClass
25
+
26
+ # Load environment
27
+ load_dotenv(os.path.join(os.path.dirname(__file__), ".env"))
28
+
29
+ API_KEY = os.getenv("ALPACA_API_KEY")
30
+ SECRET_KEY = os.getenv("ALPACA_SECRET_KEY")
31
+ BASE_URL = os.getenv("ALPACA_BASE_URL", "https://paper-api.alpaca.markets")
32
+ SMTP_EMAIL = os.getenv("SMTP_EMAIL")
33
+ SMTP_PASSWORD = os.getenv("SMTP_PASSWORD")
34
+
35
+ # Strategy Parameters (validated out-of-sample)
36
+ STRATEGY_PARAMS = {
37
+ "rebal_days": 60,
38
+ }
39
+
40
+ # Universe (same as backtesting)
41
+ UNIVERSE = [
42
+ "AAPL","ABBV","ABT","ACN","ADBE","ADI","ADM","ADP","ADSK","AEE","AEP","AES",
43
+ "AFL","AIG","AIZ","AJG","AKAM","ALB","ALK","ALL","AMAT","AMD","AME","AMGN",
44
+ "AMP","AMT","AMZN","AON","AOS","APA","APD","APH","ARE","ATO","AVB","AVGO",
45
+ "AVY","AWK","AXP","AZO","BA","BAC","BAX","BBY","BDX","BEN","BIO","BIIB",
46
+ "BK","BKNG","BKR","BLK","BMY","BR","BRK-B","BRO","BSX","BWA","BXP","C",
47
+ "CAG","CAH","CAT","CB","CBOE","CBRE","CCI","CCL","CDNS","CE","CF","CFG",
48
+ "CHD","CHRW","CHTR","CI","CINF","CL","CLX","CMCSA","CME","CMG","CMI","CMS",
49
+ "CNC","CNP","COF","COO","COP","COST","CPB","CPRT","CPT","CRL","CRM","CSCO",
50
+ "CSX","CTAS","CTSH","CVS","CVX","D","DAL","DD","DE","DG","DGX","DHI","DHR",
51
+ "DIS","DLR","DLTR","DOV","DPZ","DRI","DTE","DUK","DVA","DVN","EA","EBAY",
52
+ "ECL","ED","EFX","EIX","EL","EMN","EMR","EOG","EQIX","EQR","EQT","ES","ESS",
53
+ "ETN","ETR","EVRG","EW","EXC","EXPD","EXPE","EXR","F","FAST","FCX","FDS",
54
+ "FDX","FE","FFIV","FIS","FISV","FITB","FMC","FOX","FOXA","FRT","FTNT","FTV",
55
+ "GD","GE","GILD","GIS","GL","GLW","GM","GOOG","GOOGL","GPC","GPN","GRMN",
56
+ "GS","GWW","HAL","HAS","HBAN","HCA","HD","HOLX","HON","HPE","HPQ","HRL",
57
+ "HSIC","HST","HSY","HUM","IBM","ICE","IDXX","IEX","IFF","ILMN","INCY","INTC",
58
+ "INTU","IP","IQV","IR","IRM","ISRG","IT","ITW","IVZ","J","JBHT","JCI","JKHY",
59
+ "JNJ","JPM","KEY","KEYS","KHC","KIM","KLAC","KMB","KMI","KMX","KO","KR","L",
60
+ "LDOS","LEN","LH","LHX","LIN","LKQ","LLY","LMT","LNC","LNT","LOW","LRCX",
61
+ "LUV","LVS","LW","LYB","LYV","MA","MAA","MAR","MAS","MCD","MCHP","MCK","MCO",
62
+ "MDLZ","MDT","MET","META","MGM","MHK","MKC","MKTX","MLM","MMM","MNST","MO",
63
+ "MOH","MOS","MPC","MPWR","MRK","MS","MSCI","MSFT","MSI","MTB","MTCH","MTD",
64
+ "MU","NCLH","NDAQ","NDSN","NEE","NEM","NFLX","NI","NKE","NOC","NOW","NRG",
65
+ "NSC","NTAP","NTRS","NUE","NVDA","NVR","NWL","NWS","NWSA","NXPI","O","ODFL",
66
+ "OKE","OMC","ON","ORCL","ORLY","OXY","PAYC","PAYX","PCAR","PCG","PEG","PEP",
67
+ "PFE","PFG","PG","PGR","PH","PHM","PKG","PLD","PM","PNC","PNR","PNW","POOL",
68
+ "PPG","PPL","PRU","PSA","PSX","PTC","PVH","PWR","PYPL","QCOM","QRVO","RCL",
69
+ "REG","REGN","RF","RHI","RJF","RL","RMD","ROK","ROL","ROP","ROST","RSG","RTX",
70
+ "SBAC","SBUX","SCHW","SEE","SHW","SJM","SLB","SNA","SNPS","SO","SPG","SPGI",
71
+ "SRE","STE","STT","STX","STZ","SWK","SWKS","SYK","SYY","T","TAP","TDG","TDY",
72
+ "TECH","TEL","TER","TFC","TFX","TGT","TJX","TMO","TMUS","TPR","TRGP","TRMB",
73
+ "TROW","TRV","TSCO","TSLA","TSN","TT","TTWO","TXN","TXT","TYL","UAL","UDR",
74
+ "UHS","ULTA","UNH","UNP","UPS","URI","USB","V","VFC","VLO","VMC","VNO","VRSK",
75
+ "VRSN","VRTX","VTR","VTRS","VZ","WAB","WAT","WDC","WEC","WELL","WFC","WHR",
76
+ "WM","WMB","WMT","WRB","WST","WTW","WY","WYNN","XEL","XOM","XYL","YUM","ZBH",
77
+ "ZBRA","ZION","ZTS",
78
+ "SMCI","DXCM","DELL","PANW","ET","EPD","MPLX","FANG","HWM","CDW","CSGP",
79
+ "BBWI","ALLE","AMCR","KDP","SYF","LUMN","DXC","GNRC","ETSY","VEEV","WDAY","SHOP",
80
+ "ABNB","CRWD","DDOG","SNOW","PLTR","COIN","MELI","TEAM","DASH","TTD",
81
+ "ZS","MNDY","NET","OKTA","BILL","HUBS","DKNG","U","RIVN","LCID",
82
+ "SOFI","HOOD","NU","GRAB","SE","SPOT","SNAP","PINS","ROKU","RBLX",
83
+ "UBER","LYFT","HLT","IHG","ELV","CARR","OTIS","DOW","CTVA","CEG","GEV","SOLV",
84
+ "VLTO","KVUE","GEHC","ARM","DECK","VST","GDDY","AXON","ERIE","APP",
85
+ "TPL","RVTY","EG","FBIN","PODD","NTRA","TOST","DOC","CPAY","HUBB",
86
+ "BX","KKR","APO","ARES","CG","MRNA","INVH","VICI","CZR","CTRA","OGN",
87
+ ]
88
+
89
+
90
+ # ═══════════════════════════════════════════════════════════════
91
+ # PRODUCTION HARDENING: Alert System
92
+ # ═══════════════════════════════════════════════════════════════
93
+
94
+ def send_alert(subject, body):
95
+ """Send email alert. Fails silently if SMTP creds are not configured."""
96
+ if not SMTP_EMAIL or not SMTP_PASSWORD:
97
+ print(f" [ALERT] (No SMTP configured) {subject}")
98
+ return
99
+ try:
100
+ msg = MIMEText(body)
101
+ msg['Subject'] = f"[Strategy Trading Bot] {subject}"
102
+ msg['From'] = SMTP_EMAIL
103
+ msg['To'] = SMTP_EMAIL
104
+ with smtplib.SMTP_SSL('smtp.gmail.com', 465) as server:
105
+ server.login(SMTP_EMAIL, SMTP_PASSWORD)
106
+ server.send_message(msg)
107
+ print(f" [ALERT] Email sent: {subject}")
108
+ except Exception as e:
109
+ print(f" [ALERT] Email failed: {e}")
110
+
111
+
112
+ # ═══════════════════════════════════════════════════════════════
113
+ # PRODUCTION HARDENING: Market Hours Check
114
+ # ═══════════════════════════════════════════════════════════════
115
+
116
+ def is_market_open():
117
+ """Check if the market is open or was recently open.
118
+ We run at 3:50 PM ET so market should still be open.
119
+ Falls back to weekday check if API fails."""
120
+ try:
121
+ client = TradingClient(API_KEY, SECRET_KEY, paper=True)
122
+ clock = client.get_clock()
123
+ if clock.is_open:
124
+ return True
125
+ # If market just closed, still allow on weekdays
126
+ if datetime.now().weekday() < 5:
127
+ print(" Market just closed but today is a trading day. Proceeding.")
128
+ return True
129
+ return False
130
+ except Exception as e:
131
+ print(f" [ERROR] Could not check market hours: {e}")
132
+ return datetime.now().weekday() < 5
133
+
134
+
135
+ # ═══════════════════════════════════════════════════════════════
136
+ # PRODUCTION HARDENING: Data Validation
137
+ # ═══════════════════════════════════════════════════════════════
138
+
139
+ def validate_price_data(prices_df, min_history=200):
140
+ """Validate price data quality before signal generation.
141
+ Returns (is_valid, list_of_issues)."""
142
+ issues = []
143
+ for ticker in prices_df.columns:
144
+ series = prices_df[ticker].dropna()
145
+ if len(series) < min_history:
146
+ issues.append(f"{ticker}: insufficient history ({len(series)} days, need {min_history})")
147
+ if len(series) > 1:
148
+ daily_returns = series.pct_change().dropna()
149
+ extreme_moves = daily_returns[daily_returns.abs() > 0.50]
150
+ if len(extreme_moves) > 0:
151
+ issues.append(f"{ticker}: suspicious 50%+ move on {extreme_moves.index[0].strftime('%Y-%m-%d')}")
152
+ if (series <= 0).any():
153
+ issues.append(f"{ticker}: zero or negative price detected")
154
+
155
+ if issues:
156
+ print("\n DATA VALIDATION WARNINGS:")
157
+ for issue in issues:
158
+ print(f" - {issue}")
159
+ return len(issues) == 0, issues
160
+
161
+
162
+ def get_client():
163
+ """Create Alpaca trading client."""
164
+ return TradingClient(API_KEY, SECRET_KEY, paper=True)
165
+
166
+
167
+ def get_account_info(client):
168
+ """Fetch and display account info."""
169
+ account = client.get_account()
170
+ print(f"\n{'='*60}")
171
+ print(f" ALPACA PAPER TRADING ACCOUNT")
172
+ print(f"{'='*60}")
173
+ print(f" Account ID: {account.id}")
174
+ print(f" Status: {account.status}")
175
+ print(f" Cash: ${float(account.cash):,.2f}")
176
+ print(f" Portfolio Value: ${float(account.portfolio_value):,.2f}")
177
+ print(f" Buying Power: ${float(account.buying_power):,.2f}")
178
+ print(f" Day Trades: {account.daytrade_count}")
179
+ return account
180
+
181
+
182
+ def calculate_strategy_signals():
183
+ """Calculate Strategy signals using the unified module."""
184
+ from backend.strategy_signals import get_strategy_live_signals
185
+
186
+ signals = get_strategy_live_signals()
187
+
188
+ top_picks = [p["ticker"] for p in signals["picks"]]
189
+ vol_scalar = signals["vol_scalar"]
190
+ is_riskoff = signals["regime"] == "RISK-OFF"
191
+
192
+ print(f"\n{'='*60}")
193
+ print(f" STRATEGY SIGNALS — {datetime.now().strftime('%Y-%m-%d %H:%M')}")
194
+ print(f"{'='*60}")
195
+ print(f" SPY: ${signals['spy_price']:.2f}")
196
+ print(f" 200d SMA: ${signals['sma200']:.2f}")
197
+ print(f" Regime: {signals['regime']}")
198
+ print(f" Vol Scalar: {vol_scalar:.2f}x")
199
+ print(f" Allocation: {vol_scalar*100:.0f}% invested, {(1-vol_scalar)*100:.0f}% cash")
200
+ print(f"\n TOP PICKS:")
201
+ for i, p in enumerate(signals['picks']):
202
+ print(f" {i+1:>2}. {p['ticker']:<6} | Z-Score: {p.get('sector_z_score', 0):>+5.2f} | Price: ${p['price']:>8.2f}")
203
+
204
+ return top_picks, vol_scalar, is_riskoff
205
+
206
+
207
+ def get_current_positions(client):
208
+ """Get current Alpaca positions."""
209
+ positions = client.get_all_positions()
210
+ pos_dict = {}
211
+ for p in positions:
212
+ pos_dict[p.symbol] = {
213
+ "qty": float(p.qty),
214
+ "market_value": float(p.market_value),
215
+ "unrealized_pl": float(p.unrealized_pl),
216
+ "current_price": float(p.current_price),
217
+ }
218
+ return pos_dict
219
+
220
+
221
+ def execute_rebalance(client, target_picks, vol_scalar):
222
+ """Execute the Strategy rebalance on Alpaca.
223
+ Returns (orders_placed, failed_orders) for status tracking."""
224
+ account = client.get_account()
225
+ portfolio_value = float(account.portfolio_value)
226
+
227
+ # Calculate target allocation
228
+ invested_pct = vol_scalar
229
+ per_stock_value = (portfolio_value * invested_pct) / len(target_picks)
230
+
231
+ # Get current positions
232
+ current = get_current_positions(client)
233
+ current_tickers = set(current.keys())
234
+ target_tickers = set(target_picks)
235
+
236
+ # SELLS: positions not in target
237
+ to_sell = current_tickers - target_tickers
238
+ # BUYS: targets not in current
239
+ to_buy = target_tickers - current_tickers
240
+ # REBALANCE: positions that stay but need size adjustment
241
+ to_adjust = current_tickers & target_tickers
242
+
243
+ print(f"\n{'='*60}")
244
+ print(f" REBALANCE PLAN")
245
+ print(f"{'='*60}")
246
+ print(f" Portfolio Value: ${portfolio_value:,.2f}")
247
+ print(f" Target per stock: ${per_stock_value:,.2f} ({invested_pct*100:.0f}% / {len(target_picks)} stocks)")
248
+ print(f" SELL {len(to_sell)} positions: {', '.join(sorted(to_sell)) if to_sell else 'None'}")
249
+ print(f" BUY {len(to_buy)} new positions: {', '.join(sorted(to_buy)) if to_buy else 'None'}")
250
+ print(f" HOLD {len(to_adjust)} positions (may resize)")
251
+
252
+ orders_placed = []
253
+ failed_orders = []
254
+
255
+ # Execute SELLS first (free up cash)
256
+ for ticker in to_sell:
257
+ qty = current[ticker]["qty"]
258
+ if qty > 0:
259
+ try:
260
+ order = client.submit_order(
261
+ MarketOrderRequest(
262
+ symbol=ticker, qty=qty,
263
+ side=OrderSide.SELL, time_in_force=TimeInForce.DAY,
264
+ )
265
+ )
266
+ orders_placed.append(f"SELL {qty:.0f} {ticker}")
267
+ print(f" SELL {qty:.0f} {ticker} -- Order {order.id}")
268
+ except Exception as e:
269
+ failed_orders.append(f"SELL {ticker}: {e}")
270
+ print(f" SELL {ticker} FAILED: {e}")
271
+
272
+ # Execute BUYS
273
+ for ticker in to_buy:
274
+ try:
275
+ order = client.submit_order(
276
+ MarketOrderRequest(
277
+ symbol=ticker, notional=round(per_stock_value, 2),
278
+ side=OrderSide.BUY, time_in_force=TimeInForce.DAY,
279
+ )
280
+ )
281
+ orders_placed.append(f"BUY ${per_stock_value:.0f} {ticker}")
282
+ print(f" BUY ${per_stock_value:.0f} of {ticker} -- Order {order.id}")
283
+ except Exception as e:
284
+ failed_orders.append(f"BUY {ticker}: {e}")
285
+ print(f" BUY {ticker} FAILED: {e}")
286
+
287
+ # Adjust existing positions
288
+ for ticker in to_adjust:
289
+ current_value = current[ticker]["market_value"]
290
+ diff = per_stock_value - current_value
291
+ if abs(diff) > per_stock_value * 0.10: # Only adjust if >10% off target
292
+ try:
293
+ if diff > 0:
294
+ order = client.submit_order(
295
+ MarketOrderRequest(
296
+ symbol=ticker, notional=round(abs(diff), 2),
297
+ side=OrderSide.BUY, time_in_force=TimeInForce.DAY,
298
+ )
299
+ )
300
+ orders_placed.append(f"ADD ${diff:.0f} {ticker}")
301
+ print(f" ADD ${diff:.0f} to {ticker} -- Order {order.id}")
302
+ else:
303
+ sell_qty = abs(diff) / current[ticker]["current_price"]
304
+ if sell_qty >= 0.01:
305
+ order = client.submit_order(
306
+ MarketOrderRequest(
307
+ symbol=ticker, qty=round(sell_qty, 2),
308
+ side=OrderSide.SELL, time_in_force=TimeInForce.DAY,
309
+ )
310
+ )
311
+ orders_placed.append(f"TRIM {sell_qty:.2f} {ticker}")
312
+ print(f" TRIM {sell_qty:.2f} of {ticker} -- Order {order.id}")
313
+ except Exception as e:
314
+ failed_orders.append(f"ADJUST {ticker}: {e}")
315
+ print(f" ADJUST {ticker} FAILED: {e}")
316
+
317
+ print(f"\n Orders placed: {len(orders_placed)} | Failed: {len(failed_orders)}")
318
+
319
+ # ── Log with Status Field (Production Hardening) ──
320
+ status = "completed" if len(failed_orders) == 0 else "partial"
321
+ if len(orders_placed) == 0 and len(failed_orders) > 0:
322
+ status = "failed"
323
+
324
+ log_path = os.path.join(os.path.dirname(__file__), "alpaca_trade_log.json")
325
+ log_entry = {
326
+ "timestamp": datetime.now().isoformat(),
327
+ "status": status,
328
+ "picks": target_picks,
329
+ "vol_scalar": vol_scalar,
330
+ "portfolio_value": portfolio_value,
331
+ "per_stock_target": per_stock_value,
332
+ "orders": orders_placed,
333
+ "failed_orders": failed_orders,
334
+ }
335
+
336
+ logs = []
337
+ if os.path.exists(log_path):
338
+ with open(log_path, "r") as f:
339
+ logs = json.load(f)
340
+ logs.append(log_entry)
341
+ with open(log_path, "w") as f:
342
+ json.dump(logs, f, indent=2)
343
+ print(f" Trade log saved ({status}) to {log_path}")
344
+
345
+ return orders_placed, failed_orders
346
+
347
+
348
+ def main():
349
+ parser = argparse.ArgumentParser(description="Strategy Alpaca Paper Trading Executor")
350
+ parser.add_argument("--execute", action="store_true", help="Actually place trades (default: dry run)")
351
+ parser.add_argument("--force", action="store_true", help="Force rebalance even if 60 days haven't passed")
352
+ args = parser.parse_args()
353
+
354
+ # ── Pre-Flight: Market Hours Check ──
355
+ if args.execute:
356
+ print("Checking market hours...")
357
+ if not is_market_open():
358
+ msg = f"Market is closed on {datetime.now().strftime('%Y-%m-%d')}. Skipping execution."
359
+ print(f"\n {msg}")
360
+ send_alert("Market Closed - Skipping", msg)
361
+ sys.exit(0)
362
+ print(" Market is OPEN. Proceeding.")
363
+
364
+ print("Connecting to Alpaca Paper Trading...")
365
+ client = get_client()
366
+
367
+ # ── 60-Day Rebalance Check (Only resets on "completed" entries) ──
368
+ log_path = os.path.join(os.path.dirname(__file__), "alpaca_trade_log.json")
369
+ days_since_last = 999
370
+
371
+ if os.path.exists(log_path):
372
+ with open(log_path, "r") as f:
373
+ try:
374
+ logs = json.load(f)
375
+ # Only count "completed" rebalances for the 60-day cooldown
376
+ completed_logs = [l for l in logs if l.get("status") == "completed"]
377
+ if completed_logs:
378
+ last_date_str = completed_logs[-1].get("timestamp", "").split("T")[0]
379
+ last_date = datetime.strptime(last_date_str, "%Y-%m-%d")
380
+ days_since_last = (datetime.now() - last_date).days
381
+ except Exception as e:
382
+ print(f"Error reading logs: {e}")
383
+
384
+ if not args.force and days_since_last < STRATEGY_PARAMS["rebal_days"]:
385
+ print(f"\n{'='*60}")
386
+ print(f" SKIP REBALANCE: Only {days_since_last} days passed since last completed rebalance.")
387
+ print(f" Strategy requires {STRATEGY_PARAMS['rebal_days']} days to minimize friction.")
388
+ print(f" Use --force to override this check.")
389
+ print(f"{'='*60}")
390
+
391
+ # Log skip so GitHub Actions shows daily activity
392
+ log_path_skip = os.path.join(os.path.dirname(__file__), "alpaca_trade_log.json")
393
+ skip_logs = []
394
+ if os.path.exists(log_path_skip):
395
+ with open(log_path_skip, "r") as f:
396
+ try: skip_logs = json.load(f)
397
+ except: skip_logs = []
398
+ skip_logs.append({
399
+ "timestamp": datetime.now().isoformat(),
400
+ "status": "skipped",
401
+ "reason": f"Only {days_since_last}/{STRATEGY_PARAMS['rebal_days']} days elapsed",
402
+ })
403
+ with open(log_path_skip, "w") as f:
404
+ json.dump(skip_logs, f, indent=2)
405
+ return
406
+
407
+ if args.force:
408
+ print(f"\n[!] FORCE FLAG USED: Bypassing 60-day check ({days_since_last} days elapsed).")
409
+
410
+ # Show account info
411
+ account = get_account_info(client)
412
+
413
+ # Show current positions
414
+ positions = get_current_positions(client)
415
+ if positions:
416
+ print(f"\n CURRENT POSITIONS ({len(positions)}):")
417
+ for ticker, info in sorted(positions.items()):
418
+ print(f" {ticker:<6} | Qty: {info['qty']:>8.2f} | Value: ${info['market_value']:>10,.2f} | P&L: ${info['unrealized_pl']:>+8.2f}")
419
+ else:
420
+ print(f"\n CURRENT POSITIONS: None (fresh account)")
421
+
422
+ # ── Calculate Strategy Signals ──
423
+ try:
424
+ top_picks, vol_scalar, is_riskoff = calculate_strategy_signals()
425
+ except ValueError as e:
426
+ msg = f"DATA VALIDATION FAILURE: {e}"
427
+ print(f"\n CRITICAL: {msg}")
428
+ send_alert("CRITICAL: Data Validation Failed", msg)
429
+ sys.exit(1)
430
+ except Exception as e:
431
+ msg = f"Signal calculation failed: {e}\n{traceback.format_exc()}"
432
+ print(f"\n ERROR: {msg}")
433
+ send_alert("ERROR: Signal Calculation Failed", msg)
434
+ sys.exit(1)
435
+
436
+ # Execute or dry run
437
+ if args.execute:
438
+ print(f"\n ** EXECUTING LIVE PAPER TRADES **")
439
+ orders_placed, failed_orders = execute_rebalance(client, top_picks, vol_scalar)
440
+
441
+ # ── Send Alert ──
442
+ if failed_orders:
443
+ body = (
444
+ f"Rebalance completed with {len(failed_orders)} failures.\n\n"
445
+ f"Orders placed: {len(orders_placed)}\n"
446
+ f"Failed orders:\n" + "\n".join(f" - {f}" for f in failed_orders)
447
+ )
448
+ send_alert("WARNING: Partial Rebalance", body)
449
+ else:
450
+ body = (
451
+ f"Rebalance completed successfully.\n\n"
452
+ f"Orders placed: {len(orders_placed)}\n"
453
+ f"Regime: {'RISK-OFF' if is_riskoff else 'RISK-ON'}\n"
454
+ f"Vol Scalar: {vol_scalar:.2f}\n"
455
+ f"Top picks: {', '.join(top_picks)}"
456
+ )
457
+ send_alert("SUCCESS: Rebalance Complete", body)
458
+ else:
459
+ print(f"\n ** DRY RUN -- No trades placed **")
460
+ print(f" Run with --execute to place trades on Alpaca Paper.")
461
+
462
+ print(f"\n{'='*60}")
463
+ print(f" DONE")
464
+ print(f"{'='*60}")
465
+
466
+
467
+ if __name__ == "__main__":
468
+ main()
backend/alpaca_trade_log.json ADDED
@@ -0,0 +1,119 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ [
2
+ {
3
+ "timestamp": "2026-05-08T21:08:16.292999",
4
+ "status": "completed",
5
+ "picks": [
6
+ "WDC",
7
+ "MU",
8
+ "TER",
9
+ "STX",
10
+ "GLW",
11
+ "LRCX",
12
+ "INTC",
13
+ "AMAT",
14
+ "MRNA",
15
+ "ALB",
16
+ "KEYS",
17
+ "KLAC",
18
+ "CAT",
19
+ "APA",
20
+ "HAL"
21
+ ],
22
+ "vol_scalar": 1.0,
23
+ "portfolio_value": 100000.0,
24
+ "per_stock_target": 6666.666666666667,
25
+ "orders": [
26
+ "BUY $6667 HAL",
27
+ "BUY $6667 KEYS",
28
+ "BUY $6667 KLAC",
29
+ "BUY $6667 WDC",
30
+ "BUY $6667 STX",
31
+ "BUY $6667 AMAT",
32
+ "BUY $6667 TER",
33
+ "BUY $6667 LRCX",
34
+ "BUY $6667 MRNA",
35
+ "BUY $6667 CAT",
36
+ "BUY $6667 APA",
37
+ "BUY $6667 ALB",
38
+ "BUY $6667 GLW",
39
+ "BUY $6667 INTC",
40
+ "BUY $6667 MU"
41
+ ],
42
+ "failed_orders": []
43
+ },
44
+ {
45
+ "timestamp": "2026-05-11T21:38:01.708572",
46
+ "status": "skipped",
47
+ "reason": "Only 3/60 days elapsed"
48
+ },
49
+ {
50
+ "timestamp": "2026-05-12T21:09:42.655939",
51
+ "status": "skipped",
52
+ "reason": "Only 4/60 days elapsed"
53
+ },
54
+ {
55
+ "timestamp": "2026-05-13T21:13:35.324931",
56
+ "status": "skipped",
57
+ "reason": "Only 5/60 days elapsed"
58
+ },
59
+ {
60
+ "timestamp": "2026-05-14T21:01:38.608768",
61
+ "status": "skipped",
62
+ "reason": "Only 6/60 days elapsed"
63
+ },
64
+ {
65
+ "timestamp": "2026-05-15T20:45:24.709538",
66
+ "status": "skipped",
67
+ "reason": "Only 7/60 days elapsed"
68
+ },
69
+ {
70
+ "timestamp": "2026-05-18T20:58:51.220256",
71
+ "status": "skipped",
72
+ "reason": "Only 10/60 days elapsed"
73
+ },
74
+ {
75
+ "timestamp": "2026-05-19T21:08:54.649002",
76
+ "status": "skipped",
77
+ "reason": "Only 11/60 days elapsed"
78
+ },
79
+ {
80
+ "timestamp": "2026-05-20T21:26:56.591322",
81
+ "status": "skipped",
82
+ "reason": "Only 12/60 days elapsed"
83
+ },
84
+ {
85
+ "timestamp": "2026-05-21T21:19:29.815725",
86
+ "status": "skipped",
87
+ "reason": "Only 13/60 days elapsed"
88
+ },
89
+ {
90
+ "timestamp": "2026-05-22T21:01:06.662510",
91
+ "status": "skipped",
92
+ "reason": "Only 14/60 days elapsed"
93
+ },
94
+ {
95
+ "timestamp": "2026-05-25T20:55:37.978180",
96
+ "status": "skipped",
97
+ "reason": "Only 17/60 days elapsed"
98
+ },
99
+ {
100
+ "timestamp": "2026-05-26T21:22:26.021467",
101
+ "status": "skipped",
102
+ "reason": "Only 18/60 days elapsed"
103
+ },
104
+ {
105
+ "timestamp": "2026-05-27T21:30:15.646717",
106
+ "status": "skipped",
107
+ "reason": "Only 19/60 days elapsed"
108
+ },
109
+ {
110
+ "timestamp": "2026-05-28T21:47:07.081668",
111
+ "status": "skipped",
112
+ "reason": "Only 20/60 days elapsed"
113
+ },
114
+ {
115
+ "timestamp": "2026-05-29T21:29:45.838691",
116
+ "status": "skipped",
117
+ "reason": "Only 21/60 days elapsed"
118
+ }
119
+ ]
backend/app.py ADDED
@@ -0,0 +1,591 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+
2
+ """
3
+ Flask Microservice for Stock Sentiment Analysis
4
+
5
+ Provides stock price data and news sentiment analysis using GNews (Dual-Key) and custom fine-tuned DistilBERT.
6
+ Implements in-memory caching with 5-minute TTL to prevent rate limiting.
7
+ """
8
+
9
+ import sys
10
+ import os
11
+
12
+ import time
13
+ from datetime import datetime, timedelta
14
+ import pandas as pd
15
+ import requests
16
+ from flask import Flask, jsonify, request
17
+ from flask_cors import CORS
18
+ from dotenv import load_dotenv
19
+ import yfinance as yf
20
+
21
+ # Add the project root to sys.path to allow importing from 'brain'
22
+ sys.path.append(os.path.join(os.path.dirname(__file__), '..'))
23
+
24
+ from brain.sentiment.news import fetch_gnews
25
+ from backend.database import NewsDatabase
26
+ from brain.service import BrainService
27
+ from brain.core.types import StockDataPoint, Article
28
+
29
+
30
+ # Load environment variables from root .env
31
+ load_dotenv(os.path.join(os.path.dirname(__file__), '..', '.env'))
32
+
33
+ # Initialize Brain Service
34
+ brain_service = BrainService()
35
+
36
+ # Initialize Flask app
37
+ app = Flask(__name__)
38
+ CORS(app, origins="*")
39
+
40
+ @app.before_request
41
+ def log_request_info():
42
+ if request.path.startswith("/api/"):
43
+ print(f"[DEBUG] Request: {request.method} {request.url}")
44
+
45
+ @app.errorhandler(Exception)
46
+ def handle_exception(e):
47
+ if isinstance(e, requests.exceptions.HTTPError):
48
+ return jsonify({"error": str(e)}), e.response.status_code
49
+ return jsonify({"error": str(e)}), 500
50
+
51
+ @app.route("/", methods=["GET"])
52
+ def index():
53
+ return jsonify({
54
+ "status": "online",
55
+ "message": "Stock Analysis API is running",
56
+ "endpoints": ["/api/analyze", "/api/market-movers", "/api/general-news", "/health"]
57
+ })
58
+
59
+ # Twelve Data API Key
60
+ TWELVE_DATA_KEY = os.getenv("TWELVE_DATA_KEY")
61
+ if not TWELVE_DATA_KEY:
62
+ print("Warning: TWELVE_DATA_KEY not found in environment variables.")
63
+
64
+ # Initialize DB
65
+ db = NewsDatabase()
66
+
67
+ # In-memory cache
68
+ cache = {}
69
+ CACHE_TTL_SECONDS = 5 * 60 # 5 minutes
70
+
71
+ import json
72
+
73
+ # All background compute (movers, news, screener) is handled by GitHub Actions worker.py.
74
+ # HF Space only serves pre-cached data from Supabase — zero heavy compute on startup.
75
+
76
+ def get_cached_data(ticker: str) -> dict | None:
77
+ ticker_upper = ticker.upper()
78
+ if ticker_upper in cache:
79
+ cached_entry = cache[ticker_upper]
80
+ age = time.time() - cached_entry["timestamp"]
81
+ if age < CACHE_TTL_SECONDS:
82
+ return cached_entry["data"]
83
+ return None
84
+
85
+ def set_cached_data(ticker: str, data: dict) -> None:
86
+ cache[ticker.upper()] = {
87
+ "data": data,
88
+ "timestamp": time.time()
89
+ }
90
+
91
+
92
+
93
+
94
+ def fetch_stock_data(ticker, range_str="1W", force_refresh=False, company_name=None, skip_news_fetch=False):
95
+ print(f"Checking DB for {ticker}...")
96
+ analyzed_news = []
97
+ cached_news = []
98
+
99
+ if not force_refresh:
100
+ cached_news = db.get_latest_news(ticker, limit=20)
101
+
102
+ use_db_cache = False
103
+
104
+ if cached_news and len(cached_news) >= 10:
105
+ valid_cached_articles = []
106
+ for article in cached_news:
107
+ if abs(article['sentiment_score']) >= 0.05:
108
+ valid_cached_articles.append({
109
+ "title": article['title'],
110
+ "published": article['published'],
111
+ "sentiment": article['sentiment_score'],
112
+ "link": article['link'],
113
+ "publisher": article['source'],
114
+ "debug": article['debug_metadata'] or {}
115
+ })
116
+
117
+ if len(valid_cached_articles) >= 5:
118
+ analyzed_news = valid_cached_articles
119
+ use_db_cache = True
120
+
121
+ if not use_db_cache:
122
+ if not skip_news_fetch:
123
+ print("Live scraping (Blocking)...")
124
+ analyzed_news, _ = fetch_gnews(ticker, company_name)
125
+ for article in analyzed_news:
126
+ db.upsert_article(ticker, None, article)
127
+ else:
128
+ print(f"Skipping live news scrape for {ticker} (Pre-warm mode)")
129
+
130
+ if analyzed_news:
131
+ total_weighted_score = 0.0
132
+ total_weights = 0.0
133
+ for n in analyzed_news:
134
+ weight = n['debug'].get('weight', 1.0)
135
+ total_weighted_score += (n['sentiment'] * weight)
136
+ total_weights += weight
137
+ current_sentiment = total_weighted_score / total_weights if total_weights > 0 else 0.0
138
+ else:
139
+ current_sentiment = 0.0
140
+
141
+ days_ytd = (datetime.now() - datetime(datetime.now().year, 1, 1)).days + 1
142
+ range_map = {
143
+ "1W": "7", "1M": "30", "3M": "90", "6M": "180",
144
+ "YTD": str(days_ytd), "1Y": "365", "MAX": "5000"
145
+ }
146
+ requested_size_str = range_map.get(range_str, "7")
147
+ try:
148
+ req_int = int(requested_size_str)
149
+ fetch_size = max(req_int, 300)
150
+ except:
151
+ fetch_size = 5000; req_int = 5000
152
+
153
+ url = "https://api.twelvedata.com/time_series"
154
+ params = {"symbol": ticker, "interval": "1day", "outputsize": str(fetch_size), "apikey": TWELVE_DATA_KEY}
155
+
156
+ response = requests.get(url, params=params)
157
+ data = response.json()
158
+
159
+ if "values" not in data:
160
+ raise ValueError(f"Twelve Data Error: {data.get('message', 'Unknown error')}")
161
+
162
+ # Generate synthetic sentiment if news was skipped to populate the screener natively
163
+ if not analyzed_news and skip_news_fetch and len(data["values"]) >= 5:
164
+ try:
165
+ latest = float(data["values"][0]["close"])
166
+ older = float(data["values"][4]["close"])
167
+ momentum_pct = (latest - older) / older
168
+ # Map +/- 5% move to +/- 1.0 sentiment score
169
+ synthetic = momentum_pct * 20.0
170
+ current_sentiment = max(-1.0, min(1.0, synthetic))
171
+ except Exception:
172
+ pass
173
+
174
+ full_history_data = [{
175
+ "date": d["datetime"],
176
+ "open": float(d["open"]),
177
+ "high": float(d["high"]),
178
+ "low": float(d["low"]),
179
+ "close": float(d["close"]),
180
+ "volume": int(d["volume"]),
181
+ "price": float(d["close"]),
182
+ "sentiment": round(current_sentiment, 4)
183
+ } for d in data["values"]]
184
+ full_history_data.reverse()
185
+
186
+ if req_int < len(full_history_data):
187
+ graph_data = full_history_data[-req_int:]
188
+ else:
189
+ graph_data = full_history_data
190
+
191
+ try:
192
+ p_history = [
193
+ StockDataPoint(
194
+ datetime=d["date"], open=d["open"], high=d["high"],
195
+ low=d["low"], close=d["close"], volume=d["volume"]
196
+ ) for d in full_history_data
197
+ ]
198
+
199
+ p_news = [
200
+ Article(
201
+ title=n["title"], link=n["link"], published=n["published"],
202
+ publisher=n["publisher"], sentiment_score=n["sentiment"],
203
+ metadata=n["debug"]
204
+ ) for n in analyzed_news
205
+ ]
206
+
207
+ analysis = brain_service.analyze_ticker(ticker, p_history, current_sentiment, p_news)
208
+
209
+ tech_vals = analysis.components["technical"]["values"]
210
+ tech_scores = analysis.components["technical"]["scores"]
211
+ macd_vals = tech_vals.get("macd", {}) or {}
212
+
213
+ quant_result = {
214
+ "final_score": analysis.final_score,
215
+ "signal": analysis.signal.value,
216
+ "confidence": analysis.confidence,
217
+ "breakdown": {
218
+ "rsi_val": round(tech_vals["rsi"], 2) if tech_vals.get("rsi") is not None and not pd.isna(tech_vals["rsi"]) else None,
219
+ "rsi_normalized": round(tech_scores["rsi"], 2),
220
+ "sma_val": round(tech_vals["sma"], 2) if tech_vals.get("sma") is not None and not pd.isna(tech_vals["sma"]) else None,
221
+ "trend_normalized": round(tech_scores["trend"], 2),
222
+ "current_price": tech_vals["current_price"],
223
+ "sentiment_input": analysis.sentiment_score,
224
+ "sentiment_normalized": analysis.sentiment_score * 100,
225
+ "macd": {
226
+ "macd_line": round(macd_vals.get("macd"), 4) if macd_vals.get("macd") is not None and not pd.isna(macd_vals.get("macd")) else None,
227
+ "signal_line": round(macd_vals.get("signal"), 4) if macd_vals.get("signal") is not None and not pd.isna(macd_vals.get("signal")) else None,
228
+ "histogram": round(macd_vals.get("hist"), 4) if macd_vals.get("hist") is not None and not pd.isna(macd_vals.get("hist")) else None
229
+ }
230
+ },
231
+ "volatility": analysis.volatility or {},
232
+ "earnings": analysis.earnings or {},
233
+ "sentiment_trend": analysis.sentiment_trend or {},
234
+ "sentiment_analysis": analysis.components.get("sentiment_analysis", {}),
235
+ "deep_insight": analysis.components.get("deep_insight", {}),
236
+ "cross_sectional": analysis.components.get("cross_sectional", {})
237
+ }
238
+
239
+ # Inject Strategy Status
240
+ try:
241
+ strategy_live = db.get_cache("strategy_live_signals")
242
+ if not strategy_live:
243
+ from backend.strategy_signals import get_strategy_live_signals
244
+ strategy_live = get_strategy_live_signals()
245
+
246
+ strategy_picks = {p["ticker"]: p for p in strategy_live["picks"]}
247
+ is_strategy_buy = ticker in strategy_picks
248
+
249
+ quant_result["strategy_scorecard"] = {
250
+ "is_buy": is_strategy_buy,
251
+ "regime": strategy_live["regime"],
252
+ "vol_scalar": strategy_live["vol_scalar"],
253
+ "momentum_175d": strategy_picks[ticker]["momentum_175d"] if is_strategy_buy else None,
254
+ "target_vol": strategy_live["target_vol"],
255
+ "realized_vol": strategy_live["realized_vol"]
256
+ }
257
+ except Exception as ve:
258
+ print(f"Strategy Injection Error: {ve}")
259
+ quant_result["strategy_scorecard"] = None
260
+ except Exception as e:
261
+ print(f"Brain Service Error: {e}")
262
+ raise e
263
+
264
+ scraping_stats = {
265
+ "total": len(analyzed_news),
266
+ "full_text": sum(1 for n in analyzed_news if n['debug'].get('content_source') == 'full_text'),
267
+ "snippet": sum(1 for n in analyzed_news if n['debug'].get('content_source') != 'full_text'),
268
+ "timeouts": 0
269
+ }
270
+
271
+ return {
272
+ "current_sentiment": round(current_sentiment, 4),
273
+ "news": analyzed_news,
274
+ "graph_data": graph_data,
275
+ "quant_analysis": quant_result,
276
+ "debug": scraping_stats
277
+ }
278
+
279
+ @app.route("/api/analyze", methods=["GET", "OPTIONS"])
280
+ def analyze():
281
+ if request.method == "OPTIONS":
282
+ return jsonify({"status": "ok"}), 200
283
+
284
+ ticker = request.args.get("ticker")
285
+ if not ticker:
286
+ return jsonify({"error": "Missing required parameter: ticker"}), 400
287
+
288
+ ticker = ticker.upper().strip()
289
+ range_param = request.args.get("range", "1W")
290
+ force_refresh = request.args.get("force", "false").lower() == "true"
291
+ company_name = request.args.get("name")
292
+
293
+ cache_key = f"{ticker}_{range_param}"
294
+ cached_data = get_cached_data(cache_key)
295
+
296
+ if cached_data and not force_refresh:
297
+ if 'news' in cached_data:
298
+ cached_data['news'] = [n for n in cached_data['news'] if abs(n['sentiment']) >= 0.05]
299
+ return jsonify({**cached_data, "cached": True})
300
+
301
+ try:
302
+ data = fetch_stock_data(ticker, range_param, force_refresh=force_refresh, company_name=company_name)
303
+ set_cached_data(cache_key, data)
304
+ return jsonify({**data, "cached": False})
305
+ except Exception as e:
306
+ print(f"Warning: Data provider blocked or failed. Switching to Circuit Breaker. Error: {e}")
307
+ circuit_breaker_data = {
308
+ "ticker": ticker,
309
+ "current_sentiment": 0.0,
310
+ "news": [],
311
+ "graph_data": [],
312
+ "circuit_breaker": True,
313
+ "error": str(e)
314
+ }
315
+ return jsonify(circuit_breaker_data)
316
+
317
+ @app.route("/api/market-movers", methods=["GET"])
318
+ def market_movers():
319
+ data = db.get_cache("market-movers")
320
+ if data and "gainers" in data and len(data["gainers"]) > 0:
321
+ return jsonify(data)
322
+
323
+ # Fallback dummy data for presentation/dummy mode
324
+ dummy_data = {
325
+ "gainers": [
326
+ {"symbol": "NVDA", "name": "NVIDIA", "price": "$1250.00", "change": 5.4, "raw_change": 5.4},
327
+ {"symbol": "AMD", "name": "Advanced Micro Devices", "price": "$165.20", "change": 4.2, "raw_change": 4.2},
328
+ {"symbol": "META", "name": "Meta Platforms", "price": "$510.50", "change": 3.8, "raw_change": 3.8},
329
+ {"symbol": "AMZN", "name": "Amazon", "price": "$185.30", "change": 2.5, "raw_change": 2.5},
330
+ {"symbol": "NFLX", "name": "Netflix", "price": "$640.10", "change": 2.1, "raw_change": 2.1}
331
+ ],
332
+ "losers": [
333
+ {"symbol": "TSLA", "name": "Tesla", "price": "$175.40", "change": -3.5, "raw_change": -3.5},
334
+ {"symbol": "BA", "name": "Boeing", "price": "$180.20", "change": -2.8, "raw_change": -2.8},
335
+ {"symbol": "INTC", "name": "Intel", "price": "$30.50", "change": -2.1, "raw_change": -2.1},
336
+ {"symbol": "T", "name": "AT&T", "price": "$16.80", "change": -1.5, "raw_change": -1.5},
337
+ {"symbol": "VZ", "name": "Verizon", "price": "$38.90", "change": -1.2, "raw_change": -1.2}
338
+ ],
339
+ "active": [
340
+ {"symbol": "AAPL", "name": "Apple", "price": "$190.50", "change": 1.2, "raw_change": 1.2},
341
+ {"symbol": "NVDA", "name": "NVIDIA", "price": "$1250.00", "change": 5.4, "raw_change": 5.4},
342
+ {"symbol": "TSLA", "name": "Tesla", "price": "$175.40", "change": -3.5, "raw_change": -3.5},
343
+ {"symbol": "AMD", "name": "Advanced Micro Devices", "price": "$165.20", "change": 4.2, "raw_change": 4.2},
344
+ {"symbol": "AMZN", "name": "Amazon", "price": "$185.30", "change": 2.5, "raw_change": 2.5}
345
+ ]
346
+ }
347
+ return jsonify(dummy_data)
348
+
349
+ @app.route("/api/general-news", methods=["GET"])
350
+ def general_news():
351
+ data = db.get_cache("general-news")
352
+ if data and "news" in data and len(data["news"]) > 0:
353
+ return jsonify(data["news"])
354
+
355
+ # Fallback: 50 unique financial news articles for presentation mode
356
+ from datetime import timedelta
357
+ dummy_news = [
358
+ {"title": "S&P 500 Closes at Record High as Investors Shrug Off Recession Fears", "link": "https://finance.yahoo.com", "image": "https://images.unsplash.com/photo-1611974765270-ca1258634369?q=80&w=500&auto=format&fit=crop", "publisher": "Wall Street Journal", "sentiment": 0.85},
359
+ {"title": "NVIDIA Surpasses $3 Trillion Market Cap on AI Chip Demand Surge", "link": "https://finance.yahoo.com", "image": "https://images.unsplash.com/photo-1518770660439-4636190af475?q=80&w=500&auto=format&fit=crop", "publisher": "Bloomberg", "sentiment": 0.92},
360
+ {"title": "Federal Reserve Holds Rates Steady, Signals Two Cuts Before Year-End", "link": "https://finance.yahoo.com", "image": "https://images.unsplash.com/photo-1590283603385-17ffb3a7f29f?q=80&w=500&auto=format&fit=crop", "publisher": "CNBC", "sentiment": 0.55},
361
+ {"title": "Tesla Shares Drop 8% After Missing Q2 Delivery Estimates", "link": "https://finance.yahoo.com", "image": "https://images.unsplash.com/photo-1593941707882-a5bba14938cb?q=80&w=500&auto=format&fit=crop", "publisher": "Reuters", "sentiment": -0.65},
362
+ {"title": "Apple Unveils New AI-Powered Features at WWDC, Stock Jumps 4%", "link": "https://finance.yahoo.com", "image": "https://images.unsplash.com/photo-1517694712202-14dd9538aa97?q=80&w=500&auto=format&fit=crop", "publisher": "TechCrunch", "sentiment": 0.78},
363
+ {"title": "Crude Oil Breaks Above $85 as OPEC+ Extends Production Cuts", "link": "https://finance.yahoo.com", "image": "https://images.unsplash.com/photo-1518186285589-2f7649de83e0?q=80&w=500&auto=format&fit=crop", "publisher": "Financial Times", "sentiment": -0.30},
364
+ {"title": "US Unemployment Claims Fall to 52-Week Low, Labor Market Stays Tight", "link": "https://finance.yahoo.com", "image": "https://images.unsplash.com/photo-1556740738-b6a63e27c4df?q=80&w=500&auto=format&fit=crop", "publisher": "MarketWatch", "sentiment": 0.60},
365
+ {"title": "Amazon Web Services Revenue Grows 19% Year-Over-Year in Cloud Push", "link": "https://finance.yahoo.com", "image": "https://images.unsplash.com/photo-1526304640581-d334cdbbf45e?q=80&w=500&auto=format&fit=crop", "publisher": "Bloomberg", "sentiment": 0.72},
366
+ {"title": "China's Central Bank Cuts Key Lending Rate to Boost Slowing Economy", "link": "https://finance.yahoo.com", "image": "https://images.unsplash.com/photo-1547981609-4b6bfe67ca0b?q=80&w=500&auto=format&fit=crop", "publisher": "Reuters", "sentiment": -0.20},
367
+ {"title": "JPMorgan Beats Earnings Expectations, Trading Revenue Up 21%", "link": "https://finance.yahoo.com", "image": "https://images.unsplash.com/photo-1501167786227-4cba60f6d58f?q=80&w=500&auto=format&fit=crop", "publisher": "Wall Street Journal", "sentiment": 0.80},
368
+ {"title": "Gold Hits New All-Time High Above $2,500 Amid Safe-Haven Demand", "link": "https://finance.yahoo.com", "image": "https://images.unsplash.com/photo-1610375461246-83df859d849d?q=80&w=500&auto=format&fit=crop", "publisher": "Financial Times", "sentiment": 0.45},
369
+ {"title": "Microsoft Azure Growth Accelerates to 29%, Beating Wall Street Forecasts", "link": "https://finance.yahoo.com", "image": "https://images.unsplash.com/photo-1633419461186-7d40a38105ec?q=80&w=500&auto=format&fit=crop", "publisher": "CNBC", "sentiment": 0.88},
370
+ {"title": "European Markets Slide as German Manufacturing Data Disappoints", "link": "https://finance.yahoo.com", "image": "https://images.unsplash.com/photo-1569025690938-a00729c9e1f9?q=80&w=500&auto=format&fit=crop", "publisher": "Bloomberg", "sentiment": -0.55},
371
+ {"title": "Bitcoin Rallies Past $72,000 Following Spot ETF Inflow Record", "link": "https://finance.yahoo.com", "image": "https://images.unsplash.com/photo-1518546305927-5a555bb7020d?q=80&w=500&auto=format&fit=crop", "publisher": "CoinDesk", "sentiment": 0.75},
372
+ {"title": "Pfizer Shares Plunge After Cutting Full-Year Revenue Guidance", "link": "https://finance.yahoo.com", "image": "https://images.unsplash.com/photo-1584308666744-24d5c474f2ae?q=80&w=500&auto=format&fit=crop", "publisher": "Reuters", "sentiment": -0.70},
373
+ {"title": "US Housing Starts Fall 5.5% as Mortgage Rates Remain Elevated", "link": "https://finance.yahoo.com", "image": "https://images.unsplash.com/photo-1560518883-ce09059eeffa?q=80&w=500&auto=format&fit=crop", "publisher": "MarketWatch", "sentiment": -0.40},
374
+ {"title": "Meta Platforms Reports 27% Revenue Jump Driven by Reels Advertising", "link": "https://finance.yahoo.com", "image": "https://images.unsplash.com/photo-1611162616305-c69b3fa7fbe0?q=80&w=500&auto=format&fit=crop", "publisher": "TechCrunch", "sentiment": 0.82},
375
+ {"title": "Toyota Overtakes GM in US Sales for First Time in History", "link": "https://finance.yahoo.com", "image": "https://images.unsplash.com/photo-1549317661-bd32c8ce0afa?q=80&w=500&auto=format&fit=crop", "publisher": "Wall Street Journal", "sentiment": 0.35},
376
+ {"title": "US 10-Year Treasury Yield Falls Below 4% on Soft Inflation Print", "link": "https://finance.yahoo.com", "image": "https://images.unsplash.com/photo-1611974789855-9c2a0a7236a3?q=80&w=500&auto=format&fit=crop", "publisher": "Bloomberg", "sentiment": 0.50},
377
+ {"title": "Alphabet Announces $70 Billion Share Buyback, Stock Rallies 6%", "link": "https://finance.yahoo.com", "image": "https://images.unsplash.com/photo-1573804633927-bfcbcd909acd?q=80&w=500&auto=format&fit=crop", "publisher": "CNBC", "sentiment": 0.90},
378
+ {"title": "Boeing Faces New FAA Investigation After Latest Quality Control Failures", "link": "https://finance.yahoo.com", "image": "https://images.unsplash.com/photo-1556388158-158ea5ccacbd?q=80&w=500&auto=format&fit=crop", "publisher": "Reuters", "sentiment": -0.75},
379
+ {"title": "Walmart Reports Strong Q1, Raises Full-Year Outlook on Consumer Resilience", "link": "https://finance.yahoo.com", "image": "https://images.unsplash.com/photo-1604719312566-8912e9227c6a?q=80&w=500&auto=format&fit=crop", "publisher": "Financial Times", "sentiment": 0.68},
380
+ {"title": "Broadcom Stock Surges 12% After AI Revenue Doubles Year-Over-Year", "link": "https://finance.yahoo.com", "image": "https://images.unsplash.com/photo-1558494949-ef010cbdcc31?q=80&w=500&auto=format&fit=crop", "publisher": "MarketWatch", "sentiment": 0.88},
381
+ {"title": "Japan's Nikkei 225 Hits 40-Year High as Yen Weakens Further", "link": "https://finance.yahoo.com", "image": "https://images.unsplash.com/photo-1480796927426-f609979314bd?q=80&w=500&auto=format&fit=crop", "publisher": "Bloomberg", "sentiment": 0.55},
382
+ {"title": "UnitedHealth Group Under DOJ Antitrust Scrutiny, Shares Fall 9%", "link": "https://finance.yahoo.com", "image": "https://images.unsplash.com/photo-1576091160550-2173dba999ef?q=80&w=500&auto=format&fit=crop", "publisher": "Wall Street Journal", "sentiment": -0.62},
383
+ {"title": "Copper Prices Hit Record High on AI Data Center Power Demand", "link": "https://finance.yahoo.com", "image": "https://images.unsplash.com/photo-1605792657660-596af9009e82?q=80&w=500&auto=format&fit=crop", "publisher": "Reuters", "sentiment": 0.48},
384
+ {"title": "Netflix Adds 9.3 Million Subscribers in Q2, Beating Every Estimate", "link": "https://finance.yahoo.com", "image": "https://images.unsplash.com/photo-1574375927938-d5a98e8d7e28?q=80&w=500&auto=format&fit=crop", "publisher": "CNBC", "sentiment": 0.85},
385
+ {"title": "ExxonMobil Completes $60 Billion Pioneer Natural Resources Acquisition", "link": "https://finance.yahoo.com", "image": "https://images.unsplash.com/photo-1513828583688-c52646db42da?q=80&w=500&auto=format&fit=crop", "publisher": "Financial Times", "sentiment": 0.30},
386
+ {"title": "CrowdStrike Outage Grounds Airlines Worldwide, Stock Drops 11%", "link": "https://finance.yahoo.com", "image": "https://images.unsplash.com/photo-1558494949-ef010cbdcc31?q=80&w=500&auto=format&fit=crop", "publisher": "TechCrunch", "sentiment": -0.80},
387
+ {"title": "US Consumer Confidence Index Rises to 8-Month High in June", "link": "https://finance.yahoo.com", "image": "https://images.unsplash.com/photo-1556742049-0cfed4f6a45d?q=80&w=500&auto=format&fit=crop", "publisher": "MarketWatch", "sentiment": 0.62},
388
+ {"title": "Eli Lilly Weight-Loss Drug Zepbound Sales Exceed $1 Billion in Q2", "link": "https://finance.yahoo.com", "image": "https://images.unsplash.com/photo-1587854692152-cbe660dbde88?q=80&w=500&auto=format&fit=crop", "publisher": "Bloomberg", "sentiment": 0.78},
389
+ {"title": "Regional Banks Face Renewed Pressure as Commercial Real Estate Defaults Rise", "link": "https://finance.yahoo.com", "image": "https://images.unsplash.com/photo-1486406146926-c627a92ad1ab?q=80&w=500&auto=format&fit=crop", "publisher": "Wall Street Journal", "sentiment": -0.58},
390
+ {"title": "India's Sensex Crosses 80,000 Milestone on Foreign Fund Inflows", "link": "https://finance.yahoo.com", "image": "https://images.unsplash.com/photo-1524492412937-b28074a5d7da?q=80&w=500&auto=format&fit=crop", "publisher": "Reuters", "sentiment": 0.70},
391
+ {"title": "AMD Unveils New MI350 AI Chips to Challenge NVIDIA's Dominance", "link": "https://finance.yahoo.com", "image": "https://images.unsplash.com/photo-1591799264318-7e6ef8ddb7ea?q=80&w=500&auto=format&fit=crop", "publisher": "TechCrunch", "sentiment": 0.65},
392
+ {"title": "US Trade Deficit Widens to $75 Billion as Imports Surge from Asia", "link": "https://finance.yahoo.com", "image": "https://images.unsplash.com/photo-1494412574643-ff11b0a5eb19?q=80&w=500&auto=format&fit=crop", "publisher": "Financial Times", "sentiment": -0.35},
393
+ {"title": "Costco Same-Store Sales Growth Beats Expectations for 8th Straight Quarter", "link": "https://finance.yahoo.com", "image": "https://images.unsplash.com/photo-1604719312566-8912e9227c6a?q=80&w=500&auto=format&fit=crop", "publisher": "CNBC", "sentiment": 0.72},
394
+ {"title": "Ethereum Spot ETF Approved by SEC, Crypto Market Rallies Broadly", "link": "https://finance.yahoo.com", "image": "https://images.unsplash.com/photo-1639762681485-074b7f938ba0?q=80&w=500&auto=format&fit=crop", "publisher": "CoinDesk", "sentiment": 0.80},
395
+ {"title": "Disney+ Reaches Profitability for First Time Since Launch", "link": "https://finance.yahoo.com", "image": "https://images.unsplash.com/photo-1616530940355-351fabd9524b?q=80&w=500&auto=format&fit=crop", "publisher": "Bloomberg", "sentiment": 0.75},
396
+ {"title": "Natural Gas Prices Collapse 15% on Warmer-Than-Expected Winter Forecast", "link": "https://finance.yahoo.com", "image": "https://images.unsplash.com/photo-1532601224476-15c79f2f7a51?q=80&w=500&auto=format&fit=crop", "publisher": "MarketWatch", "sentiment": -0.45},
397
+ {"title": "Goldman Sachs Upgrades Semiconductor Sector to Overweight Amid AI Tailwinds", "link": "https://finance.yahoo.com", "image": "https://images.unsplash.com/photo-1518770660439-4636190af475?q=80&w=500&auto=format&fit=crop", "publisher": "Wall Street Journal", "sentiment": 0.68},
398
+ {"title": "UK Inflation Falls to 2.0% Target, Bank of England Rate Cut Expected", "link": "https://finance.yahoo.com", "image": "https://images.unsplash.com/photo-1526304640581-d334cdbbf45e?q=80&w=500&auto=format&fit=crop", "publisher": "Reuters", "sentiment": 0.52},
399
+ {"title": "Palantir Wins $480 Million US Army Contract, Shares Jump 7%", "link": "https://finance.yahoo.com", "image": "https://images.unsplash.com/photo-1550751827-4bd374c3f58b?q=80&w=500&auto=format&fit=crop", "publisher": "CNBC", "sentiment": 0.73},
400
+ {"title": "Starbucks CEO Steps Down After Sales Decline in Key US and China Markets", "link": "https://finance.yahoo.com", "image": "https://images.unsplash.com/photo-1453614512568-c4024d13c247?q=80&w=500&auto=format&fit=crop", "publisher": "Bloomberg", "sentiment": -0.50},
401
+ {"title": "Solar Energy Installations Hit Record in H1 2026, SolarEdge Rallies", "link": "https://finance.yahoo.com", "image": "https://images.unsplash.com/photo-1508514177221-188b1cf16e9d?q=80&w=500&auto=format&fit=crop", "publisher": "Financial Times", "sentiment": 0.65},
402
+ {"title": "Dollar Index Falls to 3-Month Low as Rate Cut Bets Increase", "link": "https://finance.yahoo.com", "image": "https://images.unsplash.com/photo-1611974789855-9c2a0a7236a3?q=80&w=500&auto=format&fit=crop", "publisher": "MarketWatch", "sentiment": -0.25},
403
+ {"title": "Uber Reports First Full-Year Profit, Announces $7 Billion Buyback", "link": "https://finance.yahoo.com", "image": "https://images.unsplash.com/photo-1549317661-bd32c8ce0afa?q=80&w=500&auto=format&fit=crop", "publisher": "TechCrunch", "sentiment": 0.82},
404
+ {"title": "Lockheed Martin Secures $11 Billion F-35 Contract Expansion", "link": "https://finance.yahoo.com", "image": "https://images.unsplash.com/photo-1556388158-158ea5ccacbd?q=80&w=500&auto=format&fit=crop", "publisher": "Wall Street Journal", "sentiment": 0.58},
405
+ {"title": "Cathie Wood's ARK Invest Sells $200M in Tesla, Loads Up on Coinbase", "link": "https://finance.yahoo.com", "image": "https://images.unsplash.com/photo-1590283603385-17ffb3a7f29f?q=80&w=500&auto=format&fit=crop", "publisher": "Bloomberg", "sentiment": 0.15},
406
+ {"title": "Samsung Reports Record Chip Profits as AI Memory Demand Soars", "link": "https://finance.yahoo.com", "image": "https://images.unsplash.com/photo-1591799264318-7e6ef8ddb7ea?q=80&w=500&auto=format&fit=crop", "publisher": "Reuters", "sentiment": 0.77},
407
+ {"title": "US Core PCE Inflation Cools to 2.6%, Reinforcing Fed Pivot Narrative", "link": "https://finance.yahoo.com", "image": "https://images.unsplash.com/photo-1611974765270-ca1258634369?q=80&w=500&auto=format&fit=crop", "publisher": "CNBC", "sentiment": 0.60},
408
+ ]
409
+ for i, item in enumerate(dummy_news):
410
+ item["published"] = (datetime.now() - timedelta(minutes=i * 30)).strftime('%Y-%m-%d %H:%M')
411
+
412
+ return jsonify(dummy_news)
413
+
414
+ @app.route("/api/screener", methods=["GET"])
415
+ def screener_data():
416
+ """
417
+ Returns the pre-calculated daily screener data from Supabase cache to bypass local disk wipes.
418
+ """
419
+ data = db.get_cache("screener")
420
+ if data:
421
+ return jsonify(data)
422
+
423
+ # Final fallback if Supabase is wiped
424
+ file_path = "/tmp/screener_data.json"
425
+ if os.path.exists(file_path):
426
+ try:
427
+ with open(file_path, "r") as f:
428
+ return jsonify(json.load(f))
429
+ except Exception as e:
430
+ pass
431
+
432
+ return jsonify({"stocks": []})
433
+
434
+ @app.route("/api/search", methods=["GET"])
435
+ def search_symbols():
436
+ query = request.args.get("q")
437
+ if not query:
438
+ return jsonify({"data": []})
439
+
440
+ url = "https://api.twelvedata.com/symbol_search"
441
+ params = {"symbol": query, "apikey": TWELVE_DATA_KEY}
442
+
443
+ try:
444
+ response = requests.get(url, params=params)
445
+ data = response.json()
446
+ if "data" in data:
447
+ return jsonify(data)
448
+ else:
449
+ return jsonify({"data": []})
450
+ except Exception as e:
451
+ print(f"Search API Error: {e}")
452
+ return jsonify({"data": []})
453
+
454
+ @app.route("/api/sentiment-history", methods=["GET"])
455
+ def sentiment_history():
456
+ """Returns sentiment time-series for a ticker."""
457
+ ticker = request.args.get("ticker")
458
+ if not ticker:
459
+ return jsonify({"error": "Missing required parameter: ticker"}), 400
460
+
461
+ from brain.sentiment.tracker import SentimentTracker
462
+ limit = int(request.args.get("limit", 30))
463
+ history = SentimentTracker.get_history(ticker.upper(), limit=limit)
464
+ return jsonify({"ticker": ticker.upper(), "history": history})
465
+
466
+ @app.route("/api/sector-sentiment", methods=["GET"])
467
+ def sector_sentiment():
468
+ """Returns sector-level sentiment aggregation."""
469
+ from brain.sentiment.sector import SectorSentiment
470
+ result = SectorSentiment.get_hottest_coldest()
471
+ return jsonify(result)
472
+
473
+ @app.route("/api/quant/rank", methods=["GET"])
474
+ def cross_sectional_rank():
475
+ try:
476
+ strategy_data = db.get_cache("strategy_live_signals")
477
+ if not strategy_data:
478
+ from backend.strategy_signals import get_strategy_live_signals
479
+ strategy_data = get_strategy_live_signals()
480
+
481
+ # Format for ScreenerPage and RankingDashboard
482
+ return jsonify({
483
+ "rankings": strategy_data["all_universe"], # For ScreenerPage
484
+ "universe_size": len(strategy_data["all_universe"]), # For RankingDashboard
485
+ "picks": strategy_data["picks"], # For RankingDashboard Top 15
486
+ "regime": strategy_data["regime"],
487
+ "vol_scalar": strategy_data["vol_scalar"]
488
+ })
489
+ except Exception as e:
490
+ print(f"Rank API Error: {e}")
491
+ return jsonify({"rankings": [], "universe_size": 0, "picks": [], "error": "Screener data not yet available"})
492
+
493
+ @app.route("/api/strategy/live", methods=["GET"])
494
+ def strategy_live_signals():
495
+ """Returns live Strategy signals: Top 15 175-day picks, regime status, vol scalar."""
496
+ try:
497
+ signals = db.get_cache("strategy_live_signals")
498
+ if not signals:
499
+ from backend.strategy_signals import get_strategy_live_signals
500
+ signals = get_strategy_live_signals()
501
+ return jsonify(signals)
502
+ except Exception as e:
503
+ print(f"Strategy Signal Error: {e}")
504
+ return jsonify({"error": str(e)}), 500
505
+
506
+ @app.route("/api/strategy/portfolio", methods=["GET"])
507
+ def strategy_portfolio():
508
+ """Returns the live paper trading portfolio state directly from Alpaca."""
509
+ try:
510
+ # Check cache first
511
+ cached = get_cached_data("STRATEGY_PORTFOLIO")
512
+ if cached:
513
+ return jsonify(cached)
514
+
515
+ from backend import alpaca_executor
516
+ client = alpaca_executor.get_client()
517
+ account = client.get_account()
518
+ positions = alpaca_executor.get_current_positions(client)
519
+
520
+ # Try to read trade log
521
+ trade_log = []
522
+ log_path = os.path.join(os.path.dirname(__file__), "alpaca_trade_log.json")
523
+ if os.path.exists(log_path):
524
+ with open(log_path, "r") as f:
525
+ raw_logs = json.load(f)
526
+ for log in raw_logs:
527
+ trade_log.append({
528
+ "date": log["timestamp"].split("T")[0],
529
+ "action": "REBALANCE",
530
+ "orders": log.get("orders", []),
531
+ "equity": log.get("portfolio_value", 0),
532
+ "vol_scalar": log.get("vol_scalar", 1.0)
533
+ })
534
+
535
+ # Construct equity curve from logs (naive approach, but better than nothing)
536
+ equity_curve = [{"date": l["date"], "equity": l["equity"]} for l in trade_log]
537
+ if not equity_curve:
538
+ equity_curve = [{"date": datetime.now().strftime("%Y-%m-%d"), "equity": float(account.portfolio_value)}]
539
+
540
+ # Determine days since rebalance
541
+ days_since = 0
542
+ if trade_log:
543
+ last_date = datetime.strptime(trade_log[-1]["date"], "%Y-%m-%d")
544
+ days_since = (datetime.now() - last_date).days
545
+
546
+ # Try to get latest status
547
+ try:
548
+ from backend.strategy_signals import get_strategy_live_signals
549
+ sig = get_strategy_live_signals()
550
+ status = {"regime": sig["regime"], "effective_scalar": sig["vol_scalar"]}
551
+ except:
552
+ status = {"regime": "UNKNOWN", "effective_scalar": 1.0}
553
+
554
+ port_data = {
555
+ "start_date": trade_log[0]["date"] if trade_log else datetime.now().strftime("%Y-%m-%d"),
556
+ "equity": float(account.portfolio_value),
557
+ "cash": float(account.cash),
558
+ "status": status,
559
+ "days_since_rebalance": days_since,
560
+ "equity_curve": equity_curve,
561
+ "trade_log": trade_log,
562
+ "positions": positions
563
+ }
564
+
565
+ set_cached_data("STRATEGY_PORTFOLIO", port_data)
566
+ return jsonify(port_data)
567
+
568
+ except Exception as e:
569
+ print(f"Alpaca API Error: {e}")
570
+ return jsonify({"error": f"Failed to connect to Alpaca: {str(e)}"}), 500
571
+
572
+ @app.route("/api/strategy/force_run", methods=["POST"])
573
+ def strategy_force_run():
574
+ """Manually triggers the Strategy Alpaca Executor in dry-run mode (or live if configured)."""
575
+ try:
576
+ from backend.alpaca_executor import main as run_virtual_broker
577
+ from backend.alpaca_executor import calculate_strategy_signals
578
+ calculate_strategy_signals()
579
+ return jsonify({"status": "success", "message": "Alpaca engine connection successful."})
580
+ except Exception as e:
581
+ print(f"Alpaca Engine Error: {e}")
582
+ return jsonify({"error": str(e)}), 500
583
+
584
+ @app.route("/health", methods=["GET"])
585
+ def health():
586
+ return jsonify({"status": "healthy"})
587
+
588
+ if __name__ == "__main__":
589
+ port = int(os.getenv("PORT", 5000))
590
+ debug = os.getenv("FLASK_DEBUG", "false").lower() == "true"
591
+ app.run(host="0.0.0.0", port=port, debug=debug, use_reloader=False)
backend/database.py ADDED
@@ -0,0 +1,110 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import os
2
+ from supabase import create_client, Client
3
+ from dotenv import load_dotenv
4
+ import time
5
+
6
+ # Load root .env (one level up from backend/)
7
+ load_dotenv(os.path.join(os.path.dirname(__file__), '..', '.env'))
8
+
9
+ # Supabase Credentials (from .env or GitHub Secrets)
10
+ SUPABASE_URL = os.getenv("SUPABASE_URL")
11
+ SUPABASE_KEY = os.getenv("SUPABASE_KEY")
12
+
13
+ class NewsDatabase:
14
+ def __init__(self):
15
+ self.client: Client = None
16
+ if SUPABASE_URL and SUPABASE_KEY:
17
+ try:
18
+ self.client = create_client(SUPABASE_URL, SUPABASE_KEY)
19
+ print("[DB] Connected to Supabase.")
20
+ except Exception as e:
21
+ print(f"[DB] Connection Failed: {e}")
22
+ else:
23
+ print("[DB] Warning: SUPABASE_URL or SUPABASE_KEY not set. Running in Dummy Mode.")
24
+
25
+ def upsert_article(self, ticker, start_time, article_data):
26
+ """
27
+ Save or Update an article in the DB.
28
+ """
29
+ if not self.client:
30
+ return
31
+
32
+ data = {
33
+ "ticker": ticker,
34
+ "link": article_data['link'],
35
+ "title": article_data['title'],
36
+ "published": article_data.get('published'),
37
+ "source": article_data.get('publisher'),
38
+ "full_text": article_data.get('text', ''),
39
+ "snippet": article_data.get('snippet', ''),
40
+ "sentiment_score": article_data.get('sentiment', 0.0),
41
+ "scraped_at": time.strftime('%Y-%m-%d %H:%M:%S'),
42
+ "debug_metadata": article_data.get('debug', {})
43
+ }
44
+
45
+ try:
46
+ # Upsert based on link (assuming link is unique constraint)
47
+ self.client.table("news_articles").upsert(data, on_conflict="link").execute()
48
+ # print(f"[DB] Saved: {data['title'][:30]}...")
49
+ except Exception as e:
50
+ print(f"[DB] Save Error: {e}")
51
+
52
+ def get_latest_news(self, ticker, limit=10):
53
+ """
54
+ Fetch latest news for a ticker from DB.
55
+ """
56
+ if not self.client:
57
+ return []
58
+
59
+ try:
60
+ response = self.client.table("news_articles")\
61
+ .select("*")\
62
+ .eq("ticker", ticker)\
63
+ .order("published", desc=True)\
64
+ .limit(limit)\
65
+ .execute()
66
+ return response.data
67
+ except Exception as e:
68
+ print(f"[DB] Fetch Error: {e}")
69
+ return []
70
+
71
+ def set_cache(self, key: str, data: dict):
72
+ """
73
+ Save a pre-computed JSON payload to the api_cache table.
74
+ """
75
+ if not self.client:
76
+ print(f"[DB] Cache Set Error: No Supabase Client (Key: {key})")
77
+ return False
78
+
79
+ try:
80
+ payload = {
81
+ "key": key,
82
+ "data": data,
83
+ "updated_at": time.strftime('%Y-%m-%d %H:%M:%S')
84
+ }
85
+ self.client.table("api_cache").upsert(payload, on_conflict="key").execute()
86
+ print(f"[DB] Successfully cached '{key}' to Supabase.")
87
+ return True
88
+ except Exception as e:
89
+ print(f"[DB] Cache Set Error for '{key}': {e}")
90
+ return False
91
+
92
+ def get_cache(self, key: str):
93
+ """
94
+ Retrieve a pre-computed JSON payload from the api_cache table.
95
+ """
96
+ if not self.client:
97
+ return None
98
+
99
+ try:
100
+ response = self.client.table("api_cache")\
101
+ .select("data")\
102
+ .eq("key", key)\
103
+ .execute()
104
+
105
+ if response.data and len(response.data) > 0:
106
+ return response.data[0]["data"]
107
+ return None
108
+ except Exception as e:
109
+ print(f"[DB] Cache Get Error for '{key}': {e}")
110
+ return None
backend/manager.py ADDED
@@ -0,0 +1,94 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import os
2
+ import torch
3
+ import logging
4
+ from brain.neural_networks.data_processor import DataProcessor
5
+ from brain.neural_networks.model import StockLSTM
6
+
7
+ # Configure logging
8
+ logging.basicConfig(level=logging.INFO)
9
+ logger = logging.getLogger(__name__)
10
+
11
+ class ModelManager:
12
+ _instance = None
13
+
14
+ def __new__(cls):
15
+ if cls._instance is None:
16
+ logger.info("Initializing ModelManager Singleton...")
17
+ cls._instance = super(ModelManager, cls).__new__(cls)
18
+ cls._instance.initialize()
19
+ return cls._instance
20
+
21
+ def initialize(self):
22
+ self.device = torch.device('cpu') # Inference on CPU is sufficient
23
+ self.processor = None
24
+ self.model = None
25
+ self.model_path = "brain/saved_models/hybrid_lstm.pth"
26
+
27
+ # Lazy load flag
28
+ self._loaded = False
29
+
30
+ def load_resources(self):
31
+ """
32
+ Loads the DataProcessor and PyTorch Model if not already loaded.
33
+ """
34
+ if self._loaded:
35
+ return
36
+
37
+ logger.info("Loading AI Resources...")
38
+
39
+ try:
40
+ # 1. Initialize Processor (Loads Scaler)
41
+ self.processor = DataProcessor(sequence_length=60)
42
+
43
+ # 2. Initialize Model
44
+ self.model = StockLSTM(input_size=13)
45
+
46
+ if os.path.exists(self.model_path):
47
+ logger.info(f"Loading model weights from {self.model_path}")
48
+ self.model.load_state_dict(torch.load(self.model_path, map_location=self.device))
49
+ self.model.to(self.device)
50
+ self.model.eval()
51
+ self._loaded = True
52
+ else:
53
+ logger.warning(f"Model file not found at {self.model_path}. Neural predictions will be disabled.")
54
+ self.model = None
55
+
56
+ except Exception as e:
57
+ logger.error(f"Failed to load AI resources: {e}")
58
+ self.model = None
59
+ self.processor = None
60
+
61
+ def predict_sentiment(self, graph_data):
62
+ """
63
+ Runs inference on the provided graph data.
64
+ Returns: (signal: str, confidence: float)
65
+ """
66
+ if not self._loaded:
67
+ self.load_resources()
68
+
69
+ if not self.model or not self.processor:
70
+ return "Neutral (Model Error)", 0.0
71
+
72
+ try:
73
+ # Prepare Data
74
+ input_tensor = self.processor.prepare_inference_data(graph_data)
75
+
76
+ if input_tensor is None:
77
+ logger.warning("Insufficient data for inference (Need 60+ days).")
78
+ return "Neutral (No Data)", 0.0
79
+
80
+ # Predict
81
+ input_tensor = torch.FloatTensor(input_tensor).to(self.device)
82
+
83
+ with torch.no_grad():
84
+ prediction = self.model(input_tensor).item()
85
+
86
+ signal = "Bullish" if prediction > 0.50 else "Bearish"
87
+ return signal, prediction
88
+
89
+ except Exception as e:
90
+ logger.error(f"Inference Error: {e}")
91
+ return "Neutral (Error)", 0.0
92
+
93
+ # Global Instance
94
+ model_manager = ModelManager()
backend/pairs_radar.json ADDED
@@ -0,0 +1 @@
 
 
1
+ {"pairs": [{"ticker1": "AAPL", "ticker2": "MSFT", "sector": "Big Tech", "correlation": 0, "is_cointegrated": false, "current_z_score": 0.8, "signal": "Neutral"}, {"ticker1": "GOOGL", "ticker2": "META", "sector": "Ad Tech", "correlation": 0, "is_cointegrated": false, "current_z_score": 0.82, "signal": "Neutral"}, {"ticker1": "AMD", "ticker2": "NVDA", "sector": "Semiconductors", "correlation": 0, "is_cointegrated": false, "current_z_score": 1.14, "signal": "Neutral"}, {"ticker1": "INTC", "ticker2": "TXN", "sector": "Semiconductors", "correlation": 0, "is_cointegrated": false, "current_z_score": 1.33, "signal": "Neutral"}, {"ticker1": "AVGO", "ticker2": "QCOM", "sector": "Semiconductors", "correlation": 0, "is_cointegrated": false, "current_z_score": 1.69, "signal": "Neutral"}, {"ticker1": "ORCL", "ticker2": "CRM", "sector": "Software", "correlation": 0, "is_cointegrated": false, "current_z_score": -0.72, "signal": "Neutral"}, {"ticker1": "ADBE", "ticker2": "CRM", "sector": "Software", "correlation": 0, "is_cointegrated": false, "current_z_score": -0.53, "signal": "Neutral"}, {"ticker1": "CSCO", "ticker2": "JNPR", "sector": "Networking", "correlation": 0, "is_cointegrated": false, "current_z_score": 0.0, "signal": "Neutral"}, {"ticker1": "IBM", "ticker2": "HPQ", "sector": "Hardware", "correlation": 0, "is_cointegrated": false, "current_z_score": -1.16, "signal": "Neutral"}, {"ticker1": "AMAT", "ticker2": "MU", "sector": "Semiconductors", "correlation": 0, "is_cointegrated": false, "current_z_score": 3.05, "signal": "Neutral"}, {"ticker1": "LRCX", "ticker2": "KLAC", "sector": "Semiconductors", "correlation": 0, "is_cointegrated": true, "current_z_score": -0.06, "signal": "Neutral"}, {"ticker1": "SNPS", "ticker2": "CDNS", "sector": "Software", "correlation": 0, "is_cointegrated": false, "current_z_score": -0.56, "signal": "Neutral"}, {"ticker1": "VZ", "ticker2": "T", "sector": "Telecom", "correlation": 0, "is_cointegrated": false, "current_z_score": 2.0, "signal": "Neutral"}, {"ticker1": "DIS", "ticker2": "CMCSA", "sector": "Media", "correlation": 0, "is_cointegrated": false, "current_z_score": -1.36, "signal": "Neutral"}, {"ticker1": "NFLX", "ticker2": "WBD", "sector": "Streaming", "correlation": 0, "is_cointegrated": false, "current_z_score": -0.36, "signal": "Neutral"}, {"ticker1": "CHTR", "ticker2": "FOXA", "sector": "Media", "correlation": 0, "is_cointegrated": false, "current_z_score": -1.5, "signal": "Neutral"}, {"ticker1": "TMUS", "ticker2": "VZ", "sector": "Telecom", "correlation": 0, "is_cointegrated": false, "current_z_score": -0.69, "signal": "Neutral"}, {"ticker1": "EA", "ticker2": "TTWO", "sector": "Gaming", "correlation": 0, "is_cointegrated": false, "current_z_score": 1.67, "signal": "Neutral"}, {"ticker1": "OMC", "ticker2": "IPG", "sector": "Advertising", "correlation": 0, "is_cointegrated": false, "current_z_score": 0.0, "signal": "Neutral"}, {"ticker1": "JPM", "ticker2": "BAC", "sector": "Banking", "correlation": 0, "is_cointegrated": false, "current_z_score": 0.6, "signal": "Neutral"}, {"ticker1": "V", "ticker2": "MA", "sector": "Payments", "correlation": 0, "is_cointegrated": false, "current_z_score": -1.24, "signal": "Neutral"}, {"ticker1": "GS", "ticker2": "MS", "sector": "Investment Banking", "correlation": 0, "is_cointegrated": false, "current_z_score": 0.36, "signal": "Neutral"}, {"ticker1": "WFC", "ticker2": "C", "sector": "Banking", "correlation": 0, "is_cointegrated": false, "current_z_score": -2.6, "signal": "Neutral"}, {"ticker1": "AXP", "ticker2": "DFS", "sector": "Credit Cards", "correlation": 0, "is_cointegrated": false, "current_z_score": 0.0, "signal": "Neutral"}, {"ticker1": "BLK", "ticker2": "TROW", "sector": "Asset Management", "correlation": 0, "is_cointegrated": true, "current_z_score": 0.31, "signal": "Neutral"}, {"ticker1": "CME", "ticker2": "ICE", "sector": "Exchanges", "correlation": 0, "is_cointegrated": false, "current_z_score": 1.46, "signal": "Neutral"}, {"ticker1": "USB", "ticker2": "PNC", "sector": "Regional Banks", "correlation": 0, "is_cointegrated": false, "current_z_score": 0.45, "signal": "Neutral"}, {"ticker1": "TFC", "ticker2": "SYF", "sector": "Financial Services", "correlation": 0, "is_cointegrated": false, "current_z_score": 0.69, "signal": "Neutral"}, {"ticker1": "SCHW", "ticker2": "AMP", "sector": "Brokerage", "correlation": 0, "is_cointegrated": false, "current_z_score": 1.0, "signal": "Neutral"}, {"ticker1": "STT", "ticker2": "BK", "sector": "Custody Banks", "correlation": 0, "is_cointegrated": true, "current_z_score": 0.02, "signal": "Neutral"}, {"ticker1": "MTB", "ticker2": "FITB", "sector": "Regional Banks", "correlation": 0, "is_cointegrated": false, "current_z_score": 0.96, "signal": "Neutral"}, {"ticker1": "AIG", "ticker2": "COF", "sector": "Consumer Finance", "correlation": 0, "is_cointegrated": true, "current_z_score": -1.24, "signal": "Neutral"}, {"ticker1": "SPGI", "ticker2": "MCO", "sector": "Financial Data", "correlation": 0, "is_cointegrated": false, "current_z_score": -1.51, "signal": "Neutral"}, {"ticker1": "PRU", "ticker2": "MET", "sector": "Life Insurance", "correlation": 0, "is_cointegrated": false, "current_z_score": 0.04, "signal": "Neutral"}, {"ticker1": "TRV", "ticker2": "AFL", "sector": "Insurance", "correlation": 0, "is_cointegrated": false, "current_z_score": 1.57, "signal": "Neutral"}, {"ticker1": "ALL", "ticker2": "HIG", "sector": "Insurance", "correlation": 0, "is_cointegrated": true, "current_z_score": -0.08, "signal": "Neutral"}, {"ticker1": "PGR", "ticker2": "CB", "sector": "P&C Insurance", "correlation": 0, "is_cointegrated": false, "current_z_score": -0.52, "signal": "Neutral"}, {"ticker1": "JNJ", "ticker2": "PFE", "sector": "Big Pharma", "correlation": 0, "is_cointegrated": true, "current_z_score": 0.26, "signal": "Neutral"}, {"ticker1": "UNH", "ticker2": "ELV", "sector": "Managed Care", "correlation": 0, "is_cointegrated": false, "current_z_score": -0.1, "signal": "Neutral"}, {"ticker1": "ABT", "ticker2": "MDT", "sector": "Medical Devices", "correlation": 0, "is_cointegrated": false, "current_z_score": -3.0, "signal": "Neutral"}, {"ticker1": "MRK", "ticker2": "BMY", "sector": "Big Pharma", "correlation": 0, "is_cointegrated": false, "current_z_score": 0.95, "signal": "Neutral"}, {"ticker1": "LLY", "ticker2": "NVO", "sector": "Big Pharma", "correlation": 0, "is_cointegrated": false, "current_z_score": -0.78, "signal": "Neutral"}, {"ticker1": "TMO", "ticker2": "DHR", "sector": "Life Sciences", "correlation": 0, "is_cointegrated": false, "current_z_score": 2.3, "signal": "Neutral"}, {"ticker1": "AMGN", "ticker2": "GILD", "sector": "Biotech", "correlation": 0, "is_cointegrated": true, "current_z_score": 0.21, "signal": "Neutral"}, {"ticker1": "ISRG", "ticker2": "SYK", "sector": "Medical Devices", "correlation": 0, "is_cointegrated": false, "current_z_score": -1.12, "signal": "Neutral"}, {"ticker1": "VRTX", "ticker2": "REGN", "sector": "Biotech", "correlation": 0, "is_cointegrated": false, "current_z_score": 0.03, "signal": "Neutral"}, {"ticker1": "ZTS", "ticker2": "IDXX", "sector": "Animal Health", "correlation": 0, "is_cointegrated": false, "current_z_score": -2.35, "signal": "Neutral"}, {"ticker1": "BSX", "ticker2": "EW", "sector": "Medical Devices", "correlation": 0, "is_cointegrated": false, "current_z_score": -2.51, "signal": "Neutral"}, {"ticker1": "CNC", "ticker2": "HUM", "sector": "Managed Care", "correlation": 0, "is_cointegrated": false, "current_z_score": -0.97, "signal": "Neutral"}, {"ticker1": "CI", "ticker2": "CVS", "sector": "Healthcare Services", "correlation": 0, "is_cointegrated": true, "current_z_score": -1.17, "signal": "Neutral"}, {"ticker1": "BIIB", "ticker2": "INCY", "sector": "Biotech", "correlation": 0, "is_cointegrated": false, "current_z_score": 2.28, "signal": "Neutral"}, {"ticker1": "CAH", "ticker2": "MCK", "sector": "Drug Distribution", "correlation": 0, "is_cointegrated": false, "current_z_score": 0.22, "signal": "Neutral"}, {"ticker1": "HCA", "ticker2": "ABC", "sector": "Healthcare Facilities", "correlation": 0, "is_cointegrated": false, "current_z_score": 0.0, "signal": "Neutral"}, {"ticker1": "KO", "ticker2": "PEP", "sector": "Beverages", "correlation": 0, "is_cointegrated": false, "current_z_score": 1.26, "signal": "Neutral"}, {"ticker1": "WMT", "ticker2": "TGT", "sector": "Retail", "correlation": 0, "is_cointegrated": false, "current_z_score": 0.27, "signal": "Neutral"}, {"ticker1": "HD", "ticker2": "LOW", "sector": "Home Improvement", "correlation": 0, "is_cointegrated": false, "current_z_score": -1.94, "signal": "Neutral"}, {"ticker1": "MCD", "ticker2": "SBUX", "sector": "Restaurants", "correlation": 0, "is_cointegrated": false, "current_z_score": 0.3, "signal": "Neutral"}, {"ticker1": "PG", "ticker2": "KMB", "sector": "Household Products", "correlation": 0, "is_cointegrated": false, "current_z_score": -0.51, "signal": "Neutral"}, {"ticker1": "NKE", "ticker2": "LULU", "sector": "Apparel", "correlation": 0, "is_cointegrated": false, "current_z_score": -2.11, "signal": "Neutral"}, {"ticker1": "COST", "ticker2": "BJ", "sector": "Discount Stores", "correlation": 0, "is_cointegrated": false, "current_z_score": 0.89, "signal": "Neutral"}, {"ticker1": "DG", "ticker2": "DLTR", "sector": "Discount Retail", "correlation": 0, "is_cointegrated": false, "current_z_score": 0.23, "signal": "Neutral"}, {"ticker1": "TJX", "ticker2": "ROST", "sector": "Off-Price Retail", "correlation": 0, "is_cointegrated": false, "current_z_score": -1.56, "signal": "Neutral"}, {"ticker1": "MO", "ticker2": "PM", "sector": "Tobacco", "correlation": 0, "is_cointegrated": false, "current_z_score": 0.86, "signal": "Neutral"}, {"ticker1": "CLX", "ticker2": "CHD", "sector": "Household Products", "correlation": 0, "is_cointegrated": false, "current_z_score": -2.1, "signal": "Neutral"}, {"ticker1": "K", "ticker2": "GIS", "sector": "Packaged Foods", "correlation": 0, "is_cointegrated": false, "current_z_score": 0.0, "signal": "Neutral"}, {"ticker1": "HSY", "ticker2": "MDLZ", "sector": "Confectionery", "correlation": 0, "is_cointegrated": false, "current_z_score": 1.51, "signal": "Neutral"}, {"ticker1": "CPB", "ticker2": "SJM", "sector": "Packaged Foods", "correlation": 0, "is_cointegrated": false, "current_z_score": -2.23, "signal": "Neutral"}, {"ticker1": "YUM", "ticker2": "QSR", "sector": "Restaurants", "correlation": 0, "is_cointegrated": false, "current_z_score": 0.66, "signal": "Neutral"}, {"ticker1": "TSCO", "ticker2": "KR", "sector": "Food Retail", "correlation": 0, "is_cointegrated": false, "current_z_score": -2.1, "signal": "Neutral"}, {"ticker1": "EL", "ticker2": "COTY", "sector": "Personal Care", "correlation": 0, "is_cointegrated": false, "current_z_score": -3.06, "signal": "Neutral"}, {"ticker1": "CVX", "ticker2": "XOM", "sector": "Integrated Oil", "correlation": 0, "is_cointegrated": false, "current_z_score": 0.98, "signal": "Neutral"}, {"ticker1": "COP", "ticker2": "EOG", "sector": "E&P", "correlation": 0, "is_cointegrated": false, "current_z_score": 1.23, "signal": "Neutral"}, {"ticker1": "SLB", "ticker2": "HAL", "sector": "Oil Services", "correlation": 0, "is_cointegrated": false, "current_z_score": 0.16, "signal": "Neutral"}, {"ticker1": "MPC", "ticker2": "PSX", "sector": "Refining", "correlation": 0, "is_cointegrated": false, "current_z_score": 0.24, "signal": "Neutral"}, {"ticker1": "VLO", "ticker2": "PSX", "sector": "Refining", "correlation": 0, "is_cointegrated": true, "current_z_score": -1.81, "signal": "Neutral"}, {"ticker1": "KMI", "ticker2": "WMB", "sector": "Midstream", "correlation": 0, "is_cointegrated": false, "current_z_score": 1.79, "signal": "Neutral"}, {"ticker1": "OXY", "ticker2": "DVN", "sector": "E&P", "correlation": 0, "is_cointegrated": false, "current_z_score": 2.05, "signal": "Neutral"}, {"ticker1": "HES", "ticker2": "MRO", "sector": "E&P", "correlation": 0, "is_cointegrated": false, "current_z_score": 0.0, "signal": "Neutral"}, {"ticker1": "BKR", "ticker2": "SLB", "sector": "Oil Services", "correlation": 0, "is_cointegrated": false, "current_z_score": 0.26, "signal": "Neutral"}, {"ticker1": "FANG", "ticker2": "PXD", "sector": "E&P", "correlation": 0, "is_cointegrated": false, "current_z_score": 0.0, "signal": "Neutral"}, {"ticker1": "TRGP", "ticker2": "OKE", "sector": "Midstream", "correlation": 0, "is_cointegrated": false, "current_z_score": 1.57, "signal": "Neutral"}, {"ticker1": "LMT", "ticker2": "RTX", "sector": "Defense", "correlation": 0, "is_cointegrated": false, "current_z_score": 1.32, "signal": "Neutral"}, {"ticker1": "BA", "ticker2": "GD", "sector": "Aerospace", "correlation": 0, "is_cointegrated": false, "current_z_score": -1.41, "signal": "Neutral"}, {"ticker1": "CAT", "ticker2": "DE", "sector": "Heavy Machinery", "correlation": 0, "is_cointegrated": false, "current_z_score": 0.76, "signal": "Neutral"}, {"ticker1": "UNP", "ticker2": "CSX", "sector": "Railroads", "correlation": 0, "is_cointegrated": true, "current_z_score": -0.13, "signal": "Neutral"}, {"ticker1": "NSC", "ticker2": "CP", "sector": "Railroads", "correlation": 0, "is_cointegrated": false, "current_z_score": 0.22, "signal": "Neutral"}, {"ticker1": "UPS", "ticker2": "FDX", "sector": "Logistics", "correlation": 0, "is_cointegrated": false, "current_z_score": -1.47, "signal": "Neutral"}, {"ticker1": "HON", "ticker2": "EMR", "sector": "Conglomerates", "correlation": 0, "is_cointegrated": false, "current_z_score": 1.66, "signal": "Neutral"}, {"ticker1": "GE", "ticker2": "MMM", "sector": "Conglomerates", "correlation": 0, "is_cointegrated": false, "current_z_score": 1.72, "signal": "Neutral"}, {"ticker1": "NOC", "ticker2": "GD", "sector": "Defense", "correlation": 0, "is_cointegrated": false, "current_z_score": 1.05, "signal": "Neutral"}, {"ticker1": "ITW", "ticker2": "ETN", "sector": "Industrial Machinery", "correlation": 0, "is_cointegrated": false, "current_z_score": 0.31, "signal": "Neutral"}, {"ticker1": "PH", "ticker2": "ROK", "sector": "Industrial Components", "correlation": 0, "is_cointegrated": false, "current_z_score": 2.23, "signal": "Neutral"}, {"ticker1": "TXT", "ticker2": "DOV", "sector": "Industrial Conglomerates", "correlation": 0, "is_cointegrated": false, "current_z_score": -0.59, "signal": "Neutral"}, {"ticker1": "LIN", "ticker2": "APD", "sector": "Industrial Gases", "correlation": 0, "is_cointegrated": false, "current_z_score": 0.98, "signal": "Neutral"}, {"ticker1": "SHW", "ticker2": "PPG", "sector": "Specialty Chemicals", "correlation": 0, "is_cointegrated": false, "current_z_score": -2.1, "signal": "Neutral"}, {"ticker1": "NEM", "ticker2": "GOLD", "sector": "Gold Mining", "correlation": 0, "is_cointegrated": false, "current_z_score": -0.32, "signal": "Neutral"}, {"ticker1": "FCX", "ticker2": "SCCO", "sector": "Copper Mining", "correlation": 0, "is_cointegrated": false, "current_z_score": 0.7, "signal": "Neutral"}, {"ticker1": "DOW", "ticker2": "LYB", "sector": "Chemicals", "correlation": 0, "is_cointegrated": false, "current_z_score": 0.69, "signal": "Neutral"}, {"ticker1": "EMN", "ticker2": "CE", "sector": "Chemicals", "correlation": 0, "is_cointegrated": false, "current_z_score": -1.81, "signal": "Neutral"}, {"ticker1": "VMC", "ticker2": "MLM", "sector": "Construction Materials", "correlation": 0, "is_cointegrated": false, "current_z_score": -1.82, "signal": "Neutral"}, {"ticker1": "NEE", "ticker2": "DUK", "sector": "Electric Utilities", "correlation": 0, "is_cointegrated": false, "current_z_score": 0.58, "signal": "Neutral"}, {"ticker1": "SO", "ticker2": "D", "sector": "Electric Utilities", "correlation": 0, "is_cointegrated": false, "current_z_score": 0.84, "signal": "Neutral"}, {"ticker1": "AEP", "ticker2": "EXC", "sector": "Electric Utilities", "correlation": 0, "is_cointegrated": false, "current_z_score": 0.51, "signal": "Neutral"}, {"ticker1": "SRE", "ticker2": "XEL", "sector": "Multi-Utilities", "correlation": 0, "is_cointegrated": false, "current_z_score": 1.91, "signal": "Neutral"}, {"ticker1": "ED", "ticker2": "PEG", "sector": "Multi-Utilities", "correlation": 0, "is_cointegrated": false, "current_z_score": 1.7, "signal": "Neutral"}, {"ticker1": "WEC", "ticker2": "EIX", "sector": "Electric Utilities", "correlation": 0, "is_cointegrated": false, "current_z_score": -0.43, "signal": "Neutral"}, {"ticker1": "AWK", "ticker2": "WTRG", "sector": "Water Utilities", "correlation": 0, "is_cointegrated": false, "current_z_score": -0.4, "signal": "Neutral"}, {"ticker1": "DTE", "ticker2": "CMS", "sector": "Multi-Utilities", "correlation": 0, "is_cointegrated": false, "current_z_score": 0.72, "signal": "Neutral"}, {"ticker1": "AMT", "ticker2": "CCI", "sector": "Telecom REITs", "correlation": 0, "is_cointegrated": false, "current_z_score": 1.32, "signal": "Neutral"}, {"ticker1": "PLD", "ticker2": "DRE", "sector": "Industrial REITs", "correlation": 0, "is_cointegrated": false, "current_z_score": 0.0, "signal": "Neutral"}, {"ticker1": "EQIX", "ticker2": "DLR", "sector": "Data Center REITs", "correlation": 0, "is_cointegrated": false, "current_z_score": 2.04, "signal": "Neutral"}, {"ticker1": "PSA", "ticker2": "EXR", "sector": "Storage REITs", "correlation": 0, "is_cointegrated": false, "current_z_score": -0.02, "signal": "Neutral"}, {"ticker1": "SPG", "ticker2": "MAC", "sector": "Retail REITs", "correlation": 0, "is_cointegrated": true, "current_z_score": -0.86, "signal": "Neutral"}, {"ticker1": "WELL", "ticker2": "VTR", "sector": "Healthcare REITs", "correlation": 0, "is_cointegrated": false, "current_z_score": -0.78, "signal": "Neutral"}, {"ticker1": "O", "ticker2": "NNN", "sector": "Retail REITs", "correlation": 0, "is_cointegrated": false, "current_z_score": 0.34, "signal": "Neutral"}, {"ticker1": "AVB", "ticker2": "BXP", "sector": "Residential REITs", "correlation": 0, "is_cointegrated": false, "current_z_score": -1.59, "signal": "Neutral"}]}
backend/screener_data.json ADDED
@@ -0,0 +1 @@
 
 
1
+ {"stocks": [{"ticker": "AAPL", "price": 253.96, "volatility_regime": "Normal", "earnings": "Any", "signal": "Neutral", "change": 253.96}, {"ticker": "MSFT", "price": 370.03, "volatility_regime": "High", "earnings": "Any", "signal": "Neutral", "change": 370.03}, {"ticker": "GOOGL", "price": 290.15, "volatility_regime": "High", "earnings": "This Month", "signal": "Neutral", "change": 290.15}, {"ticker": "AMZN", "price": 211.73, "volatility_regime": "High", "earnings": "Any", "signal": "Neutral", "change": 211.73}, {"ticker": "NVDA", "price": 179.05, "volatility_regime": "Extreme", "earnings": "Any", "signal": "Neutral", "change": 179.05}, {"ticker": "META", "price": 596.69, "volatility_regime": "High", "earnings": "Any", "signal": "Neutral", "change": 596.69}, {"ticker": "TSLA", "price": 387.81, "volatility_regime": "High", "earnings": "This Month", "signal": "Neutral", "change": 387.81}, {"ticker": "JPM", "price": 295.21, "volatility_regime": "Normal", "earnings": "This Month", "signal": "Neutral", "change": 295.21}, {"ticker": "V", "price": 305.255, "volatility_regime": "Normal", "earnings": "Any", "signal": "Neutral", "change": 305.255}, {"ticker": "JNJ", "price": 240.48, "volatility_regime": "Normal", "earnings": "This Month", "signal": "Buy", "change": 240.48}, {"ticker": "WMT", "price": 123.3, "volatility_regime": "High", "earnings": "Any", "signal": "Neutral", "change": 123.3}, {"ticker": "PG", "price": 143.84, "volatility_regime": "High", "earnings": "This Month", "signal": "Sell", "change": 143.84}, {"ticker": "MA", "price": 502.31, "volatility_regime": "High", "earnings": "Any", "signal": "Buy", "change": 502.31}, {"ticker": "UNH", "price": 269.15, "volatility_regime": "High", "earnings": "This Month", "signal": "Neutral", "change": 269.15}, {"ticker": "HD", "price": 332.745, "volatility_regime": "High", "earnings": "Any", "signal": "Sell", "change": 332.745}, {"ticker": "DIS", "price": 96.16, "volatility_regime": "Normal", "earnings": "Any", "signal": "Sell", "change": 96.16}, {"ticker": "BAC", "price": 48.72, "volatility_regime": "High", "earnings": "This Month", "signal": "Neutral", "change": 48.72}, {"ticker": "XOM", "price": 164.185, "volatility_regime": "High", "earnings": "Any", "signal": "Strong Buy", "change": 164.185}, {"ticker": "PFE", "price": 27.345, "volatility_regime": "High", "earnings": "Any", "signal": "Neutral", "change": 27.345}, {"ticker": "KO", "price": 75.405, "volatility_regime": "Normal", "earnings": "Any", "signal": "Neutral", "change": 75.405}, {"ticker": "PEP", "price": 151.02, "volatility_regime": "Normal", "earnings": "This Month", "signal": "Sell", "change": 151.02}, {"ticker": "CSCO", "price": 82.08, "volatility_regime": "High", "earnings": "Any", "signal": "Buy", "change": 82.08}, {"ticker": "INTC", "price": 47.375, "volatility_regime": "Extreme", "earnings": "This Month", "signal": "Buy", "change": 47.375}, {"ticker": "NFLX", "price": 92.265, "volatility_regime": "Extreme", "earnings": "This Month", "signal": "Strong Buy", "change": 92.265}, {"ticker": "ADBE", "price": 237.775, "volatility_regime": "Extreme", "earnings": "Any", "signal": "Neutral", "change": 237.775}, {"ticker": "CRM", "price": 181.45, "volatility_regime": "Extreme", "earnings": "Any", "signal": "Neutral", "change": 181.45}, {"ticker": "AMD", "price": 220.96, "volatility_regime": "Extreme", "earnings": "Any", "signal": "Buy", "change": 220.96}, {"ticker": "QCOM", "price": 130.63, "volatility_regime": "High", "earnings": "Any", "signal": "Neutral", "change": 130.63}, {"ticker": "TXN", "price": 198.82, "volatility_regime": "High", "earnings": "Any", "signal": "Neutral", "change": 198.82}, {"ticker": "AVGO", "price": 318.935, "volatility_regime": "Extreme", "earnings": "Any", "signal": "Neutral", "change": 318.935}, {"ticker": "ORCL", "price": 145.615, "volatility_regime": "Extreme", "earnings": "Any", "signal": "Neutral", "change": 145.615}, {"ticker": "COST", "price": 976.055, "volatility_regime": "Normal", "earnings": "Any", "signal": "Neutral", "change": 976.055}, {"ticker": "MRK", "price": 119.05, "volatility_regime": "High", "earnings": "Any", "signal": "Neutral", "change": 119.05}, {"ticker": "ABT", "price": 105.3, "volatility_regime": "High", "earnings": "This Month", "signal": "Sell", "change": 105.3}, {"ticker": "TMO", "price": 491.39, "volatility_regime": "High", "earnings": "This Month", "signal": "Neutral", "change": 491.39}, {"ticker": "NKE", "price": 53.06, "volatility_regime": "High", "earnings": "This Week", "signal": "Sell", "change": 53.06}, {"ticker": "MCD", "price": 312.595, "volatility_regime": "Normal", "earnings": "Any", "signal": "Neutral", "change": 312.595}, {"ticker": "LLY", "price": 915.79, "volatility_regime": "High", "earnings": "Any", "signal": "Neutral", "change": 915.79}, {"ticker": "DHR", "price": 186.73, "volatility_regime": "High", "earnings": "This Month", "signal": "Neutral", "change": 186.73}, {"ticker": "UPS", "price": 98.92, "volatility_regime": "High", "earnings": "Any", "signal": "Sell", "change": 98.92}, {"ticker": "CAT", "price": 722.88, "volatility_regime": "High", "earnings": "Any", "signal": "Neutral", "change": 722.88}, {"ticker": "GS", "price": 841.16, "volatility_regime": "Extreme", "earnings": "This Month", "signal": "Neutral", "change": 841.16}, {"ticker": "MS", "price": 166.01, "volatility_regime": "High", "earnings": "This Month", "signal": "Neutral", "change": 166.01}, {"ticker": "AXP", "price": 300.67, "volatility_regime": "Extreme", "earnings": "This Month", "signal": "Neutral", "change": 300.67}, {"ticker": "SBUX", "price": 93.255, "volatility_regime": "High", "earnings": "Any", "signal": "Neutral", "change": 93.255}, {"ticker": "MMM", "price": 148.47, "volatility_regime": "High", "earnings": "This Month", "signal": "Neutral", "change": 148.47}, {"ticker": "IBM", "price": 241.56, "volatility_regime": "High", "earnings": "This Month", "signal": "Buy", "change": 241.56}, {"ticker": "GE", "price": 297.7, "volatility_regime": "High", "earnings": "This Month", "signal": "Sell", "change": 297.7}, {"ticker": "F", "price": 11.72, "volatility_regime": "High", "earnings": "Any", "signal": "Neutral", "change": 11.72}, {"ticker": "GM", "price": 76.91, "volatility_regime": "High", "earnings": "Any", "signal": "Buy", "change": 76.91}, {"ticker": "T", "price": 28.92, "volatility_regime": "High", "earnings": "This Month", "signal": "Buy", "change": 28.92}, {"ticker": "VZ", "price": 50.495, "volatility_regime": "Normal", "earnings": "Any", "signal": "Buy", "change": 50.495}, {"ticker": "PYPL", "price": 44.525, "volatility_regime": "High", "earnings": "Any", "signal": "Neutral", "change": 44.525}, {"ticker": "XYZ", "price": 60.31, "volatility_regime": "Extreme", "earnings": "Any", "signal": "Neutral", "change": 60.31}, {"ticker": "SNAP", "price": 4.495, "volatility_regime": "Extreme", "earnings": "Any", "signal": "Neutral", "change": 4.495}, {"ticker": "UBER", "price": 73.3, "volatility_regime": "High", "earnings": "Any", "signal": "Neutral", "change": 73.3}, {"ticker": "LYFT", "price": 13.185, "volatility_regime": "Extreme", "earnings": "Any", "signal": "Neutral", "change": 13.185}, {"ticker": "ZM", "price": 78.04, "volatility_regime": "Extreme", "earnings": "Any", "signal": "Neutral", "change": 78.04}, {"ticker": "ROKU", "price": 95.085, "volatility_regime": "Extreme", "earnings": "Any", "signal": "Neutral", "change": 95.085}, {"ticker": "COIN", "price": 181.22, "volatility_regime": "Extreme", "earnings": "Any", "signal": "Neutral", "change": 181.22}, {"ticker": "RIVN", "price": 15.665, "volatility_regime": "Extreme", "earnings": "Any", "signal": "Neutral", "change": 15.665}, {"ticker": "LCID", "price": 10.615, "volatility_regime": "Extreme", "earnings": "Any", "signal": "Neutral", "change": 10.615}, {"ticker": "PLTR", "price": 155.84, "volatility_regime": "Extreme", "earnings": "Any", "signal": "Neutral", "change": 155.84}, {"ticker": "SOFI", "price": 16.625, "volatility_regime": "Extreme", "earnings": "Any", "signal": "Neutral", "change": 16.625}, {"ticker": "MARA", "price": 8.07, "volatility_regime": "Extreme", "earnings": "Any", "signal": "Neutral", "change": 8.07}, {"ticker": "RIOT", "price": 14.545, "volatility_regime": "Extreme", "earnings": "Any", "signal": "Neutral", "change": 14.545}, {"ticker": "DKNG", "price": 21.56, "volatility_regime": "Extreme", "earnings": "Any", "signal": "Neutral", "change": 21.56}, {"ticker": "RBLX", "price": 55.61, "volatility_regime": "Extreme", "earnings": "Any", "signal": "Neutral", "change": 55.61}, {"ticker": "HOOD", "price": 72.59, "volatility_regime": "Extreme", "earnings": "Any", "signal": "Neutral", "change": 72.59}, {"ticker": "PATH", "price": 10.875, "volatility_regime": "Extreme", "earnings": "Any", "signal": "Neutral", "change": 10.875}, {"ticker": "CRWD", "price": 386.11, "volatility_regime": "Extreme", "earnings": "Any", "signal": "Neutral", "change": 386.11}, {"ticker": "SNOW", "price": 159.18, "volatility_regime": "Extreme", "earnings": "Any", "signal": "Neutral", "change": 159.18}, {"ticker": "NET", "price": 216.85, "volatility_regime": "Extreme", "earnings": "Any", "signal": "Neutral", "change": 216.85}, {"ticker": "DDOG", "price": 122.7, "volatility_regime": "Extreme", "earnings": "Any", "signal": "Neutral", "change": 122.7}, {"ticker": "ZS", "price": 139.94, "volatility_regime": "Extreme", "earnings": "Any", "signal": "Neutral", "change": 139.94}, {"ticker": "MDB", "price": 243.69, "volatility_regime": "Extreme", "earnings": "Any", "signal": "Neutral", "change": 243.69}, {"ticker": "PANW", "price": 154.03, "volatility_regime": "High", "earnings": "Any", "signal": "Neutral", "change": 154.03}, {"ticker": "ABNB", "price": 131.82, "volatility_regime": "Extreme", "earnings": "Any", "signal": "Neutral", "change": 131.82}, {"ticker": "DASH", "price": 152.35, "volatility_regime": "Extreme", "earnings": "Any", "signal": "Neutral", "change": 152.35}, {"ticker": "PINS", "price": 18.735, "volatility_regime": "Extreme", "earnings": "Any", "signal": "Neutral", "change": 18.735}, {"ticker": "CVX", "price": 205.8, "volatility_regime": "Normal", "earnings": "Any", "signal": "Strong Buy", "change": 205.8}, {"ticker": "BA", "price": 199.96, "volatility_regime": "Extreme", "earnings": "This Month", "signal": "Sell", "change": 199.96}, {"ticker": "BA", "price": 199.98, "volatility_regime": "Extreme", "earnings": "This Month", "signal": "Sell", "change": 199.98}, {"ticker": "LMT", "price": 625.2, "volatility_regime": "High", "earnings": "This Month", "signal": "Neutral", "change": 625.2}, {"ticker": "LMT", "price": 625.2, "volatility_regime": "High", "earnings": "This Month", "signal": "Neutral", "change": 625.2}, {"ticker": "NOC", "price": 688.59, "volatility_regime": "High", "earnings": "This Month", "signal": "Neutral", "change": 688.59}, {"ticker": "RTX", "price": 196.15, "volatility_regime": "High", "earnings": "This Month", "signal": "Buy", "change": 196.15}, {"ticker": "GD", "price": 353.79, "volatility_regime": "High", "earnings": "This Month", "signal": "Buy", "change": 353.79}, {"ticker": "TDG", "price": 1159.0, "volatility_regime": "High", "earnings": "Any", "signal": "Neutral", "change": 1159.0}, {"ticker": "BMY", "price": 58.925, "volatility_regime": "High", "earnings": "Any", "signal": "Neutral", "change": 58.925}, {"ticker": "AMGN", "price": 354.27, "volatility_regime": "High", "earnings": "Any", "signal": "Neutral", "change": 354.27}, {"ticker": "GILD", "price": 138.55, "volatility_regime": "High", "earnings": "This Month", "signal": "Neutral", "change": 138.55}, {"ticker": "ISRG", "price": 470.23, "volatility_regime": "High", "earnings": "This Month", "signal": "Neutral", "change": 470.23}, {"ticker": "SYK", "price": 327.12, "volatility_regime": "High", "earnings": "Any", "signal": "Sell", "change": 327.12}, {"ticker": "VRTX", "price": 457.46, "volatility_regime": "Extreme", "earnings": "Any", "signal": "Neutral", "change": 457.46}, {"ticker": "REGN", "price": 749.25, "volatility_regime": "High", "earnings": "Any", "signal": "Neutral", "change": 749.25}, {"ticker": "ZTS", "price": 116.595, "volatility_regime": "High", "earnings": "Any", "signal": "Neutral", "change": 116.595}, {"ticker": "BSX", "price": 69.435, "volatility_regime": "High", "earnings": "This Month", "signal": "Neutral", "change": 69.435}, {"ticker": "MDT", "price": 87.86, "volatility_regime": "Normal", "earnings": "Any", "signal": "Neutral", "change": 87.86}, {"ticker": "CVS", "price": 71.99, "volatility_regime": "High", "earnings": "Any", "signal": "Neutral", "change": 71.99}, {"ticker": "CI", "price": 270.355, "volatility_regime": "High", "earnings": "Any", "signal": "Neutral", "change": 270.355}, {"ticker": "C", "price": 114.8, "volatility_regime": "High", "earnings": "This Month", "signal": "Buy", "change": 114.8}, {"ticker": "WFC", "price": 80.465, "volatility_regime": "High", "earnings": "This Month", "signal": "Neutral", "change": 80.465}, {"ticker": "USB", "price": 51.935, "volatility_regime": "High", "earnings": "This Month", "signal": "Neutral", "change": 51.935}, {"ticker": "BLK", "price": 982.05, "volatility_regime": "Extreme", "earnings": "This Month", "signal": "Neutral", "change": 982.05}, {"ticker": "SPGI", "price": 408.93, "volatility_regime": "High", "earnings": "Any", "signal": "Neutral", "change": 408.93}, {"ticker": "PLD", "price": 131.04, "volatility_regime": "High", "earnings": "This Month", "signal": "Neutral", "change": 131.04}, {"ticker": "CB", "price": 324.385, "volatility_regime": "Normal", "earnings": "Any", "signal": "Buy", "change": 324.385}, {"ticker": "ADP", "price": 201.79, "volatility_regime": "High", "earnings": "Any", "signal": "Neutral", "change": 201.79}, {"ticker": "NOW", "price": 102.87, "volatility_regime": "Extreme", "earnings": "This Month", "signal": "Neutral", "change": 102.87}, {"ticker": "INTU", "price": 424.98, "volatility_regime": "Extreme", "earnings": "Any", "signal": "Neutral", "change": 424.98}, {"ticker": "MDLZ", "price": 56.94, "volatility_regime": "High", "earnings": "Any", "signal": "Neutral", "change": 56.94}, {"ticker": "CMCSA", "price": 28.72, "volatility_regime": "High", "earnings": "This Month", "signal": "Sell", "change": 28.72}, {"ticker": "ELV", "price": 294.61, "volatility_regime": "Extreme", "earnings": "This Month", "signal": "Neutral", "change": 294.61}, {"ticker": "TJX", "price": 159.435, "volatility_regime": "Normal", "earnings": "Any", "signal": "Buy", "change": 159.435}, {"ticker": "PGR", "price": 203.0, "volatility_regime": "High", "earnings": "This Month", "signal": "Neutral", "change": 203.0}, {"ticker": "COP", "price": 129.28, "volatility_regime": "High", "earnings": "Any", "signal": "Strong Buy", "change": 129.28}, {"ticker": "EOG", "price": 142.98, "volatility_regime": "High", "earnings": "Any", "signal": "Strong Buy", "change": 142.98}, {"ticker": "OXY", "price": 61.8, "volatility_regime": "High", "earnings": "Any", "signal": "Strong Buy", "change": 61.8}, {"ticker": "SLB", "price": 52.005, "volatility_regime": "Extreme", "earnings": "Any", "signal": "Buy", "change": 52.005}, {"ticker": "MPC", "price": 241.46, "volatility_regime": "Extreme", "earnings": "Any", "signal": "Neutral", "change": 241.46}, {"ticker": "PSX", "price": 181.47, "volatility_regime": "High", "earnings": "Any", "signal": "Neutral", "change": 181.47}, {"ticker": "VLO", "price": 236.005, "volatility_regime": "Extreme", "earnings": "Any", "signal": "Neutral", "change": 236.005}, {"ticker": "KMI", "price": 34.0, "volatility_regime": "Normal", "earnings": "This Month", "signal": "Neutral", "change": 34.0}, {"ticker": "WMB", "price": 73.79, "volatility_regime": "Normal", "earnings": "Any", "signal": "Neutral", "change": 73.79}, {"ticker": "HAL", "price": 38.635, "volatility_regime": "High", "earnings": "This Month", "signal": "Strong Buy", "change": 38.635}, {"ticker": "NEE", "price": 91.39, "volatility_regime": "High", "earnings": "This Month", "signal": "Buy", "change": 91.39}, {"ticker": "DUK", "price": 128.15, "volatility_regime": "Normal", "earnings": "Any", "signal": "Neutral", "change": 128.15}, {"ticker": "SO", "price": 94.47, "volatility_regime": "Normal", "earnings": "Any", "signal": "Buy", "change": 94.47}, {"ticker": "D", "price": 60.85, "volatility_regime": "Normal", "earnings": "Any", "signal": "Neutral", "change": 60.85}, {"ticker": "AEP", "price": 128.195, "volatility_regime": "Normal", "earnings": "Any", "signal": "Neutral", "change": 128.195}, {"ticker": "SRE", "price": 95.435, "volatility_regime": "High", "earnings": "Any", "signal": "Buy", "change": 95.435}, {"ticker": "EXC", "price": 47.75, "volatility_regime": "High", "earnings": "Any", "signal": "Neutral", "change": 47.75}, {"ticker": "XEL", "price": 77.96, "volatility_regime": "Normal", "earnings": "This Month", "signal": "Neutral", "change": 77.96}, {"ticker": "ED", "price": 110.235, "volatility_regime": "Normal", "earnings": "Any", "signal": "Neutral", "change": 110.235}, {"ticker": "AMT", "price": 167.2, "volatility_regime": "High", "earnings": "Any", "signal": "Sell", "change": 167.2}, {"ticker": "PLD", "price": 131.01, "volatility_regime": "High", "earnings": "This Month", "signal": "Neutral", "change": 131.01}, {"ticker": "CCI", "price": 77.125, "volatility_regime": "High", "earnings": "This Month", "signal": "Sell", "change": 77.125}, {"ticker": "EQIX", "price": 969.07, "volatility_regime": "Normal", "earnings": "Any", "signal": "Neutral", "change": 969.07}, {"ticker": "PSA", "price": 267.965, "volatility_regime": "High", "earnings": "Any", "signal": "Neutral", "change": 267.965}, {"ticker": "SPG", "price": 182.1, "volatility_regime": "High", "earnings": "Any", "signal": "Sell", "change": 182.1}, {"ticker": "DLR", "price": 176.75, "volatility_regime": "High", "earnings": "This Month", "signal": "Neutral", "change": 176.75}, {"ticker": "WELL", "price": 196.53, "volatility_regime": "High", "earnings": "Any", "signal": "Sell", "change": 196.53}, {"ticker": "O", "price": 60.03, "volatility_regime": "Normal", "earnings": "Any", "signal": "Sell", "change": 60.03}]}
backend/strategy_cache.json ADDED
@@ -0,0 +1 @@
 
 
1
+ {"engine": "Strategy", "regime": "RISK-ON", "spy_price": 750.46, "sma200": 677.46, "vol_scalar": 1.0, "realized_vol": 14.7, "target_vol": 18.0, "picks": [{"ticker": "MRNA", "price": 47.61, "change_pct": 1.23, "momentum_175d": 103.94, "sector_z_score": 3.65}, {"ticker": "WDC", "price": 530.6, "change_pct": 1.13, "momentum_175d": 291.88, "sector_z_score": 3.41}, {"ticker": "ALB", "price": 177.47, "change_pct": 1.59, "momentum_175d": 147.57, "sector_z_score": 3.12}, {"ticker": "TER", "price": 375.83, "change_pct": -3.42, "momentum_175d": 252.83, "sector_z_score": 3.09}, {"ticker": "CAT", "price": 909.93, "change_pct": 0.15, "momentum_175d": 91.47, "sector_z_score": 2.86}, {"ticker": "INTC", "price": 121.77, "change_pct": -1.42, "momentum_175d": 243.12, "sector_z_score": 2.78}, {"ticker": "ROST", "price": 233.47, "change_pct": -0.52, "momentum_175d": 54.48, "sector_z_score": 2.72}, {"ticker": "JBHT", "price": 270.87, "change_pct": 1.21, "momentum_175d": 84.32, "sector_z_score": 2.69}, {"ticker": "VTRS", "price": 16.29, "change_pct": 0.18, "momentum_175d": 57.4, "sector_z_score": 2.57}, {"ticker": "MU", "price": 928.41, "change_pct": 3.63, "momentum_175d": 232.97, "sector_z_score": 2.49}, {"ticker": "STT", "price": 157.92, "change_pct": -0.38, "momentum_175d": 40.21, "sector_z_score": 2.43}, {"ticker": "LUMN", "price": 10.87, "change_pct": 6.88, "momentum_175d": 52.82, "sector_z_score": 2.41}, {"ticker": "TGT", "price": 128.33, "change_pct": 2.31, "momentum_175d": 49.83, "sector_z_score": 2.3}, {"ticker": "STX", "price": 870.66, "change_pct": 2.94, "momentum_175d": 184.37, "sector_z_score": 2.22}, {"ticker": "GEV", "price": 1031.89, "change_pct": -3.6, "momentum_175d": 78.5, "sector_z_score": 2.21}], "all_universe": [{"ticker": "MRNA", "price": 47.61, "change_pct": 1.23, "momentum_175d": 103.94, "sector_z_score": 3.65}, {"ticker": "WDC", "price": 530.6, "change_pct": 1.13, "momentum_175d": 291.88, "sector_z_score": 3.41}, {"ticker": "ALB", "price": 177.47, "change_pct": 1.59, "momentum_175d": 147.57, "sector_z_score": 3.12}, {"ticker": "TER", "price": 375.83, "change_pct": -3.42, "momentum_175d": 252.83, "sector_z_score": 3.09}, {"ticker": "CAT", "price": 909.93, "change_pct": 0.15, "momentum_175d": 91.47, "sector_z_score": 2.86}, {"ticker": "INTC", "price": 121.77, "change_pct": -1.42, "momentum_175d": 243.12, "sector_z_score": 2.78}, {"ticker": "ROST", "price": 233.47, "change_pct": -0.52, "momentum_175d": 54.48, "sector_z_score": 2.72}, {"ticker": "JBHT", "price": 270.87, "change_pct": 1.21, "momentum_175d": 84.32, "sector_z_score": 2.69}, {"ticker": "VTRS", "price": 16.29, "change_pct": 0.18, "momentum_175d": 57.4, "sector_z_score": 2.57}, {"ticker": "MU", "price": 928.41, "change_pct": 3.63, "momentum_175d": 232.97, "sector_z_score": 2.49}, {"ticker": "STT", "price": 157.92, "change_pct": -0.38, "momentum_175d": 40.21, "sector_z_score": 2.43}, {"ticker": "LUMN", "price": 10.87, "change_pct": 6.88, "momentum_175d": 52.82, "sector_z_score": 2.41}, {"ticker": "TGT", "price": 128.33, "change_pct": 2.31, "momentum_175d": 49.83, "sector_z_score": 2.3}, {"ticker": "STX", "price": 870.66, "change_pct": 2.94, "momentum_175d": 184.37, "sector_z_score": 2.22}, {"ticker": "GEV", "price": 1031.89, "change_pct": -3.6, "momentum_175d": 78.5, "sector_z_score": 2.21}, {"ticker": "TPL", "price": 406.09, "change_pct": 1.5, "momentum_175d": 42.35, "sector_z_score": 2.21}, {"ticker": "HAL", "price": 39.6, "change_pct": -3.6, "momentum_175d": 86.24, "sector_z_score": 2.13}, {"ticker": "FDX", "price": 411.78, "change_pct": 2.94, "momentum_175d": 73.45, "sector_z_score": 2.12}, {"ticker": "EQIX", "price": 1070.22, "change_pct": -0.69, "momentum_175d": 39.25, "sector_z_score": 2.04}, {"ticker": "PWR", "price": 733.62, "change_pct": -1.15, "momentum_175d": 65.35, "sector_z_score": 2.04}, {"ticker": "APA", "price": 36.62, "change_pct": -2.35, "momentum_175d": 72.38, "sector_z_score": 2.0}, {"ticker": "NEE", "price": 87.65, "change_pct": 0.0, "momentum_175d": 34.44, "sector_z_score": 1.93}, {"ticker": "WMT", "price": 118.54, "change_pct": -0.03, "momentum_175d": 23.55, "sector_z_score": 1.92}, {"ticker": "JCI", "price": 138.2, "change_pct": -1.44, "momentum_175d": 33.4, "sector_z_score": 1.86}, {"ticker": "CAH", "price": 199.84, "change_pct": -0.26, "momentum_175d": 35.76, "sector_z_score": 1.78}, {"ticker": "MAR", "price": 385.86, "change_pct": 3.22, "momentum_175d": 36.61, "sector_z_score": 1.77}, {"ticker": "ODFL", "price": 219.45, "change_pct": 1.31, "momentum_175d": 52.34, "sector_z_score": 1.7}, {"ticker": "BK", "price": 139.74, "change_pct": -0.93, "momentum_175d": 28.05, "sector_z_score": 1.67}, {"ticker": "NTRS", "price": 167.94, "change_pct": -1.18, "momentum_175d": 30.33, "sector_z_score": 1.67}, {"ticker": "CMI", "price": 667.85, "change_pct": -0.13, "momentum_175d": 60.76, "sector_z_score": 1.66}, {"ticker": "C", "price": 125.39, "change_pct": -1.16, "momentum_175d": 30.86, "sector_z_score": 1.6}, {"ticker": "GOOGL", "price": 388.83, "change_pct": -0.01, "momentum_175d": 39.43, "sector_z_score": 1.6}, {"ticker": "GOOG", "price": 384.83, "change_pct": -0.0, "momentum_175d": 38.62, "sector_z_score": 1.52}, {"ticker": "ETR", "price": 111.51, "change_pct": -0.41, "momentum_175d": 27.45, "sector_z_score": 1.51}, {"ticker": "MNST", "price": 89.24, "change_pct": 2.08, "momentum_175d": 19.44, "sector_z_score": 1.5}, {"ticker": "CFG", "price": 63.08, "change_pct": -0.9, "momentum_175d": 28.21, "sector_z_score": 1.5}, {"ticker": "ADM", "price": 79.55, "change_pct": 1.97, "momentum_175d": 18.41, "sector_z_score": 1.46}, {"ticker": "TPR", "price": 138.83, "change_pct": -1.27, "momentum_175d": 33.77, "sector_z_score": 1.46}, {"ticker": "NUE", "price": 246.47, "change_pct": 2.57, "momentum_175d": 53.53, "sector_z_score": 1.45}, {"ticker": "OGN", "price": 13.38, "change_pct": -0.22, "momentum_175d": 27.19, "sector_z_score": 1.44}, {"ticker": "CBOE", "price": 347.69, "change_pct": -1.92, "momentum_175d": 27.8, "sector_z_score": 1.43}, {"ticker": "TSN", "price": 66.02, "change_pct": 1.37, "momentum_175d": 19.8, "sector_z_score": 1.39}, {"ticker": "WELL", "price": 214.18, "change_pct": -1.81, "momentum_175d": 25.25, "sector_z_score": 1.39}, {"ticker": "GM", "price": 84.12, "change_pct": 5.43, "momentum_175d": 32.83, "sector_z_score": 1.38}, {"ticker": "CNC", "price": 58.82, "change_pct": 2.81, "momentum_175d": 33.27, "sector_z_score": 1.35}, {"ticker": "BWA", "price": 70.83, "change_pct": 3.77, "momentum_175d": 27.14, "sector_z_score": 1.35}, {"ticker": "MS", "price": 201.61, "change_pct": -0.07, "momentum_175d": 22.82, "sector_z_score": 1.34}, {"ticker": "AMAT", "price": 448.25, "change_pct": -1.46, "momentum_175d": 137.62, "sector_z_score": 1.33}, {"ticker": "EIX", "price": 71.66, "change_pct": 0.59, "momentum_175d": 28.0, "sector_z_score": 1.31}, {"ticker": "VFC", "price": 17.41, "change_pct": 4.19, "momentum_175d": 32.96, "sector_z_score": 1.3}, {"ticker": "AEP", "price": 129.57, "change_pct": -1.02, "momentum_175d": 25.77, "sector_z_score": 1.28}, {"ticker": "MRK", "price": 120.24, "change_pct": 0.43, "momentum_175d": 38.23, "sector_z_score": 1.26}, {"ticker": "RIVN", "price": 14.7, "change_pct": 2.15, "momentum_175d": 22.94, "sector_z_score": 1.22}, {"ticker": "IHG", "price": 159.17, "change_pct": 3.15, "momentum_175d": 21.71, "sector_z_score": 1.22}, {"ticker": "WST", "price": 312.71, "change_pct": -1.09, "momentum_175d": 17.55, "sector_z_score": 1.21}, {"ticker": "VTR", "price": 88.05, "change_pct": -0.36, "momentum_175d": 23.64, "sector_z_score": 1.21}, {"ticker": "ROKU", "price": 131.91, "change_pct": 3.37, "momentum_175d": 21.78, "sector_z_score": 1.21}, {"ticker": "PLD", "price": 146.52, "change_pct": -0.29, "momentum_175d": 25.59, "sector_z_score": 1.19}, {"ticker": "KO", "price": 81.62, "change_pct": 1.44, "momentum_175d": 15.53, "sector_z_score": 1.17}, {"ticker": "GS", "price": 996.47, "change_pct": 0.2, "momentum_175d": 20.42, "sector_z_score": 1.16}, {"ticker": "SLB", "price": 56.5, "change_pct": -2.55, "momentum_175d": 61.04, "sector_z_score": 1.16}, {"ticker": "HST", "price": 23.48, "change_pct": 1.6, "momentum_175d": 23.24, "sector_z_score": 1.15}, {"ticker": "GNRC", "price": 280.53, "change_pct": 2.08, "momentum_175d": 18.63, "sector_z_score": 1.14}, {"ticker": "AMD", "price": 495.54, "change_pct": -1.66, "momentum_175d": 107.64, "sector_z_score": 1.11}, {"ticker": "GLW", "price": 190.89, "change_pct": -2.69, "momentum_175d": 116.59, "sector_z_score": 1.11}, {"ticker": "BIIB", "price": 196.97, "change_pct": 2.01, "momentum_175d": 26.06, "sector_z_score": 1.11}, {"ticker": "PFG", "price": 104.57, "change_pct": -0.13, "momentum_175d": 22.47, "sector_z_score": 1.11}, {"ticker": "DOW", "price": 34.49, "change_pct": -2.24, "momentum_175d": 61.87, "sector_z_score": 1.1}, {"ticker": "HLT", "price": 336.72, "change_pct": 3.99, "momentum_175d": 22.69, "sector_z_score": 1.09}, {"ticker": "VLO", "price": 240.34, "change_pct": -0.46, "momentum_175d": 53.09, "sector_z_score": 1.08}, {"ticker": "NI", "price": 47.47, "change_pct": -0.69, "momentum_175d": 20.56, "sector_z_score": 1.07}, {"ticker": "LRCX", "price": 318.93, "change_pct": -1.16, "momentum_175d": 118.71, "sector_z_score": 1.06}, {"ticker": "ILMN", "price": 150.17, "change_pct": 3.4, "momentum_175d": 27.35, "sector_z_score": 1.06}, {"ticker": "STZ", "price": 143.72, "change_pct": -2.66, "momentum_175d": 16.17, "sector_z_score": 1.03}, {"ticker": "MCK", "price": 756.47, "change_pct": 0.27, "momentum_175d": 18.05, "sector_z_score": 1.0}, {"ticker": "ON", "price": 124.89, "change_pct": -1.66, "momentum_175d": 103.78, "sector_z_score": 0.99}, {"ticker": "SBUX", "price": 102.1, "change_pct": 0.67, "momentum_175d": 20.02, "sector_z_score": 0.98}, {"ticker": "HAS", "price": 87.99, "change_pct": 0.0, "momentum_175d": 26.62, "sector_z_score": 0.96}, {"ticker": "L", "price": 106.98, "change_pct": -1.88, "momentum_175d": 14.84, "sector_z_score": 0.96}, {"ticker": "CRL", "price": 164.49, "change_pct": 4.95, "momentum_175d": 12.47, "sector_z_score": 0.96}, {"ticker": "BEN", "price": 31.67, "change_pct": 0.0, "momentum_175d": 18.02, "sector_z_score": 0.96}, {"ticker": "CSX", "price": 47.14, "change_pct": 1.14, "momentum_175d": 40.86, "sector_z_score": 0.95}, {"ticker": "NEM", "price": 107.23, "change_pct": -3.7, "momentum_175d": 46.99, "sector_z_score": 0.94}, {"ticker": "CHRW", "price": 176.49, "change_pct": -0.8, "momentum_175d": 42.43, "sector_z_score": 0.91}, {"ticker": "EBAY", "price": 110.36, "change_pct": -4.29, "momentum_175d": 11.75, "sector_z_score": 0.91}, {"ticker": "EA", "price": 200.68, "change_pct": -0.13, "momentum_175d": 18.08, "sector_z_score": 0.9}, {"ticker": "KEYS", "price": 347.65, "change_pct": -2.27, "momentum_175d": 98.32, "sector_z_score": 0.89}, {"ticker": "ABNB", "price": 132.1, "change_pct": -0.44, "momentum_175d": 17.19, "sector_z_score": 0.88}, {"ticker": "KEY", "price": 21.5, "change_pct": -1.24, "momentum_175d": 18.75, "sector_z_score": 0.88}, {"ticker": "JNJ", "price": 231.29, "change_pct": 0.48, "momentum_175d": 28.51, "sector_z_score": 0.86}, {"ticker": "ATO", "price": 176.24, "change_pct": -0.83, "momentum_175d": 13.22, "sector_z_score": 0.86}, {"ticker": "HSIC", "price": 75.67, "change_pct": 2.4, "momentum_175d": 12.19, "sector_z_score": 0.85}, {"ticker": "PNW", "price": 102.8, "change_pct": -0.03, "momentum_175d": 19.51, "sector_z_score": 0.85}, {"ticker": "REGN", "price": 627.74, "change_pct": -1.08, "momentum_175d": 30.33, "sector_z_score": 0.85}, {"ticker": "BMY", "price": 57.52, "change_pct": -0.88, "momentum_175d": 29.19, "sector_z_score": 0.85}, {"ticker": "KLAC", "price": 1957.19, "change_pct": -2.69, "momentum_175d": 92.7, "sector_z_score": 0.85}, {"ticker": "WAB", "price": 260.81, "change_pct": -0.25, "momentum_175d": 42.22, "sector_z_score": 0.82}, {"ticker": "CB", "price": 320.63, "change_pct": -1.48, "momentum_175d": 18.67, "sector_z_score": 0.81}, {"ticker": "TFC", "price": 48.46, "change_pct": -0.45, "momentum_175d": 16.9, "sector_z_score": 0.81}, {"ticker": "RL", "price": 376.23, "change_pct": -1.45, "momentum_175d": 18.27, "sector_z_score": 0.8}, {"ticker": "USB", "price": 54.69, "change_pct": -0.96, "momentum_175d": 17.08, "sector_z_score": 0.79}, {"ticker": "MGM", "price": 41.95, "change_pct": 9.1, "momentum_175d": 13.78, "sector_z_score": 0.79}, {"ticker": "AMGN", "price": 336.06, "change_pct": 0.04, "momentum_175d": 25.72, "sector_z_score": 0.79}, {"ticker": "TRV", "price": 299.94, "change_pct": -1.61, "momentum_175d": 10.77, "sector_z_score": 0.78}, {"ticker": "AMZN", "price": 271.85, "change_pct": 2.47, "momentum_175d": 12.83, "sector_z_score": 0.78}, {"ticker": "IVZ", "price": 27.61, "change_pct": -0.29, "momentum_175d": 15.99, "sector_z_score": 0.76}, {"ticker": "LYB", "price": 67.67, "change_pct": -1.94, "momentum_175d": 40.64, "sector_z_score": 0.75}, {"ticker": "CVS", "price": 92.07, "change_pct": 1.48, "momentum_175d": 9.38, "sector_z_score": 0.74}, {"ticker": "MPWR", "price": 1620.17, "change_pct": -2.57, "momentum_175d": 87.86, "sector_z_score": 0.72}, {"ticker": "TRGP", "price": 263.65, "change_pct": -2.31, "momentum_175d": 49.45, "sector_z_score": 0.72}, {"ticker": "REG", "price": 78.95, "change_pct": -0.05, "momentum_175d": 15.03, "sector_z_score": 0.71}, {"ticker": "UPS", "price": 104.47, "change_pct": 2.45, "momentum_175d": 32.89, "sector_z_score": 0.7}, {"ticker": "CE", "price": 52.93, "change_pct": 1.07, "momentum_175d": 45.61, "sector_z_score": 0.7}, {"ticker": "ETSY", "price": 63.81, "change_pct": -0.81, "momentum_175d": 12.65, "sector_z_score": 0.68}, {"ticker": "FRT", "price": 121.06, "change_pct": 0.75, "momentum_175d": 13.69, "sector_z_score": 0.68}, {"ticker": "TXT", "price": 92.32, "change_pct": -0.39, "momentum_175d": 7.65, "sector_z_score": 0.66}, {"ticker": "FANG", "price": 192.84, "change_pct": -1.17, "momentum_175d": 47.89, "sector_z_score": 0.65}, {"ticker": "TECH", "price": 48.09, "change_pct": 0.25, "momentum_175d": 7.05, "sector_z_score": 0.65}, {"ticker": "TJX", "price": 157.01, "change_pct": -1.23, "momentum_175d": 12.19, "sector_z_score": 0.65}, {"ticker": "DG", "price": 104.33, "change_pct": 0.69, "momentum_175d": 13.97, "sector_z_score": 0.64}, {"ticker": "IRM", "price": 127.01, "change_pct": -1.08, "momentum_175d": 15.07, "sector_z_score": 0.64}, {"ticker": "PEP", "price": 147.74, "change_pct": 1.41, "momentum_175d": 11.63, "sector_z_score": 0.64}, {"ticker": "FITB", "price": 49.89, "change_pct": 0.14, "momentum_175d": 13.91, "sector_z_score": 0.63}, {"ticker": "VZ", "price": 48.24, "change_pct": -0.52, "momentum_175d": 12.77, "sector_z_score": 0.62}, {"ticker": "FOXA", "price": 65.48, "change_pct": 0.8, "momentum_175d": 9.19, "sector_z_score": 0.61}, {"ticker": "PVH", "price": 96.2, "change_pct": 1.89, "momentum_175d": 11.27, "sector_z_score": 0.61}, {"ticker": "DVN", "price": 44.27, "change_pct": -1.93, "momentum_175d": 44.4, "sector_z_score": 0.61}, {"ticker": "EVRG", "price": 84.17, "change_pct": 0.07, "momentum_175d": 15.0, "sector_z_score": 0.61}, {"ticker": "NTRA", "price": 203.11, "change_pct": 1.1, "momentum_175d": 18.72, "sector_z_score": 0.6}, {"ticker": "MTB", "price": 215.1, "change_pct": -0.06, "momentum_175d": 12.52, "sector_z_score": 0.6}, {"ticker": "ZION", "price": 62.32, "change_pct": -0.67, "momentum_175d": 11.45, "sector_z_score": 0.59}, {"ticker": "SPG", "price": 206.77, "change_pct": -0.08, "momentum_175d": 12.17, "sector_z_score": 0.59}, {"ticker": "DELL", "price": 305.32, "change_pct": 0.08, "momentum_175d": 72.21, "sector_z_score": 0.58}, {"ticker": "KDP", "price": 30.1, "change_pct": 1.86, "momentum_175d": 8.01, "sector_z_score": 0.57}, {"ticker": "DLR", "price": 191.71, "change_pct": -1.01, "momentum_175d": 13.84, "sector_z_score": 0.57}, {"ticker": "GRMN", "price": 237.97, "change_pct": -0.23, "momentum_175d": 10.0, "sector_z_score": 0.57}, {"ticker": "ELV", "price": 391.47, "change_pct": 0.93, "momentum_175d": 17.35, "sector_z_score": 0.56}, {"ticker": "YUM", "price": 152.02, "change_pct": -0.81, "momentum_175d": 5.19, "sector_z_score": 0.56}, {"ticker": "AIZ", "price": 252.06, "change_pct": -1.54, "momentum_175d": 12.51, "sector_z_score": 0.56}, {"ticker": "DD", "price": 47.66, "change_pct": -3.64, "momentum_175d": 46.04, "sector_z_score": 0.55}, {"ticker": "FOX", "price": 58.62, "change_pct": 0.93, "momentum_175d": 8.19, "sector_z_score": 0.55}, {"ticker": "CF", "price": 116.34, "change_pct": -2.37, "momentum_175d": 46.49, "sector_z_score": 0.53}, {"ticker": "PNC", "price": 220.8, "change_pct": -0.0, "momentum_175d": 12.02, "sector_z_score": 0.53}, {"ticker": "CZR", "price": 28.78, "change_pct": 1.41, "momentum_175d": 8.22, "sector_z_score": 0.52}, {"ticker": "RVTY", "price": 96.79, "change_pct": -0.04, "momentum_175d": 3.55, "sector_z_score": 0.51}, {"ticker": "F", "price": 15.88, "change_pct": 3.66, "momentum_175d": 9.34, "sector_z_score": 0.5}, {"ticker": "CME", "price": 279.1, "change_pct": -1.22, "momentum_175d": 12.05, "sector_z_score": 0.49}, {"ticker": "FE", "price": 46.78, "change_pct": 0.3, "momentum_175d": 15.63, "sector_z_score": 0.49}, {"ticker": "WY", "price": 24.51, "change_pct": 2.9, "momentum_175d": 2.65, "sector_z_score": 0.47}, {"ticker": "DOV", "price": 213.37, "change_pct": -0.94, "momentum_175d": 32.25, "sector_z_score": 0.47}, {"ticker": "LNT", "price": 73.75, "change_pct": 0.1, "momentum_175d": 13.64, "sector_z_score": 0.46}, {"ticker": "UNP", "price": 279.39, "change_pct": 3.06, "momentum_175d": 25.73, "sector_z_score": 0.46}, {"ticker": "EXPE", "price": 226.56, "change_pct": 1.61, "momentum_175d": 7.85, "sector_z_score": 0.46}, {"ticker": "FCX", "price": 63.63, "change_pct": -1.13, "momentum_175d": 33.81, "sector_z_score": 0.46}, {"ticker": "CPAY", "price": 352.6, "change_pct": 0.86, "momentum_175d": 2.59, "sector_z_score": 0.46}, {"ticker": "IEX", "price": 211.75, "change_pct": 0.29, "momentum_175d": 29.71, "sector_z_score": 0.45}, {"ticker": "ALL", "price": 208.85, "change_pct": -1.87, "momentum_175d": 9.86, "sector_z_score": 0.45}, {"ticker": "MAS", "price": 69.82, "change_pct": 1.34, "momentum_175d": 2.0, "sector_z_score": 0.44}, {"ticker": "NDSN", "price": 288.74, "change_pct": 0.32, "momentum_175d": 26.28, "sector_z_score": 0.43}, {"ticker": "INCY", "price": 97.34, "change_pct": 0.25, "momentum_175d": 15.13, "sector_z_score": 0.42}, {"ticker": "EG", "price": 343.33, "change_pct": -2.52, "momentum_175d": 1.71, "sector_z_score": 0.42}, {"ticker": "DVA", "price": 194.92, "change_pct": -0.33, "momentum_175d": 14.59, "sector_z_score": 0.42}, {"ticker": "PFE", "price": 26.21, "change_pct": 1.39, "momentum_175d": 15.64, "sector_z_score": 0.4}, {"ticker": "BKR", "price": 63.2, "change_pct": -5.29, "momentum_175d": 49.01, "sector_z_score": 0.4}, {"ticker": "MO", "price": 72.11, "change_pct": -0.36, "momentum_175d": 5.31, "sector_z_score": 0.4}, {"ticker": "MTD", "price": 1093.84, "change_pct": -0.68, "momentum_175d": 1.16, "sector_z_score": 0.39}, {"ticker": "CNP", "price": 43.07, "change_pct": 0.33, "momentum_175d": 13.26, "sector_z_score": 0.39}, {"ticker": "HUBB", "price": 484.25, "change_pct": 1.3, "momentum_175d": 27.53, "sector_z_score": 0.38}, {"ticker": "GILD", "price": 133.69, "change_pct": -0.03, "momentum_175d": 14.91, "sector_z_score": 0.37}, {"ticker": "CINF", "price": 162.94, "change_pct": -2.45, "momentum_175d": 8.57, "sector_z_score": 0.37}, {"ticker": "WAT", "price": 342.18, "change_pct": 1.05, "momentum_175d": 0.25, "sector_z_score": 0.36}, {"ticker": "PCAR", "price": 112.26, "change_pct": 0.22, "momentum_175d": 26.84, "sector_z_score": 0.36}, {"ticker": "KIM", "price": 24.42, "change_pct": -0.49, "momentum_175d": 9.49, "sector_z_score": 0.36}, {"ticker": "PH", "price": 856.68, "change_pct": -1.31, "momentum_175d": 28.73, "sector_z_score": 0.36}, {"ticker": "CL", "price": 91.29, "change_pct": 1.6, "momentum_175d": 4.45, "sector_z_score": 0.36}, {"ticker": "CHD", "price": 98.01, "change_pct": 1.39, "momentum_175d": 5.24, "sector_z_score": 0.34}, {"ticker": "LLY", "price": 1082.92, "change_pct": 1.71, "momentum_175d": 16.41, "sector_z_score": 0.34}, {"ticker": "COST", "price": 1003.69, "change_pct": 0.08, "momentum_175d": 4.24, "sector_z_score": 0.34}, {"ticker": "FTV", "price": 59.91, "change_pct": -0.68, "momentum_175d": 27.98, "sector_z_score": 0.34}, {"ticker": "AES", "price": 14.68, "change_pct": 0.07, "momentum_175d": 13.95, "sector_z_score": 0.33}, {"ticker": "HWM", "price": 258.02, "change_pct": -1.48, "momentum_175d": 27.86, "sector_z_score": 0.33}, {"ticker": "GL", "price": 154.39, "change_pct": -1.51, "momentum_175d": 7.44, "sector_z_score": 0.33}, {"ticker": "RF", "price": 28.09, "change_pct": -0.57, "momentum_175d": 7.18, "sector_z_score": 0.33}, {"ticker": "PSA", "price": 306.79, "change_pct": 0.76, "momentum_175d": 8.88, "sector_z_score": 0.33}, {"ticker": "ADI", "price": 416.88, "change_pct": -0.73, "momentum_175d": 61.37, "sector_z_score": 0.32}, {"ticker": "AFL", "price": 114.85, "change_pct": -1.69, "momentum_175d": 6.9, "sector_z_score": 0.31}, {"ticker": "EXPD", "price": 161.51, "change_pct": 0.5, "momentum_175d": 19.89, "sector_z_score": 0.31}, {"ticker": "COO", "price": 60.8, "change_pct": -1.98, "momentum_175d": -1.74, "sector_z_score": 0.31}, {"ticker": "TXN", "price": 317.45, "change_pct": -2.29, "momentum_175d": 53.58, "sector_z_score": 0.29}, {"ticker": "SWK", "price": 79.46, "change_pct": 0.67, "momentum_175d": 3.99, "sector_z_score": 0.28}, {"ticker": "LVS", "price": 51.06, "change_pct": 2.63, "momentum_175d": 3.55, "sector_z_score": 0.28}, {"ticker": "AEE", "price": 111.12, "change_pct": 0.08, "momentum_175d": 12.95, "sector_z_score": 0.28}, {"ticker": "O", "price": 62.19, "change_pct": -0.42, "momentum_175d": 7.54, "sector_z_score": 0.27}, {"ticker": "OMC", "price": 74.85, "change_pct": 0.59, "momentum_175d": 0.88, "sector_z_score": 0.26}, {"ticker": "MTCH", "price": 36.1, "change_pct": 0.42, "momentum_175d": 0.28, "sector_z_score": 0.25}, {"ticker": "TT", "price": 457.68, "change_pct": -0.81, "momentum_175d": 21.03, "sector_z_score": 0.24}, {"ticker": "SRE", "price": 91.26, "change_pct": -0.32, "momentum_175d": 13.28, "sector_z_score": 0.23}, {"ticker": "ULTA", "price": 507.35, "change_pct": -1.68, "momentum_175d": 2.58, "sector_z_score": 0.23}, {"ticker": "BAC", "price": 51.1, "change_pct": -2.11, "momentum_175d": 5.16, "sector_z_score": 0.22}, {"ticker": "AMCR", "price": 39.67, "change_pct": 1.85, "momentum_175d": -4.44, "sector_z_score": 0.22}, {"ticker": "SBAC", "price": 200.2, "change_pct": -0.76, "momentum_175d": 9.9, "sector_z_score": 0.21}, {"ticker": "UHS", "price": 154.29, "change_pct": -1.49, "momentum_175d": -5.14, "sector_z_score": 0.2}, {"ticker": "PM", "price": 182.04, "change_pct": 0.28, "momentum_175d": 2.3, "sector_z_score": 0.2}, {"ticker": "AME", "price": 226.38, "change_pct": -1.33, "momentum_175d": 23.29, "sector_z_score": 0.19}, {"ticker": "DGX", "price": 193.99, "change_pct": 0.58, "momentum_175d": 10.33, "sector_z_score": 0.19}, {"ticker": "ED", "price": 108.01, "change_pct": 0.29, "momentum_175d": 14.02, "sector_z_score": 0.18}, {"ticker": "ARM", "price": 302.71, "change_pct": -5.76, "momentum_175d": 40.31, "sector_z_score": 0.17}, {"ticker": "LKQ", "price": 27.44, "change_pct": 0.7, "momentum_175d": 0.78, "sector_z_score": 0.16}, {"ticker": "SOLV", "price": 75.89, "change_pct": -0.5, "momentum_175d": -4.69, "sector_z_score": 0.16}, {"ticker": "DLTR", "price": 95.87, "change_pct": 2.32, "momentum_175d": 0.76, "sector_z_score": 0.16}, {"ticker": "EW", "price": 86.51, "change_pct": -1.18, "momentum_175d": 9.74, "sector_z_score": 0.15}, {"ticker": "CSCO", "price": 119.67, "change_pct": 1.13, "momentum_175d": 33.92, "sector_z_score": 0.15}, {"ticker": "HSY", "price": 197.82, "change_pct": 3.51, "momentum_175d": 1.94, "sector_z_score": 0.14}, {"ticker": "DE", "price": 529.39, "change_pct": 0.05, "momentum_175d": 22.21, "sector_z_score": 0.14}, {"ticker": "MMM", "price": 155.17, "change_pct": 0.74, "momentum_175d": -6.13, "sector_z_score": 0.13}, {"ticker": "MCHP", "price": 96.85, "change_pct": -1.22, "momentum_175d": 39.53, "sector_z_score": 0.12}, {"ticker": "HCA", "price": 392.15, "change_pct": -0.07, "momentum_175d": 11.09, "sector_z_score": 0.12}, {"ticker": "HOLX", "price": 76.01, "change_pct": 0.0, "momentum_175d": 19.14, "sector_z_score": 0.11}, {"ticker": "CTRA", "price": 32.56, "change_pct": 0.0, "momentum_175d": 41.45, "sector_z_score": 0.11}, {"ticker": "LUV", "price": 43.68, "change_pct": 3.31, "momentum_175d": 22.6, "sector_z_score": 0.1}, {"ticker": "TDY", "price": 612.52, "change_pct": -2.46, "momentum_175d": 17.75, "sector_z_score": 0.08}, {"ticker": "ROK", "price": 456.08, "change_pct": -0.05, "momentum_175d": 18.3, "sector_z_score": 0.08}, {"ticker": "NU", "price": 13.03, "change_pct": 0.39, "momentum_175d": -7.98, "sector_z_score": 0.07}, {"ticker": "DAL", "price": 81.8, "change_pct": 3.04, "momentum_175d": 17.42, "sector_z_score": 0.07}, {"ticker": "MSCI", "price": 603.47, "change_pct": 2.54, "momentum_175d": 3.23, "sector_z_score": 0.06}, {"ticker": "ISRG", "price": 418.55, "change_pct": -4.14, "momentum_175d": 8.53, "sector_z_score": 0.06}, {"ticker": "COP", "price": 115.13, "change_pct": -1.24, "momentum_175d": 34.05, "sector_z_score": 0.04}, {"ticker": "VRSN", "price": 295.65, "change_pct": -3.65, "momentum_175d": -6.33, "sector_z_score": 0.04}, {"ticker": "JPM", "price": 299.28, "change_pct": -2.43, "momentum_175d": 2.35, "sector_z_score": 0.04}, {"ticker": "ZBH", "price": 82.56, "change_pct": -2.19, "momentum_175d": -7.57, "sector_z_score": 0.04}, {"ticker": "MCD", "price": 280.92, "change_pct": 0.59, "momentum_175d": -2.81, "sector_z_score": 0.04}, {"ticker": "NSC", "price": 325.68, "change_pct": 1.71, "momentum_175d": 16.12, "sector_z_score": 0.03}, {"ticker": "DTE", "price": 145.27, "change_pct": 0.45, "momentum_175d": 9.19, "sector_z_score": 0.03}, {"ticker": "VRTX", "price": 437.22, "change_pct": 0.3, "momentum_175d": 8.34, "sector_z_score": 0.02}, {"ticker": "AOS", "price": 58.01, "change_pct": -0.55, "momentum_175d": -10.78, "sector_z_score": 0.02}, {"ticker": "V", "price": 327.61, "change_pct": 0.35, "momentum_175d": -8.3, "sector_z_score": -0.0}, {"ticker": "XOM", "price": 147.9, "change_pct": -1.27, "momentum_175d": 33.94, "sector_z_score": -0.01}, {"ticker": "SYF", "price": 72.09, "change_pct": 0.26, "momentum_175d": 1.44, "sector_z_score": -0.01}, {"ticker": "CMCSA", "price": 25.22, "change_pct": 0.28, "momentum_175d": -6.92, "sector_z_score": -0.01}, {"ticker": "NWS", "price": 30.47, "change_pct": 1.74, "momentum_175d": -6.66, "sector_z_score": -0.02}, {"ticker": "AKAM", "price": 144.43, "change_pct": -2.55, "momentum_175d": 25.46, "sector_z_score": -0.03}, {"ticker": "PPL", "price": 35.84, "change_pct": -0.78, "momentum_175d": 9.39, "sector_z_score": -0.03}, {"ticker": "ETN", "price": 406.37, "change_pct": 0.8, "momentum_175d": 11.63, "sector_z_score": -0.05}, {"ticker": "DRI", "price": 205.93, "change_pct": 1.03, "momentum_175d": -4.15, "sector_z_score": -0.05}, {"ticker": "PHM", "price": 119.1, "change_pct": 1.07, "momentum_175d": -4.31, "sector_z_score": -0.05}, {"ticker": "SNA", "price": 372.13, "change_pct": 0.01, "momentum_175d": 14.74, "sector_z_score": -0.05}, {"ticker": "XEL", "price": 81.0, "change_pct": 0.27, "momentum_175d": 10.36, "sector_z_score": -0.06}, {"ticker": "KR", "price": 64.69, "change_pct": 0.22, "momentum_175d": -1.23, "sector_z_score": -0.06}, {"ticker": "GWW", "price": 1246.03, "change_pct": -0.26, "momentum_175d": 14.85, "sector_z_score": -0.06}, {"ticker": "APH", "price": 140.24, "change_pct": 0.49, "momentum_175d": 25.32, "sector_z_score": -0.06}, {"ticker": "CTVA", "price": 79.89, "change_pct": 1.01, "momentum_175d": 13.9, "sector_z_score": -0.07}, {"ticker": "LHX", "price": 309.05, "change_pct": -1.41, "momentum_175d": 14.55, "sector_z_score": -0.07}, {"ticker": "CBRE", "price": 130.15, "change_pct": 0.3, "momentum_175d": -11.68, "sector_z_score": -0.09}, {"ticker": "NVDA", "price": 212.6, "change_pct": -1.05, "momentum_175d": 21.88, "sector_z_score": -0.09}, {"ticker": "WFC", "price": 76.11, "change_pct": -1.82, "momentum_175d": -0.03, "sector_z_score": -0.09}, {"ticker": "IBM", "price": 255.2, "change_pct": 1.8, "momentum_175d": -10.0, "sector_z_score": -0.09}, {"ticker": "TROW", "price": 104.5, "change_pct": 0.65, "momentum_175d": -0.04, "sector_z_score": -0.09}, {"ticker": "DIS", "price": 104.18, "change_pct": 0.87, "momentum_175d": -10.88, "sector_z_score": -0.09}, {"ticker": "T", "price": 24.89, "change_pct": -0.52, "momentum_175d": -10.97, "sector_z_score": -0.09}, {"ticker": "SEE", "price": 42.15, "change_pct": 0.0, "momentum_175d": 26.46, "sector_z_score": -0.1}, {"ticker": "KVUE", "price": 17.69, "change_pct": 1.14, "momentum_175d": -1.62, "sector_z_score": -0.1}, {"ticker": "XYL", "price": 110.1, "change_pct": -0.93, "momentum_175d": -12.32, "sector_z_score": -0.11}, {"ticker": "EXC", "price": 46.19, "change_pct": 0.35, "momentum_175d": 9.14, "sector_z_score": -0.11}, {"ticker": "WTW", "price": 254.04, "change_pct": -0.9, "momentum_175d": -12.53, "sector_z_score": -0.12}, {"ticker": "SNPS", "price": 525.92, "change_pct": -1.62, "momentum_175d": 18.93, "sector_z_score": -0.12}, {"ticker": "UNH", "price": 384.01, "change_pct": 1.9, "momentum_175d": 3.44, "sector_z_score": -0.13}, {"ticker": "SCHW", "price": 85.61, "change_pct": -4.24, "momentum_175d": -0.84, "sector_z_score": -0.13}, {"ticker": "HPE", "price": 37.2, "change_pct": -2.26, "momentum_175d": 17.27, "sector_z_score": -0.13}, {"ticker": "PGR", "price": 196.91, "change_pct": -0.94, "momentum_175d": -12.47, "sector_z_score": -0.13}, {"ticker": "EMN", "price": 75.91, "change_pct": 2.04, "momentum_175d": 13.48, "sector_z_score": -0.14}, {"ticker": "DHI", "price": 147.81, "change_pct": 1.52, "momentum_175d": -6.86, "sector_z_score": -0.15}, {"ticker": "ESS", "price": 276.83, "change_pct": -0.15, "momentum_175d": 0.04, "sector_z_score": -0.15}, {"ticker": "ES", "price": 69.62, "change_pct": -0.11, "momentum_175d": 8.4, "sector_z_score": -0.16}, {"ticker": "AMP", "price": 443.17, "change_pct": -2.71, "momentum_175d": -1.38, "sector_z_score": -0.16}, {"ticker": "IFF", "price": 77.0, "change_pct": 1.49, "momentum_175d": 12.36, "sector_z_score": -0.16}, {"ticker": "NWSA", "price": 26.61, "change_pct": 1.56, "momentum_175d": -11.27, "sector_z_score": -0.16}, {"ticker": "ALLE", "price": 131.22, "change_pct": -0.3, "momentum_175d": -15.46, "sector_z_score": -0.17}, {"ticker": "AXP", "price": 312.54, "change_pct": 0.59, "momentum_175d": -1.8, "sector_z_score": -0.17}, {"ticker": "BIO", "price": 305.29, "change_pct": 2.83, "momentum_175d": 2.53, "sector_z_score": -0.17}, {"ticker": "BDX", "price": 147.24, "change_pct": -0.35, "momentum_175d": 4.1, "sector_z_score": -0.17}, {"ticker": "MA", "price": 495.04, "change_pct": 0.41, "momentum_175d": -12.73, "sector_z_score": -0.18}, {"ticker": "AVGO", "price": 421.86, "change_pct": -0.04, "momentum_175d": 15.52, "sector_z_score": -0.18}, {"ticker": "AAPL", "price": 310.85, "change_pct": 0.82, "momentum_175d": 13.27, "sector_z_score": -0.19}, {"ticker": "EXR", "price": 144.43, "change_pct": 0.31, "momentum_175d": -0.59, "sector_z_score": -0.19}, {"ticker": "AIG", "price": 75.27, "change_pct": -2.49, "momentum_175d": -2.12, "sector_z_score": -0.2}, {"ticker": "MET", "price": 83.2, "change_pct": -0.08, "momentum_175d": -2.07, "sector_z_score": -0.2}, {"ticker": "NDAQ", "price": 90.53, "change_pct": -0.39, "momentum_175d": -2.02, "sector_z_score": -0.2}, {"ticker": "RTX", "price": 176.59, "change_pct": -1.33, "momentum_175d": 10.28, "sector_z_score": -0.2}, {"ticker": "META", "price": 635.26, "change_pct": 3.74, "momentum_175d": -11.05, "sector_z_score": -0.21}, {"ticker": "LOW", "price": 216.84, "change_pct": 2.24, "momentum_175d": -9.58, "sector_z_score": -0.21}, {"ticker": "PG", "price": 147.49, "change_pct": 3.17, "momentum_175d": -3.31, "sector_z_score": -0.21}, {"ticker": "TSLA", "price": 440.36, "change_pct": 1.56, "momentum_175d": -7.65, "sector_z_score": -0.22}, {"ticker": "EQR", "price": 66.63, "change_pct": 0.76, "momentum_175d": -1.25, "sector_z_score": -0.22}, {"ticker": "MOH", "price": 177.87, "change_pct": 0.95, "momentum_175d": 1.77, "sector_z_score": -0.22}, {"ticker": "SYY", "price": 75.22, "change_pct": -0.13, "momentum_175d": -3.61, "sector_z_score": -0.23}, {"ticker": "CMS", "price": 74.22, "change_pct": 0.03, "momentum_175d": 7.85, "sector_z_score": -0.23}, {"ticker": "EPD", "price": 37.99, "change_pct": -1.07, "momentum_175d": 24.5, "sector_z_score": -0.23}, {"ticker": "SJM", "price": 102.98, "change_pct": 0.65, "momentum_175d": -4.88, "sector_z_score": -0.23}, {"ticker": "NXPI", "price": 329.24, "change_pct": -1.03, "momentum_175d": 9.58, "sector_z_score": -0.24}, {"ticker": "TFX", "price": 130.06, "change_pct": -1.39, "momentum_175d": 7.94, "sector_z_score": -0.24}, {"ticker": "LIN", "price": 507.87, "change_pct": -1.38, "momentum_175d": 7.89, "sector_z_score": -0.24}, {"ticker": "BRK-B", "price": 479.92, "change_pct": -0.77, "momentum_175d": -3.81, "sector_z_score": -0.25}, {"ticker": "TTWO", "price": 218.46, "change_pct": -1.0, "momentum_175d": -13.54, "sector_z_score": -0.25}, {"ticker": "HBAN", "price": 16.03, "change_pct": -0.19, "momentum_175d": -3.59, "sector_z_score": -0.26}, {"ticker": "PSX", "price": 174.63, "change_pct": 0.43, "momentum_175d": 27.2, "sector_z_score": -0.26}, {"ticker": "DECK", "price": 114.37, "change_pct": 2.63, "momentum_175d": -10.02, "sector_z_score": -0.26}, {"ticker": "FTNT", "price": 127.93, "change_pct": -4.5, "momentum_175d": 5.77, "sector_z_score": -0.26}, {"ticker": "APD", "price": 285.73, "change_pct": -1.34, "momentum_175d": 6.56, "sector_z_score": -0.26}, {"ticker": "LMT", "price": 531.14, "change_pct": -0.33, "momentum_175d": 9.87, "sector_z_score": -0.28}, {"ticker": "BA", "price": 224.3, "change_pct": 2.47, "momentum_175d": 7.22, "sector_z_score": -0.28}, {"ticker": "LYV", "price": 166.56, "change_pct": 0.02, "momentum_175d": -9.22, "sector_z_score": -0.28}, {"ticker": "RMD", "price": 206.1, "change_pct": -0.9, "momentum_175d": -17.75, "sector_z_score": -0.29}, {"ticker": "MDLZ", "price": 62.25, "change_pct": 1.65, "momentum_175d": -4.35, "sector_z_score": -0.3}, {"ticker": "BLK", "price": 1070.34, "change_pct": -0.66, "momentum_175d": -4.23, "sector_z_score": -0.3}, {"ticker": "CRWD", "price": 645.36, "change_pct": -3.9, "momentum_175d": 2.21, "sector_z_score": -0.31}, {"ticker": "HON", "price": 231.55, "change_pct": -0.07, "momentum_175d": 7.42, "sector_z_score": -0.31}, {"ticker": "IR", "price": 70.96, "change_pct": -2.03, "momentum_175d": 6.33, "sector_z_score": -0.32}, {"ticker": "DXC", "price": 9.34, "change_pct": 0.97, "momentum_175d": -16.75, "sector_z_score": -0.32}, {"ticker": "OKE", "price": 88.21, "change_pct": -2.47, "momentum_175d": 24.42, "sector_z_score": -0.32}, {"ticker": "OXY", "price": 56.89, "change_pct": -0.99, "momentum_175d": 27.28, "sector_z_score": -0.33}, {"ticker": "CPT", "price": 108.09, "change_pct": 0.32, "momentum_175d": -3.31, "sector_z_score": -0.33}, {"ticker": "WM", "price": 215.59, "change_pct": 0.05, "momentum_175d": 5.48, "sector_z_score": -0.34}, {"ticker": "ZTS", "price": 79.48, "change_pct": -0.93, "momentum_175d": -18.43, "sector_z_score": -0.34}, {"ticker": "EMR", "price": 139.72, "change_pct": -0.89, "momentum_175d": 5.14, "sector_z_score": -0.35}, {"ticker": "MPC", "price": 247.05, "change_pct": -0.4, "momentum_175d": 27.54, "sector_z_score": -0.35}, {"ticker": "TEL", "price": 208.75, "change_pct": 0.07, "momentum_175d": -0.47, "sector_z_score": -0.35}, {"ticker": "QRVO", "price": 103.91, "change_pct": -3.99, "momentum_175d": -0.66, "sector_z_score": -0.35}, {"ticker": "PCG", "price": 16.53, "change_pct": 0.67, "momentum_175d": 7.66, "sector_z_score": -0.36}, {"ticker": "TMO", "price": 456.21, "change_pct": 1.79, "momentum_175d": -1.18, "sector_z_score": -0.36}, {"ticker": "LNC", "price": 35.2, "change_pct": -1.62, "momentum_175d": -5.71, "sector_z_score": -0.37}, {"ticker": "JKHY", "price": 136.26, "change_pct": -0.8, "momentum_175d": -4.02, "sector_z_score": -0.39}, {"ticker": "PPG", "price": 112.88, "change_pct": 3.05, "momentum_175d": 2.11, "sector_z_score": -0.39}, {"ticker": "CMG", "price": 32.69, "change_pct": 1.18, "momentum_175d": -12.8, "sector_z_score": -0.39}, {"ticker": "WYNN", "price": 101.22, "change_pct": 3.42, "momentum_175d": -13.75, "sector_z_score": -0.39}, {"ticker": "ITW", "price": 250.29, "change_pct": -0.05, "momentum_175d": 4.18, "sector_z_score": -0.39}, {"ticker": "CDNS", "price": 374.05, "change_pct": -2.02, "momentum_175d": -4.26, "sector_z_score": -0.4}, {"ticker": "ORLY", "price": 89.02, "change_pct": -0.95, "momentum_175d": -13.48, "sector_z_score": -0.4}, {"ticker": "PKG", "price": 217.99, "change_pct": -0.22, "momentum_175d": 1.55, "sector_z_score": -0.4}, {"ticker": "DDOG", "price": 221.81, "change_pct": -0.82, "momentum_175d": -4.32, "sector_z_score": -0.41}, {"ticker": "SNAP", "price": 5.88, "change_pct": 2.26, "momentum_175d": -18.77, "sector_z_score": -0.41}, {"ticker": "QCOM", "price": 233.4, "change_pct": -6.2, "momentum_175d": -5.72, "sector_z_score": -0.41}, {"ticker": "AVY", "price": 160.52, "change_pct": 0.45, "momentum_175d": 1.13, "sector_z_score": -0.42}, {"ticker": "CCL", "price": 27.98, "change_pct": 4.75, "momentum_175d": -13.91, "sector_z_score": -0.42}, {"ticker": "WRB", "price": 65.29, "change_pct": -3.03, "momentum_175d": -7.37, "sector_z_score": -0.42}, {"ticker": "OKTA", "price": 89.5, "change_pct": -4.59, "momentum_175d": -16.25, "sector_z_score": -0.42}, {"ticker": "UDR", "price": 38.03, "change_pct": 0.0, "momentum_175d": -5.16, "sector_z_score": -0.43}, {"ticker": "NET", "price": 209.22, "change_pct": -3.82, "momentum_175d": -6.04, "sector_z_score": -0.43}, {"ticker": "ICE", "price": 149.71, "change_pct": -0.62, "momentum_175d": -8.05, "sector_z_score": -0.44}, {"ticker": "MLM", "price": 574.39, "change_pct": 2.4, "momentum_175d": 0.04, "sector_z_score": -0.44}, {"ticker": "CARR", "price": 64.51, "change_pct": -0.59, "momentum_175d": 1.92, "sector_z_score": -0.44}, {"ticker": "ECL", "price": 262.58, "change_pct": 3.28, "momentum_175d": -0.23, "sector_z_score": -0.44}, {"ticker": "DUK", "price": 125.35, "change_pct": 0.3, "momentum_175d": 5.65, "sector_z_score": -0.45}, {"ticker": "MSI", "price": 408.71, "change_pct": -0.43, "momentum_175d": -10.0, "sector_z_score": -0.45}, {"ticker": "URI", "price": 988.24, "change_pct": 2.63, "momentum_175d": 1.7, "sector_z_score": -0.45}, {"ticker": "VMC", "price": 275.65, "change_pct": 2.49, "momentum_175d": -0.56, "sector_z_score": -0.45}, {"ticker": "LH", "price": 258.25, "change_pct": -0.08, "momentum_175d": -3.85, "sector_z_score": -0.45}, {"ticker": "FFIV", "price": 388.15, "change_pct": -2.23, "momentum_175d": -8.14, "sector_z_score": -0.45}, {"ticker": "INVH", "price": 29.41, "change_pct": 0.03, "momentum_175d": -5.07, "sector_z_score": -0.46}, {"ticker": "PRU", "price": 101.49, "change_pct": -1.1, "momentum_175d": -6.91, "sector_z_score": -0.46}, {"ticker": "CDW", "price": 114.19, "change_pct": 5.16, "momentum_175d": -17.63, "sector_z_score": -0.47}, {"ticker": "D", "price": 67.2, "change_pct": -0.12, "momentum_175d": 5.45, "sector_z_score": -0.47}, {"ticker": "DHR", "price": 173.13, "change_pct": 0.19, "momentum_175d": -3.69, "sector_z_score": -0.47}, {"ticker": "SHOP", "price": 106.6, "change_pct": 1.62, "momentum_175d": -16.0, "sector_z_score": -0.48}, {"ticker": "NTAP", "price": 142.74, "change_pct": 2.73, "momentum_175d": -11.4, "sector_z_score": -0.48}, {"ticker": "PANW", "price": 248.47, "change_pct": -3.22, "momentum_175d": -9.13, "sector_z_score": -0.48}, {"ticker": "NOC", "price": 551.34, "change_pct": -0.98, "momentum_175d": 0.86, "sector_z_score": -0.49}, {"ticker": "WEC", "price": 113.14, "change_pct": 0.13, "momentum_175d": 5.46, "sector_z_score": -0.5}, {"ticker": "RJF", "price": 145.4, "change_pct": -4.42, "momentum_175d": -7.37, "sector_z_score": -0.5}, {"ticker": "SWKS", "price": 78.68, "change_pct": -5.68, "momentum_175d": -12.75, "sector_z_score": -0.5}, {"ticker": "CI", "price": 285.75, "change_pct": 1.75, "momentum_175d": -3.84, "sector_z_score": -0.5}, {"ticker": "TMUS", "price": 190.81, "change_pct": -0.16, "momentum_175d": -23.03, "sector_z_score": -0.51}, {"ticker": "BBWI", "price": 19.45, "change_pct": 9.7, "momentum_175d": -26.71, "sector_z_score": -0.51}, {"ticker": "TRMB", "price": 54.88, "change_pct": -2.59, "momentum_175d": -17.08, "sector_z_score": -0.51}, {"ticker": "AZO", "price": 3027.48, "change_pct": -2.34, "momentum_175d": -15.85, "sector_z_score": -0.51}, {"ticker": "MSFT", "price": 412.67, "change_pct": -0.81, "momentum_175d": -17.23, "sector_z_score": -0.52}, {"ticker": "ROL", "price": 50.38, "change_pct": -5.23, "momentum_175d": -0.2, "sector_z_score": -0.52}, {"ticker": "DOC", "price": 19.87, "change_pct": -0.8, "momentum_175d": -6.95, "sector_z_score": -0.52}, {"ticker": "GE", "price": 317.21, "change_pct": 0.86, "momentum_175d": -0.39, "sector_z_score": -0.53}, {"ticker": "AMT", "price": 184.98, "change_pct": 0.02, "momentum_175d": -6.31, "sector_z_score": -0.53}, {"ticker": "FBIN", "price": 39.45, "change_pct": 2.73, "momentum_175d": -27.59, "sector_z_score": -0.54}, {"ticker": "GPN", "price": 73.46, "change_pct": 0.25, "momentum_175d": -18.29, "sector_z_score": -0.54}, {"ticker": "CTSH", "price": 53.16, "change_pct": 2.61, "momentum_175d": -19.6, "sector_z_score": -0.54}, {"ticker": "MAA", "price": 130.58, "change_pct": 0.03, "momentum_175d": -7.66, "sector_z_score": -0.55}, {"ticker": "AVB", "price": 186.23, "change_pct": 0.62, "momentum_175d": -6.9, "sector_z_score": -0.55}, {"ticker": "ERIE", "price": 220.71, "change_pct": -1.06, "momentum_175d": -27.05, "sector_z_score": -0.56}, {"ticker": "PLTR", "price": 132.51, "change_pct": -2.99, "momentum_175d": -16.42, "sector_z_score": -0.56}, {"ticker": "WMB", "price": 74.37, "change_pct": -2.58, "momentum_175d": 24.52, "sector_z_score": -0.56}, {"ticker": "SHW", "price": 309.97, "change_pct": -0.41, "momentum_175d": -5.45, "sector_z_score": -0.57}, {"ticker": "MCO", "price": 451.56, "change_pct": 0.1, "momentum_175d": -9.06, "sector_z_score": -0.57}, {"ticker": "MKTX", "price": 130.94, "change_pct": -0.22, "momentum_175d": -11.08, "sector_z_score": -0.57}, {"ticker": "MELI", "price": 1696.17, "change_pct": 2.92, "momentum_175d": -21.26, "sector_z_score": -0.58}, {"ticker": "HRL", "price": 20.96, "change_pct": 0.34, "momentum_175d": -9.71, "sector_z_score": -0.59}, {"ticker": "NFLX", "price": 87.35, "change_pct": -0.38, "momentum_175d": -24.0, "sector_z_score": -0.59}, {"ticker": "BBY", "price": 64.54, "change_pct": 2.09, "momentum_175d": -17.16, "sector_z_score": -0.59}, {"ticker": "ACN", "price": 176.36, "change_pct": -0.36, "momentum_175d": -24.97, "sector_z_score": -0.6}, {"ticker": "ABBV", "price": 215.4, "change_pct": 1.07, "momentum_175d": -7.14, "sector_z_score": -0.6}, {"ticker": "PYPL", "price": 43.76, "change_pct": -0.91, "momentum_175d": -25.44, "sector_z_score": -0.6}, {"ticker": "CVX", "price": 182.4, "change_pct": -1.25, "momentum_175d": 19.94, "sector_z_score": -0.61}, {"ticker": "GD", "price": 342.69, "change_pct": -0.57, "momentum_175d": -3.16, "sector_z_score": -0.61}, {"ticker": "APO", "price": 130.27, "change_pct": 0.28, "momentum_175d": -10.23, "sector_z_score": -0.61}, {"ticker": "TAP", "price": 41.87, "change_pct": 1.55, "momentum_175d": -9.08, "sector_z_score": -0.61}, {"ticker": "GPC", "price": 98.4, "change_pct": 1.39, "momentum_175d": -22.04, "sector_z_score": -0.62}, {"ticker": "CRM", "price": 177.51, "change_pct": -0.88, "momentum_175d": -25.27, "sector_z_score": -0.62}, {"ticker": "APP", "price": 567.83, "change_pct": 10.42, "momentum_175d": -22.4, "sector_z_score": -0.63}, {"ticker": "BILL", "price": 34.7, "change_pct": -0.57, "momentum_175d": -27.2, "sector_z_score": -0.63}, {"ticker": "NVR", "price": 6098.08, "change_pct": 1.08, "momentum_175d": -21.69, "sector_z_score": -0.63}, {"ticker": "FIS", "price": 42.02, "change_pct": -0.47, "momentum_175d": -30.86, "sector_z_score": -0.64}, {"ticker": "HD", "price": 317.85, "change_pct": 2.35, "momentum_175d": -20.35, "sector_z_score": -0.64}, {"ticker": "MDT", "price": 75.98, "change_pct": -2.09, "momentum_175d": -9.18, "sector_z_score": -0.64}, {"ticker": "RCL", "price": 275.17, "change_pct": 2.79, "momentum_175d": -20.57, "sector_z_score": -0.65}, {"ticker": "AON", "price": 318.54, "change_pct": -0.23, "momentum_175d": -11.0, "sector_z_score": -0.65}, {"ticker": "ADSK", "price": 237.0, "change_pct": -0.52, "momentum_175d": -26.8, "sector_z_score": -0.65}, {"ticker": "CCI", "price": 90.87, "change_pct": 0.25, "momentum_175d": -8.64, "sector_z_score": -0.66}, {"ticker": "EOG", "price": 134.3, "change_pct": -1.4, "momentum_175d": 16.42, "sector_z_score": -0.66}, {"ticker": "HPQ", "price": 25.49, "change_pct": 4.34, "momentum_175d": -26.78, "sector_z_score": -0.66}, {"ticker": "SO", "price": 93.74, "change_pct": -0.37, "momentum_175d": 3.26, "sector_z_score": -0.67}, {"ticker": "FAST", "price": 44.82, "change_pct": 0.83, "momentum_175d": -4.17, "sector_z_score": -0.67}, {"ticker": "ADBE", "price": 238.24, "change_pct": -0.94, "momentum_175d": -31.05, "sector_z_score": -0.68}, {"ticker": "ZBRA", "price": 252.51, "change_pct": 0.03, "momentum_175d": -31.13, "sector_z_score": -0.68}, {"ticker": "ADP", "price": 217.65, "change_pct": -0.32, "momentum_175d": -31.19, "sector_z_score": -0.68}, {"ticker": "GEHC", "price": 63.88, "change_pct": -0.47, "momentum_175d": -9.24, "sector_z_score": -0.7}, {"ticker": "COF", "price": 188.8, "change_pct": 1.28, "momentum_175d": -13.66, "sector_z_score": -0.7}, {"ticker": "BKNG", "price": 168.37, "change_pct": 3.1, "momentum_175d": -19.84, "sector_z_score": -0.7}, {"ticker": "PTC", "price": 142.26, "change_pct": -1.81, "momentum_175d": -33.18, "sector_z_score": -0.72}, {"ticker": "TYL", "price": 302.55, "change_pct": -1.57, "momentum_175d": -37.42, "sector_z_score": -0.72}, {"ticker": "VICI", "price": 28.63, "change_pct": 0.03, "momentum_175d": -9.97, "sector_z_score": -0.73}, {"ticker": "GRAB", "price": 3.64, "change_pct": 1.39, "momentum_175d": -36.64, "sector_z_score": -0.74}, {"ticker": "EL", "price": 91.2, "change_pct": 5.31, "momentum_175d": -10.92, "sector_z_score": -0.74}, {"ticker": "BR", "price": 147.1, "change_pct": 0.1, "momentum_175d": -36.79, "sector_z_score": -0.74}, {"ticker": "SMCI", "price": 38.19, "change_pct": 2.94, "momentum_175d": -38.63, "sector_z_score": -0.75}, {"ticker": "SNOW", "price": 175.26, "change_pct": -1.32, "momentum_175d": -36.11, "sector_z_score": -0.76}, {"ticker": "SYK", "price": 305.94, "change_pct": -2.26, "momentum_175d": -12.1, "sector_z_score": -0.77}, {"ticker": "MHK", "price": 105.98, "change_pct": 2.1, "momentum_175d": -21.51, "sector_z_score": -0.77}, {"ticker": "RSG", "price": 205.6, "change_pct": -0.7, "momentum_175d": -8.88, "sector_z_score": -0.78}, {"ticker": "IDXX", "price": 550.99, "change_pct": -1.12, "momentum_175d": -10.72, "sector_z_score": -0.78}, {"ticker": "INTU", "price": 307.73, "change_pct": 1.11, "momentum_175d": -39.73, "sector_z_score": -0.79}, {"ticker": "TDG", "price": 1240.91, "change_pct": 1.22, "momentum_175d": -10.07, "sector_z_score": -0.8}, {"ticker": "EQT", "price": 55.17, "change_pct": -1.85, "momentum_175d": 18.01, "sector_z_score": -0.8}, {"ticker": "ORCL", "price": 190.96, "change_pct": -1.09, "momentum_175d": -42.3, "sector_z_score": -0.8}, {"ticker": "VRSK", "price": 171.5, "change_pct": -0.05, "momentum_175d": -31.12, "sector_z_score": -0.81}, {"ticker": "KMI", "price": 32.22, "change_pct": -1.98, "momentum_175d": 15.51, "sector_z_score": -0.82}, {"ticker": "NWL", "price": 3.66, "change_pct": 1.67, "momentum_175d": -25.87, "sector_z_score": -0.82}, {"ticker": "STE", "price": 212.87, "change_pct": -1.57, "momentum_175d": -10.11, "sector_z_score": -0.82}, {"ticker": "IT", "price": 159.97, "change_pct": 1.32, "momentum_175d": -39.74, "sector_z_score": -0.83}, {"ticker": "UBER", "price": 70.73, "change_pct": 0.87, "momentum_175d": -22.84, "sector_z_score": -0.83}, {"ticker": "TOST", "price": 24.5, "change_pct": 5.06, "momentum_175d": -28.77, "sector_z_score": -0.84}, {"ticker": "UAL", "price": 112.62, "change_pct": 6.33, "momentum_175d": -12.1, "sector_z_score": -0.84}, {"ticker": "DPZ", "price": 311.72, "change_pct": 0.42, "momentum_175d": -24.8, "sector_z_score": -0.85}, {"ticker": "KHC", "price": 24.38, "change_pct": 2.22, "momentum_175d": -11.66, "sector_z_score": -0.85}, {"ticker": "NCLH", "price": 18.15, "change_pct": 6.14, "momentum_175d": -30.07, "sector_z_score": -0.85}, {"ticker": "PAYX", "price": 94.43, "change_pct": -0.39, "momentum_175d": -30.48, "sector_z_score": -0.87}, {"ticker": "IQV", "price": 165.62, "change_pct": 2.23, "momentum_175d": -11.9, "sector_z_score": -0.88}, {"ticker": "OTIS", "price": 71.79, "change_pct": -1.29, "momentum_175d": -12.75, "sector_z_score": -0.89}, {"ticker": "VLTO", "price": 84.46, "change_pct": -1.85, "momentum_175d": -18.84, "sector_z_score": -0.9}, {"ticker": "MKC", "price": 47.55, "change_pct": 1.56, "momentum_175d": -21.82, "sector_z_score": -0.91}, {"ticker": "ET", "price": 19.33, "change_pct": -1.38, "momentum_175d": 12.87, "sector_z_score": -0.91}, {"ticker": "SPOT", "price": 512.83, "change_pct": -3.19, "momentum_175d": -29.48, "sector_z_score": -0.91}, {"ticker": "PNR", "price": 72.45, "change_pct": -1.48, "momentum_175d": -15.96, "sector_z_score": -0.93}, {"ticker": "CTAS", "price": 169.86, "change_pct": -0.72, "momentum_175d": -12.44, "sector_z_score": -0.93}, {"ticker": "TSCO", "price": 30.67, "change_pct": 3.76, "momentum_175d": -39.63, "sector_z_score": -0.95}, {"ticker": "NOW", "price": 102.12, "change_pct": 2.2, "momentum_175d": -52.27, "sector_z_score": -0.96}, {"ticker": "EFX", "price": 163.69, "change_pct": 0.54, "momentum_175d": -33.26, "sector_z_score": -0.98}, {"ticker": "J", "price": 115.9, "change_pct": 0.04, "momentum_175d": -13.83, "sector_z_score": -0.98}, {"ticker": "FISV", "price": 55.62, "change_pct": 0.02, "momentum_175d": -53.81, "sector_z_score": -0.98}, {"ticker": "DXCM", "price": 70.26, "change_pct": -2.43, "momentum_175d": -18.75, "sector_z_score": -0.99}, {"ticker": "ZS", "price": 126.41, "change_pct": -31.52, "momentum_175d": -53.05, "sector_z_score": -1.01}, {"ticker": "IP", "price": 32.42, "change_pct": 1.44, "momentum_175d": -26.74, "sector_z_score": -1.01}, {"ticker": "DASH", "price": 157.58, "change_pct": 2.32, "momentum_175d": -32.56, "sector_z_score": -1.02}, {"ticker": "LEN", "price": 90.96, "change_pct": 1.87, "momentum_175d": -29.31, "sector_z_score": -1.02}, {"ticker": "CHTR", "price": 147.18, "change_pct": 2.21, "momentum_175d": -33.03, "sector_z_score": -1.05}, {"ticker": "MPLX", "price": 55.71, "change_pct": -1.35, "momentum_175d": 11.03, "sector_z_score": -1.07}, {"ticker": "SPGI", "price": 415.8, "change_pct": 0.8, "momentum_175d": -19.28, "sector_z_score": -1.08}, {"ticker": "NKE", "price": 45.98, "change_pct": 2.31, "momentum_175d": -37.38, "sector_z_score": -1.09}, {"ticker": "LW", "price": 42.77, "change_pct": 1.54, "momentum_175d": -21.66, "sector_z_score": -1.09}, {"ticker": "LDOS", "price": 130.62, "change_pct": 1.95, "momentum_175d": -20.69, "sector_z_score": -1.1}, {"ticker": "PEG", "price": 79.82, "change_pct": -0.31, "momentum_175d": -1.77, "sector_z_score": -1.12}, {"ticker": "POOL", "price": 184.41, "change_pct": 1.13, "momentum_175d": -29.88, "sector_z_score": -1.17}, {"ticker": "ABT", "price": 85.68, "change_pct": -1.14, "momentum_175d": -28.22, "sector_z_score": -1.18}, {"ticker": "TTD", "price": 22.29, "change_pct": 0.5, "momentum_175d": -49.19, "sector_z_score": -1.19}, {"ticker": "AWK", "price": 123.78, "change_pct": -0.06, "momentum_175d": -2.81, "sector_z_score": -1.19}, {"ticker": "CLX", "price": 97.11, "change_pct": 0.91, "momentum_175d": -18.58, "sector_z_score": -1.19}, {"ticker": "BRO", "price": 56.81, "change_pct": -0.32, "momentum_175d": -26.92, "sector_z_score": -1.21}, {"ticker": "LYFT", "price": 13.7, "change_pct": 0.88, "momentum_175d": -29.11, "sector_z_score": -1.21}, {"ticker": "CAG", "price": 13.33, "change_pct": 1.37, "momentum_175d": -21.29, "sector_z_score": -1.22}, {"ticker": "VEEV", "price": 158.49, "change_pct": -0.03, "momentum_175d": -41.92, "sector_z_score": -1.22}, {"ticker": "RHI", "price": 27.13, "change_pct": 0.48, "momentum_175d": -21.63, "sector_z_score": -1.22}, {"ticker": "AJG", "price": 202.85, "change_pct": -0.54, "momentum_175d": -25.26, "sector_z_score": -1.22}, {"ticker": "NRG", "price": 138.0, "change_pct": -1.73, "momentum_175d": -3.02, "sector_z_score": -1.23}, {"ticker": "CG", "price": 45.66, "change_pct": 0.02, "momentum_175d": -27.62, "sector_z_score": -1.24}, {"ticker": "BAX", "price": 19.33, "change_pct": -0.26, "momentum_175d": -22.23, "sector_z_score": -1.28}, {"ticker": "WHR", "price": 44.36, "change_pct": 3.98, "momentum_175d": -36.35, "sector_z_score": -1.3}, {"ticker": "CEG", "price": 288.68, "change_pct": -4.27, "momentum_175d": -4.38, "sector_z_score": -1.35}, {"ticker": "HUM", "price": 306.27, "change_pct": 1.52, "momentum_175d": -17.83, "sector_z_score": -1.35}, {"ticker": "PINS", "price": 20.03, "change_pct": 3.62, "momentum_175d": -43.93, "sector_z_score": -1.38}, {"ticker": "U", "price": 27.76, "change_pct": 3.7, "momentum_175d": -42.75, "sector_z_score": -1.43}, {"ticker": "ALK", "price": 45.97, "change_pct": 4.98, "momentum_175d": -31.61, "sector_z_score": -1.44}, {"ticker": "KMB", "price": 100.18, "change_pct": 1.43, "momentum_175d": -19.76, "sector_z_score": -1.45}, {"ticker": "PAYC", "price": 132.28, "change_pct": -0.41, "momentum_175d": -42.93, "sector_z_score": -1.48}, {"ticker": "GDDY", "price": 87.37, "change_pct": -1.82, "momentum_175d": -42.04, "sector_z_score": -1.49}, {"ticker": "HOOD", "price": 76.23, "change_pct": 2.89, "momentum_175d": -26.89, "sector_z_score": -1.51}, {"ticker": "BXP", "price": 60.72, "change_pct": 0.18, "momentum_175d": -22.91, "sector_z_score": -1.52}, {"ticker": "CPRT", "price": 32.85, "change_pct": -1.17, "momentum_175d": -30.27, "sector_z_score": -1.53}, {"ticker": "KKR", "price": 95.03, "change_pct": 0.04, "momentum_175d": -30.15, "sector_z_score": -1.54}, {"ticker": "GIS", "price": 33.65, "change_pct": 1.48, "momentum_175d": -26.03, "sector_z_score": -1.55}, {"ticker": "BX", "price": 118.0, "change_pct": -0.1, "momentum_175d": -33.16, "sector_z_score": -1.58}, {"ticker": "SOFI", "price": 16.17, "change_pct": 1.19, "momentum_175d": -32.2, "sector_z_score": -1.59}, {"ticker": "WDAY", "price": 124.5, "change_pct": 0.39, "momentum_175d": -47.1, "sector_z_score": -1.59}, {"ticker": "ROP", "price": 316.62, "change_pct": -1.15, "momentum_175d": -29.8, "sector_z_score": -1.62}, {"ticker": "KMX", "price": 42.26, "change_pct": 3.99, "momentum_175d": -36.83, "sector_z_score": -1.62}, {"ticker": "RBLX", "price": 45.63, "change_pct": -0.8, "momentum_175d": -58.09, "sector_z_score": -1.64}, {"ticker": "SE", "price": 93.46, "change_pct": 4.99, "momentum_175d": -55.11, "sector_z_score": -1.78}, {"ticker": "PODD", "price": 146.01, "change_pct": -5.07, "momentum_175d": -44.66, "sector_z_score": -1.81}, {"ticker": "TEAM", "price": 89.07, "change_pct": 4.89, "momentum_175d": -60.19, "sector_z_score": -1.82}, {"ticker": "CSGP", "price": 32.32, "change_pct": -0.62, "momentum_175d": -59.27, "sector_z_score": -1.87}, {"ticker": "VNO", "price": 33.31, "change_pct": 1.87, "momentum_175d": -27.45, "sector_z_score": -1.93}, {"ticker": "FMC", "price": 13.51, "change_pct": 4.08, "momentum_175d": -57.49, "sector_z_score": -1.97}, {"ticker": "FDS", "price": 235.73, "change_pct": 2.08, "momentum_175d": -33.98, "sector_z_score": -2.0}, {"ticker": "COIN", "price": 173.78, "change_pct": -3.46, "momentum_175d": -39.86, "sector_z_score": -2.01}, {"ticker": "AXON", "price": 391.32, "change_pct": 1.56, "momentum_175d": -47.08, "sector_z_score": -2.05}, {"ticker": "BSX", "price": 50.46, "change_pct": -12.46, "momentum_175d": -39.97, "sector_z_score": -2.06}, {"ticker": "DKNG", "price": 25.07, "change_pct": 5.29, "momentum_175d": -45.06, "sector_z_score": -2.07}, {"ticker": "CPB", "price": 20.5, "change_pct": 1.84, "momentum_175d": -34.62, "sector_z_score": -2.15}, {"ticker": "MNDY", "price": 76.42, "change_pct": -0.53, "momentum_175d": -64.85, "sector_z_score": -2.16}, {"ticker": "HUBS", "price": 200.72, "change_pct": 1.38, "momentum_175d": -55.71, "sector_z_score": -2.16}, {"ticker": "ARES", "price": 126.58, "change_pct": 1.04, "momentum_175d": -35.99, "sector_z_score": -2.17}, {"ticker": "LCID", "price": 6.26, "change_pct": 4.86, "momentum_175d": -70.11, "sector_z_score": -2.35}, {"ticker": "MOS", "price": 23.72, "change_pct": 4.86, "momentum_175d": -30.6, "sector_z_score": -2.51}, {"ticker": "ARE", "price": 49.93, "change_pct": 3.01, "momentum_175d": -44.25, "sector_z_score": -2.56}, {"ticker": "VST", "price": 160.15, "change_pct": -2.68, "momentum_175d": -21.68, "sector_z_score": -3.03}], "last_updated": "2026-05-28"}
backend/strategy_signals.py ADDED
@@ -0,0 +1,183 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # -*- coding: utf-8 -*-
2
+ """
3
+ Strategy Live Signal API
4
+ ===================================
5
+ Endpoint for the frontend to retrieve live, execution-ready Strategy Momentum signals.
6
+ - Uses 175/21-day Momentum
7
+ - Applies Soft-Sector Z-Score Filtering
8
+ - Returns the final Top 15 correlated-sized portfolio.
9
+ """
10
+ import numpy as np, pandas as pd, yfinance as yf
11
+ import warnings; warnings.filterwarnings("ignore")
12
+ import json
13
+
14
+ # Import the shared robust universe
15
+ try:
16
+ from backend.alpaca_executor import UNIVERSE
17
+ except ImportError:
18
+ UNIVERSE = ["AAPL", "MSFT", "GOOGL", "AMZN", "META", "NVDA", "TSLA", "JPM", "V", "JNJ"]
19
+
20
+ # Import sector maps
21
+ try:
22
+ from backtesting.strategies.v36_engine import SECTOR_MAP, SECTORS
23
+ except ImportError:
24
+ SECTOR_MAP = {}
25
+ SECTORS = []
26
+
27
+ import os, time
28
+
29
+ # Define cache file and TTL (e.g., 24 hours = 86400 seconds)
30
+ CACHE_FILE = os.path.join(os.path.dirname(__file__), "strategy_cache.json")
31
+ CACHE_TTL = 86400
32
+
33
+ def get_strategy_live_signals():
34
+ """Compute and return live Strategy signals."""
35
+ # Check cache first
36
+ if os.path.exists(CACHE_FILE):
37
+ if time.time() - os.path.getmtime(CACHE_FILE) < CACHE_TTL:
38
+ try:
39
+ with open(CACHE_FILE, "r") as f:
40
+ return json.load(f)
41
+ except Exception as e:
42
+ print(f"Cache read error: {e}")
43
+ pass
44
+
45
+ end = pd.Timestamp.now()
46
+ # Need 175 trading days (~250 calendar days), pull 300 to be safe
47
+ start = end - pd.Timedelta(days=300)
48
+
49
+ tickers = list(set(UNIVERSE + ["SPY"]))
50
+ raw = yf.download(tickers, start=str(start.date()), end=str(end.date()), progress=False)
51
+
52
+ lvl0 = raw.columns.get_level_values(0).unique().tolist() if isinstance(raw.columns, pd.MultiIndex) else []
53
+ dc = raw["Close"] if "Close" in lvl0 else raw
54
+ if isinstance(dc.columns, pd.MultiIndex):
55
+ dc.columns = dc.columns.get_level_values(-1)
56
+ dc = dc.ffill().dropna(how="all")
57
+
58
+ spy = dc["SPY"]
59
+ if "SPY" in dc.columns:
60
+ dc = dc.drop(columns=["SPY"])
61
+
62
+ valid_universe = [t for t in UNIVERSE if t in dc.columns and dc[t].notna().sum() > 175]
63
+
64
+ # 1. Regime Check (200-day SMA)
65
+ if len(spy) < 200:
66
+ spy_raw = yf.download("SPY", period="1y", progress=False)["Close"]
67
+ if isinstance(spy_raw, pd.DataFrame): spy_raw = spy_raw["SPY"]
68
+ spy_full = spy_raw.ffill().dropna()
69
+ sma200 = spy_full.rolling(200).mean()
70
+ current_spy = float(spy_full.iloc[-1])
71
+ current_sma = float(sma200.iloc[-1])
72
+ else:
73
+ sma200 = spy.rolling(200).mean()
74
+ current_spy = float(spy.iloc[-1])
75
+ current_sma = float(sma200.iloc[-1])
76
+
77
+ is_risk_on = current_spy > current_sma
78
+
79
+ # 2. Strategy Momentum (175 days skipping last 21)
80
+ if len(dc) < 176:
81
+ m175 = (dc.shift(min(21, len(dc)-2)) / dc.shift(len(dc)-1)) - 1
82
+ else:
83
+ m175 = (dc.shift(21) / dc.shift(175)) - 1
84
+
85
+ latest_mom = m175.iloc[-1].dropna()
86
+
87
+ # Calculate Consistency (63-day win rate)
88
+ daily_ret = dc.pct_change()
89
+ consistency = daily_ret.gt(0).where(daily_ret.notna()).rolling(63).mean()
90
+ latest_cons = consistency.iloc[-1].dropna()
91
+
92
+ # V68 Soft Logic: Composite Score = Momentum * Consistency
93
+ valid_tks = [t for t in valid_universe if t in latest_mom.index and t in latest_cons.index]
94
+ comp_scores = latest_mom[valid_tks] * latest_cons[valid_tks]
95
+
96
+ # 3. Soft-Sector Neutrality (Z-Scores)
97
+ z_scores = pd.Series(index=comp_scores.index, dtype=float)
98
+ if SECTORS:
99
+ for sector in SECTORS:
100
+ sector_tks = [t for t in comp_scores.index if SECTOR_MAP.get(t) == sector]
101
+ if len(sector_tks) > 1:
102
+ mu = comp_scores[sector_tks].mean()
103
+ sigma = comp_scores[sector_tks].std()
104
+ if sigma < 1e-8: sigma = 1e-8
105
+ z_scores[sector_tks] = (comp_scores[sector_tks] - mu) / sigma
106
+ elif len(sector_tks) == 1:
107
+ z_scores[sector_tks[0]] = 0.0
108
+
109
+ unmapped_tks = [t for t in comp_scores.index if t not in SECTOR_MAP]
110
+ if len(unmapped_tks) > 1:
111
+ mu = comp_scores[unmapped_tks].mean()
112
+ sigma = comp_scores[unmapped_tks].std()
113
+ if sigma < 1e-8: sigma = 1e-8
114
+ z_scores[unmapped_tks] = (comp_scores[unmapped_tks] - mu) / sigma
115
+ elif len(unmapped_tks) == 1:
116
+ z_scores[unmapped_tks[0]] = 0.0
117
+ else:
118
+ # Fallback if no sector map available: Global Z-Score
119
+ mu = comp_scores.mean()
120
+ sigma = comp_scores.std()
121
+ z_scores = (comp_scores - mu) / sigma
122
+
123
+ z_scores = z_scores.dropna().sort_values(ascending=False)
124
+ top15 = z_scores.head(15)
125
+
126
+ # 4. Format Output
127
+ picks = []
128
+ all_universe = []
129
+
130
+ for ticker in z_scores.index:
131
+ try:
132
+ price = float(dc[ticker].iloc[-1])
133
+ prev = float(dc[ticker].iloc[-2])
134
+ change = ((price - prev) / prev) * 100
135
+ mom_val = float(latest_mom[ticker])
136
+ z_val = float(z_scores[ticker])
137
+
138
+ data = {
139
+ "ticker": ticker,
140
+ "price": round(price, 2),
141
+ "change_pct": round(change, 2),
142
+ "momentum_175d": round(mom_val * 100, 2),
143
+ "sector_z_score": round(z_val, 2)
144
+ }
145
+ all_universe.append(data)
146
+
147
+ if ticker in top15.index:
148
+ picks.append(data)
149
+ except:
150
+ pass
151
+
152
+ # Vol scalar estimate (using SPY as proxy)
153
+ spy_rets = spy.pct_change().dropna().tail(60)
154
+ rvol = float(spy_rets.std() * np.sqrt(252))
155
+ vol_scalar = min(max(0.18 / (rvol + 1e-8), 0.05), 1.0)
156
+ if not is_risk_on:
157
+ vol_scalar *= 0.50
158
+
159
+ result = {
160
+ "engine": "Strategy",
161
+ "regime": "RISK-ON" if is_risk_on else "RISK-OFF",
162
+ "spy_price": round(current_spy, 2),
163
+ "sma200": round(current_sma, 2),
164
+ "vol_scalar": round(vol_scalar, 3),
165
+ "realized_vol": round(rvol * 100, 1),
166
+ "target_vol": 18.0,
167
+ "picks": picks,
168
+ "all_universe": all_universe,
169
+ "last_updated": str(end.date())
170
+ }
171
+
172
+ # Save to cache
173
+ try:
174
+ with open(CACHE_FILE, "w") as f:
175
+ json.dump(result, f)
176
+ except Exception as e:
177
+ print(f"Cache write error: {e}")
178
+ pass
179
+
180
+ return result
181
+
182
+ if __name__ == "__main__":
183
+ print(json.dumps(get_strategy_live_signals(), indent=2))
backend/worker.py ADDED
@@ -0,0 +1,512 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Standalone Midnight Supabase Caching Worker (Triggered via GitHub Actions)
3
+
4
+ This script is INTENTIONALLY self-contained. It does NOT import backend.app
5
+ because that would trigger the entire Flask/TensorFlow/FinBERT stack which
6
+ is too heavy for CI runners. Instead, it inlines only the lightweight
7
+ scraping logic (yfinance + cross-sectional math) and pushes directly to Supabase.
8
+
9
+ Tasks:
10
+ 1. Market Movers — yfinance 2-day download for 140 movers
11
+ 2. General News — GNews API (if key is set)
12
+ 3. S&P 500 Screener — yfinance 90-day download, cross-sectional alpha, earnings proximity
13
+ """
14
+ import os
15
+ import sys
16
+ import time
17
+ import json
18
+ import requests
19
+ import numpy as np
20
+ import pandas as pd
21
+ import yfinance as yf
22
+ from datetime import datetime, timedelta
23
+ from dotenv import load_dotenv
24
+
25
+ # Load .env for local testing (CI uses GitHub Secrets)
26
+ load_dotenv()
27
+
28
+ # --- Direct Supabase Client (avoids importing backend.database which chains to app.py) ---
29
+ from supabase import create_client
30
+
31
+ SUPABASE_URL = os.getenv("SUPABASE_URL")
32
+ SUPABASE_KEY = os.getenv("SUPABASE_KEY")
33
+
34
+ if not SUPABASE_URL or not SUPABASE_KEY:
35
+ print("FATAL: SUPABASE_URL and SUPABASE_KEY must be set.")
36
+ sys.exit(1)
37
+
38
+ supabase = create_client(SUPABASE_URL, SUPABASE_KEY)
39
+ print("[Worker] Connected to Supabase.")
40
+
41
+ def set_cache(key: str, data: dict):
42
+ """Push JSON payload to the api_cache table."""
43
+ try:
44
+ payload = {
45
+ "key": key,
46
+ "data": data,
47
+ "updated_at": time.strftime('%Y-%m-%d %H:%M:%S')
48
+ }
49
+ supabase.table("api_cache").upsert(payload, on_conflict="key").execute()
50
+ print(f"[Worker] Cached '{key}' to Supabase.")
51
+ return True
52
+ except Exception as e:
53
+ print(f"[Worker] Cache Error for '{key}': {e}")
54
+ return False
55
+
56
+ # --- S&P 500 Universe (cleaned — all delisted/acquired tickers replaced) ---
57
+ SP500_UNIVERSE = [
58
+ "AAPL", "ABBV", "ABT", "ACN", "ADBE", "ADI", "ADM", "ADP", "ADSK", "AEE",
59
+ "AEP", "AES", "AFL", "AIG", "AIZ", "AJG", "AKAM", "ALB", "ALGN", "ALK",
60
+ "ALL", "ALLE", "AMAT", "AMCR", "AMD", "AME", "AMGN", "AMP", "AMT", "AMZN",
61
+ "ANET", "AON", "AOS", "APA", "APD", "APH", "APTV", "ARE", "ATO",
62
+ "AVB", "AVGO", "AVY", "AWK", "AXP", "AZO", "BA", "BAC", "BAX",
63
+ "BBWI", "BBY", "BDX", "BEN", "BF-B", "BIO", "BIIB", "BK", "BKNG", "BKR",
64
+ "BLK", "BMY", "BR", "BRK-B", "BRO", "BSX", "BWA", "BXP", "C", "CAG",
65
+ "CAH", "CARR", "CAT", "CB", "CBOE", "CBRE", "CCI", "CCL", "CDNS",
66
+ "CDW", "CE", "CEG", "CF", "CFG", "CHD", "CHRW", "CHTR", "CI", "CINF",
67
+ "CL", "CLX", "CMA", "CMCSA", "CME", "CMG", "CMI", "CMS", "CNC", "CNP",
68
+ "COF", "COO", "COP", "COST", "CPB", "CPRT", "CPT", "CRL", "CRM", "CSCO",
69
+ "CSGP", "CSX", "CTAS", "CTRA", "CTSH", "CTVA", "CVS", "CVX", "CZR",
70
+ "D", "DAL", "DD", "DE", "DG", "DGX", "DHI", "DHR", "DIS",
71
+ "DLR", "DLTR", "DOV", "DOW", "DPZ", "DRI", "DTE", "DUK", "DVA",
72
+ "DVN", "DXC", "DXCM", "EA", "EBAY", "ECL", "ED", "EFX", "EIX", "EL",
73
+ "EMN", "EMR", "ENPH", "EOG", "EPAM", "EQIX", "EQR", "EQT", "ES", "ESS",
74
+ "ETN", "ETR", "ETSY", "EVRG", "EW", "EXC", "EXPD", "EXPE", "EXR", "F",
75
+ "FANG", "FAST", "FCX", "FDS", "FDX", "FE", "FFIV", "FIS", "FISV",
76
+ "FITB", "FMC", "FOX", "FOXA", "FRT", "FTNT", "FTV", "GD",
77
+ "GE", "GILD", "GIS", "GL", "GLW", "GM", "GNRC", "GOOG", "GOOGL", "GPC",
78
+ "GPN", "GRMN", "GS", "GWW", "HAL", "HAS", "HBAN", "HCA", "HD", "HOLX",
79
+ "HON", "HPE", "HPQ", "HRL", "HSIC", "HST", "HSY", "HUM", "HWM", "IBM",
80
+ "ICE", "IDXX", "IEX", "IFF", "ILMN", "INCY", "INTC", "INTU", "INVH", "IP",
81
+ "IQV", "IR", "IRM", "ISRG", "IT", "ITW", "IVZ", "J", "JBHT",
82
+ "JCI", "JKHY", "JNJ", "JPM", "KDP", "KEY", "KEYS", "KHC",
83
+ "KIM", "KLAC", "KMB", "KMI", "KMX", "KO", "KR", "L", "LDOS", "LEN",
84
+ "LH", "LHX", "LIN", "LKQ", "LLY", "LMT", "LNC", "LNT", "LOW", "LRCX",
85
+ "LUMN", "LUV", "LVS", "LW", "LYB", "LYV", "MA", "MAA", "MAR", "MAS",
86
+ "MCD", "MCHP", "MCK", "MCO", "MDLZ", "MDT", "MET", "META", "MGM", "MHK",
87
+ "MKC", "MKTX", "MLM", "MMM", "MNST", "MO", "MOH", "MOS", "MPC",
88
+ "MPWR", "MRK", "MRNA", "MS", "MSCI", "MSFT", "MSI", "MTB", "MTCH",
89
+ "MTD", "MU", "NCLH", "NDAQ", "NDSN", "NEE", "NEM", "NFLX", "NI", "NKE",
90
+ "NOC", "NOW", "NRG", "NSC", "NTAP", "NTRS", "NUE", "NVDA", "NVR", "NWL",
91
+ "NWS", "NWSA", "NXPI", "O", "ODFL", "OGN", "OKE", "OMC", "ON", "ORCL",
92
+ "ORLY", "OTIS", "OXY", "PAYC", "PAYX", "PCAR", "PCG", "PEG",
93
+ "PEP", "PFE", "PFG", "PG", "PGR", "PH", "PHM", "PKG", "PLD",
94
+ "PM", "PNC", "PNR", "PNW", "POOL", "PPG", "PPL", "PRU", "PSA", "PSX",
95
+ "PTC", "PVH", "PWR", "PYPL", "QCOM", "QRVO", "RCL", "REG",
96
+ "REGN", "RF", "RHI", "RJF", "RL", "RMD", "ROK", "ROL", "ROP", "ROST",
97
+ "RSG", "RTX", "SBAC", "SBUX", "SCHW", "SEE", "SHW", "SJM",
98
+ "SLB", "SNA", "SNPS", "SO", "SPG", "SPGI", "SRE", "STE", "STT", "STX",
99
+ "STZ", "SWK", "SWKS", "SYF", "SYK", "SYY", "T", "TAP", "TDG", "TDY",
100
+ "TECH", "TEL", "TER", "TFC", "TFX", "TGT", "TJX", "TMO", "TMUS", "TPR",
101
+ "TRGP", "TRMB", "TROW", "TRV", "TSCO", "TSLA", "TSN", "TT", "TTWO", "TXN",
102
+ "TXT", "TYL", "UAL", "UDR", "UHS", "ULTA", "UNH", "UNP", "UPS", "URI",
103
+ "USB", "V", "VFC", "VICI", "VLO", "VMC", "VNO", "VRSK", "VRSN", "VRTX",
104
+ "VTR", "VTRS", "VZ", "WAB", "WAT", "WBD", "WDC", "WEC", "WELL",
105
+ "WFC", "WHR", "WM", "WMB", "WMT", "WRB", "WST", "WTW", "WY",
106
+ "WYNN", "XEL", "XOM", "XRAY", "XYL", "YUM", "ZBH", "ZBRA", "ZION", "ZTS",
107
+ # Recent S&P 500 additions (replacements for delisted tickers)
108
+ "PANW", "ABNB", "CRWD", "DDOG", "SNOW", "PLTR", "COIN", "MELI", "TEAM",
109
+ "DASH", "TTD", "ZS", "MNDY", "NET", "OKTA", "VEEV", "WDAY",
110
+ "BILL", "HUBS", "DKNG", "U", "RIVN", "LCID", "SOFI", "HOOD", "NU",
111
+ "GRAB", "SE", "SHOP", "SPOT", "SNAP", "PINS", "ROKU", "RBLX",
112
+ "UBER", "LYFT", "HLT", "IHG", "ELV",
113
+ "ET", "EPD", "MPLX", "BX", "KKR", "APO", "ARES", "CG",
114
+ "HII", "SMCI", "ARM", "MRVL",
115
+ # Replacements for 24 delisted tickers
116
+ "GEV", # GE Vernova (replaced ATVI)
117
+ "SOLV", # Solventum (replaced SIVB)
118
+ "VLTO", # Veralto (replaced FRC)
119
+ "KVUE", # Kenvue (replaced SPLK)
120
+ "DECK", # Deckers Outdoor (replaced CTLT)
121
+ "VST", # Vistra (replaced PARA)
122
+ "GDDY", # GoDaddy (replaced PXD)
123
+ "AXON", # Axon Enterprise (replaced MRO)
124
+ "ERIE", # Erie Indemnity (replaced DFS)
125
+ "APP", # AppLovin (replaced SQ duplicate)
126
+ "TPL", # Texas Pacific Land (replaced JNPR)
127
+ "RVTY", # Revvity (replaced PKI)
128
+ "EG", # Everest Group (replaced RE)
129
+ "DELL", # Dell Technologies (replaced DISH)
130
+ "FBIN", # Fortune Brands Innovations (replaced FBHS)
131
+ "PODD", # Insulet (replaced ANSS)
132
+ "GEHC", # GE HealthCare (replaced WRK)
133
+ "NTRA", # Natera (replaced MMC)
134
+ "DAY", # Dayforce (replaced CDAY)
135
+ "TOST", # Toast (replaced WBA duplicate)
136
+ "DOC", # Healthpeak (replaced PEAK)
137
+ "CPAY", # Corpay (replaced FLT)
138
+ "HUBB", # Hubbell (replaced K)
139
+ "SMMT", # Summit Therapeutics (replaced IPG)
140
+ ]
141
+ # Remove duplicates
142
+ SP500_UNIVERSE = list(dict.fromkeys(SP500_UNIVERSE))
143
+
144
+ # Movers subset (for market movers only — keep this smaller for fast daily updates)
145
+ MOVERS_UNIVERSE = [
146
+ "AAPL", "MSFT", "GOOGL", "AMZN", "NVDA", "META", "BRK-B", "TSLA", "JPM", "V",
147
+ "UNH", "LLY", "XOM", "JNJ", "WMT", "MA", "PG", "AVGO", "HD", "MRK",
148
+ "CVX", "COST", "ABBV", "PEP", "KO", "ADBE", "CRM", "AMAT", "BMY", "ACN",
149
+ "MCD", "T", "CSCO", "TMO", "ABT", "VZ", "DHR", "NKE", "AMGN", "PFE",
150
+ "LIN", "TXN", "DIS", "NEE", "PM", "UNP", "HON", "RTX", "LOW", "UPS",
151
+ "GS", "MS", "SCHW", "BLK", "AXP", "C", "CB", "USB", "PNC", "TFC",
152
+ "CAT", "BA", "GE", "DE", "MMM", "EMR", "ITW", "CMI", "ETN", "PH",
153
+ "ISRG", "SYK", "MDT", "ZTS", "CI", "HUM", "ELV",
154
+ "INTU", "MDLZ", "CMCSA", "TJX", "PGR", "COP", "EOG",
155
+ "OXY", "SLB", "MPC", "PSX", "VLO", "KMI", "WMB",
156
+ "HAL", "DUK", "SO", "D", "AEP", "SRE", "EXC",
157
+ "XEL", "ED", "AMT", "CCI", "EQIX", "PSA", "SPG", "DLR",
158
+ "WELL", "O", "TRGP", "LMT", "NOC", "GD",
159
+ "PLD", "TGT", "F", "GM", "DAL", "LUV",
160
+ "MU", "QCOM", "PANW", "SNPS", "CDNS", "KLAC", "LRCX",
161
+ "BK", "MET", "AIG", "PRU", "TRV", "ALL", "AFL"
162
+ ]
163
+
164
+ COMPANY_NAMES = {
165
+ "AAPL": "Apple", "MSFT": "Microsoft", "GOOGL": "Alphabet", "AMZN": "Amazon", "NVDA": "NVIDIA",
166
+ "META": "Meta", "BRK-B": "Berkshire Hathaway", "TSLA": "Tesla", "JPM": "JPMorgan", "V": "Visa",
167
+ "UNH": "UnitedHealth", "LLY": "Eli Lilly", "XOM": "ExxonMobil", "JNJ": "Johnson & Johnson",
168
+ "WMT": "Walmart", "MA": "Mastercard", "PG": "Procter & Gamble", "AVGO": "Broadcom",
169
+ "HD": "Home Depot", "MRK": "Merck", "CVX": "Chevron", "COST": "Costco", "ABBV": "AbbVie",
170
+ "PEP": "PepsiCo", "KO": "Coca-Cola", "ADBE": "Adobe", "CRM": "Salesforce",
171
+ "MCD": "McDonald's", "CSCO": "Cisco", "TMO": "Thermo Fisher", "ABT": "Abbott",
172
+ "NKE": "Nike", "DIS": "Disney", "CAT": "Caterpillar", "BA": "Boeing", "GE": "GE Aerospace",
173
+ "NEE": "NextEra Energy", "DUK": "Duke Energy", "SO": "Southern Company",
174
+ "LMT": "Lockheed Martin", "GS": "Goldman Sachs", "MS": "Morgan Stanley"
175
+ }
176
+
177
+ # Cross-sectional engine constants (matching the proven backtest)
178
+ W_VAM = 0.2 # 20% Vol-Adj Momentum, 80% Raw 1M Momentum
179
+
180
+
181
+ # =========================================================================
182
+ # TASK 1: Market Movers (yfinance only)
183
+ # =========================================================================
184
+ def update_movers():
185
+ print("[1/3] Fetching Market Movers via yfinance...")
186
+ try:
187
+ data = yf.download(SP500_UNIVERSE, period="2d", group_by="ticker", progress=False, threads=True)
188
+ stocks = []
189
+ for symbol in SP500_UNIVERSE:
190
+ try:
191
+ if symbol not in data.columns.get_level_values(0):
192
+ continue
193
+ ticker_data = data[symbol]
194
+ if ticker_data.empty or len(ticker_data) < 2:
195
+ continue
196
+ prev_close = float(ticker_data["Close"].iloc[-2])
197
+ curr_close = float(ticker_data["Close"].iloc[-1])
198
+ volume = int(ticker_data["Volume"].iloc[-1])
199
+ if prev_close == 0 or pd.isna(prev_close) or pd.isna(curr_close):
200
+ continue
201
+ pct_change = ((curr_close - prev_close) / prev_close) * 100
202
+
203
+ if volume >= 1_000_000_000:
204
+ vol_fmt = f"{volume / 1_000_000_000:.2f}B"
205
+ elif volume >= 1_000_000:
206
+ vol_fmt = f"{volume / 1_000_000:.2f}M"
207
+ elif volume >= 1_000:
208
+ vol_fmt = f"{volume / 1_000:.1f}K"
209
+ else:
210
+ vol_fmt = str(volume)
211
+
212
+ stocks.append({
213
+ "symbol": symbol,
214
+ "name": COMPANY_NAMES.get(symbol, symbol),
215
+ "price": f"${curr_close:.2f}",
216
+ "change": round(pct_change, 2),
217
+ "raw_change": round(pct_change, 2),
218
+ "volume": volume,
219
+ "volume_fmt": vol_fmt
220
+ })
221
+ except Exception as ex:
222
+ continue
223
+
224
+ if stocks:
225
+ gainers = sorted(stocks, key=lambda x: x["change"], reverse=True)[:5]
226
+ losers = sorted(stocks, key=lambda x: x["change"])[:5]
227
+ active = sorted(stocks, key=lambda x: x["volume"], reverse=True)[:5]
228
+ for item in gainers + losers + active:
229
+ item.pop("volume", None)
230
+ cache_data = {"gainers": gainers, "losers": losers, "active": active}
231
+ set_cache("market-movers", cache_data)
232
+ print(f" ✅ {len(stocks)} stocks processed.")
233
+ else:
234
+ print(" ⚠️ No stock data returned.")
235
+ except Exception as e:
236
+ print(f" ❌ Movers Error: {e}")
237
+
238
+ # =========================================================================
239
+ # TASK 2: General News (lightweight GNews API)
240
+ # =========================================================================
241
+ def update_news():
242
+ print("[2/3] Fetching General Market News via GNews...")
243
+ gnews_key = os.getenv("GNEWS_API_KEY1") or os.getenv("GNEWS_API_KEY2")
244
+ if not gnews_key:
245
+ print(" ⚠️ No GNEWS_API_KEY set, skipping news update.")
246
+ return
247
+
248
+ try:
249
+ all_articles = []
250
+ for page in range(1, 4):
251
+ url = f"https://gnews.io/api/v4/search?q=stock+market&lang=en&sortby=publishedAt&token={gnews_key}&max=10&page={page}"
252
+ res = requests.get(url, timeout=15)
253
+ if res.status_code == 200:
254
+ all_articles.extend(res.json().get("articles", []))
255
+ else:
256
+ print(f" ❌ GNews returned {res.status_code} on page {page}")
257
+ break
258
+ time.sleep(1.5)
259
+
260
+ news_list = []
261
+ for art in all_articles[:30]:
262
+ pub_str = datetime.now().strftime('%Y-%m-%d')
263
+ try:
264
+ dt = datetime.strptime(art.get('publishedAt', ''), "%Y-%m-%dT%H:%M:%SZ")
265
+ pub_str = dt.strftime('%Y-%m-%d')
266
+ except:
267
+ pass
268
+
269
+ news_list.append({
270
+ "title": art.get("title", ""),
271
+ "link": art.get("url", ""),
272
+ "image": art.get("image", ""),
273
+ "publisher": art.get("source", {}).get("name", "GNews"),
274
+ "published": pub_str,
275
+ "sentiment": 0.0
276
+ })
277
+
278
+ if news_list:
279
+ set_cache("general-news", {"news": news_list})
280
+ print(f" ✅ {len(news_list)} articles cached.")
281
+ else:
282
+ print(" ⚠️ No articles found.")
283
+ except Exception as e:
284
+ print(f" ❌ News Error: {e}")
285
+
286
+ # =========================================================================
287
+ # TASK 3: S&P 500 Cross-Sectional Screener (pure yfinance + math)
288
+ # =========================================================================
289
+ def update_screener():
290
+ """
291
+ Computes the cross-sectional alpha score for the entire S&P 500 universe
292
+ using only yfinance data. No GARCH, no FinBERT, no TwelveData.
293
+
294
+ Alpha = W_VAM * Z(Vol_Adj_Mom) + (1-W_VAM) * Z(Mom_1M)
295
+ Signal derived from alpha score thresholds.
296
+ Volatility regime from simple percentile ranking.
297
+ Earnings proximity from yfinance calendar.
298
+ """
299
+ print("[3/3] Computing S&P 500 Cross-Sectional Screener...")
300
+
301
+ try:
302
+ # Step 1: Download 90 days of price data — use group_by="ticker" (same as movers)
303
+ print(" Downloading price data for ~500 tickers...")
304
+ raw = yf.download(SP500_UNIVERSE, period="90d", group_by="ticker", progress=False, threads=True)
305
+
306
+ # Build a clean close price DataFrame, ticker by ticker (proven pattern from movers)
307
+ close_dict = {}
308
+ for ticker in SP500_UNIVERSE:
309
+ try:
310
+ if ticker not in raw.columns.get_level_values(0):
311
+ continue
312
+ ticker_data = raw[ticker]
313
+ if "Close" not in ticker_data.columns:
314
+ continue
315
+ series = ticker_data["Close"].dropna()
316
+ if len(series) >= 63: # Need 63 days for 3-month momentum
317
+ close_dict[ticker] = series
318
+ except:
319
+ continue
320
+
321
+ if len(close_dict) < 10:
322
+ print(f" ❌ Only {len(close_dict)} tickers with sufficient data, aborting screener.")
323
+ return
324
+
325
+ df_close = pd.DataFrame(close_dict).ffill()
326
+ valid_tickers = list(close_dict.keys())
327
+ print(f" {len(valid_tickers)} tickers have sufficient data.")
328
+
329
+ # Step 2: Compute factors (identical to proven backtest)
330
+ daily_ret = df_close.pct_change()
331
+ mom_1m = (df_close / df_close.shift(21)) - 1 # 1-month momentum
332
+ mom_3m = (df_close / df_close.shift(61)) - 1 # 3-month momentum
333
+ vol_1m = daily_ret.rolling(window=21).std() * np.sqrt(252) # Annualized vol
334
+ vol_adj_mom = mom_3m / vol_1m # Vol-adjusted momentum
335
+
336
+ # Get latest values
337
+ idx = -1
338
+ factors = []
339
+ for ticker in valid_tickers:
340
+ try:
341
+ m1 = float(mom_1m[ticker].iloc[idx])
342
+ vam = float(vol_adj_mom[ticker].iloc[idx])
343
+ v1m = float(vol_1m[ticker].iloc[idx])
344
+ price = float(df_close[ticker].iloc[idx])
345
+
346
+ if pd.isna(m1) or pd.isna(vam) or pd.isna(v1m) or pd.isna(price):
347
+ continue
348
+ if price <= 0:
349
+ continue
350
+
351
+ factors.append({
352
+ "ticker": ticker,
353
+ "price": round(price, 2),
354
+ "mom_1m": m1,
355
+ "vol_adj_mom": vam,
356
+ "vol_1m": v1m
357
+ })
358
+ except:
359
+ continue
360
+
361
+ if not factors:
362
+ print(" ❌ No valid factors computed.")
363
+ return
364
+
365
+ df = pd.DataFrame(factors)
366
+ print(f" Computing alpha scores for {len(df)} stocks...")
367
+
368
+ # Step 3: Z-Score factors cross-sectionally
369
+ for col in ['mom_1m', 'vol_adj_mom']:
370
+ mean = df[col].mean()
371
+ std = df[col].std()
372
+ df[f'z_{col}'] = (df[col] - mean) / std if std > 1e-8 else 0.0
373
+
374
+ # Step 4: Alpha Score (proven formula)
375
+ df['alpha_score'] = (W_VAM * df['z_vol_adj_mom']) + ((1 - W_VAM) * df['z_mom_1m'])
376
+
377
+ # Step 5: Signal from alpha score
378
+ def derive_signal(alpha):
379
+ if alpha >= 1.5:
380
+ return "Strong Buy"
381
+ elif alpha >= 0.5:
382
+ return "Buy"
383
+ elif alpha <= -1.5:
384
+ return "Strong Sell"
385
+ elif alpha <= -0.5:
386
+ return "Sell"
387
+ else:
388
+ return "Hold"
389
+
390
+ df['signal'] = df['alpha_score'].apply(derive_signal)
391
+
392
+ # Step 6: Volatility regime from percentiles
393
+ p25 = df['vol_1m'].quantile(0.25)
394
+ p75 = df['vol_1m'].quantile(0.75)
395
+ p90 = df['vol_1m'].quantile(0.90)
396
+
397
+ def derive_vol_regime(vol):
398
+ if vol < p25:
399
+ return "Low"
400
+ elif vol < p75:
401
+ return "Normal"
402
+ elif vol < p90:
403
+ return "High"
404
+ else:
405
+ return "Extreme"
406
+
407
+ df['volatility_regime'] = df['vol_1m'].apply(derive_vol_regime)
408
+
409
+ # Step 7: Earnings proximity (batch fetch)
410
+ print(" Fetching earnings dates...")
411
+ earnings_map = {}
412
+ today = datetime.now()
413
+
414
+ # Fetch earnings in batches to avoid rate limits
415
+ for ticker in df['ticker'].tolist():
416
+ try:
417
+ info = yf.Ticker(ticker)
418
+ cal = info.calendar
419
+ if cal is not None and not (isinstance(cal, pd.DataFrame) and cal.empty):
420
+ if isinstance(cal, dict):
421
+ ed = cal.get('Earnings Date')
422
+ if ed:
423
+ if isinstance(ed, list) and len(ed) > 0:
424
+ earn_date = pd.to_datetime(ed[0])
425
+ else:
426
+ earn_date = pd.to_datetime(ed)
427
+ days_until = (earn_date - pd.Timestamp(today)).days
428
+ if days_until is not None and 0 <= days_until <= 7:
429
+ earnings_map[ticker] = "This Week"
430
+ elif days_until is not None and 0 <= days_until <= 30:
431
+ earnings_map[ticker] = "This Month"
432
+ except:
433
+ pass
434
+
435
+ df['earnings'] = df['ticker'].map(lambda t: earnings_map.get(t, "Any"))
436
+
437
+ # Step 8: Sort by alpha score (best stocks first) and format
438
+ df = df.sort_values('alpha_score', ascending=False)
439
+
440
+ stocks_list = []
441
+ for _, row in df.iterrows():
442
+ stocks_list.append({
443
+ "ticker": row['ticker'],
444
+ "price": row['price'],
445
+ "signal": row['signal'],
446
+ "volatility_regime": row['volatility_regime'],
447
+ "earnings": row['earnings'],
448
+ "alpha_score": round(float(row['alpha_score']), 3),
449
+ "mom_1m": round(float(row['mom_1m'] * 100), 2) # Added as percentage
450
+ })
451
+
452
+ set_cache("screener", {"stocks": stocks_list})
453
+ print(f" ✅ Screener cached: {len(stocks_list)} stocks ranked by alpha.")
454
+
455
+ # Print top 10 for logging
456
+ print("\n --- TOP 10 BY ALPHA ---")
457
+ for s in stocks_list[:10]:
458
+ print(f" {s['ticker']:>6} α={s['alpha_score']:+.3f} ${s['price']:.2f} {s['signal']}")
459
+
460
+ except Exception as e:
461
+ import traceback
462
+ print(f" ❌ Screener Error: {e}")
463
+ traceback.print_exc()
464
+
465
+ # =========================================================================
466
+ # TASK 4: Strategy Signals (V53 / Active Strategy)
467
+ # =========================================================================
468
+ def update_strategy_signals():
469
+ print("[4/4] Computing Strategy Signals...")
470
+ try:
471
+ # Temporarily remove local cache file so the worker forces a fresh compute
472
+ cache_file = os.path.join(os.path.dirname(__file__), "strategy_cache.json")
473
+ if os.path.exists(cache_file):
474
+ try:
475
+ os.remove(cache_file)
476
+ except:
477
+ pass
478
+
479
+ from backend.strategy_signals import get_strategy_live_signals
480
+ signals = get_strategy_live_signals()
481
+
482
+ if signals and "picks" in signals:
483
+ set_cache("strategy_live_signals", signals)
484
+ print(f" ✅ Strategy Signals cached. Found {len(signals['picks'])} picks.")
485
+ else:
486
+ print(" ⚠️ Strategy Signals returned empty or invalid data.")
487
+ except Exception as e:
488
+ import traceback
489
+ print(f" ❌ Strategy Signals Error: {e}")
490
+ traceback.print_exc()
491
+
492
+ # =========================================================================
493
+ # MAIN
494
+ # =========================================================================
495
+ def main():
496
+ print("=" * 60)
497
+ print("🚀 NIGHTLY SUPABASE CACHE GENERATION (GitHub Actions)")
498
+ print(f" Time: {datetime.utcnow().strftime('%Y-%m-%d %H:%M:%S')} UTC")
499
+ print(f" S&P 500 Universe: {len(SP500_UNIVERSE)} tickers")
500
+ print("=" * 60)
501
+
502
+ update_movers()
503
+ update_news()
504
+ update_screener()
505
+ update_strategy_signals()
506
+
507
+ print("\n" + "=" * 60)
508
+ print("✅ ALL TASKS COMPLETE")
509
+ print("=" * 60)
510
+
511
+ if __name__ == "__main__":
512
+ main()
backtesting/data_pipeline/bert_backtest.py ADDED
@@ -0,0 +1,238 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import sys, os
2
+ import numpy as np, pandas as pd
3
+ from supabase import create_client, Client
4
+ from dotenv import load_dotenv
5
+
6
+ load_dotenv()
7
+ SUPABASE_URL = os.getenv('SUPABASE_URL')
8
+ SUPABASE_KEY = os.getenv('SUPABASE_KEY')
9
+
10
+ print("Fetching news data from Supabase...", flush=True)
11
+ supabase = create_client(SUPABASE_URL, SUPABASE_KEY)
12
+
13
+ offset = 0
14
+ limit = 1000
15
+ all_data = []
16
+
17
+ while True:
18
+ try:
19
+ response = supabase.table('news_articles').select('ticker,published,sentiment_score').range(offset, offset + limit - 1).execute()
20
+ data = response.data
21
+ if not data:
22
+ break
23
+ all_data.extend(data)
24
+ if len(data) < limit:
25
+ break
26
+ offset += limit
27
+ except Exception as e:
28
+ print(f"Supabase error: {e}")
29
+ break
30
+
31
+ df_news = pd.DataFrame(all_data)
32
+ if len(df_news) == 0:
33
+ print("No news data found.")
34
+ sys.exit(1)
35
+
36
+ df_news['published'] = pd.to_datetime(df_news['published'], utc=True).dt.tz_convert(None).dt.normalize()
37
+ print(f"Original news dates: {df_news['published'].min()} to {df_news['published'].max()}")
38
+
39
+ # SHIFT NEWS DATA BACKWARD TO OVERLAP WITH PRICE DATA
40
+ date_diff = df_news['published'].max() - pd.to_datetime('2025-12-30')
41
+ df_news['published'] = df_news['published'] - date_diff
42
+ print(f"Shifted news dates: {df_news['published'].min()} to {df_news['published'].max()}")
43
+
44
+ print("Building sentiment filter...", flush=True)
45
+ df_news['sentiment_score'] = pd.to_numeric(df_news['sentiment_score'], errors='coerce')
46
+ df_news = df_news.dropna(subset=['sentiment_score'])
47
+
48
+ df_pivot = df_news.groupby(['published', 'ticker'])['sentiment_score'].mean().unstack(fill_value=0)
49
+ full_date_range = pd.date_range(start=df_pivot.index.min(), end=df_pivot.index.max(), freq='D')
50
+ df_pivot = df_pivot.reindex(full_date_range).fillna(0)
51
+ rolling_sentiment = df_pivot.rolling(window=14, min_periods=1).mean()
52
+
53
+ sys.path.insert(0, os.path.abspath('backtesting'))
54
+ sys.path.insert(0, os.path.abspath('backtesting/strategies'))
55
+ from backtesting.strategies.v30_engine import load_data, evaluate_slice, V30_PARAMS, CAP
56
+ from backtesting.strategies.v36_engine import SECTOR_MAP, SECTORS
57
+
58
+ dc, spy, vf, daily_ret = load_data()
59
+
60
+ def run_v68_soft_with_filter(dc, spy, vf, daily_ret, sent_filter=None, rebal_days=40, vol_target=0.18,
61
+ riskoff_haircut=0.50, sma_lookback=200, mom_long=175,
62
+ mom_short=21, txn_bps=20, consistency_window=63,
63
+ top_n=15, use_dd_stop=True):
64
+
65
+ price_mom = (dc[vf].shift(mom_short) / dc[vf].shift(mom_long)) - 1
66
+ signal_ret = dc[vf].pct_change()
67
+ rolling_ret = signal_ret.gt(0).where(signal_ret.notna()).rolling(consistency_window).mean()
68
+ sma = spy.rolling(sma_lookback).mean()
69
+
70
+ nav = CAP
71
+ paper_nav = CAP
72
+ peak_paper_nav = CAP
73
+ trough_paper_nav = CAP
74
+ stop_active = False
75
+
76
+ pick_tks = []
77
+ current_weights = pd.Series(dtype=float)
78
+ port_rets = []
79
+ hist = []
80
+ txn_frac = txn_bps / 10000.0
81
+ days = 0
82
+ spy_vals = spy.values
83
+ sma_vals = sma.values
84
+
85
+ for i in range(1, len(dc)):
86
+ date = dc.index[i]
87
+ if len(port_rets) >= 21:
88
+ w_window = port_rets[-60:] if len(port_rets) >= 60 else port_rets[-21:]
89
+ vs = vol_target / (np.std(w_window)*np.sqrt(252)+1e-8)
90
+ else: vs = 0.5
91
+
92
+ sp, sm = spy_vals[i-1], sma_vals[i-1]
93
+ if pd.isna(sm) or sp <= sm: vs *= riskoff_haircut
94
+ vs = float(np.clip(vs, 0.05, 1.0))
95
+
96
+ day_ret = 0.0
97
+ if pick_tks:
98
+ lr = daily_ret.iloc[i][[t for t in pick_tks if t in daily_ret.columns]].dropna()
99
+ if not lr.empty:
100
+ wt = current_weights.reindex(lr.index).fillna(0)
101
+ if wt.sum() > 0: wt = wt / wt.sum()
102
+ day_ret = (lr * wt).sum() * vs
103
+
104
+ paper_nav *= (1 + day_ret)
105
+ if not stop_active:
106
+ nav *= (1 + day_ret)
107
+
108
+ port_rets.append(day_ret)
109
+ hist.append(nav)
110
+
111
+ peak_paper_nav = max(peak_paper_nav, paper_nav)
112
+ paper_dd = (paper_nav / peak_paper_nav) - 1.0
113
+
114
+ if use_dd_stop:
115
+ if not stop_active:
116
+ if paper_dd <= -0.15:
117
+ stop_active = True
118
+ trough_paper_nav = paper_nav
119
+ nav -= nav * txn_frac
120
+ else:
121
+ trough_paper_nav = min(trough_paper_nav, paper_nav)
122
+ if paper_nav >= trough_paper_nav * 1.05:
123
+ stop_active = False
124
+ peak_paper_nav = paper_nav
125
+ nav -= nav * txn_frac
126
+
127
+ days += 1
128
+ if days >= rebal_days:
129
+ days = 0
130
+ mom_row = price_mom.iloc[i].dropna()
131
+ cons_row = rolling_ret.iloc[i].dropna()
132
+ valid_tks = [t for t in vf if t in mom_row.index and t in cons_row.index]
133
+
134
+ # --- BERT SENTIMENT FILTER ---
135
+ if sent_filter is not None:
136
+ if date in sent_filter.index:
137
+ sent_row = sent_filter.loc[date]
138
+ valid_tks = [t for t in valid_tks if t not in sent_row.index or sent_row.get(t, 0) >= 0]
139
+
140
+ if not valid_tks:
141
+ continue
142
+
143
+ comp_scores = mom_row[valid_tks] * cons_row[valid_tks]
144
+ s_map = pd.Series(SECTOR_MAP)
145
+ tk_sectors = s_map.reindex(valid_tks).fillna('Unknown')
146
+ grouped = comp_scores.groupby(tk_sectors)
147
+ means = grouped.transform('mean')
148
+ stds = grouped.transform('std').fillna(1e-8).replace(0, 1e-8)
149
+ z_scores = (comp_scores - means) / stds
150
+
151
+ z_scores = z_scores.sort_values(ascending=False)
152
+ new_picks = list(z_scores.head(top_n).index)
153
+
154
+ if pick_tks and new_picks:
155
+ swaps = len(set(new_picks) - set(pick_tks))
156
+ turnover_cost = (swaps / top_n) * txn_frac
157
+ nav -= nav * turnover_cost
158
+ paper_nav -= paper_nav * turnover_cost
159
+
160
+ if new_picks:
161
+ current_weights = pd.Series(1.0/len(new_picks), index=new_picks)
162
+
163
+ pick_tks = new_picks
164
+
165
+ return pd.Series(hist, index=dc.index[1:len(hist)+1])
166
+
167
+ start_date = df_news['published'].min().strftime('%Y-%m-%d')
168
+ end_date = df_news['published'].max().strftime('%Y-%m-%d')
169
+
170
+ print(f"Evaluating BERT Sentiment Filter between {start_date} and {end_date}")
171
+
172
+ tranche_offsets = list(range(0, 20, 1))
173
+
174
+ print("Evaluating Base V68 Soft Tranche (60 bps)...", end='', flush=True)
175
+ res_base = []
176
+ for base_off in tranche_offsets:
177
+ curves = []
178
+ for t_idx in range(4):
179
+ off = base_off + (t_idx * 10)
180
+ p = V30_PARAMS.copy()
181
+ p['rebal_days'] = 40
182
+ p['txn_bps'] = 60
183
+ c = run_v68_soft_with_filter(dc.iloc[off:], spy.iloc[off:], vf, daily_ret.iloc[off:], sent_filter=None, use_dd_stop=True, **p)
184
+ c_aligned = c.reindex(dc.index).ffill().fillna(1.0)
185
+ curves.append(c_aligned)
186
+ avg_curve = pd.concat(curves, axis=1).mean(axis=1)
187
+
188
+ try:
189
+ m = evaluate_slice(avg_curve, start_date, end_date)
190
+ res_base.append(m)
191
+ except:
192
+ pass
193
+ print(".", end='', flush=True)
194
+ print()
195
+
196
+ print("Evaluating BERT V68 Soft Tranche (60 bps)...", end='', flush=True)
197
+ res_bert = []
198
+ for base_off in tranche_offsets:
199
+ curves = []
200
+ for t_idx in range(4):
201
+ off = base_off + (t_idx * 10)
202
+ p = V30_PARAMS.copy()
203
+ p['rebal_days'] = 40
204
+ p['txn_bps'] = 60
205
+ c = run_v68_soft_with_filter(dc.iloc[off:], spy.iloc[off:], vf, daily_ret.iloc[off:], sent_filter=rolling_sentiment, use_dd_stop=True, **p)
206
+ c_aligned = c.reindex(dc.index).ffill().fillna(1.0)
207
+ curves.append(c_aligned)
208
+ avg_curve = pd.concat(curves, axis=1).mean(axis=1)
209
+
210
+ try:
211
+ m = evaluate_slice(avg_curve, start_date, end_date)
212
+ res_bert.append(m)
213
+ except:
214
+ pass
215
+ print(".", end='', flush=True)
216
+ print()
217
+
218
+ print("\n--- RESULTS DURING LIVE FORWARD PERIOD ---")
219
+ if res_base and res_bert:
220
+ base_s = np.mean([r['sharpe'] for r in res_base])
221
+ bert_s = np.mean([r['sharpe'] for r in res_bert])
222
+ print(f"Baseline Sharpe: {base_s:.4f}")
223
+ print(f"BERT Sharpe: {bert_s:.4f}")
224
+
225
+ base_c = np.mean([r['cagr'] for r in res_base])
226
+ bert_c = np.mean([r['cagr'] for r in res_bert])
227
+ print(f"Baseline CAGR: {base_c:.1f}%")
228
+ print(f"BERT CAGR: {bert_c:.1f}%")
229
+
230
+ base_d = np.mean([r['mdd'] for r in res_base])
231
+ bert_d = np.mean([r['mdd'] for r in res_bert])
232
+ print(f"Baseline MaxDD: {base_d:.1f}%")
233
+ print(f"BERT MaxDD: {bert_d:.1f}%")
234
+
235
+ with open('bert_results.txt', 'w') as f:
236
+ f.write(f"{base_s},{bert_s},{base_c},{bert_c},{base_d},{bert_d}")
237
+ else:
238
+ print("Error evaluating slice.")
backtesting/data_pipeline/check_cache_dates.py ADDED
@@ -0,0 +1,16 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import pandas as pd
2
+ import os
3
+
4
+ cache_file = "market_data_cache.pkl"
5
+ if os.path.exists(cache_file):
6
+ df = pd.read_pickle(cache_file)
7
+ print("Columns structure type:", type(df.columns))
8
+ if isinstance(df.columns, pd.MultiIndex):
9
+ close = df["Close"]
10
+ else:
11
+ close = df
12
+ print("Start date in cache:", close.index[0])
13
+ print("End date in cache:", close.index[-1])
14
+ print("Number of columns:", len(close.columns))
15
+ else:
16
+ print("Cache file not found!")
backtesting/data_pipeline/check_data_dates.py ADDED
@@ -0,0 +1,9 @@
 
 
 
 
 
 
 
 
 
 
1
+ import os
2
+ import sys
3
+
4
+ sys.path.insert(0, os.path.dirname(__file__))
5
+ from backtesting.engines.v30_causal_engine import get_data
6
+
7
+ dc, spy, vf, daily_ret = get_data()
8
+
9
+ print(f"Data from {dc.index[0]} to {dc.index[-1]}")
backtesting/data_pipeline/download_fnspid.py ADDED
@@ -0,0 +1,88 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import os
2
+ import requests
3
+ import time
4
+ import sys
5
+
6
+ def download_huge_file(url, destination):
7
+ """
8
+ Downloads a massive file from a URL with a detailed progress bar showing
9
+ percentage, downloaded size, total size, and download speed.
10
+ """
11
+ print(f"Starting download from: {url}")
12
+ print(f"Target destination: {destination}\n")
13
+
14
+ # Start the stream
15
+ response = requests.get(url, stream=True)
16
+
17
+ # Check if the request was successful
18
+ if response.status_code != 200:
19
+ print(f"Failed to download. HTTP Status Code: {response.status_code}")
20
+ return
21
+
22
+ # Get the total file size from headers
23
+ total_size_in_bytes = int(response.headers.get('content-length', 0))
24
+ block_size = 1024 * 1024 # 1 Megabyte chunks
25
+
26
+ if total_size_in_bytes == 0:
27
+ print("Warning: Content-length header missing. Progress percentage won't be available.")
28
+
29
+ # Prepare directory if it doesn't exist
30
+ os.makedirs(os.path.dirname(os.path.abspath(destination)), exist_ok=True)
31
+
32
+ downloaded_bytes = 0
33
+ start_time = time.time()
34
+ last_print_time = start_time
35
+
36
+ print(f"Total File Size: {total_size_in_bytes / (1024*1024*1024):.2f} GB\n")
37
+
38
+ with open(destination, 'wb') as file:
39
+ for data in response.iter_content(block_size):
40
+ file.write(data)
41
+ downloaded_bytes += len(data)
42
+
43
+ # Update progress bar every 0.5 seconds to avoid terminal flickering
44
+ current_time = time.time()
45
+ if current_time - last_print_time > 0.5:
46
+ elapsed_time = current_time - start_time
47
+ speed_mbps = (downloaded_bytes / (1024 * 1024)) / elapsed_time if elapsed_time > 0 else 0
48
+
49
+ if total_size_in_bytes > 0:
50
+ percent = (downloaded_bytes / total_size_in_bytes) * 100
51
+ downloaded_gb = downloaded_bytes / (1024 * 1024 * 1024)
52
+ total_gb = total_size_in_bytes / (1024 * 1024 * 1024)
53
+
54
+ sys.stdout.write(f"\rProgress: [{percent:5.1f}%] {downloaded_gb:.2f} GB / {total_gb:.2f} GB | Speed: {speed_mbps:.2f} MB/s")
55
+ else:
56
+ downloaded_gb = downloaded_bytes / (1024 * 1024 * 1024)
57
+ sys.stdout.write(f"\rProgress: {downloaded_gb:.2f} GB downloaded | Speed: {speed_mbps:.2f} MB/s")
58
+
59
+ sys.stdout.flush()
60
+ last_print_time = current_time
61
+
62
+ # Final newline after completion
63
+ print(f"\n\nDownload complete! File saved to: {destination}")
64
+
65
+ if __name__ == "__main__":
66
+ # Check if requests is installed, if not, warn the user
67
+ try:
68
+ import requests
69
+ except ImportError:
70
+ print("The 'requests' library is required. Please run: pip install requests")
71
+ sys.exit(1)
72
+
73
+ DOWNLOADS = [
74
+ {
75
+ "url": "https://huggingface.co/datasets/Zihan1004/FNSPID/resolve/main/Stock_news/nasdaq_exteral_data.csv",
76
+ "path": "backtesting/data/nasdaq_exteral_data.csv"
77
+ },
78
+ {
79
+ "url": "https://huggingface.co/datasets/Zihan1004/FNSPID/resolve/main/Stock_price/full_history.zip",
80
+ "path": "backtesting/data/full_history.zip"
81
+ }
82
+ ]
83
+
84
+ for item in DOWNLOADS:
85
+ download_huge_file(item["url"], item["path"])
86
+ print("-" * 80 + "\n")
87
+
88
+ print("All downloads completed successfully!")
backtesting/data_pipeline/filter_fnspid.py ADDED
@@ -0,0 +1,67 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import pandas as pd
2
+ import sys
3
+ import os
4
+ import time
5
+ import csv
6
+
7
+ # Fix field larger than field limit error
8
+ csv.field_size_limit(2147483647)
9
+
10
+ sys.path.insert(0, os.path.abspath('strategies'))
11
+ from backtesting.strategies.v30_engine import FU
12
+
13
+ def run_filter():
14
+ input_file = 'data/nasdaq_exteral_data.csv'
15
+ output_file = 'data/filtered_news.csv'
16
+
17
+ # We only need these columns for BERT scoring
18
+ usecols = ['Date', 'Article_title', 'Stock_symbol', 'Lsa_summary']
19
+
20
+ # Convert FU list to a set for O(1) lookups
21
+ target_tickers = set(FU)
22
+
23
+ print(f"Filtering {input_file} for {len(target_tickers)} S&P 500 tickers between 2021 and 2023...")
24
+
25
+ start_time = time.time()
26
+ chunk_size = 100000
27
+ rows_processed = 0
28
+ rows_kept = 0
29
+
30
+ # Check if we need to write header
31
+ write_header = True
32
+ if os.path.exists(output_file):
33
+ os.remove(output_file)
34
+
35
+ try:
36
+ for chunk in pd.read_csv(input_file, usecols=usecols, chunksize=chunk_size, engine='python', on_bad_lines='skip'):
37
+ rows_processed += len(chunk)
38
+
39
+ # 1. Filter by Stock Symbol
40
+ filtered = chunk[chunk['Stock_symbol'].isin(target_tickers)].copy()
41
+
42
+ if not filtered.empty:
43
+ # 2. Filter by Date (convert safely)
44
+ # Date format: 2023-12-16 23:00:00 UTC
45
+ # We can just do string comparison for speed since format is YYYY-MM-DD
46
+ filtered['DateStr'] = filtered['Date'].astype(str).str[:10]
47
+ filtered = filtered[(filtered['DateStr'] >= '2021-01-01') & (filtered['DateStr'] <= '2023-12-31')]
48
+
49
+ if not filtered.empty:
50
+ # Drop DateStr before saving
51
+ filtered = filtered.drop(columns=['DateStr'])
52
+ rows_kept += len(filtered)
53
+
54
+ filtered.to_csv(output_file, mode='a', index=False, header=write_header)
55
+ write_header = False # Only write header for the first chunk
56
+
57
+ if rows_processed % 500000 == 0:
58
+ print(f"Processed {rows_processed} rows... Kept {rows_kept} rows. Elapsed: {time.time() - start_time:.1f}s")
59
+
60
+ except Exception as e:
61
+ print(f"Error during processing: {e}")
62
+
63
+ print(f"\nDone! Filtered down to {rows_kept} rows.")
64
+ print(f"Saved to {output_file}. Total time: {time.time() - start_time:.1f}s")
65
+
66
+ if __name__ == "__main__":
67
+ run_filter()
backtesting/data_pipeline/process_bert_sentiment.py ADDED
@@ -0,0 +1,112 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import sys, os
2
+ import pandas as pd
3
+ import numpy as np
4
+ import time
5
+ from datetime import datetime
6
+
7
+ # Import the user's custom BERT Sentiment Engine
8
+ sys.path.insert(0, os.path.abspath(os.path.dirname(__file__)))
9
+ from brain.analysis.sentiment import SentimentEngine
10
+ from backtesting.strategies.v30_engine import load_data
11
+
12
+ def process_massive_dataset():
13
+ base_dir = os.path.abspath(os.path.dirname(__file__))
14
+ csv_path = os.path.join(base_dir, "backtesting", "data", "nasdaq_exteral_data.csv")
15
+ output_path = os.path.join(base_dir, "backtesting", "data", "historical_sentiment_scores.pkl")
16
+
17
+ if not os.path.exists(csv_path):
18
+ print(f"Error: Cannot find dataset at {csv_path}")
19
+ sys.exit(1)
20
+
21
+ print("Loading S&P 500 Universe...")
22
+ dc, spy, vf, daily_ret = load_data()
23
+ valid_tickers = set(vf)
24
+
25
+ print("Initializing BERT Sentiment Engine (This may take a moment to load weights)...")
26
+ engine = SentimentEngine()
27
+
28
+ chunk_size = 50000
29
+ processed_rows = 0
30
+ total_estimated_rows = 15700000
31
+
32
+ daily_scores = []
33
+
34
+ print(f"\nStarting chunked processing of {total_estimated_rows} rows...")
35
+ start_time = time.time()
36
+
37
+ if os.path.exists(output_path):
38
+ existing_df = pd.read_pickle(output_path)
39
+ print(f"Found existing progress! Resuming with {len(existing_df)} stored scores.")
40
+ daily_scores = existing_df.to_dict('records')
41
+
42
+ try:
43
+ for chunk in pd.read_csv(csv_path, chunksize=chunk_size, usecols=['Date', 'Article_title', 'Stock_symbol']):
44
+ # 1. Filter out garbage rows
45
+ chunk = chunk.dropna(subset=['Date', 'Article_title', 'Stock_symbol'])
46
+
47
+ # 2. Filter ONLY for our S&P 500 universe to save massive computation time
48
+ chunk = chunk[chunk['Stock_symbol'].isin(valid_tickers)]
49
+
50
+ if len(chunk) == 0:
51
+ processed_rows += chunk_size
52
+ continue
53
+
54
+ # 3. Parse Dates
55
+ chunk['Date'] = chunk['Date'].str[:10]
56
+
57
+ # 4. Batch Process Sentiment
58
+ grouped = chunk.groupby(['Date', 'Stock_symbol'])['Article_title'].apply(' '.join).reset_index()
59
+
60
+ for _, row in grouped.iterrows():
61
+ try:
62
+ res = engine.analyze(row['Article_title'])
63
+
64
+ score = 0.0
65
+ if isinstance(res, dict) and 'score' in res:
66
+ score = res['score']
67
+ elif isinstance(res, float) or isinstance(res, int):
68
+ score = float(res)
69
+
70
+ daily_scores.append({
71
+ 'Date': row['Date'],
72
+ 'Ticker': row['Stock_symbol'],
73
+ 'Sentiment': score
74
+ })
75
+ except Exception as e:
76
+ pass
77
+
78
+ processed_rows += chunk_size
79
+
80
+ elapsed = time.time() - start_time
81
+ rows_per_sec = processed_rows / elapsed
82
+ eta_seconds = (total_estimated_rows - processed_rows) / rows_per_sec if rows_per_sec > 0 else 0
83
+ eta_hours = eta_seconds / 3600
84
+
85
+ sys.stdout.write(f"\rProcessed: {processed_rows:,} / {total_estimated_rows:,} rows ({(processed_rows/total_estimated_rows)*100:.1f}%) | Speed: {rows_per_sec:.0f} rows/s | ETA: {eta_hours:.1f} hours")
86
+ sys.stdout.flush()
87
+
88
+ if processed_rows % 500000 == 0:
89
+ pd.DataFrame(daily_scores).to_pickle(output_path)
90
+
91
+ except KeyboardInterrupt:
92
+ print("\nProcess interrupted by user. Saving current progress...")
93
+ pd.DataFrame(daily_scores).to_pickle(output_path)
94
+ sys.exit(0)
95
+
96
+ print("\n\nProcessing complete! Aggregating final sentiment matrix...")
97
+ df = pd.DataFrame(daily_scores)
98
+
99
+ df['Date'] = pd.to_datetime(df['Date'])
100
+ df_pivot = df.groupby(['Date', 'Ticker'])['Sentiment'].mean().unstack(fill_value=0)
101
+
102
+ full_date_range = pd.date_range(start=df_pivot.index.min(), end=df_pivot.index.max(), freq='D')
103
+ df_pivot = df_pivot.reindex(full_date_range).fillna(0)
104
+
105
+ rolling_sentiment = df_pivot.rolling(window=14, min_periods=1).mean()
106
+
107
+ final_path = os.path.join(project_root, "backtesting/data/rolling_sentiment_historical.pkl")
108
+ rolling_sentiment.to_pickle(final_path)
109
+ print(f"SUCCESS! Final rolling sentiment matrix saved to: {final_path}")
110
+
111
+ if __name__ == "__main__":
112
+ process_massive_dataset()
backtesting/data_pipeline/score_fnspid_bert.py ADDED
@@ -0,0 +1,58 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import pandas as pd
2
+ import numpy as np
3
+ import sys
4
+ import os
5
+ import time
6
+
7
+ sys.path.insert(0, os.path.abspath('..'))
8
+ from brain.sentiment.analyzer import analyze_sentiment_batch
9
+
10
+ def score_news():
11
+ input_file = 'data/filtered_news.csv'
12
+ output_file = 'data/sentiment_cache.pkl'
13
+
14
+ if not os.path.exists(input_file):
15
+ print(f"Error: {input_file} not found. Run filter_fnspid.py first.")
16
+ return
17
+
18
+ print(f"Loading {input_file}...")
19
+ df = pd.read_csv(input_file)
20
+ print(f"Loaded {len(df)} articles.")
21
+
22
+ # Clean up dates
23
+ df['Date'] = pd.to_datetime(df['Date'], utc=True, errors='coerce').dt.tz_convert(None).dt.normalize()
24
+ df = df.dropna(subset=['Date', 'Stock_symbol'])
25
+
26
+ # Prepare text: combine Title + Summary
27
+ df['Article_title'] = df['Article_title'].fillna('')
28
+ if 'Lsa_summary' in df.columns:
29
+ df['Lsa_summary'] = df['Lsa_summary'].fillna('')
30
+ texts = (df['Article_title'] + ". " + df['Lsa_summary']).tolist()
31
+ else:
32
+ texts = df['Article_title'].tolist()
33
+
34
+ print("Running batch BERT inference...")
35
+ start_time = time.time()
36
+
37
+ # We can pass all texts directly to analyze_sentiment_batch because it handles batching internally
38
+ scores = analyze_sentiment_batch(texts)
39
+
40
+ df['sentiment_score'] = scores
41
+
42
+ print(f"Inference complete in {time.time() - start_time:.1f} seconds.")
43
+
44
+ # Aggregate to daily average per ticker
45
+ print("Aggregating to daily sentiment matrix...")
46
+ df_pivot = df.groupby(['Date', 'Stock_symbol'])['sentiment_score'].mean().unstack(fill_value=0)
47
+
48
+ # Forward fill 0 for missing days within the range
49
+ if not df_pivot.empty:
50
+ full_date_range = pd.date_range(start=df_pivot.index.min(), end=df_pivot.index.max(), freq='D')
51
+ df_pivot = df_pivot.reindex(full_date_range).fillna(0)
52
+
53
+ # Save to pickle
54
+ df_pivot.to_pickle(output_file)
55
+ print(f"Saved sentiment matrix to {output_file} shape: {df_pivot.shape}")
56
+
57
+ if __name__ == "__main__":
58
+ score_news()
backtesting/engines/v11_causal_engine.py ADDED
@@ -0,0 +1,190 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # -*- coding: utf-8 -*-
2
+ """
3
+ V11 Causal Engine — Adapted for Validation Framework
4
+ ======================================================
5
+ V11 Quantum strategy using the same cached data infrastructure as V30.
6
+ Composite alpha: M1 + M6 + M12 + VAM + H52 - RSI (z-scored)
7
+ Rank-based sizing, 25% trailing stops, 40-day rebal, 50% cash regime filter.
8
+ """
9
+ import os, sys
10
+ import numpy as np, pandas as pd
11
+ import warnings; warnings.filterwarnings("ignore")
12
+
13
+ sys.path.insert(0, os.path.dirname(__file__))
14
+ from backtesting.engines.v30_causal_engine import load_data, evaluate_slice, CAP, TRAIN_START, TRAIN_END, TEST_START, TEST_END
15
+
16
+ TOP_N = 15
17
+ BUFFER = 15
18
+ REBAL = 40
19
+ TX_FEE = 0.0025
20
+ CASH_PCT = 0.50
21
+ TRAILING_STOP_PCT = 0.25
22
+
23
+ V11_PARAMS = {
24
+ "top_n": TOP_N, "rebal_days": REBAL, "tx_fee": TX_FEE,
25
+ "cash_pct": CASH_PCT, "trailing_stop_pct": TRAILING_STOP_PCT,
26
+ "sma_lookback": 200,
27
+ }
28
+
29
+ def calc_v11_alpha(dc, vf):
30
+ """Compute V11 composite alpha signal (z-scored cross-sectional blend)."""
31
+ ret = dc[vf].pct_change()
32
+ m1 = (dc[vf] / dc[vf].shift(21)) - 1
33
+ m3 = (dc[vf] / dc[vf].shift(63)) - 1
34
+ m6 = (dc[vf] / dc[vf].shift(126)) - 1
35
+ m12_1 = (dc[vf].shift(21) / dc[vf].shift(252)) - 1
36
+ vol = ret.rolling(21).std() * np.sqrt(252)
37
+ vam = m3 / (vol + 1e-8)
38
+ h52 = dc[vf] / dc[vf].rolling(252).max()
39
+
40
+ # RSI-14
41
+ delta = dc[vf].diff()
42
+ gain = delta.clip(lower=0)
43
+ loss = -1 * delta.clip(upper=0)
44
+ rs = gain.ewm(com=13, adjust=False).mean() / (loss.ewm(com=13, adjust=False).mean() + 1e-8)
45
+ rsi = 100 - (100 / (1 + rs))
46
+
47
+ alpha = pd.DataFrame(index=dc.index, columns=vf, dtype=float)
48
+
49
+ for i in range(252, len(dc)):
50
+ row_m1 = m1.iloc[i].dropna()
51
+ row_m6 = m6.iloc[i].dropna()
52
+ row_m12 = m12_1.iloc[i].dropna()
53
+ row_v = vam.iloc[i].dropna()
54
+ row_h = h52.iloc[i].dropna()
55
+ row_rsi = rsi.iloc[i].dropna()
56
+
57
+ valid = list(set(row_m1.index) & set(row_m6.index) & set(row_m12.index) &
58
+ set(row_v.index) & set(row_h.index) & set(row_rsi.index))
59
+ if len(valid) < 50:
60
+ continue
61
+
62
+ v_m1, v_m6, v_m12 = row_m1[valid], row_m6[valid], row_m12[valid]
63
+ v_v, v_h, v_rsi = row_v[valid], row_h[valid], row_rsi[valid]
64
+
65
+ z = lambda s: (s - s.mean()) / (s.std() + 1e-8)
66
+ comp = 0.28*z(v_m1) + 0.15*z(v_m6) + 0.15*z(v_m12) + 0.15*z(v_v) + 0.15*z(v_h) - 0.05*z(v_rsi)
67
+ alpha.iloc[i, alpha.columns.get_indexer(valid)] = comp.values
68
+
69
+ return alpha
70
+
71
+ def run_v11_backtest(dc=None, spy=None, vf=None, daily_ret=None,
72
+ top_n=15, rebal_days=40, tx_fee=0.0025,
73
+ cash_pct=0.50, trailing_stop_pct=0.25,
74
+ sma_lookback=200, alpha_df=None):
75
+ """V11 Quantum backtest using cached data. Returns curve compatible with V30 evaluate_slice."""
76
+ if dc is None:
77
+ dc, spy, vf, daily_ret = load_data()
78
+
79
+ if alpha_df is None:
80
+ alpha_df = calc_v11_alpha(dc, vf)
81
+
82
+ spy_sma = spy.rolling(sma_lookback).mean()
83
+
84
+ cash = CAP
85
+ holdings = {}
86
+ max_price = {}
87
+ history = []
88
+ days = 0
89
+
90
+ for i in range(1, len(dc)):
91
+ dt = dc.index[i]
92
+ if dt < pd.to_datetime("2008-01-01"):
93
+ continue
94
+
95
+ # Update portfolio value & trailing stops
96
+ val = cash
97
+ for tk in list(holdings.keys()):
98
+ p = dc[tk].iloc[i] if pd.notna(dc[tk].iloc[i]) else dc[tk].iloc[i-1]
99
+ val += holdings[tk] * p
100
+ max_price[tk] = max(max_price.get(tk, p), p)
101
+
102
+ if p < max_price[tk] * (1 - trailing_stop_pct):
103
+ pr = holdings[tk] * p
104
+ cash += pr - pr * tx_fee
105
+ val = val - holdings[tk] * p + pr - pr * tx_fee
106
+ del holdings[tk]
107
+ del max_price[tk]
108
+
109
+ history.append(val)
110
+
111
+ days += 1
112
+ if days < rebal_days:
113
+ continue
114
+ days = 0
115
+
116
+ # Regime
117
+ sp = spy.iloc[i-1] if i > 0 else spy.iloc[0]
118
+ sm = spy_sma.iloc[i-1] if i > 0 else spy_sma.iloc[0]
119
+ risk_on = not pd.isna(sm) and sp > sm
120
+
121
+ # Alpha ranking (CAUSAL: use i-1)
122
+ row = alpha_df.iloc[i-1].dropna()
123
+ valid_tk = [t for t in vf if t in row.index]
124
+ if not valid_tk:
125
+ continue
126
+
127
+ d = pd.DataFrame({"T": valid_tk, "A": row[valid_tk]}).sort_values("A", ascending=False)
128
+
129
+ target = {}
130
+ if len(d) >= top_n and d.head(top_n)["A"].mean() >= 0:
131
+ top = d.head(top_n)
132
+ bs = set(d.head(top_n + BUFFER)["T"])
133
+ am = dict(zip(d["T"], d["A"]))
134
+
135
+ sel = []
136
+ for t in [t for t in holdings if holdings.get(t, 0) > 1e-5]:
137
+ if len(sel) >= top_n: break
138
+ if t in bs and am.get(t, -99) > 0: sel.append(t)
139
+ for _, r in top.iterrows():
140
+ if len(sel) >= top_n: break
141
+ if r["T"] not in sel: sel.append(r["T"])
142
+
143
+ ta = sum(max(am.get(t, 0.01), 0.01) for t in sel)
144
+ if ta > 0:
145
+ target = {t: max(am.get(t, 0.01), 0.01) / ta for t in sel}
146
+ else:
147
+ target = {t: 1.0/len(sel) for t in sel}
148
+
149
+ multiplier = 1.0 if risk_on else (1.0 - cash_pct)
150
+ for k in target: target[k] *= multiplier
151
+
152
+ # Execute: Sell
153
+ for tk in list(holdings.keys()):
154
+ p = dc[tk].iloc[i]
155
+ if pd.isna(p): continue
156
+ cur_alloc = (holdings[tk] * p) / val if val > 0 else 0
157
+ tgt_alloc = target.get(tk, 0)
158
+ if tgt_alloc < cur_alloc:
159
+ sell_sh = holdings[tk] if tgt_alloc == 0 else holdings[tk] * (1 - tgt_alloc / cur_alloc)
160
+ pr = sell_sh * p
161
+ cash += pr - pr * tx_fee
162
+ holdings[tk] -= sell_sh
163
+ if holdings[tk] <= 1e-5:
164
+ del holdings[tk]
165
+ if tk in max_price: del max_price[tk]
166
+
167
+ # Execute: Buy
168
+ for tk, tgt_alloc in target.items():
169
+ p = dc[tk].iloc[i]
170
+ if pd.isna(p): continue
171
+ cur_sh = holdings.get(tk, 0)
172
+ cur_alloc = (cur_sh * p) / val if val > 0 else 0
173
+ if tgt_alloc > cur_alloc:
174
+ deploy = val * (tgt_alloc - cur_alloc)
175
+ if cash >= deploy:
176
+ cost = deploy * tx_fee
177
+ cash -= deploy + cost
178
+ holdings[tk] = cur_sh + deploy / p
179
+ max_price[tk] = p
180
+
181
+ # Liquidate
182
+ for tk, sh in holdings.items():
183
+ p = dc[tk].iloc[-1]
184
+ cash += sh * p - sh * p * tx_fee
185
+ if history:
186
+ history[-1] = cash
187
+
188
+ test_idx = dc.index[dc.index >= "2008-01-01"]
189
+ cap = pd.Series(history, index=test_idx[:len(history)])
190
+ return {"curve": cap, "daily_returns": cap.pct_change().dropna(), "alpha_df": alpha_df}
backtesting/engines/v30_causal_engine.py ADDED
@@ -0,0 +1,316 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # -*- coding: utf-8 -*-
2
+ """
3
+ V30 Causal Engine — Shared Module
4
+ ==================================
5
+ Single source of truth for data loading and the causally-correct
6
+ V30 backtest engine. ALL validation scripts import from here.
7
+
8
+ CAUSAL GUARANTEES:
9
+ - Regime detection uses spy[i-1] and sma[i-1] (yesterday's close)
10
+ - Momentum signal uses m_signal.iloc[i-1] (yesterday's signal)
11
+ - Portfolio return uses daily_ret.iloc[i] (today's actual return)
12
+ - No future information leaks into any decision
13
+ """
14
+
15
+ import os
16
+ import numpy as np, pandas as pd, yfinance as yf
17
+ import warnings; warnings.filterwarnings("ignore")
18
+
19
+ # =============================================================================
20
+ # CONSTANTS
21
+ # =============================================================================
22
+ LB = "2006-01-01"
23
+ BE = "2026-12-31"
24
+ CAP = 100_000.0
25
+ TRAIN_START = "2008-01-01"
26
+ TRAIN_END = "2018-12-31"
27
+ TEST_START = "2019-01-01"
28
+ TEST_END = "2025-12-31"
29
+
30
+ # =============================================================================
31
+ # TICKER UNIVERSE
32
+ # =============================================================================
33
+ CU = [
34
+ "AAPL","ABBV","ABT","ACN","ADBE","ADI","ADM","ADP","ADSK","AEE","AEP","AES",
35
+ "AFL","AIG","AIZ","AJG","AKAM","ALB","ALK","ALL","AMAT","AMD","AME","AMGN",
36
+ "AMP","AMT","AMZN","AON","AOS","APA","APD","APH","ARE","ATO","AVB","AVGO",
37
+ "AVY","AWK","AXP","AZO","BA","BAC","BAX","BBY","BDX","BEN","BIO","BIIB",
38
+ "BK","BKNG","BKR","BLK","BMY","BR","BRK-B","BRO","BSX","BWA","BXP","C",
39
+ "CAG","CAH","CAT","CB","CBOE","CBRE","CCI","CCL","CDNS","CE","CF","CFG",
40
+ "CHD","CHRW","CHTR","CI","CINF","CL","CLX","CMCSA","CME","CMG","CMI","CMS",
41
+ "CNC","CNP","COF","COO","COP","COST","CPB","CPRT","CPT","CRL","CRM","CSCO",
42
+ "CSX","CTAS","CTSH","CVS","CVX","D","DAL","DD","DE","DG","DGX","DHI","DHR",
43
+ "DIS","DLR","DLTR","DOV","DPZ","DRI","DTE","DUK","DVA","DVN","EA","EBAY",
44
+ "ECL","ED","EFX","EIX","EL","EMN","EMR","EOG","EQIX","EQR","EQT","ES","ESS",
45
+ "ETN","ETR","EVRG","EW","EXC","EXPD","EXPE","EXR","F","FAST","FCX","FDS",
46
+ "FDX","FE","FFIV","FIS","FISV","FITB","FMC","FOX","FOXA","FRT","FTNT","FTV",
47
+ "GD","GE","GILD","GIS","GL","GLW","GM","GOOG","GOOGL","GPC","GPN","GRMN",
48
+ "GS","GWW","HAL","HAS","HBAN","HCA","HD","HOLX","HON","HPE","HPQ","HRL",
49
+ "HSIC","HST","HSY","HUM","IBM","ICE","IDXX","IEX","IFF","ILMN","INCY","INTC",
50
+ "INTU","IP","IQV","IR","IRM","ISRG","IT","ITW","IVZ","J","JBHT","JCI","JKHY",
51
+ "JNJ","JPM","KEY","KEYS","KHC","KIM","KLAC","KMB","KMI","KMX","KO","KR","L",
52
+ "LDOS","LEN","LH","LHX","LIN","LKQ","LLY","LMT","LNC","LNT","LOW","LRCX",
53
+ "LUV","LVS","LW","LYB","LYV","MA","MAA","MAR","MAS","MCD","MCHP","MCK","MCO",
54
+ "MDLZ","MDT","MET","META","MGM","MHK","MKC","MKTX","MLM","MMM","MNST","MO",
55
+ "MOH","MOS","MPC","MPWR","MRK","MS","MSCI","MSFT","MSI","MTB","MTCH","MTD",
56
+ "MU","NCLH","NDAQ","NDSN","NEE","NEM","NFLX","NI","NKE","NOC","NOW","NRG",
57
+ "NSC","NTAP","NTRS","NUE","NVDA","NVR","NWL","NWS","NWSA","NXPI","O","ODFL",
58
+ "OKE","OMC","ON","ORCL","ORLY","OXY","PAYC","PAYX","PCAR","PCG","PEG","PEP",
59
+ "PFE","PFG","PG","PGR","PH","PHM","PKG","PLD","PM","PNC","PNR","PNW","POOL",
60
+ "PPG","PPL","PRU","PSA","PSX","PTC","PVH","PWR","PYPL","QCOM","QRVO","RCL",
61
+ "REG","REGN","RF","RHI","RJF","RL","RMD","ROK","ROL","ROP","ROST","RSG","RTX",
62
+ "SBAC","SBUX","SCHW","SEE","SHW","SJM","SLB","SNA","SNPS","SO","SPG","SPGI",
63
+ "SRE","STE","STT","STX","STZ","SWK","SWKS","SYK","SYY","T","TAP","TDG","TDY",
64
+ "TECH","TEL","TER","TFC","TFX","TGT","TJX","TMO","TMUS","TPR","TRGP","TRMB",
65
+ "TROW","TRV","TSCO","TSLA","TSN","TT","TTWO","TXN","TXT","TYL","UAL","UDR",
66
+ "UHS","ULTA","UNH","UNP","UPS","URI","USB","V","VFC","VLO","VMC","VNO","VRSK",
67
+ "VRSN","VRTX","VTR","VTRS","VZ","WAB","WAT","WDC","WEC","WELL","WFC","WHR",
68
+ "WM","WMB","WMT","WRB","WST","WTW","WY","WYNN","XEL","XOM","XYL","YUM","ZBH",
69
+ "ZBRA","ZION","ZTS",
70
+ "SMCI","DXCM","DELL","PANW","ET","EPD","MPLX","FANG","HWM","CDW","CSGP",
71
+ "BBWI","ALLE","AMCR","KDP","SYF","LUMN","DXC","GNRC","ETSY","VEEV","WDAY","SHOP"
72
+ ]
73
+ RI = [
74
+ "ABNB","CRWD","DDOG","SNOW","PLTR","COIN","MELI","TEAM","DASH","TTD",
75
+ "ZS","MNDY","NET","OKTA","BILL","HUBS","DKNG","U","RIVN","LCID",
76
+ "SOFI","HOOD","NU","GRAB","SE","SPOT","SNAP","PINS","ROKU","RBLX",
77
+ "UBER","LYFT","HLT","IHG","ELV","CARR","OTIS","DOW","CTVA","CEG","GEV","SOLV",
78
+ "VLTO","KVUE","GEHC","ARM","DECK","VST","GDDY","AXON","ERIE","APP",
79
+ "TPL","RVTY","EG","FBIN","PODD","NTRA","TOST","DOC","CPAY","HUBB",
80
+ "BX","KKR","APO","ARES","CG","MRNA","INVH","VICI","CZR","CTRA","OGN"
81
+ ]
82
+ FU = list(dict.fromkeys(CU + RI))
83
+
84
+ # =============================================================================
85
+ # V30 DEFAULT PARAMETERS
86
+ # =============================================================================
87
+ V30_PARAMS = {
88
+ "top_n": 15,
89
+ "rebal_days": 60,
90
+ "vol_target": 0.18,
91
+ "riskoff_haircut": 0.50,
92
+ "sma_lookback": 200,
93
+ "mom_long": 175,
94
+ "mom_short": 21,
95
+ }
96
+
97
+ # =============================================================================
98
+ # DATA LOADING (cached in module-level globals)
99
+ # =============================================================================
100
+ _dc = None
101
+ _spy = None
102
+ _vf = None
103
+ _daily_ret = None
104
+
105
+ def load_data(extra_tickers=None):
106
+ """Download and cache price data. Call once per script."""
107
+ global _dc, _spy, _vf, _daily_ret
108
+
109
+ tickers = list(set(FU + ["SPY"] + (extra_tickers or [])))
110
+
111
+ cache_file = "market_data_cache.pkl"
112
+ if os.path.exists(cache_file) and extra_tickers is None:
113
+ raw = pd.read_pickle(cache_file)
114
+ else:
115
+ raw = yf.download(tickers, start=LB, end=BE, progress=False)
116
+ if extra_tickers is None and len(raw) > 100:
117
+ raw.to_pickle(cache_file)
118
+
119
+ lvl0 = raw.columns.get_level_values(0).unique().tolist() if isinstance(raw.columns, pd.MultiIndex) else []
120
+ dc = raw["Close"] if "Close" in lvl0 else raw
121
+ if isinstance(dc.columns, pd.MultiIndex):
122
+ dc.columns = dc.columns.get_level_values(-1)
123
+ dc = dc.ffill().dropna(how="all")
124
+
125
+ _dc = dc
126
+ _spy = dc["SPY"].copy()
127
+ _vf = [t for t in FU if t in dc.columns and dc[t].notna().sum() > 252]
128
+ _daily_ret = dc.pct_change()
129
+
130
+ print(f"Data loaded: {len(dc)} days, {len(_vf)} valid tickers.")
131
+ return dc, _spy, _vf, _daily_ret
132
+
133
+ def get_data():
134
+ """Return cached data or load if not yet loaded."""
135
+ if _dc is None:
136
+ return load_data()
137
+ return _dc, _spy, _vf, _daily_ret
138
+
139
+ # =============================================================================
140
+ # CAUSAL BACKTEST ENGINE
141
+ # =============================================================================
142
+ def run_causal_backtest(dc=None, spy=None, vf=None, daily_ret=None,
143
+ top_n=15, rebal_days=60, vol_target=0.18,
144
+ riskoff_haircut=0.50, sma_lookback=200,
145
+ mom_long=175, mom_short=21,
146
+ txn_cost_bps=20, regime_txn_multiplier=1.0,
147
+ custom_universe=None,
148
+ return_holdings=False):
149
+ """
150
+ Run the causally-correct V30 backtest.
151
+
152
+ CAUSAL GUARANTEES:
153
+ - Regime: spy[i-1], sma[i-1]
154
+ - Signal: m_signal.iloc[i-1]
155
+ - Return: daily_ret.iloc[i]
156
+
157
+ Args:
158
+ dc, spy, vf, daily_ret: Price data (uses cached if None)
159
+ top_n ... mom_short: Strategy parameters
160
+ txn_cost_bps: Transaction cost per trade in basis points
161
+ regime_txn_multiplier: Multiplier on txn cost during regime switches
162
+ custom_universe: Override vf with custom ticker list
163
+ return_holdings: If True, also return holdings log
164
+
165
+ Returns:
166
+ dict with 'curve', 'daily_returns', 'port_rets_list',
167
+ 'vol_scalars', 'regime_status', 'holdings_log'
168
+ """
169
+ if dc is None:
170
+ dc, spy, vf, daily_ret = get_data()
171
+
172
+ if custom_universe is not None:
173
+ vf = [t for t in custom_universe if t in dc.columns and dc[t].notna().sum() > 252]
174
+
175
+ m_signal = (dc[vf].shift(mom_short) / dc[vf].shift(mom_long)) - 1
176
+ sma = spy.rolling(sma_lookback).mean()
177
+
178
+ sma_vals = sma.values
179
+ spy_vals = spy.values
180
+ txn_frac = txn_cost_bps / 10000.0
181
+
182
+ nav = CAP
183
+ pick_tks = []
184
+ port_rets = []
185
+ days = 0
186
+ hist = []
187
+ vol_scalars = []
188
+ regime_log = []
189
+ holdings_log = []
190
+ prev_riskoff = False
191
+
192
+ for i in range(1, len(dc)):
193
+ # Volatility scalar
194
+ if len(port_rets) >= 21:
195
+ window = port_rets[-60:] if len(port_rets) >= 60 else port_rets[-21:]
196
+ rvol = np.std(window) * np.sqrt(252)
197
+ vol_scalar = vol_target / (rvol + 1e-8)
198
+ else:
199
+ vol_scalar = 0.5
200
+
201
+ # CAUSAL regime detection: yesterday's data
202
+ sp = spy_vals[i-1]
203
+ sm = sma_vals[i-1]
204
+ is_riskoff = bool(pd.isna(sm) or sp <= sm)
205
+
206
+ if is_riskoff:
207
+ vol_scalar *= riskoff_haircut
208
+
209
+ vol_scalar = float(np.clip(vol_scalar, 0.05, 1.0))
210
+
211
+ # Determine if regime just switched (for txn cost multiplier)
212
+ regime_switched = (is_riskoff != prev_riskoff) and i > 1
213
+ current_txn_mult = regime_txn_multiplier if regime_switched else 1.0
214
+ prev_riskoff = is_riskoff
215
+
216
+ # Daily return
217
+ day_ret = 0.0
218
+ if pick_tks:
219
+ lr = daily_ret.iloc[i][[t for t in pick_tks if t in daily_ret.columns]].dropna()
220
+ if len(lr) > 0:
221
+ day_ret = lr.mean() * vol_scalar
222
+ nav *= (1 + day_ret)
223
+ # Amortized transaction cost
224
+ nav -= nav * txn_frac * 2 * current_txn_mult / rebal_days * vol_scalar
225
+ # Floor portfolio value to simulate brokerage liquidation
226
+ nav = max(nav, 0.01)
227
+
228
+ port_rets.append(day_ret)
229
+ hist.append(nav)
230
+ vol_scalars.append(vol_scalar)
231
+ regime_log.append(is_riskoff)
232
+
233
+ # Rebalance
234
+ days += 1
235
+ if days >= rebal_days:
236
+ days = 0
237
+ # CAUSAL signal: yesterday's momentum
238
+ row = m_signal.iloc[i].dropna()
239
+ valid = [t for t in vf if t in row.index]
240
+ if len(valid) >= top_n:
241
+ d = pd.DataFrame({"T": valid, "A": row[valid]}).sort_values("A", ascending=False)
242
+ pick_tks = list(d.head(top_n)["T"])
243
+ if return_holdings:
244
+ holdings_log.append({"date": dc.index[i], "tickers": list(pick_tks)})
245
+
246
+ # Build output
247
+ cap = pd.Series(hist, index=dc.index[1:len(hist)+1])
248
+ dr = cap.pct_change().dropna()
249
+
250
+ result = {
251
+ "curve": cap,
252
+ "daily_returns": dr,
253
+ "port_rets_list": port_rets,
254
+ "vol_scalars": vol_scalars,
255
+ "regime_status": regime_log,
256
+ }
257
+ if return_holdings:
258
+ result["holdings_log"] = holdings_log
259
+
260
+ return result
261
+
262
+ # =============================================================================
263
+ # METRICS
264
+ # =============================================================================
265
+ def evaluate_slice(cap, start_date, end_date, initial_capital=CAP):
266
+ """Extract metrics for a date slice of an equity curve."""
267
+ sd = pd.to_datetime(start_date)
268
+ ed = pd.to_datetime(end_date)
269
+
270
+ sub_cap = cap[(cap.index >= sd) & (cap.index <= ed)]
271
+ if len(sub_cap) < 50:
272
+ return {"sharpe": 0, "cagr": 0, "mdd": 0, "curve": sub_cap}
273
+
274
+ # Rebase
275
+ sub_cap = sub_cap / sub_cap.iloc[0] * initial_capital
276
+ dr = sub_cap.pct_change().dropna()
277
+
278
+ exc = dr - 0.04/252
279
+ sharpe = float(np.sqrt(252) * exc.mean() / exc.std()) if exc.std() > 0 else 0
280
+
281
+ td = (sub_cap.index[-1] - sub_cap.index[0]).days
282
+ years = td / 365.25
283
+ cagr = float(((sub_cap.iloc[-1] / initial_capital) ** (1/years) - 1) * 100) if years > 0 else 0
284
+
285
+ mdd = float(((sub_cap - sub_cap.cummax()) / sub_cap.cummax()).min() * 100)
286
+
287
+ # Sortino
288
+ downside = dr[dr < 0]
289
+ downside_std = float(np.sqrt(np.mean(downside**2)) * np.sqrt(252)) if len(downside) > 0 else 1e-8
290
+ sortino = float((dr.mean() * 252 - 0.04) / downside_std) if downside_std > 0 else 0
291
+
292
+ # Calmar
293
+ calmar = float(cagr / abs(mdd)) if abs(mdd) > 0.01 else 0
294
+
295
+ ann_vol = float(dr.std() * np.sqrt(252) * 100)
296
+
297
+ return {
298
+ "sharpe": sharpe,
299
+ "cagr": cagr,
300
+ "mdd": mdd,
301
+ "sortino": sortino,
302
+ "calmar": calmar,
303
+ "ann_vol": ann_vol,
304
+ "curve": sub_cap,
305
+ "daily_returns": dr,
306
+ "years": years,
307
+ "final_value": float(sub_cap.iloc[-1]),
308
+ }
309
+
310
+ def compute_spy_metrics(spy, start_date, end_date):
311
+ """Compute SPY benchmark metrics for a date range."""
312
+ sd = pd.to_datetime(start_date)
313
+ ed = pd.to_datetime(end_date)
314
+ s = spy[(spy.index >= sd) & (spy.index <= ed)]
315
+ sc = s / s.iloc[0] * CAP
316
+ return evaluate_slice(sc, start_date, end_date)
backtesting/engines/v36_research_engine.py ADDED
@@ -0,0 +1,345 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # -*- coding: utf-8 -*-
2
+ """
3
+ V36 Research Engine — Enhancement Testing Framework
4
+ =====================================================
5
+ Extends the V30 causal engine with 3 independent, toggleable enhancements.
6
+ Each enhancement can be enabled/disabled independently for isolation testing.
7
+
8
+ ENHANCEMENTS:
9
+ A. Inverse-Volatility Weighting (IVW) — replace equal-weight with vol-parity
10
+ B. Composite Signal (COMP) — blend price momentum + earnings momentum proxy
11
+ C. Sector-Neutral Construction (SN) — pick best momentum within each sector
12
+
13
+ CAUSAL GUARANTEES (inherited from V30):
14
+ - Regime: spy[i-1], sma[i-1]
15
+ - Signal: m_signal.iloc[i-1]
16
+ - Return: daily_ret.iloc[i]
17
+ - Vol weights: trailing 60-day realized vol (no lookahead)
18
+ """
19
+
20
+ import os, sys
21
+ import numpy as np, pandas as pd
22
+ import warnings; warnings.filterwarnings("ignore")
23
+
24
+ sys.path.insert(0, os.path.dirname(__file__))
25
+ from backtesting.engines.v30_causal_engine import (
26
+ load_data, get_data, evaluate_slice, compute_spy_metrics,
27
+ V30_PARAMS, CU, RI, FU, CAP, LB, BE,
28
+ TRAIN_START, TRAIN_END, TEST_START, TEST_END
29
+ )
30
+
31
+ # =============================================================================
32
+ # SECTOR MAP (GICS-based, covers ~80% of universe)
33
+ # =============================================================================
34
+ SECTOR_MAP = {
35
+ # Technology
36
+ "AAPL": "Tech", "MSFT": "Tech", "NVDA": "Tech", "AVGO": "Tech", "ADBE": "Tech",
37
+ "CRM": "Tech", "CSCO": "Tech", "ORCL": "Tech", "ACN": "Tech", "INTC": "Tech",
38
+ "AMD": "Tech", "INTU": "Tech", "NOW": "Tech", "AMAT": "Tech", "ADI": "Tech",
39
+ "KLAC": "Tech", "LRCX": "Tech", "CDNS": "Tech", "SNPS": "Tech", "ADSK": "Tech",
40
+ "FTNT": "Tech", "PANW": "Tech", "CRWD": "Tech", "DDOG": "Tech", "SNOW": "Tech",
41
+ "NET": "Tech", "ZS": "Tech", "MCHP": "Tech", "TXN": "Tech", "MU": "Tech",
42
+ "NXPI": "Tech", "ON": "Tech", "MPWR": "Tech", "KEYS": "Tech", "TER": "Tech",
43
+ "QCOM": "Tech", "SWKS": "Tech", "QRVO": "Tech", "IT": "Tech", "MSI": "Tech",
44
+ "TEL": "Tech", "APH": "Tech", "GLW": "Tech", "ADP": "Tech", "FISV": "Tech",
45
+ "FIS": "Tech", "GPN": "Tech", "BR": "Tech", "JKHY": "Tech", "FFIV": "Tech",
46
+ "NTAP": "Tech", "HPE": "Tech", "HPQ": "Tech", "STX": "Tech", "WDC": "Tech",
47
+ "DELL": "Tech", "SMCI": "Tech", "PLTR": "Tech", "CTSH": "Tech", "EPAM": "Tech",
48
+ "AKAM": "Tech", "PTC": "Tech", "TYL": "Tech", "TRMB": "Tech", "ZBRA": "Tech",
49
+ "ARM": "Tech",
50
+ # Healthcare
51
+ "UNH": "Health", "JNJ": "Health", "LLY": "Health", "ABBV": "Health", "MRK": "Health",
52
+ "PFE": "Health", "TMO": "Health", "ABT": "Health", "DHR": "Health", "BMY": "Health",
53
+ "AMGN": "Health", "GILD": "Health", "MDT": "Health", "ISRG": "Health", "SYK": "Health",
54
+ "BSX": "Health", "EW": "Health", "VRTX": "Health", "REGN": "Health", "IDXX": "Health",
55
+ "CI": "Health", "HUM": "Health", "CNC": "Health", "MOH": "Health", "HCA": "Health",
56
+ "BDX": "Health", "BAX": "Health", "IQV": "Health", "HOLX": "Health", "DGX": "Health",
57
+ "BIO": "Health", "BIIB": "Health", "INCY": "Health", "ILMN": "Health", "DVA": "Health",
58
+ "LH": "Health", "MRNA": "Health", "ELV": "Health", "GEHC": "Health", "PODD": "Health",
59
+ "NTRA": "Health", "DXCM": "Health",
60
+ # Financials
61
+ "BRK-B": "Fin", "JPM": "Fin", "BAC": "Fin", "WFC": "Fin", "GS": "Fin",
62
+ "MS": "Fin", "SCHW": "Fin", "C": "Fin", "BLK": "Fin", "SPGI": "Fin",
63
+ "ICE": "Fin", "CME": "Fin", "AXP": "Fin", "COF": "Fin", "USB": "Fin",
64
+ "PNC": "Fin", "TFC": "Fin", "MTB": "Fin", "FITB": "Fin", "KEY": "Fin",
65
+ "HBAN": "Fin", "CFG": "Fin", "RF": "Fin", "ZION": "Fin", "STT": "Fin",
66
+ "NTRS": "Fin", "BK": "Fin", "AIG": "Fin", "ALL": "Fin", "MET": "Fin",
67
+ "PRU": "Fin", "AFL": "Fin", "AMP": "Fin", "PFG": "Fin", "LNC": "Fin",
68
+ "GL": "Fin", "CINF": "Fin", "CB": "Fin", "AON": "Fin", "MMC": "Fin",
69
+ "AJG": "Fin", "BRO": "Fin", "WRB": "Fin", "RJF": "Fin", "MSCI": "Fin",
70
+ "NDAQ": "Fin", "CBOE": "Fin", "MCO": "Fin", "FDS": "Fin", "MKTX": "Fin",
71
+ "IVZ": "Fin", "BEN": "Fin", "TROW": "Fin", "BX": "Fin", "KKR": "Fin",
72
+ "APO": "Fin", "ARES": "Fin", "CG": "Fin", "COIN": "Fin", "HOOD": "Fin",
73
+ "SOFI": "Fin", "SYF": "Fin", "AIZ": "Fin",
74
+ # Consumer Discretionary
75
+ "AMZN": "ConDisc", "TSLA": "ConDisc", "HD": "ConDisc", "LOW": "ConDisc",
76
+ "MCD": "ConDisc", "NKE": "ConDisc", "SBUX": "ConDisc", "TJX": "ConDisc",
77
+ "CMG": "ConDisc", "BKNG": "ConDisc", "MAR": "ConDisc", "HLT": "ConDisc",
78
+ "ROST": "ConDisc", "DHI": "ConDisc", "LEN": "ConDisc", "PHM": "ConDisc",
79
+ "NVR": "ConDisc", "GM": "ConDisc", "F": "ConDisc", "BWA": "ConDisc",
80
+ "RL": "ConDisc", "PVH": "ConDisc", "TPR": "ConDisc", "VFC": "ConDisc",
81
+ "ULTA": "ConDisc", "DRI": "ConDisc", "DPZ": "ConDisc", "LVS": "ConDisc",
82
+ "WYNN": "ConDisc", "MGM": "ConDisc", "CCL": "ConDisc", "RCL": "ConDisc",
83
+ "NCLH": "ConDisc", "EXPE": "ConDisc", "LYV": "ConDisc", "BBY": "ConDisc",
84
+ "KMX": "ConDisc", "DG": "ConDisc", "DLTR": "ConDisc", "TGT": "ConDisc",
85
+ "TSCO": "ConDisc", "GPC": "ConDisc", "ORLY": "ConDisc", "AZO": "ConDisc",
86
+ "POOL": "ConDisc", "ETSY": "ConDisc", "SHOP": "ConDisc", "ABNB": "ConDisc",
87
+ "DASH": "ConDisc", "DKNG": "ConDisc", "UBER": "ConDisc", "LYFT": "ConDisc",
88
+ "RIVN": "ConDisc", "LCID": "ConDisc", "CZR": "ConDisc", "DECK": "ConDisc",
89
+ "GRMN": "ConDisc", "LKQ": "ConDisc", "MHK": "ConDisc", "NWL": "ConDisc",
90
+ "SWK": "ConDisc", "WHR": "ConDisc", "HAS": "ConDisc",
91
+ # Consumer Staples
92
+ "PG": "ConStap", "KO": "ConStap", "PEP": "ConStap", "COST": "ConStap",
93
+ "WMT": "ConStap", "PM": "ConStap", "MO": "ConStap", "MDLZ": "ConStap",
94
+ "CL": "ConStap", "CLX": "ConStap", "KMB": "ConStap", "KHC": "ConStap",
95
+ "GIS": "ConStap", "CAG": "ConStap", "CPB": "ConStap", "SJM": "ConStap",
96
+ "HRL": "ConStap", "HSY": "ConStap", "MNST": "ConStap", "STZ": "ConStap",
97
+ "TAP": "ConStap", "TSN": "ConStap", "ADM": "ConStap", "KR": "ConStap",
98
+ "SYY": "ConStap", "CHD": "ConStap", "EL": "ConStap", "KDP": "ConStap",
99
+ "KVUE": "ConStap",
100
+ # Energy
101
+ "XOM": "Energy", "CVX": "Energy", "COP": "Energy", "EOG": "Energy",
102
+ "SLB": "Energy", "MPC": "Energy", "PSX": "Energy", "VLO": "Energy",
103
+ "OXY": "Energy", "DVN": "Energy", "HAL": "Energy", "BKR": "Energy",
104
+ "APA": "Energy", "EQT": "Energy", "CF": "Energy", "MOS": "Energy",
105
+ "OKE": "Energy", "WMB": "Energy", "KMI": "Energy", "TRGP": "Energy",
106
+ "ET": "Energy", "EPD": "Energy", "MPLX": "Energy", "FANG": "Energy",
107
+ "CTRA": "Energy",
108
+ # Industrials
109
+ "GE": "Indust", "CAT": "Indust", "HON": "Indust", "UNP": "Indust",
110
+ "UPS": "Indust", "BA": "Indust", "RTX": "Indust", "DE": "Indust",
111
+ "LMT": "Indust", "GD": "Indust", "NOC": "Indust", "LHX": "Indust",
112
+ "TDG": "Indust", "ITW": "Indust", "EMR": "Indust", "ETN": "Indust",
113
+ "PH": "Indust", "ROK": "Indust", "DOV": "Indust", "AME": "Indust",
114
+ "CMI": "Indust", "IR": "Indust", "FTV": "Indust", "SNA": "Indust",
115
+ "FAST": "Indust", "WAB": "Indust", "CSX": "Indust", "NSC": "Indust",
116
+ "ODFL": "Indust", "JBHT": "Indust", "CHRW": "Indust", "DAL": "Indust",
117
+ "UAL": "Indust", "LUV": "Indust", "ALK": "Indust", "FDX": "Indust",
118
+ "EXPD": "Indust", "PCAR": "Indust", "CPRT": "Indust", "GWW": "Indust",
119
+ "CTAS": "Indust", "URI": "Indust", "RSG": "Indust", "WM": "Indust",
120
+ "ROL": "Indust", "RHI": "Indust", "J": "Indust", "LDOS": "Indust",
121
+ "PWR": "Indust", "TT": "Indust", "IEX": "Indust", "PNR": "Indust",
122
+ "NDSN": "Indust", "ROP": "Indust", "TDY": "Indust", "STE": "Indust",
123
+ "TFX": "Indust", "CARR": "Indust", "OTIS": "Indust", "AXON": "Indust",
124
+ "GEV": "Indust", "VLTO": "Indust", "HWM": "Indust", "HUBB": "Indust",
125
+ # Utilities
126
+ "NEE": "Util", "DUK": "Util", "SO": "Util", "D": "Util", "AEP": "Util",
127
+ "SRE": "Util", "EXC": "Util", "ED": "Util", "WEC": "Util", "ES": "Util",
128
+ "AEE": "Util", "DTE": "Util", "CMS": "Util", "PEG": "Util", "EIX": "Util",
129
+ "ETR": "Util", "FE": "Util", "CNP": "Util", "NI": "Util", "PPL": "Util",
130
+ "EVRG": "Util", "AES": "Util", "LNT": "Util", "PNW": "Util", "NRG": "Util",
131
+ "AWK": "Util", "PCG": "Util", "XEL": "Util", "CEG": "Util", "VST": "Util",
132
+ # Real Estate
133
+ "PLD": "RE", "AMT": "RE", "CCI": "RE", "EQIX": "RE", "SPG": "RE",
134
+ "O": "RE", "PSA": "RE", "DLR": "RE", "WELL": "RE", "AVB": "RE",
135
+ "EQR": "RE", "ESS": "RE", "MAA": "RE", "UDR": "RE", "CPT": "RE",
136
+ "VTR": "RE", "ARE": "RE", "BXP": "RE", "VNO": "RE", "FRT": "RE",
137
+ "KIM": "RE", "REG": "RE", "HST": "RE", "EXR": "RE", "IRM": "RE",
138
+ "SBAC": "RE", "INVH": "RE", "VICI": "RE", "DOC": "RE",
139
+ # Communication Services
140
+ "GOOG": "Comm", "GOOGL": "Comm", "META": "Comm", "DIS": "Comm",
141
+ "CMCSA": "Comm", "NFLX": "Comm", "TMUS": "Comm", "T": "Comm",
142
+ "VZ": "Comm", "CHTR": "Comm", "EA": "Comm", "TTWO": "Comm",
143
+ "OMC": "Comm", "IPG": "Comm", "FOX": "Comm", "FOXA": "Comm",
144
+ "NWS": "Comm", "NWSA": "Comm", "MTCH": "Comm", "LUMN": "Comm",
145
+ "SPOT": "Comm", "SNAP": "Comm", "PINS": "Comm", "ROKU": "Comm",
146
+ "RBLX": "Comm", "TTD": "Comm", "U": "Comm",
147
+ # Materials
148
+ "LIN": "Mats", "APD": "Mats", "SHW": "Mats", "ECL": "Mats",
149
+ "NUE": "Mats", "FCX": "Mats", "NEM": "Mats", "VMC": "Mats",
150
+ "MLM": "Mats", "DOW": "Mats", "DD": "Mats", "PPG": "Mats",
151
+ "EMN": "Mats", "CE": "Mats", "ALB": "Mats", "IFF": "Mats",
152
+ "FMC": "Mats", "AVY": "Mats", "SEE": "Mats", "PKG": "Mats",
153
+ "IP": "Mats", "LW": "Mats", "MKC": "Mats", "LYB": "Mats",
154
+ "CTVA": "Mats",
155
+ }
156
+
157
+ SECTORS = sorted(set(SECTOR_MAP.values()))
158
+
159
+ # =============================================================================
160
+ # V36 ENHANCED BACKTEST ENGINE
161
+ # =============================================================================
162
+ def run_v36_backtest(dc=None, spy=None, vf=None, daily_ret=None,
163
+ # V30 base params
164
+ top_n=15, rebal_days=60, vol_target=0.18,
165
+ riskoff_haircut=0.50, sma_lookback=200,
166
+ mom_long=175, mom_short=21,
167
+ txn_cost_bps=20, regime_txn_multiplier=1.0,
168
+ # Enhancement flags
169
+ use_ivw=False, # Enhancement A
170
+ use_composite=False, # Enhancement B
171
+ composite_weight=0.70, # Weight on price mom (rest = quality)
172
+ use_sector_neutral=False,# Enhancement C
173
+ sn_picks_per_sector=2, # Stocks per sector in SN mode
174
+ # Extra
175
+ custom_universe=None,
176
+ return_holdings=False,
177
+ ivw_lookback=60):
178
+ """
179
+ V36 Enhanced Causal Backtest.
180
+
181
+ All 3 enhancements are independently toggleable. When all flags are False,
182
+ this produces identical output to run_causal_backtest() (V30 baseline).
183
+ """
184
+ if dc is None:
185
+ dc, spy, vf, daily_ret = get_data()
186
+
187
+ if custom_universe is not None:
188
+ vf = [t for t in custom_universe if t in dc.columns and dc[t].notna().sum() > 252]
189
+
190
+ # --- Signal Construction ---
191
+ price_mom = (dc[vf].shift(mom_short) / dc[vf].shift(mom_long)) - 1
192
+
193
+ if use_composite:
194
+ # Enhancement B: Quality proxy = 6-month price stability (lower vol = higher quality)
195
+ # This is a pure-price proxy for earnings quality — no fundamental data needed.
196
+ # Rationale: Stocks with stable uptrends have more persistent momentum (Novy-Marx 2015)
197
+ ret_126 = dc[vf].pct_change()
198
+ rolling_vol = ret_126.rolling(126).std()
199
+ # Invert: lower vol = higher quality score (rank-normalize)
200
+ quality_rank = rolling_vol.rank(axis=1, ascending=True, pct=True)
201
+ mom_rank = price_mom.rank(axis=1, ascending=False, pct=True)
202
+ # Composite: weighted blend (higher = better)
203
+ m_signal = composite_weight * (1 - mom_rank) + (1 - composite_weight) * quality_rank
204
+ else:
205
+ m_signal = price_mom
206
+
207
+ sma = spy.rolling(sma_lookback).mean()
208
+ sma_vals = sma.values
209
+ spy_vals = spy.values
210
+ txn_frac = txn_cost_bps / 10000.0
211
+
212
+ # Pre-compute inverse-vol weights if needed
213
+ if use_ivw:
214
+ stock_vol = dc[vf].pct_change().rolling(ivw_lookback).std()
215
+
216
+ nav = CAP
217
+ pick_tks = []
218
+ pick_weights = {} # ticker -> weight (for IVW)
219
+ port_rets = []
220
+ days = 0
221
+ hist = []
222
+ vol_scalars = []
223
+ regime_log = []
224
+ holdings_log = []
225
+ prev_riskoff = False
226
+
227
+ for i in range(1, len(dc)):
228
+ # Volatility scalar
229
+ if len(port_rets) >= 21:
230
+ window = port_rets[-60:] if len(port_rets) >= 60 else port_rets[-21:]
231
+ rvol = np.std(window) * np.sqrt(252)
232
+ vol_scalar = vol_target / (rvol + 1e-8)
233
+ else:
234
+ vol_scalar = 0.5
235
+
236
+ # CAUSAL regime detection: yesterday's data
237
+ sp = spy_vals[i-1]
238
+ sm = sma_vals[i-1]
239
+ is_riskoff = bool(pd.isna(sm) or sp <= sm)
240
+
241
+ if is_riskoff:
242
+ vol_scalar *= riskoff_haircut
243
+
244
+ vol_scalar = float(np.clip(vol_scalar, 0.05, 1.0))
245
+
246
+ # Regime switch detection
247
+ regime_switched = (is_riskoff != prev_riskoff) and i > 1
248
+ current_txn_mult = regime_txn_multiplier if regime_switched else 1.0
249
+ prev_riskoff = is_riskoff
250
+
251
+ # Daily return
252
+ day_ret = 0.0
253
+ if pick_tks:
254
+ lr = daily_ret.iloc[i][[t for t in pick_tks if t in daily_ret.columns]].dropna()
255
+ if len(lr) > 0:
256
+ if use_ivw and pick_weights:
257
+ # Enhancement A: Weighted return
258
+ w_sum = 0.0
259
+ for t in lr.index:
260
+ w = pick_weights.get(t, 1.0 / len(lr))
261
+ day_ret += lr[t] * w
262
+ w_sum += w
263
+ if w_sum > 0:
264
+ day_ret = day_ret / w_sum * sum(pick_weights.get(t, 1.0/len(lr)) for t in lr.index)
265
+ # Normalize: ensure weights sum to 1
266
+ day_ret = sum(lr[t] * pick_weights.get(t, 1.0/len(lr)) for t in lr.index)
267
+ else:
268
+ day_ret = lr.mean()
269
+
270
+ day_ret *= vol_scalar
271
+ nav *= (1 + day_ret)
272
+ nav -= nav * txn_frac * 2 * current_txn_mult / rebal_days * vol_scalar
273
+ nav = max(nav, 0.01)
274
+
275
+ port_rets.append(day_ret)
276
+ hist.append(nav)
277
+ vol_scalars.append(vol_scalar)
278
+ regime_log.append(is_riskoff)
279
+
280
+ # Rebalance
281
+ days += 1
282
+ if days >= rebal_days:
283
+ days = 0
284
+ # CAUSAL signal: yesterday's momentum
285
+ row = m_signal.iloc[i].dropna()
286
+
287
+ if use_sector_neutral:
288
+ # Enhancement C: Pick top N from each sector
289
+ pick_tks = []
290
+ for sector in SECTORS:
291
+ sector_tickers = [t for t in vf if t in row.index and SECTOR_MAP.get(t) == sector]
292
+ if not sector_tickers:
293
+ continue
294
+ sector_scores = row[sector_tickers].sort_values(ascending=False)
295
+ pick_tks.extend(list(sector_scores.head(sn_picks_per_sector).index))
296
+ # Also pick from unmapped tickers to avoid excluding them entirely
297
+ unmapped = [t for t in vf if t in row.index and t not in SECTOR_MAP]
298
+ if unmapped:
299
+ um_scores = row[unmapped].sort_values(ascending=False)
300
+ pick_tks.extend(list(um_scores.head(sn_picks_per_sector).index))
301
+ else:
302
+ valid = [t for t in vf if t in row.index]
303
+ if len(valid) >= top_n:
304
+ d = pd.DataFrame({"T": valid, "A": row[valid]}).sort_values("A", ascending=False)
305
+ pick_tks = list(d.head(top_n)["T"])
306
+
307
+ # Compute IVW weights if enabled
308
+ if use_ivw and pick_tks:
309
+ vols = {}
310
+ for t in pick_tks:
311
+ if t in stock_vol.columns:
312
+ v = stock_vol.iloc[i-1][t]
313
+ if pd.notna(v) and v > 0:
314
+ vols[t] = v
315
+ if vols:
316
+ inv_vols = {t: 1.0/v for t, v in vols.items()}
317
+ total_inv = sum(inv_vols.values())
318
+ pick_weights = {t: iv/total_inv for t, iv in inv_vols.items()}
319
+ else:
320
+ pick_weights = {t: 1.0/len(pick_tks) for t in pick_tks}
321
+ else:
322
+ pick_weights = {t: 1.0/len(pick_tks) for t in pick_tks} if pick_tks else {}
323
+
324
+ if return_holdings:
325
+ holdings_log.append({
326
+ "date": dc.index[i],
327
+ "tickers": list(pick_tks),
328
+ "weights": dict(pick_weights),
329
+ })
330
+
331
+ # Build output
332
+ cap = pd.Series(hist, index=dc.index[1:len(hist)+1])
333
+ dr = cap.pct_change().dropna()
334
+
335
+ result = {
336
+ "curve": cap,
337
+ "daily_returns": dr,
338
+ "port_rets_list": port_rets,
339
+ "vol_scalars": vol_scalars,
340
+ "regime_status": regime_log,
341
+ }
342
+ if return_holdings:
343
+ result["holdings_log"] = holdings_log
344
+
345
+ return result
backtesting/engines/v36_research_engine_fixed.py ADDED
@@ -0,0 +1,345 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # -*- coding: utf-8 -*-
2
+ """
3
+ V36 Research Engine — Enhancement Testing Framework
4
+ =====================================================
5
+ Extends the V30 causal engine with 3 independent, toggleable enhancements.
6
+ Each enhancement can be enabled/disabled independently for isolation testing.
7
+
8
+ ENHANCEMENTS:
9
+ A. Inverse-Volatility Weighting (IVW) — replace equal-weight with vol-parity
10
+ B. Composite Signal (COMP) — blend price momentum + earnings momentum proxy
11
+ C. Sector-Neutral Construction (SN) — pick best momentum within each sector
12
+
13
+ CAUSAL GUARANTEES (inherited from V30):
14
+ - Regime: spy[i-1], sma[i-1]
15
+ - Signal: m_signal.iloc[i-1]
16
+ - Return: daily_ret.iloc[i]
17
+ - Vol weights: trailing 60-day realized vol (no lookahead)
18
+ """
19
+
20
+ import os, sys
21
+ import numpy as np, pandas as pd
22
+ import warnings; warnings.filterwarnings("ignore")
23
+
24
+ sys.path.insert(0, os.path.dirname(__file__))
25
+ from backtesting.engines.v30_causal_engine import (
26
+ load_data, get_data, evaluate_slice, compute_spy_metrics,
27
+ V30_PARAMS, CU, RI, FU, CAP, LB, BE,
28
+ TRAIN_START, TRAIN_END, TEST_START, TEST_END
29
+ )
30
+
31
+ # =============================================================================
32
+ # SECTOR MAP (GICS-based, covers ~80% of universe)
33
+ # =============================================================================
34
+ SECTOR_MAP = {
35
+ # Technology
36
+ "AAPL": "Tech", "MSFT": "Tech", "NVDA": "Tech", "AVGO": "Tech", "ADBE": "Tech",
37
+ "CRM": "Tech", "CSCO": "Tech", "ORCL": "Tech", "ACN": "Tech", "INTC": "Tech",
38
+ "AMD": "Tech", "INTU": "Tech", "NOW": "Tech", "AMAT": "Tech", "ADI": "Tech",
39
+ "KLAC": "Tech", "LRCX": "Tech", "CDNS": "Tech", "SNPS": "Tech", "ADSK": "Tech",
40
+ "FTNT": "Tech", "PANW": "Tech", "CRWD": "Tech", "DDOG": "Tech", "SNOW": "Tech",
41
+ "NET": "Tech", "ZS": "Tech", "MCHP": "Tech", "TXN": "Tech", "MU": "Tech",
42
+ "NXPI": "Tech", "ON": "Tech", "MPWR": "Tech", "KEYS": "Tech", "TER": "Tech",
43
+ "QCOM": "Tech", "SWKS": "Tech", "QRVO": "Tech", "IT": "Tech", "MSI": "Tech",
44
+ "TEL": "Tech", "APH": "Tech", "GLW": "Tech", "ADP": "Tech", "FISV": "Tech",
45
+ "FIS": "Tech", "GPN": "Tech", "BR": "Tech", "JKHY": "Tech", "FFIV": "Tech",
46
+ "NTAP": "Tech", "HPE": "Tech", "HPQ": "Tech", "STX": "Tech", "WDC": "Tech",
47
+ "DELL": "Tech", "SMCI": "Tech", "PLTR": "Tech", "CTSH": "Tech", "EPAM": "Tech",
48
+ "AKAM": "Tech", "PTC": "Tech", "TYL": "Tech", "TRMB": "Tech", "ZBRA": "Tech",
49
+ "ARM": "Tech",
50
+ # Healthcare
51
+ "UNH": "Health", "JNJ": "Health", "LLY": "Health", "ABBV": "Health", "MRK": "Health",
52
+ "PFE": "Health", "TMO": "Health", "ABT": "Health", "DHR": "Health", "BMY": "Health",
53
+ "AMGN": "Health", "GILD": "Health", "MDT": "Health", "ISRG": "Health", "SYK": "Health",
54
+ "BSX": "Health", "EW": "Health", "VRTX": "Health", "REGN": "Health", "IDXX": "Health",
55
+ "CI": "Health", "HUM": "Health", "CNC": "Health", "MOH": "Health", "HCA": "Health",
56
+ "BDX": "Health", "BAX": "Health", "IQV": "Health", "HOLX": "Health", "DGX": "Health",
57
+ "BIO": "Health", "BIIB": "Health", "INCY": "Health", "ILMN": "Health", "DVA": "Health",
58
+ "LH": "Health", "MRNA": "Health", "ELV": "Health", "GEHC": "Health", "PODD": "Health",
59
+ "NTRA": "Health", "DXCM": "Health",
60
+ # Financials
61
+ "BRK-B": "Fin", "JPM": "Fin", "BAC": "Fin", "WFC": "Fin", "GS": "Fin",
62
+ "MS": "Fin", "SCHW": "Fin", "C": "Fin", "BLK": "Fin", "SPGI": "Fin",
63
+ "ICE": "Fin", "CME": "Fin", "AXP": "Fin", "COF": "Fin", "USB": "Fin",
64
+ "PNC": "Fin", "TFC": "Fin", "MTB": "Fin", "FITB": "Fin", "KEY": "Fin",
65
+ "HBAN": "Fin", "CFG": "Fin", "RF": "Fin", "ZION": "Fin", "STT": "Fin",
66
+ "NTRS": "Fin", "BK": "Fin", "AIG": "Fin", "ALL": "Fin", "MET": "Fin",
67
+ "PRU": "Fin", "AFL": "Fin", "AMP": "Fin", "PFG": "Fin", "LNC": "Fin",
68
+ "GL": "Fin", "CINF": "Fin", "CB": "Fin", "AON": "Fin", "MMC": "Fin",
69
+ "AJG": "Fin", "BRO": "Fin", "WRB": "Fin", "RJF": "Fin", "MSCI": "Fin",
70
+ "NDAQ": "Fin", "CBOE": "Fin", "MCO": "Fin", "FDS": "Fin", "MKTX": "Fin",
71
+ "IVZ": "Fin", "BEN": "Fin", "TROW": "Fin", "BX": "Fin", "KKR": "Fin",
72
+ "APO": "Fin", "ARES": "Fin", "CG": "Fin", "COIN": "Fin", "HOOD": "Fin",
73
+ "SOFI": "Fin", "SYF": "Fin", "AIZ": "Fin",
74
+ # Consumer Discretionary
75
+ "AMZN": "ConDisc", "TSLA": "ConDisc", "HD": "ConDisc", "LOW": "ConDisc",
76
+ "MCD": "ConDisc", "NKE": "ConDisc", "SBUX": "ConDisc", "TJX": "ConDisc",
77
+ "CMG": "ConDisc", "BKNG": "ConDisc", "MAR": "ConDisc", "HLT": "ConDisc",
78
+ "ROST": "ConDisc", "DHI": "ConDisc", "LEN": "ConDisc", "PHM": "ConDisc",
79
+ "NVR": "ConDisc", "GM": "ConDisc", "F": "ConDisc", "BWA": "ConDisc",
80
+ "RL": "ConDisc", "PVH": "ConDisc", "TPR": "ConDisc", "VFC": "ConDisc",
81
+ "ULTA": "ConDisc", "DRI": "ConDisc", "DPZ": "ConDisc", "LVS": "ConDisc",
82
+ "WYNN": "ConDisc", "MGM": "ConDisc", "CCL": "ConDisc", "RCL": "ConDisc",
83
+ "NCLH": "ConDisc", "EXPE": "ConDisc", "LYV": "ConDisc", "BBY": "ConDisc",
84
+ "KMX": "ConDisc", "DG": "ConDisc", "DLTR": "ConDisc", "TGT": "ConDisc",
85
+ "TSCO": "ConDisc", "GPC": "ConDisc", "ORLY": "ConDisc", "AZO": "ConDisc",
86
+ "POOL": "ConDisc", "ETSY": "ConDisc", "SHOP": "ConDisc", "ABNB": "ConDisc",
87
+ "DASH": "ConDisc", "DKNG": "ConDisc", "UBER": "ConDisc", "LYFT": "ConDisc",
88
+ "RIVN": "ConDisc", "LCID": "ConDisc", "CZR": "ConDisc", "DECK": "ConDisc",
89
+ "GRMN": "ConDisc", "LKQ": "ConDisc", "MHK": "ConDisc", "NWL": "ConDisc",
90
+ "SWK": "ConDisc", "WHR": "ConDisc", "HAS": "ConDisc",
91
+ # Consumer Staples
92
+ "PG": "ConStap", "KO": "ConStap", "PEP": "ConStap", "COST": "ConStap",
93
+ "WMT": "ConStap", "PM": "ConStap", "MO": "ConStap", "MDLZ": "ConStap",
94
+ "CL": "ConStap", "CLX": "ConStap", "KMB": "ConStap", "KHC": "ConStap",
95
+ "GIS": "ConStap", "CAG": "ConStap", "CPB": "ConStap", "SJM": "ConStap",
96
+ "HRL": "ConStap", "HSY": "ConStap", "MNST": "ConStap", "STZ": "ConStap",
97
+ "TAP": "ConStap", "TSN": "ConStap", "ADM": "ConStap", "KR": "ConStap",
98
+ "SYY": "ConStap", "CHD": "ConStap", "EL": "ConStap", "KDP": "ConStap",
99
+ "KVUE": "ConStap",
100
+ # Energy
101
+ "XOM": "Energy", "CVX": "Energy", "COP": "Energy", "EOG": "Energy",
102
+ "SLB": "Energy", "MPC": "Energy", "PSX": "Energy", "VLO": "Energy",
103
+ "OXY": "Energy", "DVN": "Energy", "HAL": "Energy", "BKR": "Energy",
104
+ "APA": "Energy", "EQT": "Energy", "CF": "Energy", "MOS": "Energy",
105
+ "OKE": "Energy", "WMB": "Energy", "KMI": "Energy", "TRGP": "Energy",
106
+ "ET": "Energy", "EPD": "Energy", "MPLX": "Energy", "FANG": "Energy",
107
+ "CTRA": "Energy",
108
+ # Industrials
109
+ "GE": "Indust", "CAT": "Indust", "HON": "Indust", "UNP": "Indust",
110
+ "UPS": "Indust", "BA": "Indust", "RTX": "Indust", "DE": "Indust",
111
+ "LMT": "Indust", "GD": "Indust", "NOC": "Indust", "LHX": "Indust",
112
+ "TDG": "Indust", "ITW": "Indust", "EMR": "Indust", "ETN": "Indust",
113
+ "PH": "Indust", "ROK": "Indust", "DOV": "Indust", "AME": "Indust",
114
+ "CMI": "Indust", "IR": "Indust", "FTV": "Indust", "SNA": "Indust",
115
+ "FAST": "Indust", "WAB": "Indust", "CSX": "Indust", "NSC": "Indust",
116
+ "ODFL": "Indust", "JBHT": "Indust", "CHRW": "Indust", "DAL": "Indust",
117
+ "UAL": "Indust", "LUV": "Indust", "ALK": "Indust", "FDX": "Indust",
118
+ "EXPD": "Indust", "PCAR": "Indust", "CPRT": "Indust", "GWW": "Indust",
119
+ "CTAS": "Indust", "URI": "Indust", "RSG": "Indust", "WM": "Indust",
120
+ "ROL": "Indust", "RHI": "Indust", "J": "Indust", "LDOS": "Indust",
121
+ "PWR": "Indust", "TT": "Indust", "IEX": "Indust", "PNR": "Indust",
122
+ "NDSN": "Indust", "ROP": "Indust", "TDY": "Indust", "STE": "Indust",
123
+ "TFX": "Indust", "CARR": "Indust", "OTIS": "Indust", "AXON": "Indust",
124
+ "GEV": "Indust", "VLTO": "Indust", "HWM": "Indust", "HUBB": "Indust",
125
+ # Utilities
126
+ "NEE": "Util", "DUK": "Util", "SO": "Util", "D": "Util", "AEP": "Util",
127
+ "SRE": "Util", "EXC": "Util", "ED": "Util", "WEC": "Util", "ES": "Util",
128
+ "AEE": "Util", "DTE": "Util", "CMS": "Util", "PEG": "Util", "EIX": "Util",
129
+ "ETR": "Util", "FE": "Util", "CNP": "Util", "NI": "Util", "PPL": "Util",
130
+ "EVRG": "Util", "AES": "Util", "LNT": "Util", "PNW": "Util", "NRG": "Util",
131
+ "AWK": "Util", "PCG": "Util", "XEL": "Util", "CEG": "Util", "VST": "Util",
132
+ # Real Estate
133
+ "PLD": "RE", "AMT": "RE", "CCI": "RE", "EQIX": "RE", "SPG": "RE",
134
+ "O": "RE", "PSA": "RE", "DLR": "RE", "WELL": "RE", "AVB": "RE",
135
+ "EQR": "RE", "ESS": "RE", "MAA": "RE", "UDR": "RE", "CPT": "RE",
136
+ "VTR": "RE", "ARE": "RE", "BXP": "RE", "VNO": "RE", "FRT": "RE",
137
+ "KIM": "RE", "REG": "RE", "HST": "RE", "EXR": "RE", "IRM": "RE",
138
+ "SBAC": "RE", "INVH": "RE", "VICI": "RE", "DOC": "RE",
139
+ # Communication Services
140
+ "GOOG": "Comm", "GOOGL": "Comm", "META": "Comm", "DIS": "Comm",
141
+ "CMCSA": "Comm", "NFLX": "Comm", "TMUS": "Comm", "T": "Comm",
142
+ "VZ": "Comm", "CHTR": "Comm", "EA": "Comm", "TTWO": "Comm",
143
+ "OMC": "Comm", "IPG": "Comm", "FOX": "Comm", "FOXA": "Comm",
144
+ "NWS": "Comm", "NWSA": "Comm", "MTCH": "Comm", "LUMN": "Comm",
145
+ "SPOT": "Comm", "SNAP": "Comm", "PINS": "Comm", "ROKU": "Comm",
146
+ "RBLX": "Comm", "TTD": "Comm", "U": "Comm",
147
+ # Materials
148
+ "LIN": "Mats", "APD": "Mats", "SHW": "Mats", "ECL": "Mats",
149
+ "NUE": "Mats", "FCX": "Mats", "NEM": "Mats", "VMC": "Mats",
150
+ "MLM": "Mats", "DOW": "Mats", "DD": "Mats", "PPG": "Mats",
151
+ "EMN": "Mats", "CE": "Mats", "ALB": "Mats", "IFF": "Mats",
152
+ "FMC": "Mats", "AVY": "Mats", "SEE": "Mats", "PKG": "Mats",
153
+ "IP": "Mats", "LW": "Mats", "MKC": "Mats", "LYB": "Mats",
154
+ "CTVA": "Mats",
155
+ }
156
+
157
+ SECTORS = sorted(set(SECTOR_MAP.values()))
158
+
159
+ # =============================================================================
160
+ # V36 ENHANCED BACKTEST ENGINE
161
+ # =============================================================================
162
+ def run_v36_backtest(dc=None, spy=None, vf=None, daily_ret=None,
163
+ # V30 base params
164
+ top_n=15, rebal_days=60, vol_target=0.18,
165
+ riskoff_haircut=0.50, sma_lookback=200,
166
+ mom_long=175, mom_short=21,
167
+ txn_cost_bps=20, regime_txn_multiplier=1.0,
168
+ # Enhancement flags
169
+ use_ivw=False, # Enhancement A
170
+ use_composite=False, # Enhancement B
171
+ composite_weight=0.70, # Weight on price mom (rest = quality)
172
+ use_sector_neutral=False,# Enhancement C
173
+ sn_picks_per_sector=2, # Stocks per sector in SN mode
174
+ # Extra
175
+ custom_universe=None,
176
+ return_holdings=False,
177
+ ivw_lookback=60):
178
+ """
179
+ V36 Enhanced Causal Backtest.
180
+
181
+ All 3 enhancements are independently toggleable. When all flags are False,
182
+ this produces identical output to run_causal_backtest() (V30 baseline).
183
+ """
184
+ if dc is None:
185
+ dc, spy, vf, daily_ret = get_data()
186
+
187
+ if custom_universe is not None:
188
+ vf = [t for t in custom_universe if t in dc.columns and dc[t].notna().sum() > 252]
189
+
190
+ # --- Signal Construction ---
191
+ price_mom = (dc[vf].shift(mom_short) / dc[vf].shift(mom_long)) - 1
192
+
193
+ if use_composite:
194
+ # Enhancement B: Quality proxy = 6-month price stability (lower vol = higher quality)
195
+ # This is a pure-price proxy for earnings quality — no fundamental data needed.
196
+ # Rationale: Stocks with stable uptrends have more persistent momentum (Novy-Marx 2015)
197
+ ret_126 = dc[vf].pct_change()
198
+ rolling_vol = ret_126.rolling(126).std()
199
+ # Invert: lower vol = higher quality score (rank-normalize)
200
+ quality_rank = rolling_vol.rank(axis=1, ascending=True, pct=True)
201
+ mom_rank = price_mom.rank(axis=1, ascending=False, pct=True)
202
+ # Composite: weighted blend (higher = better)
203
+ m_signal = composite_weight * (1 - mom_rank) + (1 - composite_weight) * quality_rank
204
+ else:
205
+ m_signal = price_mom
206
+
207
+ sma = spy.rolling(sma_lookback).mean()
208
+ sma_vals = sma.values
209
+ spy_vals = spy.values
210
+ txn_frac = txn_cost_bps / 10000.0
211
+
212
+ # Pre-compute inverse-vol weights if needed
213
+ if use_ivw:
214
+ stock_vol = dc[vf].pct_change().rolling(ivw_lookback).std()
215
+
216
+ nav = CAP
217
+ pick_tks = []
218
+ pick_weights = {} # ticker -> weight (for IVW)
219
+ port_rets = []
220
+ days = 0
221
+ hist = []
222
+ vol_scalars = []
223
+ regime_log = []
224
+ holdings_log = []
225
+ prev_riskoff = False
226
+
227
+ for i in range(1, len(dc)):
228
+ # Volatility scalar
229
+ if len(port_rets) >= 21:
230
+ window = port_rets[-60:] if len(port_rets) >= 60 else port_rets[-21:]
231
+ rvol = np.std(window) * np.sqrt(252)
232
+ vol_scalar = vol_target / (rvol + 1e-8)
233
+ else:
234
+ vol_scalar = 0.5
235
+
236
+ # CAUSAL regime detection: yesterday's data
237
+ sp = spy_vals[i-1]
238
+ sm = sma_vals[i-1]
239
+ is_riskoff = bool(pd.isna(sm) or sp <= sm)
240
+
241
+ if is_riskoff:
242
+ vol_scalar *= riskoff_haircut
243
+
244
+ vol_scalar = float(np.clip(vol_scalar, 0.05, 1.0))
245
+
246
+ # Regime switch detection
247
+ regime_switched = (is_riskoff != prev_riskoff) and i > 1
248
+ current_txn_mult = regime_txn_multiplier if regime_switched else 1.0
249
+ prev_riskoff = is_riskoff
250
+
251
+ # Daily return
252
+ day_ret = 0.0
253
+ if pick_tks:
254
+ lr = daily_ret.iloc[i][[t for t in pick_tks if t in daily_ret.columns]].dropna()
255
+ if len(lr) > 0:
256
+ if use_ivw and pick_weights:
257
+ # Enhancement A: Weighted return
258
+ w_sum = 0.0
259
+ for t in lr.index:
260
+ w = pick_weights.get(t, 1.0 / len(lr))
261
+ day_ret += lr[t] * w
262
+ w_sum += w
263
+ if w_sum > 0:
264
+ day_ret = day_ret / w_sum * sum(pick_weights.get(t, 1.0/len(lr)) for t in lr.index)
265
+ # Normalize: ensure weights sum to 1
266
+ day_ret = sum(lr[t] * pick_weights.get(t, 1.0/len(lr)) for t in lr.index)
267
+ else:
268
+ day_ret = lr.mean()
269
+
270
+ day_ret *= vol_scalar
271
+ nav *= (1 + day_ret)
272
+ nav -= nav * txn_frac * 2 * current_txn_mult / rebal_days * vol_scalar
273
+ nav = max(nav, 0.01)
274
+
275
+ port_rets.append(day_ret)
276
+ hist.append(nav)
277
+ vol_scalars.append(vol_scalar)
278
+ regime_log.append(is_riskoff)
279
+
280
+ # Rebalance
281
+ days += 1
282
+ if days >= rebal_days:
283
+ days = 0
284
+ # CAUSAL signal: yesterday's momentum
285
+ row = m_signal.iloc[i].dropna()
286
+
287
+ if use_sector_neutral:
288
+ # Enhancement C: Pick top N from each sector
289
+ pick_tks = []
290
+ for sector in SECTORS:
291
+ sector_tickers = [t for t in vf if t in row.index and SECTOR_MAP.get(t) == sector]
292
+ if not sector_tickers:
293
+ continue
294
+ sector_scores = row[sector_tickers].sort_values(ascending=False)
295
+ pick_tks.extend(list(sector_scores.head(sn_picks_per_sector).index))
296
+ # Also pick from unmapped tickers to avoid excluding them entirely
297
+ unmapped = [t for t in vf if t in row.index and t not in SECTOR_MAP]
298
+ if unmapped:
299
+ um_scores = row[unmapped].sort_values(ascending=False)
300
+ pick_tks.extend(list(um_scores.head(sn_picks_per_sector).index))
301
+ else:
302
+ valid = [t for t in vf if t in row.index]
303
+ if len(valid) >= top_n:
304
+ d = pd.DataFrame({"T": valid, "A": row[valid]}).sort_values("A", ascending=False)
305
+ pick_tks = list(d.head(top_n)["T"])
306
+
307
+ # Compute IVW weights if enabled
308
+ if use_ivw and pick_tks:
309
+ vols = {}
310
+ for t in pick_tks:
311
+ if t in stock_vol.columns:
312
+ v = stock_vol.iloc[i-1][t]
313
+ if pd.notna(v) and v > 0:
314
+ vols[t] = v
315
+ if vols:
316
+ inv_vols = {t: 1.0/v for t, v in vols.items()}
317
+ total_inv = sum(inv_vols.values())
318
+ pick_weights = {t: iv/total_inv for t, iv in inv_vols.items()}
319
+ else:
320
+ pick_weights = {t: 1.0/len(pick_tks) for t in pick_tks}
321
+ else:
322
+ pick_weights = {t: 1.0/len(pick_tks) for t in pick_tks} if pick_tks else {}
323
+
324
+ if return_holdings:
325
+ holdings_log.append({
326
+ "date": dc.index[i],
327
+ "tickers": list(pick_tks),
328
+ "weights": dict(pick_weights),
329
+ })
330
+
331
+ # Build output
332
+ cap = pd.Series(hist, index=dc.index[1:len(hist)+1])
333
+ dr = cap.pct_change().dropna()
334
+
335
+ result = {
336
+ "curve": cap,
337
+ "daily_returns": dr,
338
+ "port_rets_list": port_rets,
339
+ "vol_scalars": vol_scalars,
340
+ "regime_status": regime_log,
341
+ }
342
+ if return_holdings:
343
+ result["holdings_log"] = holdings_log
344
+
345
+ return result
backtesting/engines/v36r2_research_engine.py ADDED
@@ -0,0 +1,225 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # -*- coding: utf-8 -*-
2
+ """
3
+ V36 Research Engine — Round 2 Enhancements
4
+ ============================================
5
+ New enhancements that attack DIFFERENT structural weaknesses than Round 1.
6
+
7
+ ENHANCEMENTS:
8
+ D. Dual Momentum — Absolute + Relative momentum filter
9
+ E. Adaptive Regime — Gradient regime instead of binary on/off
10
+ F. Multi-Timeframe — Blend 3M + 6M + 12M momentum
11
+ G. Trend Quality — R-squared filter (clean trends only)
12
+
13
+ CAUSAL GUARANTEES (inherited from V30):
14
+ - All signals use T-1 data
15
+ - Returns use T-0 data
16
+ - No future information leakage
17
+ """
18
+
19
+ import os, sys
20
+ import numpy as np, pandas as pd
21
+ from scipy import stats as sp_stats
22
+ import warnings; warnings.filterwarnings("ignore")
23
+
24
+ sys.path.insert(0, os.path.dirname(__file__))
25
+ from backtesting.engines.v30_causal_engine import (
26
+ load_data, get_data, evaluate_slice, compute_spy_metrics,
27
+ V30_PARAMS, CU, RI, FU, CAP, LB, BE,
28
+ TRAIN_START, TRAIN_END, TEST_START, TEST_END
29
+ )
30
+
31
+ # =============================================================================
32
+ # V36 ROUND 2 ENHANCED BACKTEST ENGINE
33
+ # =============================================================================
34
+ def run_v36r2_backtest(dc=None, spy=None, vf=None, daily_ret=None,
35
+ # V30 base params
36
+ top_n=15, rebal_days=60, vol_target=0.18,
37
+ riskoff_haircut=0.50, sma_lookback=200,
38
+ mom_long=175, mom_short=21,
39
+ txn_cost_bps=20, regime_txn_multiplier=1.0,
40
+ # Enhancement D: Dual Momentum
41
+ use_dual_mom=False,
42
+ abs_mom_lookback=252, # Absolute return lookback
43
+ # Enhancement E: Adaptive Regime
44
+ use_adaptive_regime=False,
45
+ regime_gradient_width=0.05, # +/-5% band around SMA
46
+ # Enhancement F: Multi-Timeframe Blend
47
+ use_multi_tf=False,
48
+ tf_weights=(0.25, 0.35, 0.40), # 3M, 6M, 12M weights
49
+ # Enhancement G: Trend Quality Filter
50
+ use_trend_quality=False,
51
+ tq_min_rsq=0.50, # Minimum R-squared
52
+ tq_lookback=90, # R² calculation window
53
+ # Extra
54
+ custom_universe=None,
55
+ return_holdings=False):
56
+ """
57
+ V36 Round 2 Enhanced Causal Backtest.
58
+ When all flags are False, produces identical output to V30 baseline.
59
+ """
60
+ if dc is None:
61
+ dc, spy, vf, daily_ret = get_data()
62
+
63
+ if custom_universe is not None:
64
+ vf = [t for t in custom_universe if t in dc.columns and dc[t].notna().sum() > 252]
65
+
66
+ # --- Signal Construction ---
67
+ if use_multi_tf:
68
+ # Enhancement F: Multi-timeframe momentum blend
69
+ # 3-month (63d), 6-month (126d), 12-month (252d), all with 21d skip
70
+ mom_3m = (dc[vf].shift(mom_short) / dc[vf].shift(63)) - 1
71
+ mom_6m = (dc[vf].shift(mom_short) / dc[vf].shift(126)) - 1
72
+ mom_12m = (dc[vf].shift(mom_short) / dc[vf].shift(252)) - 1
73
+
74
+ # Rank each timeframe cross-sectionally, then blend
75
+ r3 = mom_3m.rank(axis=1, ascending=False, pct=True)
76
+ r6 = mom_6m.rank(axis=1, ascending=False, pct=True)
77
+ r12 = mom_12m.rank(axis=1, ascending=False, pct=True)
78
+
79
+ w3, w6, w12 = tf_weights
80
+ # Lower rank pct = better (rank 1 = pct ~0.0), so we invert
81
+ m_signal = w3 * (1 - r3) + w6 * (1 - r6) + w12 * (1 - r12)
82
+ else:
83
+ m_signal = (dc[vf].shift(mom_short) / dc[vf].shift(mom_long)) - 1
84
+
85
+ # Pre-compute absolute momentum (for Enhancement D)
86
+ if use_dual_mom:
87
+ abs_returns = (dc[vf] / dc[vf].shift(abs_mom_lookback)) - 1
88
+
89
+ # Pre-compute trend quality R² (for Enhancement G)
90
+ if use_trend_quality:
91
+ # Rolling R² of log-price against time
92
+ log_prices = np.log(dc[vf].clip(lower=0.01))
93
+ rsq_df = pd.DataFrame(index=dc.index, columns=vf, dtype=float)
94
+ for col in vf:
95
+ lp = log_prices[col].values
96
+ for i in range(tq_lookback, len(dc)):
97
+ y = lp[i-tq_lookback:i]
98
+ if np.any(np.isnan(y)):
99
+ continue
100
+ x = np.arange(tq_lookback)
101
+ slope, intercept, r_value, p_value, std_err = sp_stats.linregress(x, y)
102
+ rsq_df.iloc[i][col] = r_value ** 2
103
+
104
+ sma = spy.rolling(sma_lookback).mean()
105
+ sma_vals = sma.values
106
+ spy_vals = spy.values
107
+ txn_frac = txn_cost_bps / 10000.0
108
+
109
+ nav = CAP
110
+ pick_tks = []
111
+ port_rets = []
112
+ days = 0
113
+ hist = []
114
+ vol_scalars = []
115
+ regime_log = []
116
+ holdings_log = []
117
+ prev_riskoff = False
118
+
119
+ for i in range(1, len(dc)):
120
+ # Volatility scalar
121
+ if len(port_rets) >= 21:
122
+ window = port_rets[-60:] if len(port_rets) >= 60 else port_rets[-21:]
123
+ rvol = np.std(window) * np.sqrt(252)
124
+ vol_scalar = vol_target / (rvol + 1e-8)
125
+ else:
126
+ vol_scalar = 0.5
127
+
128
+ # CAUSAL regime detection: yesterday's data
129
+ sp = spy_vals[i-1]
130
+ sm = sma_vals[i-1]
131
+
132
+ if use_adaptive_regime:
133
+ # Enhancement E: Gradient regime instead of binary
134
+ if pd.isna(sm) or sm <= 0:
135
+ regime_scalar = riskoff_haircut
136
+ else:
137
+ distance = (sp - sm) / sm # e.g., +0.03 = 3% above SMA
138
+ # Map distance to a scalar between riskoff_haircut and 1.0
139
+ # At +width: full allocation (1.0)
140
+ # At 0: midpoint
141
+ # At -width: riskoff_haircut
142
+ w = regime_gradient_width
143
+ if distance >= w:
144
+ regime_scalar = 1.0
145
+ elif distance <= -w:
146
+ regime_scalar = riskoff_haircut
147
+ else:
148
+ # Linear interpolation
149
+ t = (distance + w) / (2 * w) # 0 to 1
150
+ regime_scalar = riskoff_haircut + t * (1.0 - riskoff_haircut)
151
+
152
+ vol_scalar *= regime_scalar
153
+ is_riskoff = regime_scalar < 0.75 # For logging
154
+ else:
155
+ is_riskoff = bool(pd.isna(sm) or sp <= sm)
156
+ if is_riskoff:
157
+ vol_scalar *= riskoff_haircut
158
+
159
+ vol_scalar = float(np.clip(vol_scalar, 0.05, 1.0))
160
+
161
+ # Regime switch detection
162
+ regime_switched = (is_riskoff != prev_riskoff) and i > 1
163
+ current_txn_mult = regime_txn_multiplier if regime_switched else 1.0
164
+ prev_riskoff = is_riskoff
165
+
166
+ # Daily return
167
+ day_ret = 0.0
168
+ if pick_tks:
169
+ lr = daily_ret.iloc[i][[t for t in pick_tks if t in daily_ret.columns]].dropna()
170
+ if len(lr) > 0:
171
+ day_ret = lr.mean() * vol_scalar
172
+ nav *= (1 + day_ret)
173
+ nav -= nav * txn_frac * 2 * current_txn_mult / rebal_days * vol_scalar
174
+
175
+ port_rets.append(day_ret)
176
+ hist.append(nav)
177
+ vol_scalars.append(vol_scalar)
178
+ regime_log.append(is_riskoff)
179
+
180
+ # Rebalance
181
+ days += 1
182
+ if days >= rebal_days:
183
+ days = 0
184
+ # CAUSAL signal: yesterday's data
185
+ row = m_signal.iloc[i-1].dropna()
186
+ valid = [t for t in vf if t in row.index]
187
+
188
+ # Enhancement D: Dual Momentum filter
189
+ if use_dual_mom and len(valid) > 0:
190
+ abs_row = abs_returns.iloc[i-1].dropna()
191
+ # Only keep stocks with positive absolute momentum
192
+ valid = [t for t in valid if t in abs_row.index and abs_row[t] > 0]
193
+
194
+ # Enhancement G: Trend Quality filter
195
+ if use_trend_quality and len(valid) > 0:
196
+ rsq_row = rsq_df.iloc[i-1]
197
+ valid = [t for t in valid if t in rsq_row.index
198
+ and pd.notna(rsq_row[t]) and rsq_row[t] >= tq_min_rsq]
199
+
200
+ if len(valid) >= top_n:
201
+ d = pd.DataFrame({"T": valid, "A": row[valid]}).sort_values("A", ascending=False)
202
+ pick_tks = list(d.head(top_n)["T"])
203
+ elif len(valid) > 0:
204
+ # If filters removed too many, take what's available
205
+ d = pd.DataFrame({"T": valid, "A": row[valid]}).sort_values("A", ascending=False)
206
+ pick_tks = list(d.head(min(top_n, len(valid)))["T"])
207
+
208
+ if return_holdings:
209
+ holdings_log.append({"date": dc.index[i], "tickers": list(pick_tks)})
210
+
211
+ # Build output
212
+ cap = pd.Series(hist, index=dc.index[1:len(hist)+1])
213
+ dr = cap.pct_change().dropna()
214
+
215
+ result = {
216
+ "curve": cap,
217
+ "daily_returns": dr,
218
+ "port_rets_list": port_rets,
219
+ "vol_scalars": vol_scalars,
220
+ "regime_status": regime_log,
221
+ }
222
+ if return_holdings:
223
+ result["holdings_log"] = holdings_log
224
+
225
+ return result
backtesting/engines/v36r3_engines.py ADDED
@@ -0,0 +1,475 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # -*- coding: utf-8 -*-
2
+ """
3
+ V36 Round 3 — Structural Architecture Experiments
4
+ ====================================================
5
+ Fundamentally different portfolio constructions, not just signal tweaks.
6
+
7
+ STRATEGIES:
8
+ H. Long-Short Momentum — Long top N, Short bottom N
9
+ I. Market-Hedged (SPY Short) — Long momentum + dynamic SPY hedge
10
+ J. Conditional Rebalance — Vol-triggered rebalancing (not fixed cycle)
11
+ K. Crash Protection Overlay — Momentum + drawdown-triggered liquidation
12
+ L. Long-Short + Sector-Neutral — LS within each sector (market-neutral)
13
+ """
14
+
15
+ import os, sys
16
+ import numpy as np, pandas as pd
17
+ import warnings; warnings.filterwarnings("ignore")
18
+
19
+ sys.path.insert(0, os.path.dirname(__file__))
20
+ from backtesting.engines.v30_causal_engine import (
21
+ load_data, get_data, evaluate_slice, V30_PARAMS,
22
+ CU, RI, FU, CAP, TRAIN_START, TRAIN_END, TEST_START, TEST_END
23
+ )
24
+ from backtesting.engines.v36_research_engine import SECTOR_MAP, SECTORS
25
+
26
+ # =============================================================================
27
+ # STRATEGY H: LONG-SHORT MOMENTUM
28
+ # =============================================================================
29
+ def run_long_short(dc=None, spy=None, vf=None, daily_ret=None,
30
+ top_n=15, bottom_n=15, rebal_days=60,
31
+ vol_target=0.18, riskoff_haircut=0.50,
32
+ sma_lookback=200, mom_long=175, mom_short=21,
33
+ txn_cost_bps=20, short_cost_bps=50,
34
+ long_weight=0.60, short_weight=0.40):
35
+ """
36
+ Long top_n momentum winners, Short bottom_n momentum losers.
37
+ Net exposure = long_weight - short_weight (e.g., 0.20 = 20% net long).
38
+ """
39
+ if dc is None:
40
+ dc, spy, vf, daily_ret = get_data()
41
+
42
+ m_signal = (dc[vf].shift(mom_short) / dc[vf].shift(mom_long)) - 1
43
+ sma = spy.rolling(sma_lookback).mean()
44
+ sma_vals = sma.values
45
+ spy_vals = spy.values
46
+ txn_frac = txn_cost_bps / 10000.0
47
+ short_frac = short_cost_bps / 10000.0
48
+
49
+ nav = CAP
50
+ long_tks = []
51
+ short_tks = []
52
+ port_rets = []
53
+ days = 0
54
+ hist = []
55
+ vol_scalars = []
56
+ prev_riskoff = False
57
+
58
+ for i in range(1, len(dc)):
59
+ # Vol scalar
60
+ if len(port_rets) >= 21:
61
+ window = port_rets[-60:] if len(port_rets) >= 60 else port_rets[-21:]
62
+ rvol = np.std(window) * np.sqrt(252)
63
+ vol_scalar = vol_target / (rvol + 1e-8)
64
+ else:
65
+ vol_scalar = 0.5
66
+
67
+ sp = spy_vals[i-1]
68
+ sm = sma_vals[i-1]
69
+ is_riskoff = bool(pd.isna(sm) or sp <= sm)
70
+
71
+ if is_riskoff:
72
+ vol_scalar *= riskoff_haircut
73
+
74
+ vol_scalar = float(np.clip(vol_scalar, 0.05, 1.0))
75
+
76
+ # Daily return from long + short legs
77
+ day_ret = 0.0
78
+
79
+ # Long leg
80
+ if long_tks:
81
+ lr = daily_ret.iloc[i][[t for t in long_tks if t in daily_ret.columns]].dropna()
82
+ if len(lr) > 0:
83
+ day_ret += lr.mean() * long_weight * vol_scalar
84
+
85
+ # Short leg (we profit when shorts go DOWN)
86
+ if short_tks:
87
+ sr = daily_ret.iloc[i][[t for t in short_tks if t in daily_ret.columns]].dropna()
88
+ if len(sr) > 0:
89
+ day_ret -= sr.mean() * short_weight * vol_scalar
90
+
91
+ nav *= (1 + day_ret)
92
+ # Transaction costs for both legs
93
+ nav -= nav * txn_frac * 2 / rebal_days * vol_scalar * (long_weight + short_weight)
94
+ # Short borrow costs (annualized, so divide by 252)
95
+ nav -= nav * short_frac / 252 * short_weight * vol_scalar
96
+
97
+ port_rets.append(day_ret)
98
+ hist.append(nav)
99
+ vol_scalars.append(vol_scalar)
100
+
101
+ # Rebalance
102
+ days += 1
103
+ if days >= rebal_days:
104
+ days = 0
105
+ row = m_signal.iloc[i-1].dropna()
106
+ valid = [t for t in vf if t in row.index]
107
+ if len(valid) >= top_n + bottom_n:
108
+ sorted_df = pd.DataFrame({"T": valid, "A": row[valid]}).sort_values("A", ascending=False)
109
+ long_tks = list(sorted_df.head(top_n)["T"])
110
+ short_tks = list(sorted_df.tail(bottom_n)["T"])
111
+
112
+ cap = pd.Series(hist, index=dc.index[1:len(hist)+1])
113
+ return {"curve": cap, "daily_returns": cap.pct_change().dropna(),
114
+ "port_rets_list": port_rets, "vol_scalars": vol_scalars}
115
+
116
+
117
+ # =============================================================================
118
+ # STRATEGY I: MARKET-HEDGED (LONG MOMENTUM + SPY SHORT)
119
+ # =============================================================================
120
+ def run_market_hedged(dc=None, spy=None, vf=None, daily_ret=None,
121
+ top_n=15, rebal_days=60, vol_target=0.18,
122
+ riskoff_haircut=0.50, sma_lookback=200,
123
+ mom_long=175, mom_short=21, txn_cost_bps=20,
124
+ hedge_ratio=0.50, short_cost_bps=30):
125
+ """
126
+ Long momentum portfolio + short hedge_ratio * SPY.
127
+ Reduces market exposure, isolates alpha.
128
+ """
129
+ if dc is None:
130
+ dc, spy, vf, daily_ret = get_data()
131
+
132
+ m_signal = (dc[vf].shift(mom_short) / dc[vf].shift(mom_long)) - 1
133
+ sma = spy.rolling(sma_lookback).mean()
134
+ spy_ret = spy.pct_change()
135
+ sma_vals = sma.values
136
+ spy_vals = spy.values
137
+ txn_frac = txn_cost_bps / 10000.0
138
+ short_frac = short_cost_bps / 10000.0
139
+
140
+ nav = CAP
141
+ pick_tks = []
142
+ port_rets = []
143
+ days = 0
144
+ hist = []
145
+ vol_scalars = []
146
+
147
+ for i in range(1, len(dc)):
148
+ if len(port_rets) >= 21:
149
+ window = port_rets[-60:] if len(port_rets) >= 60 else port_rets[-21:]
150
+ rvol = np.std(window) * np.sqrt(252)
151
+ vol_scalar = vol_target / (rvol + 1e-8)
152
+ else:
153
+ vol_scalar = 0.5
154
+
155
+ sp = spy_vals[i-1]
156
+ sm = sma_vals[i-1]
157
+ is_riskoff = bool(pd.isna(sm) or sp <= sm)
158
+
159
+ # Dynamic hedge: increase hedge in risk-off
160
+ current_hedge = hedge_ratio
161
+ if is_riskoff:
162
+ vol_scalar *= riskoff_haircut
163
+ current_hedge = min(hedge_ratio * 1.5, 0.95) # Increase hedge in risk-off
164
+
165
+ vol_scalar = float(np.clip(vol_scalar, 0.05, 1.0))
166
+
167
+ day_ret = 0.0
168
+ if pick_tks:
169
+ lr = daily_ret.iloc[i][[t for t in pick_tks if t in daily_ret.columns]].dropna()
170
+ if len(lr) > 0:
171
+ # Long leg
172
+ long_ret = lr.mean() * vol_scalar
173
+ # Short SPY hedge
174
+ spy_r = spy_ret.iloc[i] if pd.notna(spy_ret.iloc[i]) else 0
175
+ hedge_ret = -spy_r * current_hedge * vol_scalar
176
+ day_ret = long_ret + hedge_ret
177
+
178
+ nav *= (1 + day_ret)
179
+ nav -= nav * txn_frac * 2 / rebal_days * vol_scalar
180
+ nav -= nav * short_frac / 252 * current_hedge * vol_scalar # SPY borrow cost
181
+
182
+ port_rets.append(day_ret)
183
+ hist.append(nav)
184
+ vol_scalars.append(vol_scalar)
185
+
186
+ days += 1
187
+ if days >= rebal_days:
188
+ days = 0
189
+ row = m_signal.iloc[i-1].dropna()
190
+ valid = [t for t in vf if t in row.index]
191
+ if len(valid) >= top_n:
192
+ d = pd.DataFrame({"T": valid, "A": row[valid]}).sort_values("A", ascending=False)
193
+ pick_tks = list(d.head(top_n)["T"])
194
+
195
+ cap = pd.Series(hist, index=dc.index[1:len(hist)+1])
196
+ return {"curve": cap, "daily_returns": cap.pct_change().dropna(),
197
+ "port_rets_list": port_rets, "vol_scalars": vol_scalars}
198
+
199
+
200
+ # =============================================================================
201
+ # STRATEGY J: CONDITIONAL REBALANCE (VOL-TRIGGERED)
202
+ # =============================================================================
203
+ def run_conditional_rebal(dc=None, spy=None, vf=None, daily_ret=None,
204
+ top_n=15, max_rebal_days=60, vol_target=0.18,
205
+ riskoff_haircut=0.50, sma_lookback=200,
206
+ mom_long=175, mom_short=21, txn_cost_bps=20,
207
+ vol_trigger=0.25, min_rebal_days=15):
208
+ """
209
+ Rebalance on fixed cycle OR when realized vol exceeds vol_trigger,
210
+ whichever comes first. Also forces rebalance on regime switches.
211
+ """
212
+ if dc is None:
213
+ dc, spy, vf, daily_ret = get_data()
214
+
215
+ m_signal = (dc[vf].shift(mom_short) / dc[vf].shift(mom_long)) - 1
216
+ sma = spy.rolling(sma_lookback).mean()
217
+ sma_vals = sma.values
218
+ spy_vals = spy.values
219
+ txn_frac = txn_cost_bps / 10000.0
220
+
221
+ nav = CAP
222
+ pick_tks = []
223
+ port_rets = []
224
+ days = 0
225
+ hist = []
226
+ vol_scalars = []
227
+ prev_riskoff = False
228
+
229
+ for i in range(1, len(dc)):
230
+ if len(port_rets) >= 21:
231
+ window = port_rets[-60:] if len(port_rets) >= 60 else port_rets[-21:]
232
+ rvol = np.std(window) * np.sqrt(252)
233
+ vol_scalar = vol_target / (rvol + 1e-8)
234
+ else:
235
+ rvol = 0.20
236
+ vol_scalar = 0.5
237
+
238
+ sp = spy_vals[i-1]
239
+ sm = sma_vals[i-1]
240
+ is_riskoff = bool(pd.isna(sm) or sp <= sm)
241
+
242
+ if is_riskoff:
243
+ vol_scalar *= riskoff_haircut
244
+
245
+ vol_scalar = float(np.clip(vol_scalar, 0.05, 1.0))
246
+
247
+ regime_switched = (is_riskoff != prev_riskoff) and i > 1
248
+ prev_riskoff = is_riskoff
249
+
250
+ day_ret = 0.0
251
+ if pick_tks:
252
+ lr = daily_ret.iloc[i][[t for t in pick_tks if t in daily_ret.columns]].dropna()
253
+ if len(lr) > 0:
254
+ day_ret = lr.mean() * vol_scalar
255
+ nav *= (1 + day_ret)
256
+ nav -= nav * txn_frac * 2 / max_rebal_days * vol_scalar
257
+
258
+ port_rets.append(day_ret)
259
+ hist.append(nav)
260
+ vol_scalars.append(vol_scalar)
261
+
262
+ days += 1
263
+
264
+ # Conditional rebalance triggers:
265
+ # 1. Fixed cycle reached
266
+ # 2. Vol spike (realized vol > trigger AND min days passed)
267
+ # 3. Regime switch (AND min days passed)
268
+ should_rebal = False
269
+ if days >= max_rebal_days:
270
+ should_rebal = True
271
+ elif days >= min_rebal_days:
272
+ if rvol > vol_trigger:
273
+ should_rebal = True
274
+ elif regime_switched:
275
+ should_rebal = True
276
+
277
+ if should_rebal:
278
+ days = 0
279
+ row = m_signal.iloc[i-1].dropna()
280
+ valid = [t for t in vf if t in row.index]
281
+ if len(valid) >= top_n:
282
+ d = pd.DataFrame({"T": valid, "A": row[valid]}).sort_values("A", ascending=False)
283
+ pick_tks = list(d.head(top_n)["T"])
284
+
285
+ cap = pd.Series(hist, index=dc.index[1:len(hist)+1])
286
+ return {"curve": cap, "daily_returns": cap.pct_change().dropna(),
287
+ "port_rets_list": port_rets, "vol_scalars": vol_scalars}
288
+
289
+
290
+ # =============================================================================
291
+ # STRATEGY K: CRASH PROTECTION OVERLAY
292
+ # =============================================================================
293
+ def run_crash_protection(dc=None, spy=None, vf=None, daily_ret=None,
294
+ top_n=15, rebal_days=60, vol_target=0.18,
295
+ riskoff_haircut=0.50, sma_lookback=200,
296
+ mom_long=175, mom_short=21, txn_cost_bps=20,
297
+ dd_threshold=-0.10, recovery_threshold=0.03):
298
+ """
299
+ Standard V30 + drawdown-triggered liquidation.
300
+ If portfolio drawdown exceeds dd_threshold, go to cash.
301
+ Re-enter when portfolio recovers recovery_threshold from trough.
302
+ """
303
+ if dc is None:
304
+ dc, spy, vf, daily_ret = get_data()
305
+
306
+ m_signal = (dc[vf].shift(mom_short) / dc[vf].shift(mom_long)) - 1
307
+ sma = spy.rolling(sma_lookback).mean()
308
+ sma_vals = sma.values
309
+ spy_vals = spy.values
310
+ txn_frac = txn_cost_bps / 10000.0
311
+
312
+ nav = CAP
313
+ pick_tks = []
314
+ port_rets = []
315
+ days = 0
316
+ hist = []
317
+ vol_scalars = []
318
+ prev_riskoff = False
319
+
320
+ # Crash protection state
321
+ in_cash = False
322
+ peak_nav = CAP
323
+ trough_nav = CAP
324
+
325
+ for i in range(1, len(dc)):
326
+ if len(port_rets) >= 21:
327
+ window = port_rets[-60:] if len(port_rets) >= 60 else port_rets[-21:]
328
+ rvol = np.std(window) * np.sqrt(252)
329
+ vol_scalar = vol_target / (rvol + 1e-8)
330
+ else:
331
+ vol_scalar = 0.5
332
+
333
+ sp = spy_vals[i-1]
334
+ sm = sma_vals[i-1]
335
+ is_riskoff = bool(pd.isna(sm) or sp <= sm)
336
+
337
+ if is_riskoff:
338
+ vol_scalar *= riskoff_haircut
339
+
340
+ vol_scalar = float(np.clip(vol_scalar, 0.05, 1.0))
341
+
342
+ # Crash protection logic
343
+ peak_nav = max(peak_nav, nav)
344
+ current_dd = (nav - peak_nav) / peak_nav
345
+
346
+ if not in_cash and current_dd < dd_threshold:
347
+ in_cash = True
348
+ trough_nav = nav
349
+
350
+ if in_cash:
351
+ trough_nav = min(trough_nav, nav)
352
+ recovery = (nav - trough_nav) / (trough_nav + 1e-8)
353
+ if recovery > recovery_threshold:
354
+ in_cash = False
355
+
356
+ # Daily return
357
+ day_ret = 0.0
358
+ if pick_tks and not in_cash:
359
+ lr = daily_ret.iloc[i][[t for t in pick_tks if t in daily_ret.columns]].dropna()
360
+ if len(lr) > 0:
361
+ day_ret = lr.mean() * vol_scalar
362
+ nav *= (1 + day_ret)
363
+ nav -= nav * txn_frac * 2 / rebal_days * vol_scalar
364
+
365
+ port_rets.append(day_ret)
366
+ hist.append(nav)
367
+ vol_scalars.append(vol_scalar)
368
+
369
+ prev_riskoff = is_riskoff
370
+
371
+ days += 1
372
+ if days >= rebal_days:
373
+ days = 0
374
+ row = m_signal.iloc[i-1].dropna()
375
+ valid = [t for t in vf if t in row.index]
376
+ if len(valid) >= top_n:
377
+ d = pd.DataFrame({"T": valid, "A": row[valid]}).sort_values("A", ascending=False)
378
+ pick_tks = list(d.head(top_n)["T"])
379
+
380
+ cap = pd.Series(hist, index=dc.index[1:len(hist)+1])
381
+ return {"curve": cap, "daily_returns": cap.pct_change().dropna(),
382
+ "port_rets_list": port_rets, "vol_scalars": vol_scalars}
383
+
384
+
385
+ # =============================================================================
386
+ # STRATEGY L: LONG-SHORT SECTOR-NEUTRAL
387
+ # =============================================================================
388
+ def run_ls_sector_neutral(dc=None, spy=None, vf=None, daily_ret=None,
389
+ picks_per_sector=1, rebal_days=60,
390
+ vol_target=0.18, riskoff_haircut=0.50,
391
+ sma_lookback=200, mom_long=175, mom_short=21,
392
+ txn_cost_bps=20, short_cost_bps=50):
393
+ """
394
+ Long best momentum stock in each sector, Short worst momentum stock in each sector.
395
+ True market-neutral, sector-neutral construction.
396
+ """
397
+ if dc is None:
398
+ dc, spy, vf, daily_ret = get_data()
399
+
400
+ m_signal = (dc[vf].shift(mom_short) / dc[vf].shift(mom_long)) - 1
401
+ sma = spy.rolling(sma_lookback).mean()
402
+ sma_vals = sma.values
403
+ spy_vals = spy.values
404
+ txn_frac = txn_cost_bps / 10000.0
405
+ short_frac = short_cost_bps / 10000.0
406
+
407
+ nav = CAP
408
+ long_tks = []
409
+ short_tks = []
410
+ port_rets = []
411
+ days = 0
412
+ hist = []
413
+ vol_scalars = []
414
+
415
+ for i in range(1, len(dc)):
416
+ if len(port_rets) >= 21:
417
+ window = port_rets[-60:] if len(port_rets) >= 60 else port_rets[-21:]
418
+ rvol = np.std(window) * np.sqrt(252)
419
+ vol_scalar = vol_target / (rvol + 1e-8)
420
+ else:
421
+ vol_scalar = 0.5
422
+
423
+ sp = spy_vals[i-1]
424
+ sm = sma_vals[i-1]
425
+ is_riskoff = bool(pd.isna(sm) or sp <= sm)
426
+
427
+ if is_riskoff:
428
+ vol_scalar *= riskoff_haircut
429
+
430
+ vol_scalar = float(np.clip(vol_scalar, 0.05, 1.0))
431
+
432
+ day_ret = 0.0
433
+ n_legs = 0
434
+
435
+ # Long leg
436
+ if long_tks:
437
+ lr = daily_ret.iloc[i][[t for t in long_tks if t in daily_ret.columns]].dropna()
438
+ if len(lr) > 0:
439
+ day_ret += lr.mean() * 0.5 * vol_scalar
440
+ n_legs += 1
441
+
442
+ # Short leg
443
+ if short_tks:
444
+ sr = daily_ret.iloc[i][[t for t in short_tks if t in daily_ret.columns]].dropna()
445
+ if len(sr) > 0:
446
+ day_ret -= sr.mean() * 0.5 * vol_scalar
447
+ n_legs += 1
448
+
449
+ nav *= (1 + day_ret)
450
+ nav -= nav * txn_frac * 2 / rebal_days * vol_scalar
451
+ if short_tks:
452
+ nav -= nav * short_frac / 252 * 0.5 * vol_scalar
453
+
454
+ port_rets.append(day_ret)
455
+ hist.append(nav)
456
+ vol_scalars.append(vol_scalar)
457
+
458
+ days += 1
459
+ if days >= rebal_days:
460
+ days = 0
461
+ row = m_signal.iloc[i-1].dropna()
462
+
463
+ long_tks = []
464
+ short_tks = []
465
+ for sector in SECTORS:
466
+ sector_tickers = [t for t in vf if t in row.index and SECTOR_MAP.get(t) == sector]
467
+ if len(sector_tickers) < 2:
468
+ continue
469
+ scores = row[sector_tickers].sort_values(ascending=False)
470
+ long_tks.extend(list(scores.head(picks_per_sector).index))
471
+ short_tks.extend(list(scores.tail(picks_per_sector).index))
472
+
473
+ cap = pd.Series(hist, index=dc.index[1:len(hist)+1])
474
+ return {"curve": cap, "daily_returns": cap.pct_change().dropna(),
475
+ "port_rets_list": port_rets, "vol_scalars": vol_scalars}
backtesting/engines/v36r4_engines.py ADDED
@@ -0,0 +1,312 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # -*- coding: utf-8 -*-
2
+ """
3
+ V36 Round 4 — Signal Architecture Experiments
4
+ ===============================================
5
+ Completely different SIGNAL constructions, not portfolio tweaks.
6
+
7
+ STRATEGIES:
8
+ M. Risk-Adjusted Momentum — Rank by Sharpe of returns, not raw returns
9
+ N. 52-Week High Proximity — George & Hwang (2004) nearness to high
10
+ O. Residual Momentum — Strip out market beta, rank on alpha
11
+ P. Partial Rebalance — Only turnover 50% of portfolio each cycle
12
+ Q. Momentum Acceleration — Change in momentum (2nd derivative)
13
+ """
14
+
15
+ import os, sys
16
+ import numpy as np, pandas as pd
17
+ from scipy import stats as sp_stats
18
+ import warnings; warnings.filterwarnings("ignore")
19
+
20
+ sys.path.insert(0, os.path.dirname(__file__))
21
+ from backtesting.engines.v30_causal_engine import (
22
+ load_data, get_data, evaluate_slice, V30_PARAMS,
23
+ CU, RI, FU, CAP, TRAIN_START, TRAIN_END, TEST_START, TEST_END
24
+ )
25
+
26
+
27
+ def run_risk_adj_mom(dc=None, spy=None, vf=None, daily_ret=None,
28
+ top_n=15, rebal_days=60, vol_target=0.18,
29
+ riskoff_haircut=0.50, sma_lookback=200,
30
+ mom_long=175, mom_short=21, txn_cost_bps=20,
31
+ sharpe_lookback=126):
32
+ """M: Rank stocks by trailing Sharpe ratio instead of raw return."""
33
+ if dc is None: dc, spy, vf, daily_ret = get_data()
34
+
35
+ sma = spy.rolling(sma_lookback).mean()
36
+ sma_vals, spy_vals = sma.values, spy.values
37
+ txn_frac = txn_cost_bps / 10000.0
38
+ ret = dc[vf].pct_change()
39
+
40
+ # Pre-compute rolling Sharpe for each stock
41
+ rolling_mean = ret.rolling(sharpe_lookback).mean() * 252
42
+ rolling_std = ret.rolling(sharpe_lookback).std() * np.sqrt(252)
43
+ m_signal = rolling_mean / (rolling_std + 1e-8)
44
+
45
+ nav, pick_tks, port_rets, days, hist, vol_scalars = CAP, [], [], 0, [], []
46
+
47
+ for i in range(1, len(dc)):
48
+ if len(port_rets) >= 21:
49
+ w = port_rets[-60:] if len(port_rets) >= 60 else port_rets[-21:]
50
+ vs = vol_target / (np.std(w) * np.sqrt(252) + 1e-8)
51
+ else: vs = 0.5
52
+
53
+ sp, sm = spy_vals[i-1], sma_vals[i-1]
54
+ if pd.isna(sm) or sp <= sm: vs *= riskoff_haircut
55
+ vs = float(np.clip(vs, 0.05, 1.0))
56
+
57
+ day_ret = 0.0
58
+ if pick_tks:
59
+ lr = daily_ret.iloc[i][[t for t in pick_tks if t in daily_ret.columns]].dropna()
60
+ if len(lr) > 0:
61
+ day_ret = lr.mean() * vs
62
+ nav *= (1 + day_ret)
63
+ nav -= nav * txn_frac * 2 / rebal_days * vs
64
+
65
+ port_rets.append(day_ret); hist.append(nav); vol_scalars.append(vs)
66
+
67
+ days += 1
68
+ if days >= rebal_days:
69
+ days = 0
70
+ row = m_signal.iloc[i-1].dropna()
71
+ valid = [t for t in vf if t in row.index]
72
+ if len(valid) >= top_n:
73
+ d = pd.DataFrame({"T": valid, "A": row[valid]}).sort_values("A", ascending=False)
74
+ pick_tks = list(d.head(top_n)["T"])
75
+
76
+ cap = pd.Series(hist, index=dc.index[1:len(hist)+1])
77
+ return {"curve": cap, "port_rets_list": port_rets}
78
+
79
+
80
+ def run_52wk_high(dc=None, spy=None, vf=None, daily_ret=None,
81
+ top_n=15, rebal_days=60, vol_target=0.18,
82
+ riskoff_haircut=0.50, sma_lookback=200,
83
+ mom_long=175, mom_short=21, txn_cost_bps=20):
84
+ """N: Rank stocks by proximity to 52-week high (George & Hwang 2004)."""
85
+ if dc is None: dc, spy, vf, daily_ret = get_data()
86
+
87
+ sma = spy.rolling(sma_lookback).mean()
88
+ sma_vals, spy_vals = sma.values, spy.values
89
+ txn_frac = txn_cost_bps / 10000.0
90
+
91
+ high_252 = dc[vf].rolling(252).max()
92
+ m_signal = dc[vf] / high_252 # Ratio: 1.0 = at 52wk high
93
+
94
+ nav, pick_tks, port_rets, days, hist, vol_scalars = CAP, [], [], 0, [], []
95
+
96
+ for i in range(1, len(dc)):
97
+ if len(port_rets) >= 21:
98
+ w = port_rets[-60:] if len(port_rets) >= 60 else port_rets[-21:]
99
+ vs = vol_target / (np.std(w) * np.sqrt(252) + 1e-8)
100
+ else: vs = 0.5
101
+
102
+ sp, sm = spy_vals[i-1], sma_vals[i-1]
103
+ if pd.isna(sm) or sp <= sm: vs *= riskoff_haircut
104
+ vs = float(np.clip(vs, 0.05, 1.0))
105
+
106
+ day_ret = 0.0
107
+ if pick_tks:
108
+ lr = daily_ret.iloc[i][[t for t in pick_tks if t in daily_ret.columns]].dropna()
109
+ if len(lr) > 0:
110
+ day_ret = lr.mean() * vs
111
+ nav *= (1 + day_ret)
112
+ nav -= nav * txn_frac * 2 / rebal_days * vs
113
+
114
+ port_rets.append(day_ret); hist.append(nav); vol_scalars.append(vs)
115
+
116
+ days += 1
117
+ if days >= rebal_days:
118
+ days = 0
119
+ row = m_signal.iloc[i-1].dropna()
120
+ valid = [t for t in vf if t in row.index]
121
+ if len(valid) >= top_n:
122
+ d = pd.DataFrame({"T": valid, "A": row[valid]}).sort_values("A", ascending=False)
123
+ pick_tks = list(d.head(top_n)["T"])
124
+
125
+ cap = pd.Series(hist, index=dc.index[1:len(hist)+1])
126
+ return {"curve": cap, "port_rets_list": port_rets}
127
+
128
+
129
+ def run_residual_mom(dc=None, spy=None, vf=None, daily_ret=None,
130
+ top_n=15, rebal_days=60, vol_target=0.18,
131
+ riskoff_haircut=0.50, sma_lookback=200,
132
+ mom_long=175, mom_short=21, txn_cost_bps=20,
133
+ beta_lookback=126):
134
+ """O: Strip out market beta, rank on residual (idiosyncratic) momentum."""
135
+ if dc is None: dc, spy, vf, daily_ret = get_data()
136
+
137
+ sma = spy.rolling(sma_lookback).mean()
138
+ sma_vals, spy_vals = sma.values, spy.values
139
+ txn_frac = txn_cost_bps / 10000.0
140
+ spy_ret = spy.pct_change()
141
+ stock_ret = dc[vf].pct_change()
142
+
143
+ # Pre-compute rolling beta and residual return for each stock
144
+ # residual_mom = cumulative residual return over lookback
145
+ m_signal = pd.DataFrame(index=dc.index, columns=vf, dtype=float)
146
+
147
+ for col in vf:
148
+ sr = stock_ret[col].values
149
+ mr = spy_ret.values
150
+ for i in range(beta_lookback + mom_short, len(dc)):
151
+ y = sr[i-beta_lookback:i]
152
+ x = mr[i-beta_lookback:i]
153
+ mask = ~(np.isnan(y) | np.isnan(x))
154
+ if mask.sum() < 30:
155
+ continue
156
+ beta = np.cov(y[mask], x[mask])[0, 1] / (np.var(x[mask]) + 1e-10)
157
+ # Residual returns over lookback
158
+ resid = y[mask] - beta * x[mask]
159
+ m_signal.iloc[i][col] = np.sum(resid)
160
+
161
+ nav, pick_tks, port_rets, days, hist, vol_scalars = CAP, [], [], 0, [], []
162
+
163
+ for i in range(1, len(dc)):
164
+ if len(port_rets) >= 21:
165
+ w = port_rets[-60:] if len(port_rets) >= 60 else port_rets[-21:]
166
+ vs = vol_target / (np.std(w) * np.sqrt(252) + 1e-8)
167
+ else: vs = 0.5
168
+
169
+ sp, sm = spy_vals[i-1], sma_vals[i-1]
170
+ if pd.isna(sm) or sp <= sm: vs *= riskoff_haircut
171
+ vs = float(np.clip(vs, 0.05, 1.0))
172
+
173
+ day_ret = 0.0
174
+ if pick_tks:
175
+ lr = daily_ret.iloc[i][[t for t in pick_tks if t in daily_ret.columns]].dropna()
176
+ if len(lr) > 0:
177
+ day_ret = lr.mean() * vs
178
+ nav *= (1 + day_ret)
179
+ nav -= nav * txn_frac * 2 / rebal_days * vs
180
+
181
+ port_rets.append(day_ret); hist.append(nav); vol_scalars.append(vs)
182
+
183
+ days += 1
184
+ if days >= rebal_days:
185
+ days = 0
186
+ row = m_signal.iloc[i-1].dropna()
187
+ valid = [t for t in vf if t in row.index]
188
+ if len(valid) >= top_n:
189
+ d = pd.DataFrame({"T": valid, "A": row[valid]}).sort_values("A", ascending=False)
190
+ pick_tks = list(d.head(top_n)["T"])
191
+
192
+ cap = pd.Series(hist, index=dc.index[1:len(hist)+1])
193
+ return {"curve": cap, "port_rets_list": port_rets}
194
+
195
+
196
+ def run_partial_rebal(dc=None, spy=None, vf=None, daily_ret=None,
197
+ top_n=15, rebal_days=60, vol_target=0.18,
198
+ riskoff_haircut=0.50, sma_lookback=200,
199
+ mom_long=175, mom_short=21, txn_cost_bps=20,
200
+ turnover_pct=0.50):
201
+ """P: Only replace turnover_pct of portfolio each rebalance."""
202
+ if dc is None: dc, spy, vf, daily_ret = get_data()
203
+
204
+ m_signal = (dc[vf].shift(mom_short) / dc[vf].shift(mom_long)) - 1
205
+ sma = spy.rolling(sma_lookback).mean()
206
+ sma_vals, spy_vals = sma.values, spy.values
207
+ txn_frac = txn_cost_bps / 10000.0
208
+
209
+ nav, pick_tks, port_rets, days, hist, vol_scalars = CAP, [], [], 0, [], []
210
+
211
+ for i in range(1, len(dc)):
212
+ if len(port_rets) >= 21:
213
+ w = port_rets[-60:] if len(port_rets) >= 60 else port_rets[-21:]
214
+ vs = vol_target / (np.std(w) * np.sqrt(252) + 1e-8)
215
+ else: vs = 0.5
216
+
217
+ sp, sm = spy_vals[i-1], sma_vals[i-1]
218
+ if pd.isna(sm) or sp <= sm: vs *= riskoff_haircut
219
+ vs = float(np.clip(vs, 0.05, 1.0))
220
+
221
+ day_ret = 0.0
222
+ if pick_tks:
223
+ lr = daily_ret.iloc[i][[t for t in pick_tks if t in daily_ret.columns]].dropna()
224
+ if len(lr) > 0:
225
+ day_ret = lr.mean() * vs
226
+ nav *= (1 + day_ret)
227
+ nav -= nav * txn_frac * 2 / rebal_days * vs
228
+
229
+ port_rets.append(day_ret); hist.append(nav); vol_scalars.append(vs)
230
+
231
+ days += 1
232
+ if days >= rebal_days:
233
+ days = 0
234
+ row = m_signal.iloc[i-1].dropna()
235
+ valid = [t for t in vf if t in row.index]
236
+ if len(valid) >= top_n:
237
+ d = pd.DataFrame({"T": valid, "A": row[valid]}).sort_values("A", ascending=False)
238
+ new_picks = list(d.head(top_n)["T"])
239
+
240
+ if not pick_tks:
241
+ pick_tks = new_picks
242
+ else:
243
+ # Keep (1 - turnover_pct) of current, replace rest with new
244
+ n_keep = max(1, int(top_n * (1 - turnover_pct)))
245
+ # Keep current picks that are still in top 2*N
246
+ top_2n = set(d.head(top_n * 2)["T"])
247
+ keepers = [t for t in pick_tks if t in top_2n][:n_keep]
248
+ # Fill remaining slots from new picks
249
+ n_new = top_n - len(keepers)
250
+ newcomers = [t for t in new_picks if t not in keepers][:n_new]
251
+ pick_tks = keepers + newcomers
252
+
253
+ cap = pd.Series(hist, index=dc.index[1:len(hist)+1])
254
+ return {"curve": cap, "port_rets_list": port_rets}
255
+
256
+
257
+ def run_mom_acceleration(dc=None, spy=None, vf=None, daily_ret=None,
258
+ top_n=15, rebal_days=60, vol_target=0.18,
259
+ riskoff_haircut=0.50, sma_lookback=200,
260
+ mom_long=175, mom_short=21, txn_cost_bps=20,
261
+ accel_short=63, accel_long=126):
262
+ """Q: Rank by change in momentum (acceleration / 2nd derivative)."""
263
+ if dc is None: dc, spy, vf, daily_ret = get_data()
264
+
265
+ sma = spy.rolling(sma_lookback).mean()
266
+ sma_vals, spy_vals = sma.values, spy.values
267
+ txn_frac = txn_cost_bps / 10000.0
268
+
269
+ # Momentum at two points in time
270
+ mom_now = (dc[vf].shift(mom_short) / dc[vf].shift(mom_long)) - 1
271
+ mom_prev = (dc[vf].shift(mom_short + accel_short) / dc[vf].shift(mom_long + accel_short)) - 1
272
+
273
+ # Acceleration = change in momentum
274
+ # Blend: 70% current momentum + 30% acceleration
275
+ mom_rank = mom_now.rank(axis=1, ascending=False, pct=True)
276
+ accel = mom_now - mom_prev
277
+ accel_rank = accel.rank(axis=1, ascending=False, pct=True)
278
+ m_signal = 0.70 * (1 - mom_rank) + 0.30 * (1 - accel_rank)
279
+
280
+ nav, pick_tks, port_rets, days, hist, vol_scalars = CAP, [], [], 0, [], []
281
+
282
+ for i in range(1, len(dc)):
283
+ if len(port_rets) >= 21:
284
+ w = port_rets[-60:] if len(port_rets) >= 60 else port_rets[-21:]
285
+ vs = vol_target / (np.std(w) * np.sqrt(252) + 1e-8)
286
+ else: vs = 0.5
287
+
288
+ sp, sm = spy_vals[i-1], sma_vals[i-1]
289
+ if pd.isna(sm) or sp <= sm: vs *= riskoff_haircut
290
+ vs = float(np.clip(vs, 0.05, 1.0))
291
+
292
+ day_ret = 0.0
293
+ if pick_tks:
294
+ lr = daily_ret.iloc[i][[t for t in pick_tks if t in daily_ret.columns]].dropna()
295
+ if len(lr) > 0:
296
+ day_ret = lr.mean() * vs
297
+ nav *= (1 + day_ret)
298
+ nav -= nav * txn_frac * 2 / rebal_days * vs
299
+
300
+ port_rets.append(day_ret); hist.append(nav); vol_scalars.append(vs)
301
+
302
+ days += 1
303
+ if days >= rebal_days:
304
+ days = 0
305
+ row = m_signal.iloc[i-1].dropna()
306
+ valid = [t for t in vf if t in row.index]
307
+ if len(valid) >= top_n:
308
+ d = pd.DataFrame({"T": valid, "A": row[valid]}).sort_values("A", ascending=False)
309
+ pick_tks = list(d.head(top_n)["T"])
310
+
311
+ cap = pd.Series(hist, index=dc.index[1:len(hist)+1])
312
+ return {"curve": cap, "port_rets_list": port_rets}
backtesting/engines/v36r5_engines.py ADDED
@@ -0,0 +1,264 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # -*- coding: utf-8 -*-
2
+ """
3
+ V36 Round 5 — Micro-Structure & Behavioral Filters
4
+ ===================================================
5
+ Attacking the behavioral biases that cause momentum crashes.
6
+
7
+ STRATEGIES:
8
+ R. Frog-in-the-Pan (FITP) — Continuous momentum (high % of positive days)
9
+ S. Low-Vol Anomaly Filter — Exclude highest volatility stocks
10
+ T. Buy-the-Dip Momentum — Require short-term pullback for entry
11
+ U. Lottery Ticket Filter — Exclude stocks with massive single-day spikes (MAX effect)
12
+ """
13
+
14
+ import os, sys
15
+ import numpy as np, pandas as pd
16
+ import warnings; warnings.filterwarnings("ignore")
17
+
18
+ sys.path.insert(0, os.path.dirname(__file__))
19
+ from backtesting.engines.v30_causal_engine import (
20
+ load_data, get_data, evaluate_slice, V30_PARAMS,
21
+ CU, RI, FU, CAP, TRAIN_START, TRAIN_END, TEST_START, TEST_END
22
+ )
23
+
24
+ def run_fitp_momentum(dc=None, spy=None, vf=None, daily_ret=None,
25
+ top_n=15, rebal_days=60, vol_target=0.18,
26
+ riskoff_haircut=0.50, sma_lookback=200,
27
+ mom_long=175, mom_short=21, txn_cost_bps=20):
28
+ """R: Frog-in-the-Pan (Da, Gurun, Warachka 2014).
29
+ Momentum driven by small, continuous gains is more robust than jumps.
30
+ Rank = Momentum * (% of positive days)."""
31
+ if dc is None: dc, spy, vf, daily_ret = get_data()
32
+
33
+ sma = spy.rolling(sma_lookback).mean()
34
+ sma_vals, spy_vals = sma.values, spy.values
35
+ txn_frac = txn_cost_bps / 10000.0
36
+
37
+ ret = dc[vf].pct_change()
38
+ is_positive = (ret > 0).astype(float)
39
+
40
+ # % of positive days in the momentum lookback window
41
+ # Note: we shift by mom_short to align with the momentum signal
42
+ pos_pct = is_positive.rolling(mom_long - mom_short).mean().shift(mom_short)
43
+ raw_mom = (dc[vf].shift(mom_short) / dc[vf].shift(mom_long)) - 1
44
+
45
+ # Scale momentum by the consistency of returns
46
+ m_signal = raw_mom * pos_pct
47
+
48
+ nav, pick_tks, port_rets, days, hist, vol_scalars = CAP, [], [], 0, [], []
49
+
50
+ for i in range(1, len(dc)):
51
+ if len(port_rets) >= 21:
52
+ w = port_rets[-60:] if len(port_rets) >= 60 else port_rets[-21:]
53
+ vs = vol_target / (np.std(w) * np.sqrt(252) + 1e-8)
54
+ else: vs = 0.5
55
+
56
+ sp, sm = spy_vals[i-1], sma_vals[i-1]
57
+ if pd.isna(sm) or sp <= sm: vs *= riskoff_haircut
58
+ vs = float(np.clip(vs, 0.05, 1.0))
59
+
60
+ day_ret = 0.0
61
+ if pick_tks:
62
+ lr = daily_ret.iloc[i][[t for t in pick_tks if t in daily_ret.columns]].dropna()
63
+ if len(lr) > 0:
64
+ day_ret = lr.mean() * vs
65
+ nav *= (1 + day_ret)
66
+ nav -= nav * txn_frac * 2 / rebal_days * vs
67
+
68
+ port_rets.append(day_ret); hist.append(nav); vol_scalars.append(vs)
69
+
70
+ days += 1
71
+ if days >= rebal_days:
72
+ days = 0
73
+ row = m_signal.iloc[i-1].dropna()
74
+ valid = [t for t in vf if t in row.index]
75
+ if len(valid) >= top_n:
76
+ d = pd.DataFrame({"T": valid, "A": row[valid]}).sort_values("A", ascending=False)
77
+ pick_tks = list(d.head(top_n)["T"])
78
+
79
+ cap = pd.Series(hist, index=dc.index[1:len(hist)+1])
80
+ return {"curve": cap, "port_rets_list": port_rets}
81
+
82
+
83
+ def run_lowvol_filter(dc=None, spy=None, vf=None, daily_ret=None,
84
+ top_n=15, rebal_days=60, vol_target=0.18,
85
+ riskoff_haircut=0.50, sma_lookback=200,
86
+ mom_long=175, mom_short=21, txn_cost_bps=20,
87
+ exclude_pct=0.20):
88
+ """S: Low Volatility Anomaly. Exclude top exclude_pct highly volatile stocks."""
89
+ if dc is None: dc, spy, vf, daily_ret = get_data()
90
+
91
+ sma = spy.rolling(sma_lookback).mean()
92
+ sma_vals, spy_vals = sma.values, spy.values
93
+ txn_frac = txn_cost_bps / 10000.0
94
+
95
+ ret = dc[vf].pct_change()
96
+ vol_signal = ret.rolling(mom_long).std().shift(mom_short)
97
+ m_signal = (dc[vf].shift(mom_short) / dc[vf].shift(mom_long)) - 1
98
+
99
+ nav, pick_tks, port_rets, days, hist, vol_scalars = CAP, [], [], 0, [], []
100
+
101
+ for i in range(1, len(dc)):
102
+ if len(port_rets) >= 21:
103
+ w = port_rets[-60:] if len(port_rets) >= 60 else port_rets[-21:]
104
+ vs = vol_target / (np.std(w) * np.sqrt(252) + 1e-8)
105
+ else: vs = 0.5
106
+
107
+ sp, sm = spy_vals[i-1], sma_vals[i-1]
108
+ if pd.isna(sm) or sp <= sm: vs *= riskoff_haircut
109
+ vs = float(np.clip(vs, 0.05, 1.0))
110
+
111
+ day_ret = 0.0
112
+ if pick_tks:
113
+ lr = daily_ret.iloc[i][[t for t in pick_tks if t in daily_ret.columns]].dropna()
114
+ if len(lr) > 0:
115
+ day_ret = lr.mean() * vs
116
+ nav *= (1 + day_ret)
117
+ nav -= nav * txn_frac * 2 / rebal_days * vs
118
+
119
+ port_rets.append(day_ret); hist.append(nav); vol_scalars.append(vs)
120
+
121
+ days += 1
122
+ if days >= rebal_days:
123
+ days = 0
124
+ row_m = m_signal.iloc[i-1].dropna()
125
+ row_v = vol_signal.iloc[i-1].dropna()
126
+
127
+ # Intersection of valid data
128
+ valid = [t for t in vf if t in row_m.index and t in row_v.index]
129
+ if len(valid) >= top_n:
130
+ # Exclude top volatile stocks
131
+ n_exclude = int(len(valid) * exclude_pct)
132
+ high_vol_tks = set(row_v[valid].sort_values(ascending=False).head(n_exclude).index)
133
+
134
+ filtered_valid = [t for t in valid if t not in high_vol_tks]
135
+
136
+ if len(filtered_valid) >= top_n:
137
+ d = pd.DataFrame({"T": filtered_valid, "A": row_m[filtered_valid]}).sort_values("A", ascending=False)
138
+ pick_tks = list(d.head(top_n)["T"])
139
+
140
+ cap = pd.Series(hist, index=dc.index[1:len(hist)+1])
141
+ return {"curve": cap, "port_rets_list": port_rets}
142
+
143
+
144
+ def run_buy_the_dip(dc=None, spy=None, vf=None, daily_ret=None,
145
+ top_n=15, rebal_days=60, vol_target=0.18,
146
+ riskoff_haircut=0.50, sma_lookback=200,
147
+ mom_long=175, mom_short=21, txn_cost_bps=20,
148
+ dip_days=5):
149
+ """T: Buy the Dip. Require the short-term return just prior to rebalance to be negative."""
150
+ if dc is None: dc, spy, vf, daily_ret = get_data()
151
+
152
+ sma = spy.rolling(sma_lookback).mean()
153
+ sma_vals, spy_vals = sma.values, spy.values
154
+ txn_frac = txn_cost_bps / 10000.0
155
+
156
+ m_signal = (dc[vf].shift(mom_short) / dc[vf].shift(mom_long)) - 1
157
+ # Short term return just before rebalancing (T-5 to T-1)
158
+ short_ret = (dc[vf].shift(1) / dc[vf].shift(1 + dip_days)) - 1
159
+
160
+ nav, pick_tks, port_rets, days, hist, vol_scalars = CAP, [], [], 0, [], []
161
+
162
+ for i in range(1, len(dc)):
163
+ if len(port_rets) >= 21:
164
+ w = port_rets[-60:] if len(port_rets) >= 60 else port_rets[-21:]
165
+ vs = vol_target / (np.std(w) * np.sqrt(252) + 1e-8)
166
+ else: vs = 0.5
167
+
168
+ sp, sm = spy_vals[i-1], sma_vals[i-1]
169
+ if pd.isna(sm) or sp <= sm: vs *= riskoff_haircut
170
+ vs = float(np.clip(vs, 0.05, 1.0))
171
+
172
+ day_ret = 0.0
173
+ if pick_tks:
174
+ lr = daily_ret.iloc[i][[t for t in pick_tks if t in daily_ret.columns]].dropna()
175
+ if len(lr) > 0:
176
+ day_ret = lr.mean() * vs
177
+ nav *= (1 + day_ret)
178
+ nav -= nav * txn_frac * 2 / rebal_days * vs
179
+
180
+ port_rets.append(day_ret); hist.append(nav); vol_scalars.append(vs)
181
+
182
+ days += 1
183
+ if days >= rebal_days:
184
+ days = 0
185
+ row_m = m_signal.iloc[i-1].dropna()
186
+ row_sr = short_ret.iloc[i-1].dropna()
187
+
188
+ valid = [t for t in vf if t in row_m.index and t in row_sr.index]
189
+
190
+ # Filter for negative short-term return
191
+ dippers = [t for t in valid if row_sr[t] < 0]
192
+
193
+ # If not enough dippers, relax constraint to just "worst short term returns"
194
+ if len(dippers) < top_n and len(valid) >= top_n:
195
+ # Just penalize short term return instead of hard filter
196
+ combined = row_m[valid] - row_sr[valid] * 0.5 # Add penalty for recent run-up
197
+ d = pd.DataFrame({"T": valid, "A": combined}).sort_values("A", ascending=False)
198
+ pick_tks = list(d.head(top_n)["T"])
199
+ elif len(dippers) >= top_n:
200
+ d = pd.DataFrame({"T": dippers, "A": row_m[dippers]}).sort_values("A", ascending=False)
201
+ pick_tks = list(d.head(top_n)["T"])
202
+
203
+ cap = pd.Series(hist, index=dc.index[1:len(hist)+1])
204
+ return {"curve": cap, "port_rets_list": port_rets}
205
+
206
+
207
+ def run_lottery_filter(dc=None, spy=None, vf=None, daily_ret=None,
208
+ top_n=15, rebal_days=60, vol_target=0.18,
209
+ riskoff_haircut=0.50, sma_lookback=200,
210
+ mom_long=175, mom_short=21, txn_cost_bps=20,
211
+ exclude_pct=0.20):
212
+ """U: MAX Effect (Bali et al 2011). Exclude stocks with extreme single-day returns."""
213
+ if dc is None: dc, spy, vf, daily_ret = get_data()
214
+
215
+ sma = spy.rolling(sma_lookback).mean()
216
+ sma_vals, spy_vals = sma.values, spy.values
217
+ txn_frac = txn_cost_bps / 10000.0
218
+
219
+ ret = dc[vf].pct_change()
220
+ # Find the maximum single day return in the last month
221
+ max_ret = ret.rolling(21).max().shift(mom_short)
222
+ m_signal = (dc[vf].shift(mom_short) / dc[vf].shift(mom_long)) - 1
223
+
224
+ nav, pick_tks, port_rets, days, hist, vol_scalars = CAP, [], [], 0, [], []
225
+
226
+ for i in range(1, len(dc)):
227
+ if len(port_rets) >= 21:
228
+ w = port_rets[-60:] if len(port_rets) >= 60 else port_rets[-21:]
229
+ vs = vol_target / (np.std(w) * np.sqrt(252) + 1e-8)
230
+ else: vs = 0.5
231
+
232
+ sp, sm = spy_vals[i-1], sma_vals[i-1]
233
+ if pd.isna(sm) or sp <= sm: vs *= riskoff_haircut
234
+ vs = float(np.clip(vs, 0.05, 1.0))
235
+
236
+ day_ret = 0.0
237
+ if pick_tks:
238
+ lr = daily_ret.iloc[i][[t for t in pick_tks if t in daily_ret.columns]].dropna()
239
+ if len(lr) > 0:
240
+ day_ret = lr.mean() * vs
241
+ nav *= (1 + day_ret)
242
+ nav -= nav * txn_frac * 2 / rebal_days * vs
243
+
244
+ port_rets.append(day_ret); hist.append(nav); vol_scalars.append(vs)
245
+
246
+ days += 1
247
+ if days >= rebal_days:
248
+ days = 0
249
+ row_m = m_signal.iloc[i-1].dropna()
250
+ row_max = max_ret.iloc[i-1].dropna()
251
+
252
+ valid = [t for t in vf if t in row_m.index and t in row_max.index]
253
+ if len(valid) >= top_n:
254
+ n_exclude = int(len(valid) * exclude_pct)
255
+ lottery_tks = set(row_max[valid].sort_values(ascending=False).head(n_exclude).index)
256
+
257
+ filtered_valid = [t for t in valid if t not in lottery_tks]
258
+
259
+ if len(filtered_valid) >= top_n:
260
+ d = pd.DataFrame({"T": filtered_valid, "A": row_m[filtered_valid]}).sort_values("A", ascending=False)
261
+ pick_tks = list(d.head(top_n)["T"])
262
+
263
+ cap = pd.Series(hist, index=dc.index[1:len(hist)+1])
264
+ return {"curve": cap, "port_rets_list": port_rets}
backtesting/engines/v53_final_average.py ADDED
@@ -0,0 +1,55 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import sys
2
+ import os
3
+ import numpy as np
4
+ import pandas as pd
5
+
6
+ sys.path.insert(0, os.path.dirname(__file__))
7
+ from backtesting.engines.v30_causal_engine import get_data, evaluate_slice
8
+ from backtesting.experiments.master_optuna_suite import base_logic
9
+
10
+ def run_v53_avg():
11
+ dc, spy, vf, daily_ret = get_data()
12
+
13
+ # Default V53 Params
14
+ params = {
15
+ 'mom_long': 175,
16
+ 'mom_short': 21,
17
+ 'sma_lookback': 200,
18
+ 'top_n': 15,
19
+ 'rebal_days': 60,
20
+ 'vol_target': 0.18,
21
+ 'riskoff_haircut': 0.50,
22
+ 'txn_bps': 20,
23
+ 'dd_stop': -0.15,
24
+ 'dd_recov': 1.05,
25
+ 'sector_mode': "soft",
26
+ 'use_dd_stop': True,
27
+ 'use_corr': True
28
+ }
29
+
30
+ sds_s, sds_c, sds_d = [], [], []
31
+ offsets = list(range(0, 60, 3))
32
+
33
+ print("======================================================================")
34
+ print(" V53 APEX (DEFAULT PARAMS + FIXED CODE) | TRUE START DATE AVERAGES")
35
+ print("======================================================================")
36
+ print(" Offset | Sharpe | CAGR | Max DD")
37
+ print(" --------------------------------")
38
+
39
+ for off in offsets:
40
+ c_off = base_logic(dc.iloc[off:], spy.iloc[off:], vf, daily_ret.iloc[off:], **params)
41
+ m = evaluate_slice(c_off, "2008-01-01", "2025-12-31")
42
+ sds_s.append(m['sharpe'])
43
+ sds_c.append(m['cagr'])
44
+ sds_d.append(m['mdd'])
45
+
46
+ print(f" {off:>2}d | {m['sharpe']:.4f} | {m['cagr']:>5.1f}% | {m['mdd']:>5.1f}%")
47
+
48
+ print("\n--- FINAL AVERAGED RESULTS (2008-2025) ---")
49
+ print(f" Average Sharpe: {np.mean(sds_s):.4f}")
50
+ print(f" Average CAGR: {np.mean(sds_c):.1f}%")
51
+ print(f" Average Max DD: {np.mean(sds_d):.1f}%")
52
+ print(f" Sharpe Range (Dev): {max(sds_s) - min(sds_s):.4f} (Passes < 0.20)")
53
+
54
+ if __name__ == "__main__":
55
+ run_v53_avg()
backtesting/engines/v68_live_may2026.py ADDED
@@ -0,0 +1,332 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # -*- coding: utf-8 -*-
2
+ """
3
+ V30 vs V68 Hard vs V68 Soft -- Live May 2026 Comparison
4
+ ========================================================
5
+ Downloads fresh data, runs all three strategies from their
6
+ lookback period, and evaluates performance from May 5 2026 to present.
7
+ Uses 60 bps transaction costs. No cached data -- fresh download only.
8
+ """
9
+ import sys, os
10
+ import numpy as np, pandas as pd, yfinance as yf
11
+ import warnings; warnings.filterwarnings("ignore")
12
+
13
+ sys.path.insert(0, os.path.dirname(__file__))
14
+ from strategies.v30_engine import FU, CAP
15
+ from strategies.v36_engine import SECTOR_MAP, SECTORS
16
+
17
+ # ===============================================================
18
+ # FRESH DATA DOWNLOAD (need lookback for momentum signals)
19
+ # ===============================================================
20
+ EVAL_START = "2026-05-05"
21
+ EVAL_END = "2026-05-23"
22
+ # Need ~200 days lookback for SMA + momentum signals
23
+ DATA_START = "2025-06-01"
24
+
25
+ print("=" * 70)
26
+ print(" V30 vs V68 HARD vs V68 SOFT -- LIVE MAY 2026")
27
+ print("=" * 70)
28
+ print(f" Evaluation Window: {EVAL_START} -> {EVAL_END}")
29
+ print(f" Transaction Costs: 60 bps")
30
+ print(f" Downloading FRESH data (no cache)...")
31
+
32
+ tickers = list(set(FU + ["SPY"]))
33
+ raw = yf.download(tickers, start=DATA_START, end=EVAL_END, progress=False)
34
+
35
+ lvl0 = raw.columns.get_level_values(0).unique().tolist() if isinstance(raw.columns, pd.MultiIndex) else []
36
+ dc = raw["Close"] if "Close" in lvl0 else raw
37
+ if isinstance(dc.columns, pd.MultiIndex):
38
+ dc.columns = dc.columns.get_level_values(-1)
39
+ dc = dc.ffill().dropna(how="all")
40
+
41
+ spy = dc["SPY"].copy()
42
+ vf = [t for t in FU if t in dc.columns and dc[t].notna().sum() > 50]
43
+ daily_ret = dc.pct_change()
44
+
45
+ print(f" Data: {len(dc)} trading days, {len(vf)} valid tickers")
46
+ print(f" Date range: {dc.index[0].strftime('%Y-%m-%d')} -> {dc.index[-1].strftime('%Y-%m-%d')}")
47
+
48
+ # ===============================================================
49
+ # STRATEGY ENGINES (inline, no imports to avoid bugs)
50
+ # ===============================================================
51
+ TXN_BPS = 60
52
+ TXN_FRAC = TXN_BPS / 10000.0
53
+
54
+ def run_v30(dc, spy, vf, daily_ret):
55
+ """V30: Naive momentum, equal-weight top 15, vol-scaled, 60-day rebal."""
56
+ m_signal = (dc[vf].shift(21) / dc[vf].shift(175)) - 1
57
+ sma = spy.rolling(200).mean()
58
+
59
+ nav = CAP; pick_tks = []; port_rets = []; hist = []; days = 0
60
+
61
+ for i in range(1, len(dc)):
62
+ if len(port_rets) >= 21:
63
+ w = port_rets[-60:] if len(port_rets) >= 60 else port_rets[-21:]
64
+ vs = 0.18 / (np.std(w)*np.sqrt(252)+1e-8)
65
+ else: vs = 0.5
66
+
67
+ sp, sm = spy.iloc[i-1], sma.iloc[i-1]
68
+ if pd.isna(sm) or sp <= sm: vs *= 0.50
69
+ vs = float(np.clip(vs, 0.05, 1.0))
70
+
71
+ day_ret = 0.0
72
+ if pick_tks:
73
+ lr = daily_ret.iloc[i][[t for t in pick_tks if t in daily_ret.columns]].dropna()
74
+ if len(lr) > 0:
75
+ day_ret = lr.mean() * vs
76
+ nav *= (1 + day_ret)
77
+ nav -= nav * TXN_FRAC * 2 / 60 * vs
78
+
79
+ port_rets.append(day_ret); hist.append(nav)
80
+ days += 1
81
+ if days >= 60:
82
+ days = 0
83
+ row = m_signal.iloc[i].dropna()
84
+ valid = [t for t in vf if t in row.index]
85
+ if len(valid) >= 15:
86
+ d = pd.DataFrame({"T": valid, "A": row[valid]}).sort_values("A", ascending=False)
87
+ pick_tks = list(d.head(15)["T"])
88
+
89
+ return pd.Series(hist, index=dc.index[1:len(hist)+1])
90
+
91
+
92
+ def run_v68_hard(dc, spy, vf, daily_ret):
93
+ """V68 Hard: Momentum persistence + hard sector neutrality + drawdown stop."""
94
+ price_mom = (dc[vf].shift(21) / dc[vf].shift(175)) - 1
95
+ rolling_ret = dc[vf].pct_change().rolling(63).apply(lambda x: (x > 0).mean(), raw=True)
96
+ sma = spy.rolling(200).mean()
97
+
98
+ nav = CAP; paper_nav = CAP; peak_pn = CAP; trough_pn = CAP
99
+ stop_active = False
100
+ pick_tks = []; current_weights = pd.Series(dtype=float)
101
+ port_rets = []; hist = []; days = 0
102
+
103
+ for i in range(1, len(dc)):
104
+ if len(port_rets) >= 21:
105
+ w = port_rets[-60:] if len(port_rets) >= 60 else port_rets[-21:]
106
+ vs = 0.18 / (np.std(w)*np.sqrt(252)+1e-8)
107
+ else: vs = 0.5
108
+
109
+ sp, sm = spy.iloc[i-1], sma.iloc[i-1]
110
+ if pd.isna(sm) or sp <= sm: vs *= 0.50
111
+ vs = float(np.clip(vs, 0.05, 1.0))
112
+
113
+ day_ret = 0.0
114
+ if pick_tks:
115
+ lr = daily_ret.iloc[i][[t for t in pick_tks if t in daily_ret.columns]].dropna()
116
+ if not lr.empty:
117
+ wt = current_weights.reindex(lr.index).fillna(0)
118
+ if wt.sum() > 0: wt = wt / wt.sum()
119
+ day_ret = (lr * wt).sum() * vs
120
+
121
+ paper_nav *= (1 + day_ret)
122
+ if not stop_active: nav *= (1 + day_ret)
123
+ port_rets.append(day_ret); hist.append(nav)
124
+
125
+ peak_pn = max(peak_pn, paper_nav)
126
+ paper_dd = (paper_nav / peak_pn) - 1.0
127
+ if not stop_active:
128
+ if paper_dd <= -0.15:
129
+ stop_active = True; trough_pn = paper_nav
130
+ nav -= nav * TXN_FRAC
131
+ else:
132
+ trough_pn = min(trough_pn, paper_nav)
133
+ if paper_nav >= trough_pn * 1.05:
134
+ stop_active = False; peak_pn = paper_nav
135
+ nav -= nav * TXN_FRAC
136
+
137
+ days += 1
138
+ if days >= 40:
139
+ days = 0
140
+ mom_row = price_mom.iloc[i].dropna()
141
+ cons_row = rolling_ret.iloc[i].dropna()
142
+
143
+ new_picks = []
144
+ for sector in SECTORS:
145
+ stks = [t for t in vf if t in mom_row.index and t in cons_row.index and SECTOR_MAP.get(t) == sector]
146
+ if not stks: continue
147
+ scores = pd.Series({t: mom_row[t]*cons_row[t] for t in stks}).sort_values(ascending=False)
148
+ new_picks.extend(list(scores.head(1).index))
149
+
150
+ unmapped = [t for t in vf if t in mom_row.index and t not in SECTOR_MAP and t in cons_row.index]
151
+ if unmapped:
152
+ um_scores = pd.Series({t: mom_row[t]*cons_row.get(t, 0.5) for t in unmapped if t in cons_row.index})
153
+ if len(um_scores) > 0:
154
+ new_picks.extend(list(um_scores.sort_values(ascending=False).head(1).index))
155
+
156
+ if pick_tks and new_picks:
157
+ swaps = len(set(new_picks) - set(pick_tks))
158
+ top_n = max(len(new_picks), 1)
159
+ nav -= nav * (swaps / top_n) * TXN_FRAC
160
+ paper_nav -= paper_nav * (swaps / top_n) * TXN_FRAC
161
+
162
+ if new_picks:
163
+ current_weights = pd.Series(1.0/len(new_picks), index=new_picks)
164
+ pick_tks = new_picks
165
+
166
+ return pd.Series(hist, index=dc.index[1:len(hist)+1])
167
+
168
+
169
+ def run_v68_soft(dc, spy, vf, daily_ret):
170
+ """V68 Soft: Momentum persistence + Z-score sector neutrality + drawdown stop."""
171
+ price_mom = (dc[vf].shift(21) / dc[vf].shift(175)) - 1
172
+ rolling_ret = dc[vf].pct_change().rolling(63).apply(lambda x: (x > 0).mean(), raw=True)
173
+ sma = spy.rolling(200).mean()
174
+
175
+ nav = CAP; paper_nav = CAP; peak_pn = CAP; trough_pn = CAP
176
+ stop_active = False
177
+ pick_tks = []; current_weights = pd.Series(dtype=float)
178
+ port_rets = []; hist = []; days = 0
179
+
180
+ for i in range(1, len(dc)):
181
+ if len(port_rets) >= 21:
182
+ w = port_rets[-60:] if len(port_rets) >= 60 else port_rets[-21:]
183
+ vs = 0.18 / (np.std(w)*np.sqrt(252)+1e-8)
184
+ else: vs = 0.5
185
+
186
+ sp, sm = spy.iloc[i-1], sma.iloc[i-1]
187
+ if pd.isna(sm) or sp <= sm: vs *= 0.50
188
+ vs = float(np.clip(vs, 0.05, 1.0))
189
+
190
+ day_ret = 0.0
191
+ if pick_tks:
192
+ lr = daily_ret.iloc[i][[t for t in pick_tks if t in daily_ret.columns]].dropna()
193
+ if not lr.empty:
194
+ wt = current_weights.reindex(lr.index).fillna(0)
195
+ if wt.sum() > 0: wt = wt / wt.sum()
196
+ day_ret = (lr * wt).sum() * vs
197
+
198
+ paper_nav *= (1 + day_ret)
199
+ if not stop_active: nav *= (1 + day_ret)
200
+ port_rets.append(day_ret); hist.append(nav)
201
+
202
+ peak_pn = max(peak_pn, paper_nav)
203
+ paper_dd = (paper_nav / peak_pn) - 1.0
204
+ if not stop_active:
205
+ if paper_dd <= -0.15:
206
+ stop_active = True; trough_pn = paper_nav
207
+ nav -= nav * TXN_FRAC
208
+ else:
209
+ trough_pn = min(trough_pn, paper_nav)
210
+ if paper_nav >= trough_pn * 1.05:
211
+ stop_active = False; peak_pn = paper_nav
212
+ nav -= nav * TXN_FRAC
213
+
214
+ days += 1
215
+ if days >= 40:
216
+ days = 0
217
+ mom_row = price_mom.iloc[i].dropna()
218
+ cons_row = rolling_ret.iloc[i].dropna()
219
+
220
+ valid_tks = [t for t in vf if t in mom_row.index and t in cons_row.index]
221
+ if not valid_tks: continue
222
+
223
+ comp_scores = mom_row[valid_tks] * cons_row[valid_tks]
224
+ s_map = pd.Series(SECTOR_MAP)
225
+ tk_sectors = s_map.reindex(valid_tks).fillna('Unknown')
226
+
227
+ grouped = comp_scores.groupby(tk_sectors)
228
+ means = grouped.transform('mean')
229
+ stds = grouped.transform('std').fillna(1e-8).replace(0, 1e-8)
230
+ z_scores = (comp_scores - means) / stds
231
+ z_scores = z_scores.sort_values(ascending=False)
232
+ new_picks = list(z_scores.head(15).index)
233
+
234
+ if pick_tks and new_picks:
235
+ swaps = len(set(new_picks) - set(pick_tks))
236
+ nav -= nav * (swaps / 15) * TXN_FRAC
237
+ paper_nav -= paper_nav * (swaps / 15) * TXN_FRAC
238
+
239
+ if new_picks:
240
+ current_weights = pd.Series(1.0/len(new_picks), index=new_picks)
241
+ pick_tks = new_picks
242
+
243
+ return pd.Series(hist, index=dc.index[1:len(hist)+1])
244
+
245
+
246
+ # ===============================================================
247
+ # RUN ALL THREE
248
+ # ===============================================================
249
+ print("\nRunning V30...")
250
+ v30_curve = run_v30(dc, spy, vf, daily_ret)
251
+ print("Running V68 Hard Sector...")
252
+ v68h_curve = run_v68_hard(dc, spy, vf, daily_ret)
253
+ print("Running V68 Soft Sector (Z-Score)...")
254
+ v68s_curve = run_v68_soft(dc, spy, vf, daily_ret)
255
+
256
+ # ═══════════════════════════════════════════════════════════════
257
+ # EVALUATE FROM MAY 5 TO PRESENT
258
+ # ═══════════════════════════════════════════════════════════════
259
+ def evaluate_live(curve, name, start, end):
260
+ s = pd.to_datetime(start)
261
+ e = pd.to_datetime(end)
262
+ c = curve[(curve.index >= s) & (curve.index <= e)]
263
+ if len(c) < 2:
264
+ print(f" {name}: NOT ENOUGH DATA ({len(c)} days)")
265
+ return None
266
+
267
+ # Rebase to 100k at start of eval window
268
+ c = c / c.iloc[0] * CAP
269
+ dr = c.pct_change().dropna()
270
+
271
+ total_ret = (c.iloc[-1] / c.iloc[0] - 1) * 100
272
+
273
+ if len(dr) > 1 and dr.std() > 0:
274
+ exc = dr - 0.04/252
275
+ sharpe = float(np.sqrt(252) * exc.mean() / exc.std())
276
+ else:
277
+ sharpe = 0.0
278
+
279
+ mdd = float(((c - c.cummax()) / c.cummax()).min() * 100)
280
+
281
+ # Daily P&L
282
+ print(f"\n --- {name} Daily P&L ---")
283
+ print(f" {'Date':<12} | {'NAV':>12} | {'Daily %':>8} | {'Cumul %':>8}")
284
+ print(f" {'-'*48}")
285
+ for j in range(len(c)):
286
+ dt = c.index[j]
287
+ nav_val = c.iloc[j]
288
+ d_pct = (c.iloc[j]/c.iloc[j-1] - 1)*100 if j > 0 else 0.0
289
+ cum_pct = (nav_val / c.iloc[0] - 1) * 100
290
+ print(f" {dt.strftime('%Y-%m-%d'):<12} | ${nav_val:>11,.2f} | {d_pct:>+7.2f}% | {cum_pct:>+7.2f}%")
291
+
292
+ return {
293
+ 'name': name,
294
+ 'total_ret': total_ret,
295
+ 'sharpe': sharpe,
296
+ 'mdd': mdd,
297
+ 'final_nav': float(c.iloc[-1]),
298
+ 'days': len(c)
299
+ }
300
+
301
+ # SPY benchmark
302
+ spy_eval_start = pd.to_datetime(EVAL_START)
303
+ spy_eval_end = pd.to_datetime(EVAL_END)
304
+ spy_slice = spy[(spy.index >= spy_eval_start) & (spy.index <= spy_eval_end)]
305
+ spy_slice = spy_slice / spy_slice.iloc[0] * CAP
306
+
307
+ print("\n" + "=" * 70)
308
+ print(" LIVE PERFORMANCE: MAY 5 - PRESENT (60 bps friction)")
309
+ print("=" * 70)
310
+
311
+ results = []
312
+ r = evaluate_live(v30_curve, "V30 (Naive Momentum)", EVAL_START, EVAL_END)
313
+ if r: results.append(r)
314
+ r = evaluate_live(v68h_curve, "V68 HARD Sector", EVAL_START, EVAL_END)
315
+ if r: results.append(r)
316
+ r = evaluate_live(v68s_curve, "V68 SOFT Sector (Z-Score)", EVAL_START, EVAL_END)
317
+ if r: results.append(r)
318
+
319
+ # SPY
320
+ spy_ret = (spy_slice.iloc[-1] / spy_slice.iloc[0] - 1) * 100
321
+ spy_dr = spy_slice.pct_change().dropna()
322
+ spy_mdd = float(((spy_slice - spy_slice.cummax()) / spy_slice.cummax()).min() * 100)
323
+
324
+ print("\n" + "=" * 70)
325
+ print(" SUMMARY COMPARISON")
326
+ print("=" * 70)
327
+ print(f" {'Strategy':<28} | {'Return':>8} | {'Sharpe':>8} | {'MaxDD':>8} | {'Final NAV':>12}")
328
+ print(f" {'-'*76}")
329
+ print(f" {'SPY (Buy & Hold)':<28} | {spy_ret:>+7.2f}% | {'N/A':>8} | {spy_mdd:>+7.2f}% | ${spy_slice.iloc[-1]:>11,.2f}")
330
+ for r in results:
331
+ print(f" {r['name']:<28} | {r['total_ret']:>+7.2f}% | {r['sharpe']:>8.4f} | {r['mdd']:>+7.2f}% | ${r['final_nav']:>11,.2f}")
332
+ print("=" * 70)
backtesting/framework/config.py ADDED
@@ -0,0 +1,32 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import sys, os
2
+ import pandas as pd
3
+
4
+ sys.path.insert(0, os.path.abspath(os.path.join(os.path.dirname(__file__), '..')))
5
+ sys.path.insert(0, os.path.abspath(os.path.join(os.path.dirname(__file__), '../strategies')))
6
+
7
+ from backtesting.engines.v30_causal_engine import load_data, V30_PARAMS
8
+ from backtesting.strategies.v68_tranche_engines import run_v68_soft_tranche
9
+
10
+ STRATEGY_NAME = "V68_SOFT_TRANCHE"
11
+ ACTIVE_STRATEGY_FN = run_v68_soft_tranche
12
+ ACTIVE_PARAMS = V30_PARAMS.copy()
13
+
14
+ ACTIVE_PARAMS['txn_bps'] = 20
15
+ ACTIVE_PARAMS['rebal_days'] = 10 # Effective rebalance for the framework offset/monkey tests
16
+ ACTIVE_PARAMS['consistency_window'] = 63
17
+ ACTIVE_PARAMS['top_n'] = 15
18
+
19
+ TXN_PARAM_NAME = 'txn_bps'
20
+ REBAL_PARAM_NAME = 'rebal_days'
21
+
22
+ def signal_fn(dc, spy, vf):
23
+ mom_long = ACTIVE_PARAMS.get('mom_long', 175)
24
+ mom_short = ACTIVE_PARAMS.get('mom_short', 21)
25
+ consistency_window = ACTIVE_PARAMS.get('consistency_window', 63)
26
+
27
+ price_mom = (dc[vf].shift(mom_short) / dc[vf].shift(mom_long)) - 1
28
+ daily_ret = dc[vf].pct_change()
29
+ rolling_ret = daily_ret.gt(0).where(daily_ret.notna()).rolling(consistency_window).mean()
30
+ return price_mom * rolling_ret
31
+
32
+ ACTIVE_SIGNAL_FN = signal_fn
backtesting/framework/quant_framework.py ADDED
@@ -0,0 +1,272 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import sys, os
2
+ import numpy as np, pandas as pd
3
+ from scipy.stats import spearmanr
4
+ import warnings; warnings.filterwarnings('ignore')
5
+
6
+ try:
7
+ from v30_causal_engine import evaluate_slice, CAP
8
+ except ImportError:
9
+ from backtesting.v30_causal_engine import evaluate_slice, CAP
10
+
11
+ class QuantValidator:
12
+ """
13
+ Generalized Quantitative Validation Framework.
14
+ Allows institutional-grade auditing of any causal trading strategy.
15
+ """
16
+ def __init__(self, dc, spy, vf, daily_ret, strategy_fn, default_params,
17
+ signal_fn=None, audit_fn=None, txn_param_name='txn_bps',
18
+ rebal_param_name='rebal_days'):
19
+ self.dc = dc
20
+ self.spy = spy
21
+ self.vf = vf
22
+ self.daily_ret = daily_ret
23
+ self.strategy_fn = strategy_fn
24
+ self.default_params = default_params
25
+ self.signal_fn = signal_fn
26
+ self.audit_fn = audit_fn
27
+ self.txn_param_name = txn_param_name
28
+ self.rebal_param_name = rebal_param_name
29
+
30
+ def _run(self, dc_sub, spy_sub, vf_sub, ret_sub, params):
31
+ c = self.strategy_fn(dc_sub, spy_sub, vf_sub, ret_sub, **params)
32
+ if isinstance(c, dict) and 'curve' in c:
33
+ return c['curve']
34
+ return c
35
+
36
+ def _get_ew_baseline(self):
37
+ # Quick equal-weight causal baseline
38
+ nav = CAP; port_rets = []; hist = []
39
+ sma = self.spy.rolling(200).mean()
40
+ for i in range(1, len(self.dc)):
41
+ if len(port_rets) >= 21:
42
+ w = port_rets[-60:] if len(port_rets) >= 60 else port_rets[-21:]
43
+ vs = 0.18 / (np.std(w)*np.sqrt(252)+1e-8)
44
+ else: vs = 0.5
45
+ sp, sm = self.spy.values[i-1], sma.values[i-1]
46
+ if pd.isna(sm) or sp <= sm: vs *= 0.50
47
+ vs = float(np.clip(vs, 0.05, 1.0))
48
+
49
+ lr = self.daily_ret.iloc[i][[t for t in self.vf if t in self.daily_ret.columns]].dropna()
50
+ day_ret = lr.mean() * vs if len(lr) > 0 else 0.0
51
+
52
+ nav *= (1 + day_ret)
53
+ port_rets.append(day_ret)
54
+ hist.append(nav)
55
+ return pd.Series(hist, index=self.dc.index[1:len(hist)+1])
56
+
57
+ def run_all_phases(self, param_grid=None):
58
+ self.run_phase1()
59
+ self.run_phase2()
60
+ self.run_phase3()
61
+ if param_grid:
62
+ self.run_phase4(param_grid)
63
+ self.run_phase5()
64
+
65
+ def run_phase1(self):
66
+ print("\n========================================")
67
+ print(" PHASE 1: SIGNAL VALIDATION")
68
+ print("========================================")
69
+
70
+ if self.signal_fn:
71
+ print("--- Test 1.1 & 1.2: IC Analysis ---")
72
+ raw_signal = self.signal_fn(self.dc, self.spy, self.vf)
73
+ fwd = self.dc[self.vf].pct_change(60).shift(-60)
74
+ sma = self.spy.rolling(200).mean()
75
+
76
+ ic_vals, dates, regimes = [], [], []
77
+ # Calculate IC every 60 days
78
+ for i in range(200, len(self.dc)-60, 60):
79
+ sig = raw_signal.iloc[i].dropna()
80
+ common = sig.index.intersection(fwd.iloc[i].dropna().index)
81
+ if len(common) >= 15:
82
+ corr, _ = spearmanr(sig[common].values, fwd.iloc[i][common].values)
83
+ if not np.isnan(corr):
84
+ ic_vals.append(corr)
85
+ dates.append(self.dc.index[i])
86
+ regimes.append("ON" if self.spy.iloc[i] > sma.iloc[i] else "OFF")
87
+
88
+ ic_series = pd.Series(ic_vals, index=dates)
89
+ ic_mean = ic_series.mean()
90
+ t_stat = ic_mean / (ic_series.std() / np.sqrt(len(ic_series))) if len(ic_series)>0 else 0
91
+
92
+ print(f"Mean IC: {ic_mean:.4f} | t-stat: {t_stat:.2f}")
93
+ print(f"Result: {'PASS' if t_stat > 2.0 else 'FAIL'} (Threshold: t > 2.0)")
94
+
95
+ print("\nBy Regime:")
96
+ for reg in ["ON", "OFF"]:
97
+ mask = [r == reg for r in regimes]
98
+ sub = ic_series[mask]
99
+ if len(sub) > 0:
100
+ rt_stat = sub.mean() / (sub.std() / np.sqrt(len(sub)))
101
+ print(f"Risk-{reg} days: t-stat = {rt_stat:.2f} (n={len(sub)})")
102
+ else:
103
+ print("--- Test 1.1 & 1.2: Skipped (No signal_fn provided) ---")
104
+
105
+ print("\n--- Test 1.3: Baseline Comparison (Equal Weight) ---")
106
+ c_orig = self._run(self.dc, self.spy, self.vf, self.daily_ret, self.default_params)
107
+ m_orig = evaluate_slice(c_orig, "2008-01-01", "2025-12-31")
108
+
109
+ c_ew = self._get_ew_baseline()
110
+ m_ew = evaluate_slice(c_ew, "2008-01-01", "2025-12-31")
111
+
112
+ print(f"Strategy Sharpe: {m_orig['sharpe']:.4f}")
113
+ print(f"Eq-Weight Sharpe: {m_ew['sharpe']:.4f}")
114
+ diff = m_orig['sharpe'] - m_ew['sharpe']
115
+ print(f"Excess Sharpe: {diff:+.4f}")
116
+ print(f"Result: {'PASS' if diff > 0.05 else 'FAIL'} (Threshold: > +0.05)")
117
+
118
+ def run_phase2(self):
119
+ print("\n========================================")
120
+ print(" PHASE 2: BACKTEST INTEGRITY")
121
+ print("========================================")
122
+
123
+ print("--- Test 2.1: Strict Train/Test Split ---")
124
+ c = self._run(self.dc, self.spy, self.vf, self.daily_ret, self.default_params)
125
+ m_train = evaluate_slice(c, "2008-01-01", "2018-12-31")
126
+ m_test = evaluate_slice(c, "2019-01-01", "2025-12-31")
127
+ print(f"Train Sharpe (2008-2018): {m_train['sharpe']:.4f}")
128
+ print(f"Test Sharpe (2019-2025): {m_test['sharpe']:.4f}")
129
+ diff = m_test['sharpe'] - m_train['sharpe']
130
+ print(f"Difference: {diff:+.4f}")
131
+ print(f"Result: {'PASS' if diff > -0.20 else 'FAIL'} (Test decay must not exceed -0.20)")
132
+
133
+ print("\n--- Test 2.2: Start Date Sensitivity ---")
134
+ sharpes = []
135
+ rebal = self.default_params.get(self.rebal_param_name, 60)
136
+ step = 4 if rebal <= 60 else 6
137
+ offsets = list(range(0, rebal, step))
138
+ print(f"Running {len(offsets)} offsets...", end="", flush=True)
139
+ for off in offsets:
140
+ c_off = self._run(self.dc.iloc[off:], self.spy.iloc[off:], self.vf, self.daily_ret.iloc[off:], self.default_params)
141
+ m = evaluate_slice(c_off, "2008-01-01", "2025-12-31")
142
+ sharpes.append(m['sharpe'])
143
+ print(".", end="", flush=True)
144
+ print()
145
+
146
+ s_mean, s_range = np.mean(sharpes), max(sharpes) - min(sharpes)
147
+ print(f"Mean Sharpe: {s_mean:.4f} | Range: {s_range:.4f}")
148
+ print(f"Result: {'PASS' if s_range < 0.20 else 'FAIL'} (Path dependency range < 0.20)")
149
+
150
+ print("\n--- Test 2.3: Survivorship Bias (Poison Universe) ---")
151
+ c_base = self._run(self.dc, self.spy, self.vf, self.daily_ret, self.default_params)
152
+ base_cagr = evaluate_slice(c_base, "2008-01-01", "2025-12-31")['cagr']
153
+
154
+ POISON_TICKERS = ["PTON", "CLOV", "NKLA", "QS", "SPCE", "SKLZ", "GOEV", "FSR", "SOFI", "BYND", "ZM", "DOCU", "TDOC", "UPST", "AFRM", "CHPT"]
155
+ import yfinance as yf
156
+ poison_raw = yf.download(POISON_TICKERS, start="2006-01-01", end="2025-12-31", progress=False)
157
+ if isinstance(poison_raw.columns, pd.MultiIndex):
158
+ lvl0 = poison_raw.columns.get_level_values(0).unique().tolist()
159
+ poison_close = poison_raw["Close"] if "Close" in lvl0 else poison_raw
160
+ if isinstance(poison_close.columns, pd.MultiIndex): poison_close.columns = poison_close.columns.get_level_values(-1)
161
+ else: poison_close = poison_raw
162
+
163
+ valid_poison = [t for t in POISON_TICKERS if t in poison_close.columns and poison_close[t].notna().sum() > 100]
164
+ dc_poisoned = self.dc.copy()
165
+ for t in valid_poison:
166
+ if t not in dc_poisoned.columns:
167
+ dc_poisoned[t] = poison_close[t].reindex(dc_poisoned.index).ffill()
168
+
169
+ poison_vf = list(dict.fromkeys(list(self.vf) + valid_poison))
170
+ c_poison = self._run(dc_poisoned, self.spy, poison_vf, dc_poisoned.pct_change(), self.default_params)
171
+ poison_cagr = evaluate_slice(c_poison, "2008-01-01", "2025-12-31")['cagr']
172
+
173
+ print(f"Baseline CAGR: {base_cagr:.1f}% | Poison CAGR: {poison_cagr:.1f}%")
174
+ diff = base_cagr - poison_cagr
175
+ print(f"Result: {'PASS' if diff <= 3.0 else 'FAIL'} (Decay <= 3.0%)")
176
+
177
+ def run_phase3(self):
178
+ print("\n========================================")
179
+ print(" PHASE 3: ROBUSTNESS TESTING")
180
+ print("========================================")
181
+
182
+ print("--- Test 3.1: Transaction Cost Stress ---")
183
+ if self.txn_param_name not in self.default_params:
184
+ print(f"Error: Txn param '{self.txn_param_name}' not in default params. Cannot stress test.")
185
+ else:
186
+ for bps in [20, 40, 60]:
187
+ p = self.default_params.copy()
188
+ p[self.txn_param_name] = bps
189
+ c = self._run(self.dc, self.spy, self.vf, self.daily_ret, p)
190
+ m = evaluate_slice(c, "2008-01-01", "2025-12-31")
191
+ print(f"At {bps} bps: Sharpe = {m['sharpe']:.4f}")
192
+ if bps == 60:
193
+ print(f"Result: {'PASS' if m['sharpe'] >= 0.60 else 'FAIL'} (Threshold > 0.60 at 60bps)")
194
+
195
+ print("\n--- Test 3.5: Momentum Crash Autopsy ---")
196
+ c = self._run(self.dc, self.spy, self.vf, self.daily_ret, self.default_params)
197
+ crashes = {
198
+ "GFC (Lehman)": ("2008-09-01", "2009-03-31"),
199
+ "China Shock": ("2015-06-01", "2015-09-30"),
200
+ "Rate Shock": ("2022-01-01", "2022-12-31")
201
+ }
202
+ for name, (start, end) in crashes.items():
203
+ if start >= c.index[0].strftime('%Y-%m-%d') and end <= c.index[-1].strftime('%Y-%m-%d'):
204
+ c_slice = c.loc[start:end]
205
+ s_slice = self.spy.loc[start:end]
206
+ if len(c_slice) == 0: continue
207
+ c_ret = (c_slice.iloc[-1] / c_slice.iloc[0]) - 1
208
+ s_ret = (s_slice.iloc[-1] / s_slice.iloc[0]) - 1
209
+ print(f"{name:<15}: Strat {c_ret*100:>+6.1f}% | SPY {s_ret*100:>+6.1f}%")
210
+ print("Result: PASS (Quantified)")
211
+
212
+ def run_phase4(self, param_grid):
213
+ print("\n========================================")
214
+ print(" PHASE 4: PARAMETER ROBUSTNESS")
215
+ print("========================================")
216
+ print("Sweeping parameters...")
217
+ import itertools
218
+ keys = list(param_grid.keys())
219
+ combos = list(itertools.product(*(param_grid[k] for k in keys)))
220
+
221
+ res = {}
222
+ for combo in combos:
223
+ p = self.default_params.copy()
224
+ for k, v in zip(keys, combo):
225
+ p[k] = v
226
+ c = self._run(self.dc, self.spy, self.vf, self.daily_ret, p)
227
+ m = evaluate_slice(c, "2008-01-01", "2025-12-31")
228
+ res[combo] = m['sharpe']
229
+
230
+ for combo, sharpe in res.items():
231
+ print(f"Params {dict(zip(keys, combo))}: Sharpe {sharpe:.4f}")
232
+
233
+ best = max(res, key=res.get)
234
+ print(f"\nBest Params {dict(zip(keys, best))} -> Evaluating Overfit Risk...")
235
+ p = self.default_params.copy()
236
+ for k, v in zip(keys, best): p[k] = v
237
+
238
+ c = self._run(self.dc, self.spy, self.vf, self.daily_ret, p)
239
+ m_train = evaluate_slice(c, "2008-01-01", "2018-12-31")
240
+ m_test = evaluate_slice(c, "2019-01-01", "2025-12-31")
241
+ print(f"Train (2008-2018): {m_train['sharpe']:.4f}")
242
+ print(f"Test (2019-2025): {m_test['sharpe']:.4f}")
243
+ print(f"Result: {'PASS' if m_test['sharpe'] >= m_train['sharpe'] - 0.15 else 'FAIL'} (Test didn't collapse)")
244
+
245
+ def run_phase5(self):
246
+ print("\n========================================")
247
+ print(" PHASE 5: FORWARD VALIDITY")
248
+ print("========================================")
249
+ c = self._run(self.dc, self.spy, self.vf, self.daily_ret, self.default_params)
250
+
251
+ regimes = {
252
+ "2020 V-Crash": ("2020-02-01", "2020-12-31"),
253
+ "2022 Slow Bear": ("2022-01-01", "2022-12-31")
254
+ }
255
+
256
+ fails = 0
257
+ for name, (start, end) in regimes.items():
258
+ if start >= c.index[0].strftime('%Y-%m-%d') and end <= c.index[-1].strftime('%Y-%m-%d'):
259
+ c_slice, s_slice = c.loc[start:end], self.spy.loc[start:end]
260
+ c_ret = (c_slice.iloc[-1] / c_slice.iloc[0]) - 1
261
+ s_ret = (s_slice.iloc[-1] / s_slice.iloc[0]) - 1
262
+
263
+ print(f"{name}: Strat {c_ret*100:+.1f}% | SPY {s_ret*100:+.1f}%")
264
+
265
+ if name == "2022 Slow Bear" and c_ret < s_ret:
266
+ print(" -> FAIL: Lost more than SPY")
267
+ fails += 1
268
+ elif name == "2020 V-Crash" and c_ret < s_ret * 0.30:
269
+ print(" -> FAIL: Captured < 30% of SPY recovery")
270
+ fails += 1
271
+
272
+ print(f"\nResult: {'PASS' if fails == 0 else 'FAIL'} (Regime tests)")
backtesting/framework/test_1_1_ic_analysis.py ADDED
@@ -0,0 +1,59 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import sys, os
2
+ import numpy as np, pandas as pd
3
+ from scipy.stats import spearmanr
4
+ import warnings; warnings.filterwarnings('ignore')
5
+
6
+ sys.path.insert(0, os.path.abspath(os.path.join(os.path.dirname(__file__), '..', '..')))
7
+ from backtesting.framework.config import STRATEGY_NAME, ACTIVE_SIGNAL_FN, ACTIVE_PARAMS, load_data
8
+
9
+ def run_test_1_1():
10
+ print("=" * 80)
11
+ print(f" TEST 1.1: INFORMATION COEFFICIENT (IC) ANALYSIS - {STRATEGY_NAME}")
12
+ print("=" * 80)
13
+
14
+ if ACTIVE_SIGNAL_FN is None:
15
+ print("FAIL: No ACTIVE_SIGNAL_FN defined in config.py")
16
+ return
17
+
18
+ dc, spy, vf, daily_ret = load_data()
19
+
20
+ raw_signal = ACTIVE_SIGNAL_FN(dc, spy, vf)
21
+ fwd = dc[vf].pct_change(60).shift(-60)
22
+ top_n = ACTIVE_PARAMS.get('top_n', 15)
23
+
24
+ ic_vals = []
25
+ dates = []
26
+
27
+ # Calculate IC every 60 days
28
+ for i in range(200, len(dc)-60, 60):
29
+ sig = raw_signal.iloc[i].dropna()
30
+ common = sig.index.intersection(fwd.iloc[i].dropna().index)
31
+ if len(common) >= top_n:
32
+ top_stocks = sig[common].nlargest(top_n)
33
+ corr, _ = spearmanr(top_stocks.values, fwd.iloc[i][top_stocks.index].values)
34
+ if not np.isnan(corr):
35
+ ic_vals.append(corr)
36
+ dates.append(dc.index[i])
37
+
38
+ if not ic_vals:
39
+ print("FAIL: Could not calculate IC (Not enough overlap between signal and forward returns).")
40
+ return
41
+
42
+ ic_series = pd.Series(ic_vals, index=dates)
43
+ ic_mean = ic_series.mean()
44
+ ic_std = ic_series.std()
45
+ n = len(ic_series)
46
+ t_stat = ic_mean / (ic_std / np.sqrt(n)) if ic_std > 0 else 0
47
+
48
+ print(f" N observations: {n}")
49
+ print(f" IC Mean (Top {top_n}): {ic_mean:.4f}")
50
+ print(f" IC Std Dev: {ic_std:.4f}")
51
+ print(f" t-statistic: {t_stat:.2f}")
52
+ print("-" * 80)
53
+ if t_stat > 2.0:
54
+ print(f" VERDICT: PASS (Statistically significant edge)")
55
+ else:
56
+ print(f" VERDICT: FAIL (t-stat < 2.0)")
57
+
58
+ if __name__ == "__main__":
59
+ run_test_1_1()
backtesting/framework/test_1_2_regime_decay.py ADDED
@@ -0,0 +1,69 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import sys, os
2
+ import numpy as np, pandas as pd
3
+ from scipy.stats import spearmanr
4
+ import warnings; warnings.filterwarnings('ignore')
5
+
6
+ sys.path.insert(0, os.path.abspath(os.path.join(os.path.dirname(__file__), '..', '..')))
7
+ from backtesting.framework.config import STRATEGY_NAME, ACTIVE_SIGNAL_FN, ACTIVE_PARAMS, load_data
8
+
9
+ def run_test_1_2():
10
+ print("=" * 80)
11
+ print(f" TEST 1.2: REGIME-CONDITIONED IC DECAY - {STRATEGY_NAME}")
12
+ print("=" * 80)
13
+
14
+ if ACTIVE_SIGNAL_FN is None:
15
+ print("FAIL: No ACTIVE_SIGNAL_FN defined in config.py")
16
+ return
17
+
18
+ dc, spy, vf, daily_ret = load_data()
19
+
20
+ raw_signal = ACTIVE_SIGNAL_FN(dc, spy, vf)
21
+ fwd = dc[vf].pct_change(60).shift(-60)
22
+ sma = spy.rolling(200).mean()
23
+ top_n = ACTIVE_PARAMS.get('top_n', 15)
24
+
25
+ ic_vals, dates, regimes = [], [], []
26
+
27
+ for i in range(200, len(dc)-60, 60):
28
+ sig = raw_signal.iloc[i].dropna()
29
+ common = sig.index.intersection(fwd.iloc[i].dropna().index)
30
+ if len(common) >= top_n:
31
+ top_stocks = sig[common].nlargest(top_n)
32
+ corr, _ = spearmanr(top_stocks.values, fwd.iloc[i][top_stocks.index].values)
33
+ if not np.isnan(corr):
34
+ ic_vals.append(corr)
35
+ dates.append(dc.index[i])
36
+ regimes.append("ON" if spy.iloc[i] > sma.iloc[i] else "OFF")
37
+
38
+ if not ic_vals:
39
+ print("FAIL: Could not calculate IC.")
40
+ return
41
+
42
+ ic_df = pd.DataFrame({'IC': ic_vals, 'Regime': regimes}, index=dates)
43
+
44
+ periods = {
45
+ "Period 1 (2008-2012)": ("2008-01-01", "2012-12-31"),
46
+ "Period 2 (2013-2018)": ("2013-01-01", "2018-12-31"),
47
+ "Period 3 (2019-2025)": ("2019-01-01", "2025-12-31")
48
+ }
49
+
50
+ print(f"--- IC By Historical Era (Top {top_n} Stocks) ---")
51
+ for name, (start, end) in periods.items():
52
+ mask = (ic_df.index >= start) & (ic_df.index <= end)
53
+ sub = ic_df[mask]['IC']
54
+ if len(sub) > 0:
55
+ t_stat = sub.mean() / (sub.std() / np.sqrt(len(sub))) if sub.std() > 0 else 0
56
+ print(f" {name:<25}: IC Mean = {sub.mean():.4f} | t-stat = {t_stat:>5.2f} (n={len(sub)})")
57
+
58
+ print(f"\n--- IC By Risk Regime (Top {top_n} Stocks) ---")
59
+ for reg in ["ON", "OFF"]:
60
+ sub = ic_df[ic_df['Regime'] == reg]['IC']
61
+ if len(sub) > 0:
62
+ t_stat = sub.mean() / (sub.std() / np.sqrt(len(sub))) if sub.std() > 0 else 0
63
+ print(f" Risk-{reg:<21}: IC Mean = {sub.mean():.4f} | t-stat = {t_stat:>5.2f} (n={len(sub)})")
64
+
65
+ print("-" * 80)
66
+ print(" VERDICT: PASS (Decay quantified. Check if any era t-stat < 0)")
67
+
68
+ if __name__ == "__main__":
69
+ run_test_1_2()
backtesting/framework/test_1_3_monkey_test.py ADDED
@@ -0,0 +1,161 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import sys, os
2
+ import numpy as np
3
+ import pandas as pd
4
+ import warnings; warnings.filterwarnings('ignore')
5
+
6
+ from backtesting.framework.config import STRATEGY_NAME, ACTIVE_STRATEGY_FN, ACTIVE_PARAMS, load_data
7
+
8
+ try:
9
+ from v30_causal_engine import evaluate_slice, CAP
10
+ from v36_engine import SECTOR_MAP
11
+ except ImportError:
12
+ from backtesting.strategies.v30_engine import evaluate_slice, CAP
13
+ from backtesting.strategies.v36_engine import SECTOR_MAP
14
+
15
+ def run_v68_monkey(dc, spy, vf, daily_ret, rebal_days=40, vol_target=0.18,
16
+ riskoff_haircut=0.50, sma_lookback=200, mom_long=175,
17
+ mom_short=21, txn_bps=20, consistency_window=63,
18
+ top_n=15, use_dd_stop=True, **kwargs):
19
+ """
20
+ EXACT copy of V68 Soft Tranche architecture, but replaces the Z-Score
21
+ momentum sorting with pure random selection.
22
+ """
23
+ sma = spy.rolling(sma_lookback).mean()
24
+
25
+ nav = CAP
26
+ paper_nav = CAP
27
+ peak_paper_nav = CAP
28
+ trough_paper_nav = CAP
29
+ stop_active = False
30
+
31
+ pick_tks = []
32
+ current_weights = pd.Series(dtype=float)
33
+ port_rets = []
34
+ hist = []
35
+ txn_frac = txn_bps / 10000.0
36
+ days = 0
37
+
38
+ spy_vals = spy.values
39
+ sma_vals = sma.values
40
+
41
+ for i in range(1, len(dc)):
42
+ if len(port_rets) >= 21:
43
+ w_window = port_rets[-60:] if len(port_rets) >= 60 else port_rets[-21:]
44
+ vs = vol_target / (np.std(w_window)*np.sqrt(252)+1e-8)
45
+ else: vs = 0.5
46
+
47
+ sp, sm = spy_vals[i-1], sma_vals[i-1]
48
+ if pd.isna(sm) or sp <= sm: vs *= riskoff_haircut
49
+ vs = float(np.clip(vs, 0.05, 1.0))
50
+
51
+ day_ret = 0.0
52
+ if pick_tks:
53
+ lr = daily_ret.iloc[i][[t for t in pick_tks if t in daily_ret.columns]].dropna()
54
+ if not lr.empty:
55
+ wt = current_weights.reindex(lr.index).fillna(0)
56
+ if wt.sum() > 0: wt = wt / wt.sum()
57
+ day_ret = (lr * wt).sum() * vs
58
+
59
+ paper_nav *= (1 + day_ret)
60
+ if not stop_active:
61
+ nav *= (1 + day_ret)
62
+
63
+ port_rets.append(day_ret)
64
+ hist.append(nav)
65
+
66
+ # Drawdown Stop Logic
67
+ peak_paper_nav = max(peak_paper_nav, paper_nav)
68
+ paper_dd = (paper_nav / peak_paper_nav) - 1.0
69
+
70
+ if use_dd_stop:
71
+ if not stop_active:
72
+ if paper_dd <= -0.15:
73
+ stop_active = True
74
+ trough_paper_nav = paper_nav
75
+ nav -= nav * txn_frac
76
+ else:
77
+ trough_paper_nav = min(trough_paper_nav, paper_nav)
78
+ if paper_nav >= trough_paper_nav * 1.05:
79
+ stop_active = False
80
+ peak_paper_nav = paper_nav
81
+ nav -= nav * txn_frac
82
+
83
+ days += 1
84
+ if days >= rebal_days:
85
+ days = 0
86
+
87
+ # Get universe of valid stocks at T-1
88
+ valid_tks = [t for t in vf if pd.notna(dc[t].iloc[i-1])]
89
+
90
+ if not valid_tks:
91
+ continue
92
+
93
+ # MONKEY: Randomly select Top N
94
+ if len(valid_tks) >= top_n:
95
+ new_picks = list(np.random.choice(valid_tks, top_n, replace=False))
96
+ else:
97
+ new_picks = list(np.random.choice(valid_tks, len(valid_tks), replace=False))
98
+
99
+ # True Turnover Cost
100
+ if pick_tks and new_picks:
101
+ swaps = len(set(new_picks) - set(pick_tks))
102
+ turnover_cost = (swaps / top_n) * txn_frac
103
+ nav -= nav * turnover_cost
104
+ paper_nav -= paper_nav * turnover_cost
105
+
106
+ if new_picks:
107
+ current_weights = pd.Series(1.0/len(new_picks), index=new_picks)
108
+
109
+ pick_tks = new_picks
110
+
111
+ return pd.Series(hist, index=dc.index[1:len(hist)+1])
112
+
113
+ def run_test_1_3():
114
+ print("=" * 80)
115
+ print(f" TEST 1.3: V68 ARCHITECTURE MONKEY TEST - {STRATEGY_NAME}")
116
+ print("=" * 80)
117
+
118
+ dc, spy, vf, daily_ret = load_data()
119
+
120
+ # 1. Evaluate Active Strategy
121
+ print("Evaluating Active V68 Strategy...")
122
+ c_orig = ACTIVE_STRATEGY_FN(dc, spy, vf, daily_ret, **ACTIVE_PARAMS)
123
+ if isinstance(c_orig, dict) and 'curve' in c_orig:
124
+ c_orig = c_orig['curve']
125
+ m_orig = evaluate_slice(c_orig, "2008-01-01", "2025-12-31")
126
+ orig_sharpe = m_orig['sharpe']
127
+ print(f" Active Sharpe: {orig_sharpe:.4f}")
128
+
129
+ # 2. Evaluate 50 Random Monkey Iterations (Inside V68 Framework)
130
+ print("\nRunning 50 Random Monkey Iterations (Apples-to-Apples)...")
131
+ np.random.seed(42) # Fixed seed for reproducibility
132
+ rand_sharpes = []
133
+
134
+ # Pre-calculate to speed up monkey tests (since we run 50 times)
135
+ for step in range(50):
136
+ c_rand = run_v68_monkey(dc, spy, vf, daily_ret, **ACTIVE_PARAMS)
137
+ m_rand = evaluate_slice(c_rand, "2008-01-01", "2025-12-31")
138
+ rand_sharpes.append(m_rand['sharpe'])
139
+ if step % 10 == 0:
140
+ print(".", end="", flush=True)
141
+ print()
142
+
143
+ r_med = np.median(rand_sharpes)
144
+ r_max = np.max(rand_sharpes)
145
+
146
+ print("-" * 80)
147
+ print(f" Random-Monkey Median: {r_med:.4f}")
148
+ print(f" Random-Monkey Max: {r_max:.4f}")
149
+
150
+ excess_monkey = orig_sharpe - r_med
151
+ print(f" Excess vs Monkey Median: {excess_monkey:+.4f}")
152
+
153
+ if excess_monkey > 0.15:
154
+ print(" VERDICT: PASS (Signal adds strong value beyond V68 risk management)")
155
+ elif excess_monkey > 0.05:
156
+ print(" VERDICT: WEAK PASS (Signal adds marginal value)")
157
+ else:
158
+ print(" VERDICT: FAIL (Signal has no mathematical edge over random selection inside V68)")
159
+
160
+ if __name__ == "__main__":
161
+ run_test_1_3()
backtesting/framework/test_2_1_train_test.py ADDED
@@ -0,0 +1,42 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import sys, os
2
+ import warnings; warnings.filterwarnings('ignore')
3
+
4
+ from backtesting.framework.config import STRATEGY_NAME, ACTIVE_STRATEGY_FN, ACTIVE_PARAMS, load_data
5
+
6
+ try:
7
+ from v30_causal_engine import evaluate_slice
8
+ except ImportError:
9
+ from backtesting.v30_causal_engine import evaluate_slice
10
+
11
+ def run_test_2_1():
12
+ print("=" * 80)
13
+ print(f" TEST 2.1: STRICT TRAIN/TEST SPLIT - {STRATEGY_NAME}")
14
+ print("=" * 80)
15
+
16
+ dc, spy, vf, daily_ret = load_data()
17
+
18
+ print("Evaluating Full Strategy...")
19
+ c = ACTIVE_STRATEGY_FN(dc, spy, vf, daily_ret, **ACTIVE_PARAMS)
20
+ if isinstance(c, dict) and 'curve' in c:
21
+ c = c['curve']
22
+
23
+ m_train = evaluate_slice(c, "2008-01-01", "2018-12-31")
24
+ m_test = evaluate_slice(c, "2019-01-01", "2025-12-31")
25
+
26
+ print(f" Train Sharpe (2008-2018): {m_train['sharpe']:.4f}")
27
+ print(f" Test Sharpe (2019-2025): {m_test['sharpe']:.4f}")
28
+
29
+ diff = m_test['sharpe'] - m_train['sharpe']
30
+ print(f" Difference (Out-of-Sample Lift): {diff:+.4f}")
31
+
32
+ print("-" * 80)
33
+ if diff > -0.20:
34
+ if diff > 0:
35
+ print(" VERDICT: STRONG PASS (Exceptionally robust, test improved)")
36
+ else:
37
+ print(" VERDICT: PASS (Test decay is within acceptable limits of -0.20)")
38
+ else:
39
+ print(" VERDICT: FAIL (Likely overfit, test decayed > -0.20)")
40
+
41
+ if __name__ == "__main__":
42
+ run_test_2_1()
backtesting/framework/test_2_2_start_date.py ADDED
@@ -0,0 +1,63 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import sys, os
2
+ import numpy as np
3
+ import warnings; warnings.filterwarnings('ignore')
4
+
5
+ from backtesting.framework.config import STRATEGY_NAME, ACTIVE_STRATEGY_FN, ACTIVE_PARAMS, REBAL_PARAM_NAME, load_data
6
+
7
+ try:
8
+ from backtesting.engines.v30_causal_engine import evaluate_slice
9
+ except ImportError:
10
+ from backtesting.engines.v30_causal_engine import evaluate_slice
11
+
12
+ def run_test_2_2():
13
+ print("=" * 80)
14
+ print(f" TEST 2.2: START DATE SENSITIVITY - {STRATEGY_NAME}")
15
+ print("=" * 80)
16
+
17
+ dc, spy, vf, daily_ret = load_data()
18
+
19
+ rebal = ACTIVE_PARAMS.get(REBAL_PARAM_NAME, 60)
20
+ # Ensure strictly ~20 offsets
21
+ step = max(1, rebal // 20)
22
+ offsets = list(range(0, rebal, step))[:20]
23
+
24
+ print(f"Running {len(offsets)} start date offsets to sample path dependency...")
25
+ sharpes = []
26
+ cagrs = []
27
+ mdds = []
28
+
29
+ for off in offsets:
30
+ c_off = ACTIVE_STRATEGY_FN(dc.iloc[off:], spy.iloc[off:], vf, daily_ret.iloc[off:], **ACTIVE_PARAMS)
31
+ if isinstance(c_off, dict) and 'curve' in c_off:
32
+ c_off = c_off['curve']
33
+
34
+ m = evaluate_slice(c_off, "2008-01-01", "2025-12-31")
35
+ sharpes.append(m['sharpe'])
36
+ cagrs.append(m['cagr'])
37
+ mdds.append(m['mdd'])
38
+ print(".", end="", flush=True)
39
+ print()
40
+
41
+ s_mean = np.mean(sharpes)
42
+ s_max = np.max(sharpes)
43
+ s_min = np.min(sharpes)
44
+ s_range = s_max - s_min
45
+
46
+ c_mean = np.mean(cagrs)
47
+ d_mean = np.mean(mdds)
48
+
49
+ print(f" Mean Sharpe: {s_mean:.4f}")
50
+ print(f" Mean CAGR: {c_mean:.1f}%")
51
+ print(f" Mean MaxDD: {d_mean:.1f}%")
52
+ print(f" Min Sharpe: {s_min:.4f}")
53
+ print(f" Max Sharpe: {s_max:.4f}")
54
+ print(f" Range: {s_range:.4f}")
55
+
56
+ print("-" * 80)
57
+ if s_range < 0.20:
58
+ print(" VERDICT: PASS (Low path dependency, structurally robust)")
59
+ else:
60
+ print(" VERDICT: FAIL (High path dependency, unreliable variance)")
61
+
62
+ if __name__ == "__main__":
63
+ run_test_2_2()
backtesting/framework/test_2_3_poison.py ADDED
@@ -0,0 +1,82 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import sys, os
2
+ import pandas as pd
3
+ import warnings; warnings.filterwarnings('ignore')
4
+
5
+ from backtesting.framework.config import STRATEGY_NAME, ACTIVE_STRATEGY_FN, ACTIVE_PARAMS, load_data
6
+
7
+ try:
8
+ from v30_causal_engine import evaluate_slice
9
+ except ImportError:
10
+ from backtesting.v30_causal_engine import evaluate_slice
11
+
12
+ def run_test_2_3():
13
+ print("=" * 80)
14
+ print(f" TEST 2.3: SURVIVORSHIP BIAS (POISON UNIVERSE) - {STRATEGY_NAME}")
15
+ print("=" * 80)
16
+
17
+ dc, spy, vf, daily_ret = load_data()
18
+
19
+ # Baseline
20
+ print("Evaluating Baseline Universe...")
21
+ c_base = ACTIVE_STRATEGY_FN(dc, spy, vf, daily_ret, **ACTIVE_PARAMS)
22
+ if isinstance(c_base, dict) and 'curve' in c_base:
23
+ c_base = c_base['curve']
24
+ base_cagr = evaluate_slice(c_base, "2008-01-01", "2025-12-31")['cagr']
25
+ print(f" Baseline CAGR: {base_cagr:.1f}%")
26
+
27
+ POISON_TICKERS = [
28
+ "BBBY", "WISH", "CLOV", "WKHS", "RIDE", "NKLA", "QS", "HYLN",
29
+ "SPCE", "SKLZ", "CLNE", "GOEV", "ARVL", "FSR", "PSFE", "OPEN",
30
+ "SOFI", "BARK", "BIRD", "BYND", "PTON", "ZM", "DOCU", "TDOC",
31
+ "FVRR", "UPST", "AFRM", "RKLB", "IONQ", "DNA", "LAZR", "VLDR",
32
+ "MVIS", "WOOF", "SDC", "LMND", "ROOT", "COUR", "DNUT", "OLO",
33
+ "TUYA", "PAYO", "BGRY", "SEER", "PRCH", "ACHR", "JOBY", "LILM",
34
+ "EVGO", "CHPT"
35
+ ]
36
+
37
+ print("\nDownloading Poison Ticker Data (Catastrophic Losers)...", end="", flush=True)
38
+ import yfinance as yf
39
+
40
+ # Suppress yfinance output to keep logs clean
41
+ old_stdout = sys.stdout
42
+ sys.stdout = open(os.devnull, 'w')
43
+ poison_raw = yf.download(POISON_TICKERS, start="2006-01-01", end="2025-12-31", progress=False)
44
+ sys.stdout = old_stdout
45
+
46
+ if isinstance(poison_raw.columns, pd.MultiIndex):
47
+ lvl0 = poison_raw.columns.get_level_values(0).unique().tolist()
48
+ poison_close = poison_raw["Close"] if "Close" in lvl0 else poison_raw
49
+ if isinstance(poison_close.columns, pd.MultiIndex):
50
+ poison_close.columns = poison_close.columns.get_level_values(-1)
51
+ else:
52
+ poison_close = poison_raw
53
+
54
+ valid_poison = [t for t in POISON_TICKERS if t in poison_close.columns and poison_close[t].notna().sum() > 100]
55
+ print(f" {len(valid_poison)} valid poison tickers integrated.")
56
+
57
+ dc_poisoned = dc.copy()
58
+ for t in valid_poison:
59
+ if t not in dc_poisoned.columns:
60
+ dc_poisoned[t] = poison_close[t].reindex(dc_poisoned.index).ffill()
61
+
62
+ poison_vf = list(dict.fromkeys(list(vf) + valid_poison))
63
+ daily_ret_poisoned = dc_poisoned.pct_change()
64
+
65
+ print("Evaluating Poisoned Universe...")
66
+ c_poison = ACTIVE_STRATEGY_FN(dc_poisoned, spy, poison_vf, daily_ret_poisoned, **ACTIVE_PARAMS)
67
+ if isinstance(c_poison, dict) and 'curve' in c_poison:
68
+ c_poison = c_poison['curve']
69
+ poison_cagr = evaluate_slice(c_poison, "2008-01-01", "2025-12-31")['cagr']
70
+
71
+ print(f" Poison Universe CAGR: {poison_cagr:.1f}%")
72
+ diff = base_cagr - poison_cagr
73
+ print(f" CAGR Decay: {diff:+.1f}%")
74
+
75
+ print("-" * 80)
76
+ if diff <= 3.0:
77
+ print(" VERDICT: PASS (Strategy naturally avoids catastrophic losers)")
78
+ else:
79
+ print(" VERDICT: FAIL (Strategy edge heavily relies on survivorship bias)")
80
+
81
+ if __name__ == "__main__":
82
+ run_test_2_3()
backtesting/framework/test_3_1_txn_costs.py ADDED
@@ -0,0 +1,44 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import sys, os
2
+ import warnings; warnings.filterwarnings('ignore')
3
+
4
+ from backtesting.framework.config import STRATEGY_NAME, ACTIVE_STRATEGY_FN, ACTIVE_PARAMS, TXN_PARAM_NAME, load_data
5
+
6
+ try:
7
+ from v30_causal_engine import evaluate_slice
8
+ except ImportError:
9
+ from backtesting.v30_causal_engine import evaluate_slice
10
+
11
+ def run_test_3_1():
12
+ print("=" * 80)
13
+ print(f" TEST 3.1: TRANSACTION COST STRESS TEST - {STRATEGY_NAME}")
14
+ print("=" * 80)
15
+
16
+ if not TXN_PARAM_NAME:
17
+ print("FAIL: No TXN_PARAM_NAME defined in config.py")
18
+ return
19
+
20
+ dc, spy, vf, daily_ret = load_data()
21
+
22
+ levels = [20, 40, 60]
23
+ results = {}
24
+
25
+ for bps in levels:
26
+ params = ACTIVE_PARAMS.copy()
27
+ params[TXN_PARAM_NAME] = bps
28
+
29
+ c = ACTIVE_STRATEGY_FN(dc, spy, vf, daily_ret, **params)
30
+ if isinstance(c, dict) and 'curve' in c:
31
+ c = c['curve']
32
+
33
+ m = evaluate_slice(c, "2008-01-01", "2025-12-31")
34
+ results[bps] = m['sharpe']
35
+ print(f" At {bps} bps: Sharpe = {m['sharpe']:.4f}")
36
+
37
+ print("-" * 80)
38
+ if results[60] >= 0.60:
39
+ print(" VERDICT: PASS (Robust to real-world execution friction)")
40
+ else:
41
+ print(" VERDICT: FAIL (Edge is heavily friction-dependent, too fragile)")
42
+
43
+ if __name__ == "__main__":
44
+ run_test_3_1()
backtesting/framework/test_3_5_crash_autopsy.py ADDED
@@ -0,0 +1,45 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import sys, os
2
+ import warnings; warnings.filterwarnings('ignore')
3
+
4
+ from backtesting.framework.config import STRATEGY_NAME, ACTIVE_STRATEGY_FN, ACTIVE_PARAMS, load_data
5
+
6
+ def run_test_3_5():
7
+ print("=" * 80)
8
+ print(f" TEST 3.5: MOMENTUM CRASH AUTOPSY - {STRATEGY_NAME}")
9
+ print("=" * 80)
10
+
11
+ dc, spy, vf, daily_ret = load_data()
12
+
13
+ c = ACTIVE_STRATEGY_FN(dc, spy, vf, daily_ret, **ACTIVE_PARAMS)
14
+ if isinstance(c, dict) and 'curve' in c:
15
+ c = c['curve']
16
+
17
+ crashes = {
18
+ "GFC (Lehman)": ("2008-09-01", "2009-03-31"),
19
+ "V-Recovery": ("2009-03-01", "2009-05-31"),
20
+ "China Shock": ("2015-06-01", "2015-09-30"),
21
+ "Vaccine Rotation": ("2020-11-01", "2020-11-30"),
22
+ "Rate Shock": ("2022-01-01", "2022-03-31")
23
+ }
24
+
25
+ print(f" {'Crash Period':<20} | {'Strategy Return':>15} | {'SPY Return':>15}")
26
+ print(" " + "-"*56)
27
+
28
+ for name, (start, end) in crashes.items():
29
+ if start >= c.index[0].strftime('%Y-%m-%d') and end <= c.index[-1].strftime('%Y-%m-%d'):
30
+ c_slice = c.loc[start:end]
31
+ s_slice = spy.loc[start:end]
32
+
33
+ if len(c_slice) == 0:
34
+ continue
35
+
36
+ c_ret = (c_slice.iloc[-1] / c_slice.iloc[0]) - 1
37
+ s_ret = (s_slice.iloc[-1] / s_slice.iloc[0]) - 1
38
+
39
+ print(f" {name:<20} | {c_ret*100:>14.1f}% | {s_ret*100:>14.1f}%")
40
+
41
+ print("-" * 80)
42
+ print(" VERDICT: PASS (Drawdowns quantified)")
43
+
44
+ if __name__ == "__main__":
45
+ run_test_3_5()
backtesting/framework/test_4_1_param_sweep.py ADDED
@@ -0,0 +1,87 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import sys, os
2
+ import itertools
3
+ import warnings; warnings.filterwarnings('ignore')
4
+
5
+ from backtesting.framework.config import STRATEGY_NAME, ACTIVE_STRATEGY_FN, ACTIVE_PARAMS, load_data
6
+
7
+ try:
8
+ from v30_causal_engine import evaluate_slice
9
+ except ImportError:
10
+ from backtesting.v30_causal_engine import evaluate_slice
11
+
12
+ def run_test_4_1():
13
+ print("=" * 80)
14
+ print(f" TEST 4.1 & 4.2: PARAMETER ROBUSTNESS & OVERFIT DETECTION - {STRATEGY_NAME}")
15
+ print("=" * 80)
16
+
17
+ dc, spy, vf, daily_ret = load_data()
18
+
19
+ # Define a default parameter grid to test if none is provided
20
+ # We will test rebalance frequency and short momentum lookback
21
+ param_grid = {
22
+ 'rebal_days': [40, 60, 80],
23
+ 'mom_short': [15, 21, 30]
24
+ }
25
+
26
+ keys = list(param_grid.keys())
27
+ combos = list(itertools.product(*(param_grid[k] for k in keys)))
28
+
29
+ print(f"Evaluating {len(combos)} parameter combinations...")
30
+ res = {}
31
+
32
+ for combo in combos:
33
+ p = ACTIVE_PARAMS.copy()
34
+ for k, v in zip(keys, combo):
35
+ p[k] = v
36
+
37
+ c = ACTIVE_STRATEGY_FN(dc, spy, vf, daily_ret, **p)
38
+ if isinstance(c, dict) and 'curve' in c:
39
+ c = c['curve']
40
+
41
+ m = evaluate_slice(c, "2008-01-01", "2025-12-31")
42
+ res[combo] = m['sharpe']
43
+ print(f" Params {dict(zip(keys, combo))}: Sharpe {m['sharpe']:.4f}")
44
+
45
+ sharpes = list(res.values())
46
+ s_min, s_max = min(sharpes), max(sharpes)
47
+
48
+ print("-" * 80)
49
+ print(f" Surface Range: {s_min:.4f} to {s_max:.4f}")
50
+ if s_min >= 0.70:
51
+ print(" VERDICT: PASS (Smooth, robust parameter surface)")
52
+ else:
53
+ print(" VERDICT: WEAK PASS / FAIL (Surface contains weak spots or cliff edges)")
54
+
55
+ print("\n--- Test 4.2: Single Parameter Overfit Detection ---")
56
+ best_combo = max(res, key=res.get)
57
+ best_params = dict(zip(keys, best_combo))
58
+
59
+ print(f" Best Params found: {best_params} -> Evaluating Overfit Risk...")
60
+ p_best = ACTIVE_PARAMS.copy()
61
+ p_best.update(best_params)
62
+
63
+ c_best = ACTIVE_STRATEGY_FN(dc, spy, vf, daily_ret, **p_best)
64
+ if isinstance(c_best, dict) and 'curve' in c_best:
65
+ c_best = c_best['curve']
66
+
67
+ m_train = evaluate_slice(c_best, "2008-01-01", "2018-12-31")
68
+ m_test = evaluate_slice(c_best, "2019-01-01", "2025-12-31")
69
+
70
+ print(f" Train Sharpe (2008-2018): {m_train['sharpe']:.4f}")
71
+ print(f" Test Sharpe (2019-2025): {m_test['sharpe']:.4f}")
72
+
73
+ diff = m_test['sharpe'] - m_train['sharpe']
74
+ print(f" Out-of-Sample Lift: {diff:+.4f}")
75
+
76
+ print("-" * 80)
77
+ # The requirement is that Test didn't collapse massively compared to train
78
+ if diff >= -0.15:
79
+ if diff > 0:
80
+ print(" VERDICT: PASS (Improvement is genuine out-of-sample)")
81
+ else:
82
+ print(" VERDICT: PASS (Optimization held out-of-sample within acceptable limits)")
83
+ else:
84
+ print(" VERDICT: FAIL (Pure overfit, optimization disappeared out-of-sample)")
85
+
86
+ if __name__ == "__main__":
87
+ run_test_4_1()