Spaces:
Sleeping
Sleeping
Commit ·
1cd56b6
0
Parent(s):
deploy: clean history for HuggingFace
Browse filesThis view is limited to 50 files because it contains too many changes. See raw diff
- .dockerignore +14 -0
- .env.example +17 -0
- .gitattributes +42 -0
- .github/workflows/scraper.yml +35 -0
- .github/workflows/v30_trader.yml +45 -0
- .gitignore +56 -0
- .hf_space +1 -0
- .huggingfaceignore +12 -0
- DL_Class_Final_Presentation.pptx +3 -0
- Dockerfile +48 -0
- README.md +11 -0
- backend/__init__.py +0 -0
- backend/alpaca_executor.py +468 -0
- backend/alpaca_trade_log.json +119 -0
- backend/app.py +591 -0
- backend/database.py +110 -0
- backend/manager.py +94 -0
- backend/pairs_radar.json +1 -0
- backend/screener_data.json +1 -0
- backend/strategy_cache.json +1 -0
- backend/strategy_signals.py +183 -0
- backend/worker.py +512 -0
- backtesting/data_pipeline/bert_backtest.py +238 -0
- backtesting/data_pipeline/check_cache_dates.py +16 -0
- backtesting/data_pipeline/check_data_dates.py +9 -0
- backtesting/data_pipeline/download_fnspid.py +88 -0
- backtesting/data_pipeline/filter_fnspid.py +67 -0
- backtesting/data_pipeline/process_bert_sentiment.py +112 -0
- backtesting/data_pipeline/score_fnspid_bert.py +58 -0
- backtesting/engines/v11_causal_engine.py +190 -0
- backtesting/engines/v30_causal_engine.py +316 -0
- backtesting/engines/v36_research_engine.py +345 -0
- backtesting/engines/v36_research_engine_fixed.py +345 -0
- backtesting/engines/v36r2_research_engine.py +225 -0
- backtesting/engines/v36r3_engines.py +475 -0
- backtesting/engines/v36r4_engines.py +312 -0
- backtesting/engines/v36r5_engines.py +264 -0
- backtesting/engines/v53_final_average.py +55 -0
- backtesting/engines/v68_live_may2026.py +332 -0
- backtesting/framework/config.py +32 -0
- backtesting/framework/quant_framework.py +272 -0
- backtesting/framework/test_1_1_ic_analysis.py +59 -0
- backtesting/framework/test_1_2_regime_decay.py +69 -0
- backtesting/framework/test_1_3_monkey_test.py +161 -0
- backtesting/framework/test_2_1_train_test.py +42 -0
- backtesting/framework/test_2_2_start_date.py +63 -0
- backtesting/framework/test_2_3_poison.py +82 -0
- backtesting/framework/test_3_1_txn_costs.py +44 -0
- backtesting/framework/test_3_5_crash_autopsy.py +45 -0
- 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()
|