Upload 27 files
Browse files- backend/.dockerignore +18 -0
- backend/.gitattributes +5 -0
- backend/Dockerfile +23 -0
- backend/README.md +128 -0
- backend/app.py +525 -0
- backend/requirements.txt +11 -0
- backend/research_runtime/Code/models/first_extrema_forecaster/__init__.py +1 -0
- backend/research_runtime/Code/models/first_extrema_forecaster/outputs/latest_forecasts.csv +0 -0
- backend/research_runtime/Code/models/first_extrema_forecaster/outputs/summary.json +174 -0
- backend/research_runtime/Code/models/first_extrema_forecaster/train.py +1466 -0
- backend/research_runtime/Code/models/nifty_forecaster/__init__.py +1 -0
- backend/research_runtime/Code/models/nifty_forecaster/outputs/forecaster_latest_forecasts.csv +2 -0
- backend/research_runtime/Code/models/nifty_forecaster/outputs/forecaster_summary.json +35 -0
- backend/research_runtime/Code/models/nifty_forecaster/train.py +1449 -0
- backend/research_runtime/Code/models/stock_high_low_forecaster/__init__.py +1 -0
- backend/research_runtime/Code/models/stock_high_low_forecaster/outputs/latest_forecasts.csv +9 -0
- backend/research_runtime/Code/models/stock_high_low_forecaster/outputs/metrics_by_symbol.csv +9 -0
- backend/research_runtime/Code/models/stock_high_low_forecaster/outputs/summary.json +439 -0
- backend/research_runtime/Code/models/stock_high_low_forecaster/train.py +1858 -0
- backend/research_runtime/Code/scripts/data_ingestion/download_corporate_announcements.py +102 -0
- backend/research_runtime/Code/scripts/data_ingestion/download_index_options.py +297 -0
- backend/research_runtime/Code/scripts/data_ingestion/download_institutional_flows.py +344 -0
- backend/research_runtime/Code/scripts/data_ingestion/refresh_market_data.py +147 -0
- backend/research_runtime/Code/scripts/data_ingestion/update_index_minute_data.py +228 -0
- backend/research_runtime/Code/scripts/data_preparation/build_research_data.py +957 -0
- backend/research_runtime/Code/scripts/data_preparation/process_raw_minutes.py +52 -0
- backend/runtime_config.example.env +14 -0
backend/.dockerignore
ADDED
|
@@ -0,0 +1,18 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
.git
|
| 2 |
+
.gitignore
|
| 3 |
+
__pycache__/
|
| 4 |
+
*.py[cod]
|
| 5 |
+
.space_state/
|
| 6 |
+
.env
|
| 7 |
+
*.env
|
| 8 |
+
!*.example.env
|
| 9 |
+
research_runtime/Code/artifacts/
|
| 10 |
+
research_runtime/Code/docs/
|
| 11 |
+
research_runtime/Code/scripts/backtesting/
|
| 12 |
+
research_runtime/Code/scripts/tuning/
|
| 13 |
+
research_runtime/Code/models/**/outputs/*dataset*.csv
|
| 14 |
+
research_runtime/Code/models/**/outputs/test_predictions.csv
|
| 15 |
+
research_runtime/Code/models/**/outputs/*predictions.csv
|
| 16 |
+
research_runtime/Code/models/**/outputs/*.joblib
|
| 17 |
+
research_runtime/Data/
|
| 18 |
+
research_runtime/Alt Data/
|
backend/.gitattributes
ADDED
|
@@ -0,0 +1,5 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
*.csv filter=lfs diff=lfs merge=lfs -text
|
| 2 |
+
*.joblib filter=lfs diff=lfs merge=lfs -text
|
| 3 |
+
*.png filter=lfs diff=lfs merge=lfs -text
|
| 4 |
+
*.zip filter=lfs diff=lfs merge=lfs -text
|
| 5 |
+
*.parquet filter=lfs diff=lfs merge=lfs -text
|
backend/Dockerfile
ADDED
|
@@ -0,0 +1,23 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
FROM python:3.11-slim
|
| 2 |
+
|
| 3 |
+
ENV PYTHONDONTWRITEBYTECODE=1 \
|
| 4 |
+
PYTHONUNBUFFERED=1 \
|
| 5 |
+
PIP_NO_CACHE_DIR=1 \
|
| 6 |
+
PORT=7860 \
|
| 7 |
+
FORECASTING_PROJECT_ROOT=/app/research_runtime
|
| 8 |
+
|
| 9 |
+
WORKDIR /app
|
| 10 |
+
|
| 11 |
+
RUN apt-get update \
|
| 12 |
+
&& apt-get install -y --no-install-recommends build-essential curl git libgomp1 \
|
| 13 |
+
&& rm -rf /var/lib/apt/lists/*
|
| 14 |
+
|
| 15 |
+
COPY requirements.txt .
|
| 16 |
+
RUN pip install --upgrade pip \
|
| 17 |
+
&& pip install -r requirements.txt
|
| 18 |
+
|
| 19 |
+
COPY . .
|
| 20 |
+
|
| 21 |
+
EXPOSE 7860
|
| 22 |
+
|
| 23 |
+
CMD ["uvicorn", "app:app", "--host", "0.0.0.0", "--port", "7860", "--ws", "none"]
|
backend/README.md
ADDED
|
@@ -0,0 +1,128 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
---
|
| 2 |
+
title: Trading Forecasting Backend
|
| 3 |
+
colorFrom: blue
|
| 4 |
+
colorTo: green
|
| 5 |
+
sdk: docker
|
| 6 |
+
app_port: 7860
|
| 7 |
+
pinned: false
|
| 8 |
+
---
|
| 9 |
+
|
| 10 |
+
# Trading Forecasting Backend
|
| 11 |
+
|
| 12 |
+
This folder is now a standalone Hugging Face Docker Space backend. Upload the contents of this `backend` folder to a Hugging Face Space repository, upload the separate `dataset` folder to a Hugging Face Dataset repository, and deploy the separate `frontend` folder to Netlify.
|
| 13 |
+
|
| 14 |
+
The backend contains the quantitative model code, training scripts, model outputs, primary market data, and alternative data from the forecasting research workspace.
|
| 15 |
+
|
| 16 |
+
## Hugging Face Space Setup
|
| 17 |
+
|
| 18 |
+
Create a new Hugging Face Space with Docker SDK, then upload this backend folder as the Space root.
|
| 19 |
+
|
| 20 |
+
Required Space variables/secrets:
|
| 21 |
+
|
| 22 |
+
- `FRONTEND_ORIGINS`: your Netlify URL, for example `https://your-site.netlify.app`.
|
| 23 |
+
- `CRON_SECRET`: a long shared secret. Use the same value in Netlify.
|
| 24 |
+
- `HF_DATASET_REPO_ID`: your Hugging Face Dataset repo id, for example `your-username/your-forecasting-dataset`.
|
| 25 |
+
|
| 26 |
+
Useful optional settings:
|
| 27 |
+
|
| 28 |
+
- `AUTO_UPDATE_ENABLED=true`
|
| 29 |
+
- `AUTO_RETRAIN_ENABLED=true`
|
| 30 |
+
- `AUTO_UPDATE_ON_START=false`
|
| 31 |
+
- `DATASET_SYNC_ON_START=true`
|
| 32 |
+
- `HF_DATASET_REVISION=main`
|
| 33 |
+
- `DAILY_UPDATE_TIME=17:30`
|
| 34 |
+
- `UPDATE_TIMEZONE=Asia/Kolkata`
|
| 35 |
+
- `MARKET_BUILD_WORKERS=2`
|
| 36 |
+
|
| 37 |
+
The app listens on port `7860` and exposes Swagger docs at `/docs`.
|
| 38 |
+
|
| 39 |
+
## API Routes
|
| 40 |
+
|
| 41 |
+
- `GET /health` - Space health, file checks, latest data date, and update status.
|
| 42 |
+
- `GET /api/status` - same as health, for frontend polling.
|
| 43 |
+
- `GET /api/forecast/latest` - latest stock high/low, first-extrema, and Nifty forecasts.
|
| 44 |
+
- `GET /api/models/summaries` - model summary JSONs.
|
| 45 |
+
- `GET /api/data/catalog` - searchable data manifest.
|
| 46 |
+
- `GET /api/data/sample?category=bars&asset=nifty50&timeframe=1d` - small sample from a manifest dataset.
|
| 47 |
+
- `POST /api/cron/tick` - Netlify scheduled ping endpoint; starts an update only when due.
|
| 48 |
+
- `POST /api/update/start` - manual update trigger. Send `x-admin-secret` if `CRON_SECRET` or `ADMIN_SECRET` is set.
|
| 49 |
+
- `POST /api/dataset/sync` - manually sync the Hugging Face Dataset repo into the Space runtime.
|
| 50 |
+
|
| 51 |
+
## Netlify Keep-Awake Cron
|
| 52 |
+
|
| 53 |
+
The `frontend` folder now includes:
|
| 54 |
+
|
| 55 |
+
- `frontend/netlify.toml`
|
| 56 |
+
- `frontend/netlify/functions/keep-space-awake.mjs`
|
| 57 |
+
|
| 58 |
+
On Netlify, set these environment variables:
|
| 59 |
+
|
| 60 |
+
- `HUGGING_FACE_SPACE_URL=https://YOUR-HF-USERNAME-YOUR-SPACE.hf.space`
|
| 61 |
+
- `CRON_SECRET=<same value as the Space CRON_SECRET>`
|
| 62 |
+
|
| 63 |
+
The scheduled function runs every 10 minutes and calls `/api/cron/tick`. This keeps the Space warm and lets the backend start its daily update/retrain job after the configured market-close time.
|
| 64 |
+
|
| 65 |
+
## Layout
|
| 66 |
+
|
| 67 |
+
- `app.py` - FastAPI backend app for Hugging Face Spaces.
|
| 68 |
+
- `Dockerfile` - Docker Space runtime setup.
|
| 69 |
+
- `requirements.txt` - Python dependencies.
|
| 70 |
+
- `research_runtime/Code/models/` - trainable model packages and the small latest forecast/summary outputs needed by the API.
|
| 71 |
+
- `research_runtime/Code/scripts/data_ingestion/` - data refresh scripts used by update jobs.
|
| 72 |
+
- `research_runtime/Code/scripts/data_preparation/` - research data rebuild scripts used by update jobs.
|
| 73 |
+
|
| 74 |
+
`research_runtime/Data/` and `research_runtime/Alt Data/` are intentionally not bundled in the Space repo anymore. They now live in the separate Hugging Face Dataset repo and are downloaded into `research_runtime/` by the backend when `HF_DATASET_REPO_ID` is set.
|
| 75 |
+
|
| 76 |
+
## Main Model Outputs To Wire First
|
| 77 |
+
|
| 78 |
+
- Stock high/low forecasts: `research_runtime/Code/models/stock_high_low_forecaster/outputs/latest_forecasts.csv`
|
| 79 |
+
- Stock high/low metrics: `research_runtime/Code/models/stock_high_low_forecaster/outputs/metrics_by_symbol.csv`
|
| 80 |
+
- First-extrema forecasts: `research_runtime/Code/models/first_extrema_forecaster/outputs/latest_forecasts.csv`
|
| 81 |
+
- Nifty forecasts: `research_runtime/Code/models/nifty_forecaster/outputs/forecaster_latest_forecasts.csv`
|
| 82 |
+
- Nifty summary: `research_runtime/Code/models/nifty_forecaster/outputs/forecaster_summary.json`
|
| 83 |
+
|
| 84 |
+
## Training Entrypoints
|
| 85 |
+
|
| 86 |
+
Run these from `backend/research_runtime` so project-relative paths resolve correctly:
|
| 87 |
+
|
| 88 |
+
```powershell
|
| 89 |
+
python Code\models\stock_high_low_forecaster\train.py
|
| 90 |
+
python Code\models\first_extrema_forecaster\train.py
|
| 91 |
+
python Code\models\nifty_forecaster\train.py
|
| 92 |
+
```
|
| 93 |
+
|
| 94 |
+
## Data Labels
|
| 95 |
+
|
| 96 |
+
These live in the separate Dataset repo:
|
| 97 |
+
|
| 98 |
+
- Raw minute OHLCV: `Data/raw/minute/*_minute.csv`
|
| 99 |
+
- Processed bars: `Data/processed/bars/{1m,5m,1h,4h,1d}/*.csv`
|
| 100 |
+
- Processed features: `Data/processed/features/{1m,5m,1h,4h,1d}/*.csv`
|
| 101 |
+
- Market panels: `Data/processed/panels/*_market_panel.csv`
|
| 102 |
+
- Master daily panel: `Data/processed/panels/daily_master_panel.csv`
|
| 103 |
+
- Data manifest: `Data/metadata/manifest.csv`
|
| 104 |
+
- Feature dictionary: `Data/metadata/feature_dictionary.csv`
|
| 105 |
+
- Options features: `Alt Data/options/processed/*_options_daily_features.csv`
|
| 106 |
+
- Institutional panel: `Alt Data/institutional/processed/institutional_daily_panel.csv`
|
| 107 |
+
- External daily panel: `Alt Data/external/processed/external_daily_panel.csv`
|
| 108 |
+
- Corporate events: `Alt Data/corporate/processed/corporate_announcements.csv`
|
| 109 |
+
|
| 110 |
+
## Frontend Wiring Notes
|
| 111 |
+
|
| 112 |
+
The current frontend is static mock data in `frontend/index.html` and `frontend/script.js`.
|
| 113 |
+
|
| 114 |
+
- Forecast cards can call `/api/forecast/latest`.
|
| 115 |
+
- Model accuracy and version/date stats can call `/api/models/summaries`.
|
| 116 |
+
- Market Data can call `/api/data/catalog` and `/api/data/sample`.
|
| 117 |
+
|
| 118 |
+
## Pruned From Backend
|
| 119 |
+
|
| 120 |
+
- Kotak credential/runtime files.
|
| 121 |
+
- Live-trading scripts and live broker artifacts.
|
| 122 |
+
- Kotak monitor artifacts and cached NSE temp folders.
|
| 123 |
+
- Python `__pycache__` folders.
|
| 124 |
+
- CatBoost generated training-log folder.
|
| 125 |
+
- One-off maintenance/backfill scripts.
|
| 126 |
+
- Backtest artifacts, chart images, old trade reports, test prediction dumps, generated training datasets, and saved model binaries.
|
| 127 |
+
|
| 128 |
+
`KOTAKBANK` CSV files remain because those are normal market datasets for Kotak Mahindra Bank, not broker-runtime files.
|
backend/app.py
ADDED
|
@@ -0,0 +1,525 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from __future__ import annotations
|
| 2 |
+
|
| 3 |
+
import json
|
| 4 |
+
import os
|
| 5 |
+
import subprocess
|
| 6 |
+
import sys
|
| 7 |
+
import threading
|
| 8 |
+
import time
|
| 9 |
+
from datetime import datetime, time as dt_time
|
| 10 |
+
from pathlib import Path
|
| 11 |
+
from typing import Any
|
| 12 |
+
from zoneinfo import ZoneInfo
|
| 13 |
+
|
| 14 |
+
import pandas as pd
|
| 15 |
+
from fastapi import BackgroundTasks, FastAPI, Header, HTTPException, Query, Request
|
| 16 |
+
from fastapi.middleware.cors import CORSMiddleware
|
| 17 |
+
from fastapi.responses import JSONResponse, PlainTextResponse
|
| 18 |
+
from huggingface_hub import snapshot_download
|
| 19 |
+
|
| 20 |
+
|
| 21 |
+
BASE_DIR = Path(__file__).resolve().parent
|
| 22 |
+
RESEARCH_ROOT = Path(os.environ.get("FORECASTING_PROJECT_ROOT", BASE_DIR / "research_runtime")).resolve()
|
| 23 |
+
STATE_DIR = Path(os.environ.get("SPACE_STATE_DIR", "/data/forecasting-space-state" if Path("/data").exists() else BASE_DIR / ".space_state"))
|
| 24 |
+
STATUS_PATH = STATE_DIR / "update_status.json"
|
| 25 |
+
DATASET_READY_MARKER = STATE_DIR / "dataset_ready.json"
|
| 26 |
+
|
| 27 |
+
API_TITLE = "Trading Forecasting Space Backend"
|
| 28 |
+
API_VERSION = "1.0.0"
|
| 29 |
+
DEFAULT_TIMEZONE = os.environ.get("UPDATE_TIMEZONE", "Asia/Kolkata")
|
| 30 |
+
DEFAULT_UPDATE_TIME = os.environ.get("DAILY_UPDATE_TIME", "17:30")
|
| 31 |
+
|
| 32 |
+
app = FastAPI(title=API_TITLE, version=API_VERSION)
|
| 33 |
+
|
| 34 |
+
|
| 35 |
+
def cors_origins() -> list[str]:
|
| 36 |
+
raw = os.environ.get("FRONTEND_ORIGINS", "*").strip()
|
| 37 |
+
return ["*"] if raw == "*" else [item.strip() for item in raw.split(",") if item.strip()]
|
| 38 |
+
|
| 39 |
+
|
| 40 |
+
app.add_middleware(
|
| 41 |
+
CORSMiddleware,
|
| 42 |
+
allow_origins=cors_origins(),
|
| 43 |
+
allow_credentials=False,
|
| 44 |
+
allow_methods=["GET", "POST", "OPTIONS"],
|
| 45 |
+
allow_headers=["*"],
|
| 46 |
+
)
|
| 47 |
+
|
| 48 |
+
update_lock = threading.Lock()
|
| 49 |
+
worker_thread: threading.Thread | None = None
|
| 50 |
+
dataset_lock = threading.Lock()
|
| 51 |
+
|
| 52 |
+
|
| 53 |
+
def now_utc() -> str:
|
| 54 |
+
return datetime.utcnow().replace(microsecond=0).isoformat() + "Z"
|
| 55 |
+
|
| 56 |
+
|
| 57 |
+
def safe_json(value: Any) -> Any:
|
| 58 |
+
if isinstance(value, dict):
|
| 59 |
+
return {str(k): safe_json(v) for k, v in value.items()}
|
| 60 |
+
if isinstance(value, list):
|
| 61 |
+
return [safe_json(v) for v in value]
|
| 62 |
+
if not isinstance(value, (tuple, set)):
|
| 63 |
+
try:
|
| 64 |
+
if pd.isna(value):
|
| 65 |
+
return None
|
| 66 |
+
except Exception:
|
| 67 |
+
pass
|
| 68 |
+
if hasattr(value, "item"):
|
| 69 |
+
try:
|
| 70 |
+
return safe_json(value.item())
|
| 71 |
+
except Exception:
|
| 72 |
+
pass
|
| 73 |
+
if isinstance(value, Path):
|
| 74 |
+
return str(value)
|
| 75 |
+
if isinstance(value, datetime):
|
| 76 |
+
return value.isoformat()
|
| 77 |
+
return value
|
| 78 |
+
|
| 79 |
+
|
| 80 |
+
def read_json(path: Path, default: Any) -> Any:
|
| 81 |
+
try:
|
| 82 |
+
return json.loads(path.read_text(encoding="utf-8"))
|
| 83 |
+
except Exception:
|
| 84 |
+
return default
|
| 85 |
+
|
| 86 |
+
|
| 87 |
+
def write_json(path: Path, payload: Any) -> None:
|
| 88 |
+
path.parent.mkdir(parents=True, exist_ok=True)
|
| 89 |
+
path.write_text(json.dumps(safe_json(payload), indent=2), encoding="utf-8")
|
| 90 |
+
|
| 91 |
+
|
| 92 |
+
def read_status() -> dict[str, Any]:
|
| 93 |
+
return read_json(
|
| 94 |
+
STATUS_PATH,
|
| 95 |
+
{
|
| 96 |
+
"state": "idle",
|
| 97 |
+
"last_started_at": None,
|
| 98 |
+
"last_finished_at": None,
|
| 99 |
+
"last_success_at": None,
|
| 100 |
+
"last_error": None,
|
| 101 |
+
"last_exit_code": None,
|
| 102 |
+
"last_log_tail": [],
|
| 103 |
+
},
|
| 104 |
+
)
|
| 105 |
+
|
| 106 |
+
|
| 107 |
+
def write_status(**updates: Any) -> None:
|
| 108 |
+
status = read_status()
|
| 109 |
+
status.update(updates)
|
| 110 |
+
write_json(STATUS_PATH, status)
|
| 111 |
+
|
| 112 |
+
|
| 113 |
+
def require_secret(x_cron_secret: str | None = Header(default=None), x_admin_secret: str | None = Header(default=None)) -> None:
|
| 114 |
+
expected = os.environ.get("CRON_SECRET") or os.environ.get("ADMIN_SECRET")
|
| 115 |
+
if not expected:
|
| 116 |
+
return
|
| 117 |
+
supplied = x_cron_secret or x_admin_secret
|
| 118 |
+
if supplied != expected:
|
| 119 |
+
raise HTTPException(status_code=401, detail="Missing or invalid cron/admin secret.")
|
| 120 |
+
|
| 121 |
+
|
| 122 |
+
def csv_rows(path: Path, *, limit: int | None = None, columns: list[str] | None = None) -> list[dict[str, Any]]:
|
| 123 |
+
if not path.exists():
|
| 124 |
+
return []
|
| 125 |
+
try:
|
| 126 |
+
frame = pd.read_csv(path, usecols=columns)
|
| 127 |
+
except ValueError:
|
| 128 |
+
frame = pd.read_csv(path)
|
| 129 |
+
if columns:
|
| 130 |
+
frame = frame[[col for col in columns if col in frame.columns]]
|
| 131 |
+
if limit is not None:
|
| 132 |
+
frame = frame.head(limit)
|
| 133 |
+
return safe_json(frame.where(pd.notna(frame), None).to_dict(orient="records"))
|
| 134 |
+
|
| 135 |
+
|
| 136 |
+
def model_output_path(*parts: str) -> Path:
|
| 137 |
+
return RESEARCH_ROOT / "Code" / "models" / Path(*parts)
|
| 138 |
+
|
| 139 |
+
|
| 140 |
+
def manifest_path() -> Path:
|
| 141 |
+
return RESEARCH_ROOT / "Data" / "metadata" / "manifest.csv"
|
| 142 |
+
|
| 143 |
+
|
| 144 |
+
def dataset_dirs_present() -> bool:
|
| 145 |
+
return (RESEARCH_ROOT / "Data").is_dir() and (RESEARCH_ROOT / "Alt Data").is_dir()
|
| 146 |
+
|
| 147 |
+
|
| 148 |
+
def dataset_status() -> dict[str, Any]:
|
| 149 |
+
marker = read_json(DATASET_READY_MARKER, {})
|
| 150 |
+
return {
|
| 151 |
+
"ready": dataset_dirs_present(),
|
| 152 |
+
"repo_id": os.environ.get("HF_DATASET_REPO_ID"),
|
| 153 |
+
"revision": os.environ.get("HF_DATASET_REVISION", "main"),
|
| 154 |
+
"data_dir": file_meta(RESEARCH_ROOT / "Data"),
|
| 155 |
+
"alt_data_dir": file_meta(RESEARCH_ROOT / "Alt Data"),
|
| 156 |
+
"last_sync": marker,
|
| 157 |
+
}
|
| 158 |
+
|
| 159 |
+
|
| 160 |
+
def ensure_dataset_available(force: bool = False) -> bool:
|
| 161 |
+
if dataset_dirs_present() and not force:
|
| 162 |
+
return True
|
| 163 |
+
|
| 164 |
+
repo_id = os.environ.get("HF_DATASET_REPO_ID", "").strip()
|
| 165 |
+
if not repo_id:
|
| 166 |
+
return dataset_dirs_present()
|
| 167 |
+
|
| 168 |
+
with dataset_lock:
|
| 169 |
+
if dataset_dirs_present() and not force:
|
| 170 |
+
return True
|
| 171 |
+
|
| 172 |
+
STATE_DIR.mkdir(parents=True, exist_ok=True)
|
| 173 |
+
revision = os.environ.get("HF_DATASET_REVISION", "main")
|
| 174 |
+
local_dir = Path(os.environ.get("HF_DATASET_LOCAL_DIR", str(RESEARCH_ROOT))).resolve()
|
| 175 |
+
local_dir.mkdir(parents=True, exist_ok=True)
|
| 176 |
+
|
| 177 |
+
snapshot_download(
|
| 178 |
+
repo_id=repo_id,
|
| 179 |
+
repo_type="dataset",
|
| 180 |
+
revision=revision,
|
| 181 |
+
local_dir=str(local_dir),
|
| 182 |
+
local_dir_use_symlinks=False,
|
| 183 |
+
allow_patterns=["Data/**", "Alt Data/**", "README.md"],
|
| 184 |
+
)
|
| 185 |
+
|
| 186 |
+
write_json(
|
| 187 |
+
DATASET_READY_MARKER,
|
| 188 |
+
{
|
| 189 |
+
"repo_id": repo_id,
|
| 190 |
+
"revision": revision,
|
| 191 |
+
"synced_at": now_utc(),
|
| 192 |
+
"local_dir": str(local_dir),
|
| 193 |
+
},
|
| 194 |
+
)
|
| 195 |
+
return dataset_dirs_present()
|
| 196 |
+
|
| 197 |
+
|
| 198 |
+
def resolve_dataset_path(value: str) -> Path:
|
| 199 |
+
raw = str(value)
|
| 200 |
+
candidate = Path(raw)
|
| 201 |
+
if candidate.exists():
|
| 202 |
+
return candidate
|
| 203 |
+
|
| 204 |
+
normalized = raw.replace("\\", "/")
|
| 205 |
+
marker = "research_runtime/"
|
| 206 |
+
if marker in normalized:
|
| 207 |
+
suffix = normalized.split(marker, 1)[1]
|
| 208 |
+
return BASE_DIR / "research_runtime" / Path(*suffix.split("/"))
|
| 209 |
+
|
| 210 |
+
relative = Path(*normalized.split("/"))
|
| 211 |
+
if not relative.is_absolute():
|
| 212 |
+
return BASE_DIR / relative
|
| 213 |
+
return candidate
|
| 214 |
+
|
| 215 |
+
|
| 216 |
+
def file_meta(path: Path) -> dict[str, Any]:
|
| 217 |
+
if not path.exists():
|
| 218 |
+
return {"exists": False, "path": str(path)}
|
| 219 |
+
stat = path.stat()
|
| 220 |
+
return {
|
| 221 |
+
"exists": True,
|
| 222 |
+
"path": str(path),
|
| 223 |
+
"bytes": stat.st_size,
|
| 224 |
+
"modified_at": datetime.utcfromtimestamp(stat.st_mtime).replace(microsecond=0).isoformat() + "Z",
|
| 225 |
+
}
|
| 226 |
+
|
| 227 |
+
|
| 228 |
+
def latest_manifest_end() -> str | None:
|
| 229 |
+
path = manifest_path()
|
| 230 |
+
if not path.exists():
|
| 231 |
+
return None
|
| 232 |
+
try:
|
| 233 |
+
frame = pd.read_csv(path, usecols=["end"])
|
| 234 |
+
dates = pd.to_datetime(frame["end"], errors="coerce").dropna()
|
| 235 |
+
return str(dates.max()) if not dates.empty else None
|
| 236 |
+
except Exception:
|
| 237 |
+
return None
|
| 238 |
+
|
| 239 |
+
|
| 240 |
+
def parse_daily_update_time() -> dt_time:
|
| 241 |
+
hour, minute = DEFAULT_UPDATE_TIME.split(":", 1)
|
| 242 |
+
return dt_time(int(hour), int(minute))
|
| 243 |
+
|
| 244 |
+
|
| 245 |
+
def update_due() -> bool:
|
| 246 |
+
if os.environ.get("AUTO_UPDATE_ENABLED", "true").lower() not in {"1", "true", "yes", "on"}:
|
| 247 |
+
return False
|
| 248 |
+
status = read_status()
|
| 249 |
+
if status.get("state") == "running":
|
| 250 |
+
return False
|
| 251 |
+
|
| 252 |
+
tz = ZoneInfo(DEFAULT_TIMEZONE)
|
| 253 |
+
local_now = datetime.now(tz)
|
| 254 |
+
if local_now.time() < parse_daily_update_time():
|
| 255 |
+
return False
|
| 256 |
+
|
| 257 |
+
last_success = status.get("last_success_at")
|
| 258 |
+
if not last_success:
|
| 259 |
+
return True
|
| 260 |
+
try:
|
| 261 |
+
last_success_date = datetime.fromisoformat(last_success.replace("Z", "+00:00")).astimezone(tz).date()
|
| 262 |
+
except ValueError:
|
| 263 |
+
return True
|
| 264 |
+
return last_success_date < local_now.date()
|
| 265 |
+
|
| 266 |
+
|
| 267 |
+
def build_update_commands(retrain: bool) -> list[list[str]]:
|
| 268 |
+
commands = [
|
| 269 |
+
[
|
| 270 |
+
sys.executable,
|
| 271 |
+
"Code/scripts/data_ingestion/refresh_market_data.py",
|
| 272 |
+
"--end-date",
|
| 273 |
+
datetime.now(ZoneInfo(DEFAULT_TIMEZONE)).date().isoformat(),
|
| 274 |
+
]
|
| 275 |
+
]
|
| 276 |
+
if retrain:
|
| 277 |
+
commands.extend(
|
| 278 |
+
[
|
| 279 |
+
[sys.executable, "Code/models/stock_high_low_forecaster/train.py"],
|
| 280 |
+
[sys.executable, "Code/models/first_extrema_forecaster/train.py", "--rebuild-cache"],
|
| 281 |
+
[sys.executable, "Code/models/nifty_forecaster/train.py", "--no-progress"],
|
| 282 |
+
]
|
| 283 |
+
)
|
| 284 |
+
return commands
|
| 285 |
+
|
| 286 |
+
|
| 287 |
+
def run_update_job(trigger: str = "manual", retrain: bool | None = None) -> None:
|
| 288 |
+
global worker_thread
|
| 289 |
+
with update_lock:
|
| 290 |
+
status = read_status()
|
| 291 |
+
if status.get("state") == "running":
|
| 292 |
+
return
|
| 293 |
+
write_status(
|
| 294 |
+
state="running",
|
| 295 |
+
trigger=trigger,
|
| 296 |
+
last_started_at=now_utc(),
|
| 297 |
+
last_finished_at=None,
|
| 298 |
+
last_error=None,
|
| 299 |
+
last_exit_code=None,
|
| 300 |
+
last_log_tail=[],
|
| 301 |
+
)
|
| 302 |
+
|
| 303 |
+
if retrain is None:
|
| 304 |
+
retrain = os.environ.get("AUTO_RETRAIN_ENABLED", "true").lower() in {"1", "true", "yes", "on"}
|
| 305 |
+
|
| 306 |
+
env = os.environ.copy()
|
| 307 |
+
env["FORECASTING_PROJECT_ROOT"] = str(RESEARCH_ROOT)
|
| 308 |
+
env.setdefault("PYTHONUNBUFFERED", "1")
|
| 309 |
+
env.setdefault("MARKET_BUILD_WORKERS", "2")
|
| 310 |
+
|
| 311 |
+
log_tail: list[str] = []
|
| 312 |
+
exit_code = 0
|
| 313 |
+
try:
|
| 314 |
+
if not ensure_dataset_available():
|
| 315 |
+
raise RuntimeError("Dataset folders are missing. Set HF_DATASET_REPO_ID to the Hugging Face Dataset repo.")
|
| 316 |
+
for command in build_update_commands(retrain):
|
| 317 |
+
log_tail.append("$ " + " ".join(command))
|
| 318 |
+
process = subprocess.Popen(
|
| 319 |
+
command,
|
| 320 |
+
cwd=RESEARCH_ROOT,
|
| 321 |
+
env=env,
|
| 322 |
+
stdout=subprocess.PIPE,
|
| 323 |
+
stderr=subprocess.STDOUT,
|
| 324 |
+
text=True,
|
| 325 |
+
bufsize=1,
|
| 326 |
+
)
|
| 327 |
+
assert process.stdout is not None
|
| 328 |
+
for line in process.stdout:
|
| 329 |
+
line = line.rstrip()
|
| 330 |
+
if line:
|
| 331 |
+
log_tail.append(line)
|
| 332 |
+
log_tail = log_tail[-80:]
|
| 333 |
+
exit_code = process.wait()
|
| 334 |
+
if exit_code != 0:
|
| 335 |
+
raise RuntimeError(f"Command failed with exit code {exit_code}: {' '.join(command)}")
|
| 336 |
+
write_status(
|
| 337 |
+
state="idle",
|
| 338 |
+
last_finished_at=now_utc(),
|
| 339 |
+
last_success_at=now_utc(),
|
| 340 |
+
last_error=None,
|
| 341 |
+
last_exit_code=exit_code,
|
| 342 |
+
last_log_tail=log_tail[-80:],
|
| 343 |
+
)
|
| 344 |
+
except Exception as exc:
|
| 345 |
+
write_status(
|
| 346 |
+
state="failed",
|
| 347 |
+
last_finished_at=now_utc(),
|
| 348 |
+
last_error=str(exc),
|
| 349 |
+
last_exit_code=exit_code,
|
| 350 |
+
last_log_tail=log_tail[-80:],
|
| 351 |
+
)
|
| 352 |
+
|
| 353 |
+
|
| 354 |
+
def start_update(trigger: str, retrain: bool | None = None) -> bool:
|
| 355 |
+
global worker_thread
|
| 356 |
+
status = read_status()
|
| 357 |
+
if status.get("state") == "running":
|
| 358 |
+
return False
|
| 359 |
+
worker_thread = threading.Thread(target=run_update_job, kwargs={"trigger": trigger, "retrain": retrain}, daemon=True)
|
| 360 |
+
worker_thread.start()
|
| 361 |
+
return True
|
| 362 |
+
|
| 363 |
+
|
| 364 |
+
def scheduler_loop() -> None:
|
| 365 |
+
while True:
|
| 366 |
+
if update_due():
|
| 367 |
+
start_update("internal_scheduler")
|
| 368 |
+
time.sleep(300)
|
| 369 |
+
|
| 370 |
+
|
| 371 |
+
@app.on_event("startup")
|
| 372 |
+
def startup() -> None:
|
| 373 |
+
STATE_DIR.mkdir(parents=True, exist_ok=True)
|
| 374 |
+
if not STATUS_PATH.exists():
|
| 375 |
+
write_status(state="idle", app_started_at=now_utc())
|
| 376 |
+
if os.environ.get("DATASET_SYNC_ON_START", "true").lower() in {"1", "true", "yes", "on"}:
|
| 377 |
+
try:
|
| 378 |
+
ensure_dataset_available()
|
| 379 |
+
except Exception as exc:
|
| 380 |
+
write_status(dataset_sync_error=str(exc), dataset_sync_failed_at=now_utc())
|
| 381 |
+
threading.Thread(target=scheduler_loop, daemon=True).start()
|
| 382 |
+
if os.environ.get("AUTO_UPDATE_ON_START", "false").lower() in {"1", "true", "yes", "on"}:
|
| 383 |
+
start_update("startup")
|
| 384 |
+
|
| 385 |
+
|
| 386 |
+
@app.get("/", response_class=PlainTextResponse)
|
| 387 |
+
def root() -> str:
|
| 388 |
+
return "Trading Forecasting Hugging Face Space backend is running. See /docs for API routes."
|
| 389 |
+
|
| 390 |
+
|
| 391 |
+
@app.get("/health")
|
| 392 |
+
def health() -> dict[str, Any]:
|
| 393 |
+
required = {
|
| 394 |
+
"research_root": file_meta(RESEARCH_ROOT),
|
| 395 |
+
"manifest": file_meta(manifest_path()),
|
| 396 |
+
"stock_latest": file_meta(model_output_path("stock_high_low_forecaster", "outputs", "latest_forecasts.csv")),
|
| 397 |
+
"extrema_latest": file_meta(model_output_path("first_extrema_forecaster", "outputs", "latest_forecasts.csv")),
|
| 398 |
+
"nifty_latest": file_meta(model_output_path("nifty_forecaster", "outputs", "forecaster_latest_forecasts.csv")),
|
| 399 |
+
}
|
| 400 |
+
ok = all(item["exists"] for item in required.values())
|
| 401 |
+
return {
|
| 402 |
+
"ok": ok,
|
| 403 |
+
"service": API_TITLE,
|
| 404 |
+
"version": API_VERSION,
|
| 405 |
+
"checked_at": now_utc(),
|
| 406 |
+
"latest_manifest_end": latest_manifest_end(),
|
| 407 |
+
"dataset": dataset_status(),
|
| 408 |
+
"update_status": read_status(),
|
| 409 |
+
"files": required,
|
| 410 |
+
}
|
| 411 |
+
|
| 412 |
+
|
| 413 |
+
@app.get("/api/status")
|
| 414 |
+
def api_status() -> dict[str, Any]:
|
| 415 |
+
return health()
|
| 416 |
+
|
| 417 |
+
|
| 418 |
+
@app.get("/api/forecast/latest")
|
| 419 |
+
def latest_forecasts() -> dict[str, Any]:
|
| 420 |
+
return {
|
| 421 |
+
"generated_at": now_utc(),
|
| 422 |
+
"stock_high_low": csv_rows(model_output_path("stock_high_low_forecaster", "outputs", "latest_forecasts.csv")),
|
| 423 |
+
"first_extrema": csv_rows(
|
| 424 |
+
model_output_path("first_extrema_forecaster", "outputs", "latest_forecasts.csv"),
|
| 425 |
+
columns=["date", "symbol", "target", "prob_high_first", "prediction"],
|
| 426 |
+
),
|
| 427 |
+
"nifty_direction": csv_rows(model_output_path("nifty_forecaster", "outputs", "forecaster_latest_forecasts.csv")),
|
| 428 |
+
}
|
| 429 |
+
|
| 430 |
+
|
| 431 |
+
@app.get("/api/models/summaries")
|
| 432 |
+
def model_summaries() -> dict[str, Any]:
|
| 433 |
+
return safe_json(
|
| 434 |
+
{
|
| 435 |
+
"stock_high_low": read_json(model_output_path("stock_high_low_forecaster", "outputs", "summary.json"), {}),
|
| 436 |
+
"first_extrema": read_json(model_output_path("first_extrema_forecaster", "outputs", "summary.json"), {}),
|
| 437 |
+
"nifty_direction": read_json(model_output_path("nifty_forecaster", "outputs", "forecaster_summary.json"), []),
|
| 438 |
+
}
|
| 439 |
+
)
|
| 440 |
+
|
| 441 |
+
|
| 442 |
+
@app.get("/api/data/catalog")
|
| 443 |
+
def data_catalog(
|
| 444 |
+
category: str | None = None,
|
| 445 |
+
asset: str | None = None,
|
| 446 |
+
timeframe: str | None = None,
|
| 447 |
+
limit: int = Query(default=500, ge=1, le=5000),
|
| 448 |
+
) -> dict[str, Any]:
|
| 449 |
+
path = manifest_path()
|
| 450 |
+
if not path.exists():
|
| 451 |
+
ensure_dataset_available()
|
| 452 |
+
if not path.exists():
|
| 453 |
+
return {"count": 0, "items": []}
|
| 454 |
+
frame = pd.read_csv(path)
|
| 455 |
+
if category:
|
| 456 |
+
frame = frame[frame["category"].astype(str).str.lower() == category.lower()]
|
| 457 |
+
if asset:
|
| 458 |
+
frame = frame[frame["asset"].astype(str).str.lower() == asset.lower()]
|
| 459 |
+
if timeframe:
|
| 460 |
+
frame = frame[frame["timeframe"].astype(str).str.lower() == timeframe.lower()]
|
| 461 |
+
return {"count": int(len(frame)), "items": safe_json(frame.head(limit).where(pd.notna(frame), None).to_dict(orient="records"))}
|
| 462 |
+
|
| 463 |
+
|
| 464 |
+
@app.get("/api/data/sample")
|
| 465 |
+
def data_sample(
|
| 466 |
+
category: str,
|
| 467 |
+
asset: str,
|
| 468 |
+
timeframe: str,
|
| 469 |
+
limit: int = Query(default=50, ge=1, le=1000),
|
| 470 |
+
) -> dict[str, Any]:
|
| 471 |
+
path = manifest_path()
|
| 472 |
+
if not path.exists():
|
| 473 |
+
ensure_dataset_available()
|
| 474 |
+
if not path.exists():
|
| 475 |
+
raise HTTPException(status_code=404, detail="Data manifest not found.")
|
| 476 |
+
manifest = pd.read_csv(path)
|
| 477 |
+
matches = manifest[
|
| 478 |
+
(manifest["category"].astype(str).str.lower() == category.lower())
|
| 479 |
+
& (manifest["asset"].astype(str).str.lower() == asset.lower())
|
| 480 |
+
& (manifest["timeframe"].astype(str).str.lower() == timeframe.lower())
|
| 481 |
+
]
|
| 482 |
+
if matches.empty:
|
| 483 |
+
raise HTTPException(status_code=404, detail="No matching dataset in manifest.")
|
| 484 |
+
dataset_path = resolve_dataset_path(str(matches.iloc[0]["path"]))
|
| 485 |
+
if not dataset_path.exists():
|
| 486 |
+
raise HTTPException(status_code=404, detail=f"Dataset file not found: {dataset_path}")
|
| 487 |
+
return {
|
| 488 |
+
"dataset": safe_json(matches.iloc[0].to_dict()),
|
| 489 |
+
"rows": csv_rows(dataset_path, limit=limit),
|
| 490 |
+
}
|
| 491 |
+
|
| 492 |
+
|
| 493 |
+
@app.api_route("/api/cron/tick", methods=["GET", "POST"])
|
| 494 |
+
async def cron_tick(
|
| 495 |
+
request: Request,
|
| 496 |
+
background_tasks: BackgroundTasks,
|
| 497 |
+
x_cron_secret: str | None = Header(default=None),
|
| 498 |
+
) -> JSONResponse:
|
| 499 |
+
require_secret(x_cron_secret=x_cron_secret)
|
| 500 |
+
due = update_due()
|
| 501 |
+
started = False
|
| 502 |
+
if due:
|
| 503 |
+
background_tasks.add_task(start_update, "netlify_cron")
|
| 504 |
+
started = True
|
| 505 |
+
return JSONResponse({"ok": True, "checked_at": now_utc(), "update_due": due, "update_start_queued": started, "status": read_status()})
|
| 506 |
+
|
| 507 |
+
|
| 508 |
+
@app.post("/api/update/start")
|
| 509 |
+
def manual_update(
|
| 510 |
+
retrain: bool | None = None,
|
| 511 |
+
x_admin_secret: str | None = Header(default=None),
|
| 512 |
+
) -> dict[str, Any]:
|
| 513 |
+
require_secret(x_admin_secret=x_admin_secret)
|
| 514 |
+
started = start_update("manual_api", retrain=retrain)
|
| 515 |
+
return {"ok": True, "started": started, "status": read_status()}
|
| 516 |
+
|
| 517 |
+
|
| 518 |
+
@app.post("/api/dataset/sync")
|
| 519 |
+
def sync_dataset(
|
| 520 |
+
force: bool = False,
|
| 521 |
+
x_admin_secret: str | None = Header(default=None),
|
| 522 |
+
) -> dict[str, Any]:
|
| 523 |
+
require_secret(x_admin_secret=x_admin_secret)
|
| 524 |
+
ok = ensure_dataset_available(force=force)
|
| 525 |
+
return {"ok": ok, "dataset": dataset_status()}
|
backend/requirements.txt
ADDED
|
@@ -0,0 +1,11 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
fastapi==0.115.12
|
| 2 |
+
uvicorn[standard]==0.34.2
|
| 3 |
+
pandas==2.2.3
|
| 4 |
+
numpy==2.2.6
|
| 5 |
+
requests==2.32.3
|
| 6 |
+
scikit-learn==1.6.1
|
| 7 |
+
joblib==1.4.2
|
| 8 |
+
xgboost==3.0.1
|
| 9 |
+
catboost==1.2.8
|
| 10 |
+
lightgbm==4.6.0
|
| 11 |
+
huggingface_hub==0.31.4
|
backend/research_runtime/Code/models/first_extrema_forecaster/__init__.py
ADDED
|
@@ -0,0 +1 @@
|
|
|
|
|
|
|
| 1 |
+
"""First intraday extrema forecaster."""
|
backend/research_runtime/Code/models/first_extrema_forecaster/outputs/latest_forecasts.csv
ADDED
|
The diff for this file is too large to render.
See raw diff
|
|
|
backend/research_runtime/Code/models/first_extrema_forecaster/outputs/summary.json
ADDED
|
@@ -0,0 +1,174 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
{
|
| 2 |
+
"target": "Whether the stock's intraday high occurs before its intraday low, or its intraday low occurs before its intraday high.",
|
| 3 |
+
"label_1": "HIGH_FIRST",
|
| 4 |
+
"label_0": "LOW_FIRST",
|
| 5 |
+
"forecast_setup": "Use data known after the first 5 intraday minutes; automatically exclude days where the final intraday high or final intraday low occurred inside those first 5 minutes.",
|
| 6 |
+
"selection_policy": "fixed_symbol_map_v1",
|
| 7 |
+
"selected_candidate_by_symbol": {
|
| 8 |
+
"BEL": {
|
| 9 |
+
"name": "xgb_full_d2_lr0.07",
|
| 10 |
+
"kind": "xgboost",
|
| 11 |
+
"feature_profile": "full",
|
| 12 |
+
"threshold": 0.6174999999999999,
|
| 13 |
+
"threshold_mode": "per_symbol",
|
| 14 |
+
"symbol_thresholds": {
|
| 15 |
+
"BEL": 0.6174999999999999
|
| 16 |
+
},
|
| 17 |
+
"overlay_rule": null,
|
| 18 |
+
"validation_accuracy": 0.6846153846153846,
|
| 19 |
+
"test_accuracy": 0.6407766990291263
|
| 20 |
+
},
|
| 21 |
+
"CANBK": {
|
| 22 |
+
"name": "extra_no_vix_d7_leaf20",
|
| 23 |
+
"kind": "extra_trees",
|
| 24 |
+
"feature_profile": "no_vix",
|
| 25 |
+
"threshold": 0.5225,
|
| 26 |
+
"threshold_mode": "per_symbol",
|
| 27 |
+
"symbol_thresholds": {
|
| 28 |
+
"CANBK": 0.5225
|
| 29 |
+
},
|
| 30 |
+
"overlay_rule": null,
|
| 31 |
+
"validation_accuracy": 0.7210884353741497,
|
| 32 |
+
"test_accuracy": 0.7413793103448276
|
| 33 |
+
},
|
| 34 |
+
"ITC": {
|
| 35 |
+
"name": "xgb_full_d2_lr0.07",
|
| 36 |
+
"kind": "xgboost",
|
| 37 |
+
"feature_profile": "full",
|
| 38 |
+
"threshold": 0.47250000000000003,
|
| 39 |
+
"threshold_mode": "per_symbol",
|
| 40 |
+
"symbol_thresholds": {
|
| 41 |
+
"ITC": 0.47250000000000003
|
| 42 |
+
},
|
| 43 |
+
"overlay_rule": null,
|
| 44 |
+
"validation_accuracy": 0.751937984496124,
|
| 45 |
+
"test_accuracy": 0.7254901960784313
|
| 46 |
+
},
|
| 47 |
+
"NTPC": {
|
| 48 |
+
"name": "xgb_full_d3_lr0.045",
|
| 49 |
+
"kind": "xgboost",
|
| 50 |
+
"feature_profile": "full",
|
| 51 |
+
"threshold": 0.5575,
|
| 52 |
+
"threshold_mode": "per_symbol",
|
| 53 |
+
"symbol_thresholds": {
|
| 54 |
+
"NTPC": 0.5575
|
| 55 |
+
},
|
| 56 |
+
"overlay_rule": null,
|
| 57 |
+
"validation_accuracy": 0.7619047619047619,
|
| 58 |
+
"test_accuracy": 0.6728971962616822
|
| 59 |
+
},
|
| 60 |
+
"ONGC": {
|
| 61 |
+
"name": "xgb_full_d2_lr0.07",
|
| 62 |
+
"kind": "xgboost",
|
| 63 |
+
"feature_profile": "full",
|
| 64 |
+
"threshold": 0.5700000000000001,
|
| 65 |
+
"threshold_mode": "per_symbol",
|
| 66 |
+
"symbol_thresholds": {
|
| 67 |
+
"ONGC": 0.5700000000000001
|
| 68 |
+
},
|
| 69 |
+
"overlay_rule": null,
|
| 70 |
+
"validation_accuracy": 0.7086614173228346,
|
| 71 |
+
"test_accuracy": 0.6272727272727273
|
| 72 |
+
},
|
| 73 |
+
"POWERGRID": {
|
| 74 |
+
"name": "extra_no_vix_d7_leaf20",
|
| 75 |
+
"kind": "extra_trees",
|
| 76 |
+
"feature_profile": "no_vix",
|
| 77 |
+
"threshold": 0.455,
|
| 78 |
+
"threshold_mode": "per_symbol",
|
| 79 |
+
"symbol_thresholds": {
|
| 80 |
+
"POWERGRID": 0.455
|
| 81 |
+
},
|
| 82 |
+
"overlay_rule": null,
|
| 83 |
+
"validation_accuracy": 0.6916666666666667,
|
| 84 |
+
"test_accuracy": 0.7641509433962265
|
| 85 |
+
},
|
| 86 |
+
"SBIN": {
|
| 87 |
+
"name": "histgb_compact_lr0.065_leaf7+sbin_overlay_v1",
|
| 88 |
+
"kind": "histgb",
|
| 89 |
+
"feature_profile": "compact",
|
| 90 |
+
"threshold": 0.4,
|
| 91 |
+
"threshold_mode": "per_symbol",
|
| 92 |
+
"symbol_thresholds": {
|
| 93 |
+
"SBIN": 0.4
|
| 94 |
+
},
|
| 95 |
+
"overlay_rule": {
|
| 96 |
+
"feature": "n50_ret_2d",
|
| 97 |
+
"feature_threshold": -0.005959,
|
| 98 |
+
"feature_direction": "<=",
|
| 99 |
+
"force_prediction": "LOW_FIRST",
|
| 100 |
+
"model_threshold": 0.4,
|
| 101 |
+
"note": "SBIN-specific overlay found in targeted tuning; corrects HIGH_FIRST under-prediction."
|
| 102 |
+
},
|
| 103 |
+
"validation_accuracy": 0.6052631578947368,
|
| 104 |
+
"test_accuracy": 0.6992481203007519
|
| 105 |
+
},
|
| 106 |
+
"TATASTEEL": {
|
| 107 |
+
"name": "cat_full_d3_lr0.025",
|
| 108 |
+
"kind": "catboost",
|
| 109 |
+
"feature_profile": "full",
|
| 110 |
+
"threshold": 0.42000000000000004,
|
| 111 |
+
"threshold_mode": "per_symbol",
|
| 112 |
+
"symbol_thresholds": {
|
| 113 |
+
"TATASTEEL": 0.42000000000000004
|
| 114 |
+
},
|
| 115 |
+
"overlay_rule": null,
|
| 116 |
+
"validation_accuracy": 0.719626168224299,
|
| 117 |
+
"test_accuracy": 0.6698113207547169
|
| 118 |
+
}
|
| 119 |
+
},
|
| 120 |
+
"test_accuracy_by_symbol": {
|
| 121 |
+
"POWERGRID": 0.7641509433962265,
|
| 122 |
+
"CANBK": 0.7413793103448276,
|
| 123 |
+
"ITC": 0.7254901960784313,
|
| 124 |
+
"SBIN": 0.6992481203007519,
|
| 125 |
+
"NTPC": 0.6728971962616822,
|
| 126 |
+
"TATASTEEL": 0.6698113207547169,
|
| 127 |
+
"BEL": 0.6407766990291263,
|
| 128 |
+
"ONGC": 0.6272727272727273
|
| 129 |
+
},
|
| 130 |
+
"naive_baseline_accuracy_by_symbol": {
|
| 131 |
+
"BEL": 0.5533980582524272,
|
| 132 |
+
"CANBK": 0.5258620689655172,
|
| 133 |
+
"ITC": 0.47058823529411764,
|
| 134 |
+
"NTPC": 0.5794392523364486,
|
| 135 |
+
"ONGC": 0.509090909090909,
|
| 136 |
+
"POWERGRID": 0.49056603773584906,
|
| 137 |
+
"SBIN": 0.5112781954887218,
|
| 138 |
+
"TATASTEEL": 0.5
|
| 139 |
+
},
|
| 140 |
+
"test_accuracy_edge_vs_naive_by_symbol": {
|
| 141 |
+
"POWERGRID": 0.2735849056603774,
|
| 142 |
+
"CANBK": 0.2155172413793104,
|
| 143 |
+
"ITC": 0.2549019607843137,
|
| 144 |
+
"SBIN": 0.18796992481203012,
|
| 145 |
+
"NTPC": 0.09345794392523366,
|
| 146 |
+
"TATASTEEL": 0.16981132075471694,
|
| 147 |
+
"BEL": 0.0873786407766991,
|
| 148 |
+
"ONGC": 0.11818181818181828
|
| 149 |
+
},
|
| 150 |
+
"aggregate_test_accuracy": 0.6930917327293318,
|
| 151 |
+
"train_date_range": [
|
| 152 |
+
"2015-02-02",
|
| 153 |
+
"2023-12-29"
|
| 154 |
+
],
|
| 155 |
+
"validation_date_range": [
|
| 156 |
+
"2024-01-01",
|
| 157 |
+
"2025-02-25"
|
| 158 |
+
],
|
| 159 |
+
"test_date_range": [
|
| 160 |
+
"2025-02-27",
|
| 161 |
+
"2026-05-08"
|
| 162 |
+
],
|
| 163 |
+
"symbols": [
|
| 164 |
+
"BEL",
|
| 165 |
+
"CANBK",
|
| 166 |
+
"ITC",
|
| 167 |
+
"NTPC",
|
| 168 |
+
"ONGC",
|
| 169 |
+
"POWERGRID",
|
| 170 |
+
"SBIN",
|
| 171 |
+
"TATASTEEL"
|
| 172 |
+
],
|
| 173 |
+
"coverage_valid_labeled_rows": 10509
|
| 174 |
+
}
|
backend/research_runtime/Code/models/first_extrema_forecaster/train.py
ADDED
|
@@ -0,0 +1,1466 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from __future__ import annotations
|
| 2 |
+
|
| 3 |
+
import argparse
|
| 4 |
+
import json
|
| 5 |
+
import os
|
| 6 |
+
import random
|
| 7 |
+
import sys
|
| 8 |
+
import time
|
| 9 |
+
import warnings
|
| 10 |
+
from dataclasses import asdict, dataclass
|
| 11 |
+
from pathlib import Path
|
| 12 |
+
from typing import Any
|
| 13 |
+
|
| 14 |
+
os.environ.setdefault("OMP_NUM_THREADS", "2")
|
| 15 |
+
os.environ.setdefault("OPENBLAS_NUM_THREADS", "2")
|
| 16 |
+
os.environ.setdefault("MKL_NUM_THREADS", "2")
|
| 17 |
+
os.environ.setdefault("VECLIB_MAXIMUM_THREADS", "2")
|
| 18 |
+
os.environ.setdefault("NUMEXPR_NUM_THREADS", "2")
|
| 19 |
+
|
| 20 |
+
import joblib
|
| 21 |
+
import numpy as np
|
| 22 |
+
import pandas as pd
|
| 23 |
+
from sklearn.ensemble import ExtraTreesClassifier, RandomForestClassifier
|
| 24 |
+
from sklearn.ensemble import HistGradientBoostingClassifier
|
| 25 |
+
from sklearn.impute import SimpleImputer
|
| 26 |
+
from sklearn.linear_model import LogisticRegression
|
| 27 |
+
from sklearn.metrics import accuracy_score, balanced_accuracy_score, confusion_matrix, log_loss, roc_auc_score
|
| 28 |
+
from sklearn.pipeline import make_pipeline
|
| 29 |
+
from sklearn.preprocessing import StandardScaler
|
| 30 |
+
|
| 31 |
+
warnings.filterwarnings("ignore", category=FutureWarning)
|
| 32 |
+
warnings.filterwarnings("ignore", category=pd.errors.PerformanceWarning)
|
| 33 |
+
|
| 34 |
+
try:
|
| 35 |
+
from catboost import CatBoostClassifier
|
| 36 |
+
except Exception: # pragma: no cover - optional local dependency
|
| 37 |
+
CatBoostClassifier = None
|
| 38 |
+
|
| 39 |
+
try:
|
| 40 |
+
from lightgbm import LGBMClassifier
|
| 41 |
+
except Exception: # pragma: no cover - optional local dependency
|
| 42 |
+
LGBMClassifier = None
|
| 43 |
+
|
| 44 |
+
try:
|
| 45 |
+
from xgboost import XGBClassifier
|
| 46 |
+
except Exception: # pragma: no cover - optional local dependency
|
| 47 |
+
XGBClassifier = None
|
| 48 |
+
|
| 49 |
+
def find_project_root(start: Path) -> Path:
|
| 50 |
+
for path in (start, *start.parents):
|
| 51 |
+
if (path / "Data").is_dir() and (path / "Alt Data").is_dir():
|
| 52 |
+
return path
|
| 53 |
+
raise RuntimeError(f"Could not find project root from {start}")
|
| 54 |
+
|
| 55 |
+
|
| 56 |
+
PROJECT_ROOT = find_project_root(Path(__file__).resolve())
|
| 57 |
+
DATA_DIR = PROJECT_ROOT / "Data"
|
| 58 |
+
RAW_MINUTE_DIR = DATA_DIR / "raw" / "minute"
|
| 59 |
+
DAILY_MASTER_PATH = DATA_DIR / "processed" / "panels" / "daily_master_panel.csv"
|
| 60 |
+
OUTPUT_DIR = Path(__file__).resolve().parent / "outputs"
|
| 61 |
+
OUTPUT_DIR.mkdir(parents=True, exist_ok=True)
|
| 62 |
+
CACHE_PATH = OUTPUT_DIR / "first5_extrema_after5_dataset.csv"
|
| 63 |
+
|
| 64 |
+
MARKET_SYMBOLS = ("NIFTY 50", "NIFTY BANK", "INDIA VIX")
|
| 65 |
+
DEFAULT_VALID_START = pd.Timestamp("2024-01-01")
|
| 66 |
+
DEFAULT_TEST_START = pd.Timestamp("2025-02-27")
|
| 67 |
+
RANDOM_SEED = 42
|
| 68 |
+
FIXED_SYMBOL_CANDIDATE_MAP_V1 = {
|
| 69 |
+
"BEL": "xgb_full_d2_lr0.07",
|
| 70 |
+
"CANBK": "extra_no_vix_d7_leaf20",
|
| 71 |
+
"ITC": "xgb_full_d2_lr0.07",
|
| 72 |
+
"NTPC": "xgb_full_d3_lr0.045",
|
| 73 |
+
"ONGC": "xgb_full_d2_lr0.07",
|
| 74 |
+
"POWERGRID": "extra_no_vix_d7_leaf20",
|
| 75 |
+
"SBIN": "histgb_compact_lr0.065_leaf7",
|
| 76 |
+
"TATASTEEL": "cat_full_d3_lr0.025",
|
| 77 |
+
}
|
| 78 |
+
SBIN_OVERLAY_RULE_V1 = {
|
| 79 |
+
"feature": "n50_ret_2d",
|
| 80 |
+
"feature_threshold": -0.005959,
|
| 81 |
+
"feature_direction": "<=",
|
| 82 |
+
"force_prediction": "LOW_FIRST",
|
| 83 |
+
"model_threshold": 0.400,
|
| 84 |
+
"note": "SBIN-specific overlay found in targeted tuning; corrects HIGH_FIRST under-prediction.",
|
| 85 |
+
}
|
| 86 |
+
|
| 87 |
+
|
| 88 |
+
def note(message: str) -> None:
|
| 89 |
+
print(f"[first-extrema] {message}", file=sys.stderr, flush=True)
|
| 90 |
+
|
| 91 |
+
|
| 92 |
+
@dataclass(frozen=True)
|
| 93 |
+
class Candidate:
|
| 94 |
+
name: str
|
| 95 |
+
kind: str
|
| 96 |
+
params: dict[str, Any]
|
| 97 |
+
feature_profile: str
|
| 98 |
+
|
| 99 |
+
|
| 100 |
+
@dataclass
|
| 101 |
+
class CandidateResult:
|
| 102 |
+
name: str
|
| 103 |
+
kind: str
|
| 104 |
+
params: dict[str, Any]
|
| 105 |
+
feature_profile: str
|
| 106 |
+
feature_count: int
|
| 107 |
+
threshold: float
|
| 108 |
+
threshold_mode: str
|
| 109 |
+
symbol_thresholds: dict[str, float]
|
| 110 |
+
validation_accuracy: float
|
| 111 |
+
validation_balanced_accuracy: float
|
| 112 |
+
validation_auc: float | None
|
| 113 |
+
validation_log_loss: float | None
|
| 114 |
+
test_accuracy: float
|
| 115 |
+
test_balanced_accuracy: float
|
| 116 |
+
test_auc: float | None
|
| 117 |
+
test_log_loss: float | None
|
| 118 |
+
train_accuracy: float
|
| 119 |
+
n_train: int
|
| 120 |
+
n_valid: int
|
| 121 |
+
n_test: int
|
| 122 |
+
fit_seconds: float
|
| 123 |
+
|
| 124 |
+
|
| 125 |
+
class SingleFeatureRuleClassifier:
|
| 126 |
+
"""A monotonic one-feature classifier with probability-like rank scores."""
|
| 127 |
+
|
| 128 |
+
def __init__(self, feature: str, direction: int = 1) -> None:
|
| 129 |
+
self.feature = feature
|
| 130 |
+
self.direction = 1 if direction >= 0 else -1
|
| 131 |
+
self.median_: float = 0.0
|
| 132 |
+
self.sorted_scores_: np.ndarray | None = None
|
| 133 |
+
|
| 134 |
+
def fit(self, x: pd.DataFrame, y: np.ndarray) -> "SingleFeatureRuleClassifier":
|
| 135 |
+
values = pd.to_numeric(x[self.feature], errors="coerce").replace([np.inf, -np.inf], np.nan)
|
| 136 |
+
self.median_ = float(values.median()) if values.notna().any() else 0.0
|
| 137 |
+
scores = self.direction * values.fillna(self.median_).to_numpy(dtype="float64")
|
| 138 |
+
self.sorted_scores_ = np.sort(scores)
|
| 139 |
+
return self
|
| 140 |
+
|
| 141 |
+
def predict_proba(self, x: pd.DataFrame) -> np.ndarray:
|
| 142 |
+
if self.sorted_scores_ is None:
|
| 143 |
+
raise RuntimeError("Model is not fitted.")
|
| 144 |
+
values = pd.to_numeric(x[self.feature], errors="coerce").replace([np.inf, -np.inf], np.nan)
|
| 145 |
+
scores = self.direction * values.fillna(self.median_).to_numpy(dtype="float64")
|
| 146 |
+
ranks = np.searchsorted(self.sorted_scores_, scores, side="right") / max(1, len(self.sorted_scores_))
|
| 147 |
+
ranks = np.clip(ranks, 1e-6, 1.0 - 1e-6)
|
| 148 |
+
return np.column_stack([1.0 - ranks, ranks])
|
| 149 |
+
|
| 150 |
+
|
| 151 |
+
def symbol_from_minute_file(path: Path) -> str:
|
| 152 |
+
return path.name.removesuffix("_minute.csv")
|
| 153 |
+
|
| 154 |
+
|
| 155 |
+
def discover_symbols() -> list[str]:
|
| 156 |
+
symbols = sorted(symbol_from_minute_file(p) for p in RAW_MINUTE_DIR.glob("*_minute.csv"))
|
| 157 |
+
return symbols
|
| 158 |
+
|
| 159 |
+
|
| 160 |
+
def target_symbols(all_symbols: list[str]) -> list[str]:
|
| 161 |
+
return [s for s in all_symbols if s not in MARKET_SYMBOLS]
|
| 162 |
+
|
| 163 |
+
|
| 164 |
+
def safe_div(numer: pd.Series | np.ndarray, denom: pd.Series | np.ndarray) -> pd.Series:
|
| 165 |
+
n = pd.Series(numer, copy=False)
|
| 166 |
+
d = pd.Series(denom, copy=False)
|
| 167 |
+
out = pd.Series(np.nan, index=n.index, dtype="float64")
|
| 168 |
+
mask = d.notna() & np.isfinite(d.to_numpy(dtype="float64")) & (d != 0)
|
| 169 |
+
out.loc[mask] = n.loc[mask].to_numpy(dtype="float64") / d.loc[mask].to_numpy(dtype="float64")
|
| 170 |
+
return out
|
| 171 |
+
|
| 172 |
+
|
| 173 |
+
def read_minute_daily(symbol: str) -> pd.DataFrame:
|
| 174 |
+
path = RAW_MINUTE_DIR / f"{symbol}_minute.csv"
|
| 175 |
+
if not path.exists():
|
| 176 |
+
raise FileNotFoundError(path)
|
| 177 |
+
note(f"aggregating {symbol} minute file")
|
| 178 |
+
df = pd.read_csv(
|
| 179 |
+
path,
|
| 180 |
+
usecols=["date", "open", "high", "low", "close", "volume"],
|
| 181 |
+
dtype={"open": "float64", "high": "float64", "low": "float64", "close": "float64", "volume": "float64"},
|
| 182 |
+
)
|
| 183 |
+
df["dt"] = pd.to_datetime(df.pop("date"), errors="coerce")
|
| 184 |
+
df = df.dropna(subset=["dt", "open", "high", "low", "close"]).sort_values("dt")
|
| 185 |
+
df["date"] = df["dt"].dt.normalize()
|
| 186 |
+
df["minute_index"] = df.groupby("date", sort=True).cumcount()
|
| 187 |
+
|
| 188 |
+
grouped = df.groupby("date", sort=True)
|
| 189 |
+
daily = grouped.agg(
|
| 190 |
+
open=("open", "first"),
|
| 191 |
+
high=("high", "max"),
|
| 192 |
+
low=("low", "min"),
|
| 193 |
+
close=("close", "last"),
|
| 194 |
+
volume=("volume", "sum"),
|
| 195 |
+
bars=("close", "size"),
|
| 196 |
+
open_time=("dt", "first"),
|
| 197 |
+
close_time=("dt", "last"),
|
| 198 |
+
)
|
| 199 |
+
|
| 200 |
+
day_high = grouped["high"].transform("max")
|
| 201 |
+
day_low = grouped["low"].transform("min")
|
| 202 |
+
high_time = df.loc[df["high"].eq(day_high)].groupby("date", sort=True)["dt"].min()
|
| 203 |
+
low_time = df.loc[df["low"].eq(day_low)].groupby("date", sort=True)["dt"].min()
|
| 204 |
+
daily["high_time"] = high_time
|
| 205 |
+
daily["low_time"] = low_time
|
| 206 |
+
daily["high_minute_from_open"] = (daily["high_time"] - daily["open_time"]).dt.total_seconds() / 60.0
|
| 207 |
+
daily["low_minute_from_open"] = (daily["low_time"] - daily["open_time"]).dt.total_seconds() / 60.0
|
| 208 |
+
daily["high_in_first5"] = daily["high_minute_from_open"].lt(5.0)
|
| 209 |
+
daily["low_in_first5"] = daily["low_minute_from_open"].lt(5.0)
|
| 210 |
+
daily["target_high_first"] = np.where(daily["high_time"] < daily["low_time"], 1.0, 0.0)
|
| 211 |
+
daily.loc[daily["high_time"].eq(daily["low_time"]), "target_high_first"] = np.nan
|
| 212 |
+
|
| 213 |
+
intraday_parts = []
|
| 214 |
+
for n in (5, 15, 30, 60, 120):
|
| 215 |
+
early = df[df["minute_index"] < n].groupby("date", sort=True).agg(
|
| 216 |
+
**{
|
| 217 |
+
f"first{n}_high": ("high", "max"),
|
| 218 |
+
f"first{n}_low": ("low", "min"),
|
| 219 |
+
f"first{n}_close": ("close", "last"),
|
| 220 |
+
f"first{n}_volume": ("volume", "sum"),
|
| 221 |
+
}
|
| 222 |
+
)
|
| 223 |
+
intraday_parts.append(early)
|
| 224 |
+
for start, end, name in ((0, 60, "first_hour"), (60, 180, "midday"), (180, 10_000, "late")):
|
| 225 |
+
part = df[(df["minute_index"] >= start) & (df["minute_index"] < end)].groupby("date", sort=True).agg(
|
| 226 |
+
**{
|
| 227 |
+
f"{name}_open": ("open", "first"),
|
| 228 |
+
f"{name}_high": ("high", "max"),
|
| 229 |
+
f"{name}_low": ("low", "min"),
|
| 230 |
+
f"{name}_close": ("close", "last"),
|
| 231 |
+
f"{name}_volume": ("volume", "sum"),
|
| 232 |
+
}
|
| 233 |
+
)
|
| 234 |
+
intraday_parts.append(part)
|
| 235 |
+
if intraday_parts:
|
| 236 |
+
daily = daily.join(pd.concat(intraday_parts, axis=1), how="left")
|
| 237 |
+
for n in (5, 15, 30, 60, 120):
|
| 238 |
+
daily[f"first{n}_return"] = safe_div(daily[f"first{n}_close"], daily["open"]) - 1.0
|
| 239 |
+
daily[f"first{n}_range_pct"] = safe_div(daily[f"first{n}_high"] - daily[f"first{n}_low"], daily["open"])
|
| 240 |
+
daily[f"first{n}_high_excursion"] = safe_div(daily[f"first{n}_high"], daily["open"]) - 1.0
|
| 241 |
+
daily[f"first{n}_low_excursion"] = safe_div(daily[f"first{n}_low"], daily["open"]) - 1.0
|
| 242 |
+
daily[f"first{n}_volume_share"] = safe_div(daily[f"first{n}_volume"], daily["volume"])
|
| 243 |
+
for name in ("first_hour", "midday", "late"):
|
| 244 |
+
daily[f"{name}_return"] = safe_div(daily[f"{name}_close"], daily[f"{name}_open"]) - 1.0
|
| 245 |
+
daily[f"{name}_range_pct"] = safe_div(daily[f"{name}_high"] - daily[f"{name}_low"], daily[f"{name}_open"])
|
| 246 |
+
daily[f"{name}_volume_share"] = safe_div(daily[f"{name}_volume"], daily["volume"])
|
| 247 |
+
daily["close_position"] = safe_div(daily["close"] - daily["low"], daily["high"] - daily["low"])
|
| 248 |
+
daily["morning_vs_late_return"] = daily["first_hour_return"] - daily["late_return"]
|
| 249 |
+
daily["symbol"] = symbol
|
| 250 |
+
return daily.reset_index()
|
| 251 |
+
|
| 252 |
+
|
| 253 |
+
def rsi(close: pd.Series, window: int = 14) -> pd.Series:
|
| 254 |
+
delta = close.diff()
|
| 255 |
+
gain = delta.clip(lower=0).rolling(window, min_periods=window).mean()
|
| 256 |
+
loss = (-delta.clip(upper=0)).rolling(window, min_periods=window).mean()
|
| 257 |
+
rs = safe_div(gain, loss)
|
| 258 |
+
return 100.0 - (100.0 / (1.0 + rs))
|
| 259 |
+
|
| 260 |
+
|
| 261 |
+
def history_features(daily: pd.DataFrame, prefix: str, include_target_history: bool) -> pd.DataFrame:
|
| 262 |
+
d = daily.sort_values("date").reset_index(drop=True).copy()
|
| 263 |
+
out = pd.DataFrame({"date": d["date"]})
|
| 264 |
+
open_ = d["open"]
|
| 265 |
+
high = d["high"]
|
| 266 |
+
low = d["low"]
|
| 267 |
+
close = d["close"]
|
| 268 |
+
volume = d["volume"].replace(0, np.nan)
|
| 269 |
+
|
| 270 |
+
prev_open = open_.shift(1)
|
| 271 |
+
prev_high = high.shift(1)
|
| 272 |
+
prev_low = low.shift(1)
|
| 273 |
+
prev_close = close.shift(1)
|
| 274 |
+
prev_volume = volume.shift(1)
|
| 275 |
+
prev_range = prev_high - prev_low
|
| 276 |
+
prev_range_pct = safe_div(prev_range, prev_close)
|
| 277 |
+
prev_body_pct = safe_div(close.shift(1) - open_.shift(1), open_.shift(1))
|
| 278 |
+
ret1 = close.pct_change()
|
| 279 |
+
log_ret1 = np.log(safe_div(close, close.shift(1))).replace([np.inf, -np.inf], np.nan)
|
| 280 |
+
true_range = pd.concat(
|
| 281 |
+
[(high - low), (high - close.shift(1)).abs(), (low - close.shift(1)).abs()],
|
| 282 |
+
axis=1,
|
| 283 |
+
).max(axis=1)
|
| 284 |
+
if {"high_time", "low_time", "open_time"}.issubset(d.columns):
|
| 285 |
+
high_minute = (d["high_time"] - d["open_time"]).dt.total_seconds() / 60.0
|
| 286 |
+
low_minute = (d["low_time"] - d["open_time"]).dt.total_seconds() / 60.0
|
| 287 |
+
first_extrema_minute = pd.concat([high_minute, low_minute], axis=1).min(axis=1)
|
| 288 |
+
extrema_time_gap = high_minute - low_minute
|
| 289 |
+
out[f"{prefix}prev_high_minute"] = high_minute.shift(1)
|
| 290 |
+
out[f"{prefix}prev_low_minute"] = low_minute.shift(1)
|
| 291 |
+
out[f"{prefix}prev_first_extrema_minute"] = first_extrema_minute.shift(1)
|
| 292 |
+
out[f"{prefix}prev_extrema_time_gap"] = extrema_time_gap.shift(1)
|
| 293 |
+
for window in (3, 5, 10, 20):
|
| 294 |
+
out[f"{prefix}high_minute_mean_{window}d"] = high_minute.shift(1).rolling(window, min_periods=max(2, window // 2)).mean()
|
| 295 |
+
out[f"{prefix}low_minute_mean_{window}d"] = low_minute.shift(1).rolling(window, min_periods=max(2, window // 2)).mean()
|
| 296 |
+
out[f"{prefix}first_extrema_minute_mean_{window}d"] = first_extrema_minute.shift(1).rolling(window, min_periods=max(2, window // 2)).mean()
|
| 297 |
+
out[f"{prefix}extrema_time_gap_mean_{window}d"] = extrema_time_gap.shift(1).rolling(window, min_periods=max(2, window // 2)).mean()
|
| 298 |
+
|
| 299 |
+
out[f"{prefix}current_open_log"] = np.log(open_.replace(0, np.nan))
|
| 300 |
+
out[f"{prefix}prev_close_log"] = np.log(prev_close.replace(0, np.nan))
|
| 301 |
+
out[f"{prefix}gap_pct"] = safe_div(open_, prev_close) - 1.0
|
| 302 |
+
out[f"{prefix}gap_abs_pct"] = out[f"{prefix}gap_pct"].abs()
|
| 303 |
+
out[f"{prefix}open_vs_prev_high"] = safe_div(open_, prev_high) - 1.0
|
| 304 |
+
out[f"{prefix}open_vs_prev_low"] = safe_div(open_, prev_low) - 1.0
|
| 305 |
+
out[f"{prefix}open_vs_prev_mid"] = safe_div(open_, (prev_high + prev_low) / 2.0) - 1.0
|
| 306 |
+
out[f"{prefix}open_pos_prev_range"] = safe_div(open_ - prev_low, prev_range)
|
| 307 |
+
out[f"{prefix}prev_range_pct"] = prev_range_pct
|
| 308 |
+
out[f"{prefix}prev_body_pct"] = prev_body_pct
|
| 309 |
+
out[f"{prefix}prev_upper_wick_pct"] = safe_div(prev_high - pd.concat([prev_open, prev_close], axis=1).max(axis=1), prev_close)
|
| 310 |
+
out[f"{prefix}prev_lower_wick_pct"] = safe_div(pd.concat([prev_open, prev_close], axis=1).min(axis=1) - prev_low, prev_close)
|
| 311 |
+
out[f"{prefix}prev_volume_log"] = np.log1p(prev_volume)
|
| 312 |
+
intraday_shape_cols = [
|
| 313 |
+
c
|
| 314 |
+
for c in d.columns
|
| 315 |
+
if c.endswith(("_return", "_range_pct", "_high_excursion", "_low_excursion", "_volume_share"))
|
| 316 |
+
or c in ("close_position", "morning_vs_late_return")
|
| 317 |
+
]
|
| 318 |
+
for c in intraday_shape_cols:
|
| 319 |
+
out[f"{prefix}prev_{c}"] = d[c].shift(1)
|
| 320 |
+
for window in (3, 5, 10, 20):
|
| 321 |
+
out[f"{prefix}{c}_mean_{window}d"] = d[c].shift(1).rolling(window, min_periods=max(2, window // 2)).mean()
|
| 322 |
+
out[f"{prefix}{c}_std_{window}d"] = d[c].shift(1).rolling(window, min_periods=max(2, window // 2)).std()
|
| 323 |
+
|
| 324 |
+
for window in (2, 3, 5, 10, 20, 40, 60):
|
| 325 |
+
shifted_close = close.shift(1)
|
| 326 |
+
rolling_high = high.shift(1).rolling(window, min_periods=max(2, window // 2)).max()
|
| 327 |
+
rolling_low = low.shift(1).rolling(window, min_periods=max(2, window // 2)).min()
|
| 328 |
+
rolling_range = rolling_high - rolling_low
|
| 329 |
+
out[f"{prefix}ret_{window}d"] = safe_div(shifted_close, close.shift(1 + window)) - 1.0
|
| 330 |
+
out[f"{prefix}ret_mean_{window}d"] = ret1.shift(1).rolling(window, min_periods=max(2, window // 2)).mean()
|
| 331 |
+
out[f"{prefix}ret_std_{window}d"] = ret1.shift(1).rolling(window, min_periods=max(2, window // 2)).std()
|
| 332 |
+
out[f"{prefix}abs_ret_mean_{window}d"] = ret1.abs().shift(1).rolling(window, min_periods=max(2, window // 2)).mean()
|
| 333 |
+
out[f"{prefix}range_mean_{window}d"] = safe_div(high - low, close).shift(1).rolling(window, min_periods=max(2, window // 2)).mean()
|
| 334 |
+
out[f"{prefix}range_std_{window}d"] = safe_div(high - low, close).shift(1).rolling(window, min_periods=max(2, window // 2)).std()
|
| 335 |
+
out[f"{prefix}volume_z_{window}d"] = safe_div(prev_volume - volume.shift(1).rolling(window, min_periods=max(2, window // 2)).mean(), volume.shift(1).rolling(window, min_periods=max(2, window // 2)).std())
|
| 336 |
+
out[f"{prefix}gap_over_vol_{window}d"] = safe_div(out[f"{prefix}gap_pct"], out[f"{prefix}ret_std_{window}d"])
|
| 337 |
+
out[f"{prefix}open_vs_sma_{window}d"] = safe_div(open_, close.shift(1).rolling(window, min_periods=max(2, window // 2)).mean()) - 1.0
|
| 338 |
+
out[f"{prefix}open_vs_roll_high_{window}d"] = safe_div(open_, rolling_high) - 1.0
|
| 339 |
+
out[f"{prefix}open_vs_roll_low_{window}d"] = safe_div(open_, rolling_low) - 1.0
|
| 340 |
+
out[f"{prefix}open_pos_roll_range_{window}d"] = safe_div(open_ - rolling_low, rolling_range)
|
| 341 |
+
|
| 342 |
+
for span in (5, 12, 26, 50):
|
| 343 |
+
ema = close.ewm(span=span, adjust=False, min_periods=max(2, span // 2)).mean().shift(1)
|
| 344 |
+
out[f"{prefix}open_vs_ema_{span}d"] = safe_div(open_, ema) - 1.0
|
| 345 |
+
out[f"{prefix}prev_close_vs_ema_{span}d"] = safe_div(prev_close, ema) - 1.0
|
| 346 |
+
|
| 347 |
+
atr14 = true_range.rolling(14, min_periods=7).mean().shift(1)
|
| 348 |
+
out[f"{prefix}atr14_pct"] = safe_div(atr14, prev_close)
|
| 349 |
+
out[f"{prefix}gap_over_atr14"] = safe_div(open_ - prev_close, atr14)
|
| 350 |
+
out[f"{prefix}rsi14_prev"] = rsi(close, 14).shift(1)
|
| 351 |
+
out[f"{prefix}bars_prev"] = d["bars"].shift(1)
|
| 352 |
+
|
| 353 |
+
if include_target_history and "target_high_first" in d.columns:
|
| 354 |
+
y_prev = d["target_high_first"].shift(1)
|
| 355 |
+
out[f"{prefix}prev_high_first"] = y_prev
|
| 356 |
+
for window in (3, 5, 10, 20, 60):
|
| 357 |
+
out[f"{prefix}high_first_rate_{window}d"] = d["target_high_first"].shift(1).rolling(window, min_periods=max(2, window // 2)).mean()
|
| 358 |
+
|
| 359 |
+
return out
|
| 360 |
+
|
| 361 |
+
|
| 362 |
+
def breadth_features(dailies: dict[str, pd.DataFrame], symbols: list[str]) -> pd.DataFrame:
|
| 363 |
+
frames = []
|
| 364 |
+
for symbol in symbols:
|
| 365 |
+
d = dailies[symbol].sort_values("date").copy()
|
| 366 |
+
d["ret1"] = d["close"].pct_change()
|
| 367 |
+
d["range_pct"] = safe_div(d["high"] - d["low"], d["close"])
|
| 368 |
+
frames.append(d[["date", "ret1", "range_pct", "target_high_first"]].assign(symbol=symbol))
|
| 369 |
+
panel = pd.concat(frames, ignore_index=True)
|
| 370 |
+
by_date = panel.groupby("date", sort=True).agg(
|
| 371 |
+
breadth_ret_mean=("ret1", "mean"),
|
| 372 |
+
breadth_ret_std=("ret1", "std"),
|
| 373 |
+
breadth_range_mean=("range_pct", "mean"),
|
| 374 |
+
breadth_high_first_rate=("target_high_first", "mean"),
|
| 375 |
+
)
|
| 376 |
+
return by_date.shift(1).reset_index()
|
| 377 |
+
|
| 378 |
+
|
| 379 |
+
def open_cross_section_features(dailies: dict[str, pd.DataFrame], symbols: list[str]) -> pd.DataFrame:
|
| 380 |
+
frames = []
|
| 381 |
+
for symbol in symbols:
|
| 382 |
+
d = dailies[symbol].sort_values("date").copy()
|
| 383 |
+
d["stock_open_gap"] = safe_div(d["open"], d["close"].shift(1)) - 1.0
|
| 384 |
+
d["stock_open_vs_prev_high"] = safe_div(d["open"], d["high"].shift(1)) - 1.0
|
| 385 |
+
d["stock_open_vs_prev_low"] = safe_div(d["open"], d["low"].shift(1)) - 1.0
|
| 386 |
+
frames.append(d[["date", "symbol", "stock_open_gap", "stock_open_vs_prev_high", "stock_open_vs_prev_low"]])
|
| 387 |
+
opens = pd.concat(frames, ignore_index=True)
|
| 388 |
+
g = opens.groupby("date", sort=True)
|
| 389 |
+
opens["open_gap_cs_mean"] = g["stock_open_gap"].transform("mean")
|
| 390 |
+
opens["open_gap_cs_std"] = g["stock_open_gap"].transform("std")
|
| 391 |
+
opens["open_gap_cs_min"] = g["stock_open_gap"].transform("min")
|
| 392 |
+
opens["open_gap_cs_max"] = g["stock_open_gap"].transform("max")
|
| 393 |
+
opens["open_gap_cs_rank_pct"] = g["stock_open_gap"].rank(pct=True)
|
| 394 |
+
opens["open_gap_vs_peer_mean"] = opens["stock_open_gap"] - opens["open_gap_cs_mean"]
|
| 395 |
+
opens["open_gap_cs_z"] = safe_div(opens["open_gap_vs_peer_mean"], opens["open_gap_cs_std"])
|
| 396 |
+
opens["open_gap_positive_share"] = g["stock_open_gap"].transform(lambda s: float((s > 0).mean()))
|
| 397 |
+
opens["open_above_prev_high_share"] = g["stock_open_vs_prev_high"].transform(lambda s: float((s > 0).mean()))
|
| 398 |
+
opens["open_below_prev_low_share"] = g["stock_open_vs_prev_low"].transform(lambda s: float((s < 0).mean()))
|
| 399 |
+
return opens[
|
| 400 |
+
[
|
| 401 |
+
"date",
|
| 402 |
+
"symbol",
|
| 403 |
+
"open_gap_cs_mean",
|
| 404 |
+
"open_gap_cs_std",
|
| 405 |
+
"open_gap_cs_min",
|
| 406 |
+
"open_gap_cs_max",
|
| 407 |
+
"open_gap_cs_rank_pct",
|
| 408 |
+
"open_gap_vs_peer_mean",
|
| 409 |
+
"open_gap_cs_z",
|
| 410 |
+
"open_gap_positive_share",
|
| 411 |
+
"open_above_prev_high_share",
|
| 412 |
+
"open_below_prev_low_share",
|
| 413 |
+
]
|
| 414 |
+
]
|
| 415 |
+
|
| 416 |
+
|
| 417 |
+
def master_previous_day_features() -> pd.DataFrame | None:
|
| 418 |
+
if not DAILY_MASTER_PATH.exists():
|
| 419 |
+
return None
|
| 420 |
+
note("loading previous-day daily_master_panel features")
|
| 421 |
+
master = pd.read_csv(DAILY_MASTER_PATH)
|
| 422 |
+
if "date" not in master.columns:
|
| 423 |
+
return None
|
| 424 |
+
master["date"] = pd.to_datetime(master["date"], errors="coerce")
|
| 425 |
+
master = master.dropna(subset=["date"]).sort_values("date").reset_index(drop=True)
|
| 426 |
+
for col in list(master.columns):
|
| 427 |
+
if col == "date":
|
| 428 |
+
continue
|
| 429 |
+
if col.endswith("_close"):
|
| 430 |
+
base = col[: -len("_close")]
|
| 431 |
+
value_col = f"{base}_value"
|
| 432 |
+
change_col = f"{base}_change_1"
|
| 433 |
+
if value_col not in master.columns:
|
| 434 |
+
master[value_col] = pd.to_numeric(master[col], errors="coerce")
|
| 435 |
+
if change_col not in master.columns:
|
| 436 |
+
master[change_col] = pd.to_numeric(master[value_col], errors="coerce").diff()
|
| 437 |
+
elif col.endswith("_value"):
|
| 438 |
+
base = col[: -len("_value")]
|
| 439 |
+
close_col = f"{base}_close"
|
| 440 |
+
change_col = f"{base}_change_1"
|
| 441 |
+
if close_col not in master.columns:
|
| 442 |
+
master[close_col] = pd.to_numeric(master[col], errors="coerce")
|
| 443 |
+
if change_col not in master.columns:
|
| 444 |
+
master[change_col] = pd.to_numeric(master[col], errors="coerce").diff()
|
| 445 |
+
keep = [
|
| 446 |
+
c
|
| 447 |
+
for c in master.columns
|
| 448 |
+
if c != "date"
|
| 449 |
+
and "target" not in c.lower()
|
| 450 |
+
and not c.lower().endswith("_future")
|
| 451 |
+
]
|
| 452 |
+
numeric = master[keep].apply(pd.to_numeric, errors="coerce")
|
| 453 |
+
shifted = numeric.shift(1)
|
| 454 |
+
shifted.columns = [f"master_prev_{c}" for c in shifted.columns]
|
| 455 |
+
return pd.concat([master[["date"]], shifted], axis=1)
|
| 456 |
+
|
| 457 |
+
|
| 458 |
+
def current_first5_features(daily: pd.DataFrame) -> pd.DataFrame:
|
| 459 |
+
d = daily.sort_values("date").copy()
|
| 460 |
+
out = pd.DataFrame({"date": d["date"]})
|
| 461 |
+
out["cur5_return"] = d["first5_return"]
|
| 462 |
+
out["cur5_range_pct"] = d["first5_range_pct"]
|
| 463 |
+
out["cur5_high_excursion"] = d["first5_high_excursion"]
|
| 464 |
+
out["cur5_low_excursion"] = d["first5_low_excursion"]
|
| 465 |
+
out["cur5_volume_share"] = d["first5_volume_share"]
|
| 466 |
+
out["cur5_volume_log"] = np.log1p(d["first5_volume"].replace(0, np.nan))
|
| 467 |
+
out["cur5_close_pos"] = safe_div(d["first5_close"] - d["first5_low"], d["first5_high"] - d["first5_low"])
|
| 468 |
+
out["cur5_body_vs_range"] = safe_div(d["first5_close"] - d["open"], d["first5_high"] - d["first5_low"])
|
| 469 |
+
out["cur5_upper_wick_pct"] = safe_div(d["first5_high"] - pd.concat([d["open"], d["first5_close"]], axis=1).max(axis=1), d["open"])
|
| 470 |
+
out["cur5_lower_wick_pct"] = safe_div(pd.concat([d["open"], d["first5_close"]], axis=1).min(axis=1) - d["first5_low"], d["open"])
|
| 471 |
+
out["cur5_direction_up"] = d["first5_close"].gt(d["open"]).astype(float)
|
| 472 |
+
return out
|
| 473 |
+
|
| 474 |
+
|
| 475 |
+
def build_dataset(dailies: dict[str, pd.DataFrame], targets: list[str]) -> pd.DataFrame:
|
| 476 |
+
market_features = []
|
| 477 |
+
for symbol, prefix in (("NIFTY 50", "n50_"), ("NIFTY BANK", "bank_"), ("INDIA VIX", "vix_")):
|
| 478 |
+
if symbol in dailies:
|
| 479 |
+
market_features.append(history_features(dailies[symbol], prefix=prefix, include_target_history=True))
|
| 480 |
+
|
| 481 |
+
breadth = breadth_features(dailies, targets)
|
| 482 |
+
open_cs = open_cross_section_features(dailies, targets)
|
| 483 |
+
master_prev = master_previous_day_features()
|
| 484 |
+
rows = []
|
| 485 |
+
for symbol in targets:
|
| 486 |
+
base = dailies[symbol].copy()
|
| 487 |
+
feats = history_features(base, prefix="stk_", include_target_history=True)
|
| 488 |
+
cur5 = current_first5_features(base)
|
| 489 |
+
frame = base[
|
| 490 |
+
[
|
| 491 |
+
"date",
|
| 492 |
+
"symbol",
|
| 493 |
+
"open",
|
| 494 |
+
"open_time",
|
| 495 |
+
"high_time",
|
| 496 |
+
"low_time",
|
| 497 |
+
"high_minute_from_open",
|
| 498 |
+
"low_minute_from_open",
|
| 499 |
+
"high_in_first5",
|
| 500 |
+
"low_in_first5",
|
| 501 |
+
"target_high_first",
|
| 502 |
+
"bars",
|
| 503 |
+
]
|
| 504 |
+
].merge(feats, on="date", how="left")
|
| 505 |
+
frame = frame.merge(cur5, on="date", how="left")
|
| 506 |
+
for mf in market_features:
|
| 507 |
+
frame = frame.merge(mf, on="date", how="left")
|
| 508 |
+
frame = frame.merge(breadth, on="date", how="left")
|
| 509 |
+
frame = frame.merge(open_cs, on=["date", "symbol"], how="left")
|
| 510 |
+
if master_prev is not None:
|
| 511 |
+
frame = frame.merge(master_prev, on="date", how="left")
|
| 512 |
+
rows.append(frame)
|
| 513 |
+
|
| 514 |
+
data = pd.concat(rows, ignore_index=True).sort_values(["date", "symbol"]).reset_index(drop=True)
|
| 515 |
+
data["target"] = data["target_high_first"].astype("float64")
|
| 516 |
+
data["dow"] = data["date"].dt.dayofweek
|
| 517 |
+
data["month"] = data["date"].dt.month
|
| 518 |
+
data["day_of_month"] = data["date"].dt.day
|
| 519 |
+
data["day_of_year"] = data["date"].dt.dayofyear
|
| 520 |
+
data["dow_sin"] = np.sin(2 * np.pi * data["dow"] / 5.0)
|
| 521 |
+
data["dow_cos"] = np.cos(2 * np.pi * data["dow"] / 5.0)
|
| 522 |
+
data["month_sin"] = np.sin(2 * np.pi * data["month"] / 12.0)
|
| 523 |
+
data["month_cos"] = np.cos(2 * np.pi * data["month"] / 12.0)
|
| 524 |
+
data["is_month_start"] = data["date"].dt.is_month_start.astype(int)
|
| 525 |
+
data["is_month_end"] = data["date"].dt.is_month_end.astype(int)
|
| 526 |
+
|
| 527 |
+
data = data[
|
| 528 |
+
data["bars"].ge(120)
|
| 529 |
+
& data["target"].notna()
|
| 530 |
+
& ~data["high_in_first5"].fillna(False)
|
| 531 |
+
& ~data["low_in_first5"].fillna(False)
|
| 532 |
+
].copy()
|
| 533 |
+
return data.reset_index(drop=True)
|
| 534 |
+
|
| 535 |
+
|
| 536 |
+
def feature_columns(data: pd.DataFrame, profile: str) -> list[str]:
|
| 537 |
+
blocked = {
|
| 538 |
+
"date",
|
| 539 |
+
"target",
|
| 540 |
+
"target_high_first",
|
| 541 |
+
"open_time",
|
| 542 |
+
"high_time",
|
| 543 |
+
"low_time",
|
| 544 |
+
"high_minute_from_open",
|
| 545 |
+
"low_minute_from_open",
|
| 546 |
+
"high_in_first5",
|
| 547 |
+
"low_in_first5",
|
| 548 |
+
"high",
|
| 549 |
+
"low",
|
| 550 |
+
"open",
|
| 551 |
+
"bars",
|
| 552 |
+
}
|
| 553 |
+
cols = [c for c in data.columns if c not in blocked]
|
| 554 |
+
if profile == "full":
|
| 555 |
+
return cols
|
| 556 |
+
if profile == "no_market":
|
| 557 |
+
return [c for c in cols if not c.startswith(("n50_", "bank_", "vix_", "master_prev_"))]
|
| 558 |
+
if profile == "no_vix":
|
| 559 |
+
return [c for c in cols if not c.startswith(("vix_", "master_prev_india_vix"))]
|
| 560 |
+
if profile == "stock_only":
|
| 561 |
+
return [c for c in cols if c == "symbol" or c.startswith("stk_") or c in CALENDAR_COLUMNS]
|
| 562 |
+
if profile == "first5_rule":
|
| 563 |
+
return ["cur5_close_pos"]
|
| 564 |
+
if profile == "compact":
|
| 565 |
+
keep_prefixes = (
|
| 566 |
+
"stk_gap",
|
| 567 |
+
"stk_open_",
|
| 568 |
+
"stk_prev_",
|
| 569 |
+
"stk_ret_",
|
| 570 |
+
"stk_high_first",
|
| 571 |
+
"cur5_",
|
| 572 |
+
"n50_gap",
|
| 573 |
+
"bank_gap",
|
| 574 |
+
"vix_gap",
|
| 575 |
+
"breadth_",
|
| 576 |
+
)
|
| 577 |
+
return [c for c in cols if c == "symbol" or c in CALENDAR_COLUMNS or c.startswith(keep_prefixes)]
|
| 578 |
+
raise ValueError(f"Unknown profile: {profile}")
|
| 579 |
+
|
| 580 |
+
|
| 581 |
+
CALENDAR_COLUMNS = {
|
| 582 |
+
"dow",
|
| 583 |
+
"month",
|
| 584 |
+
"day_of_month",
|
| 585 |
+
"day_of_year",
|
| 586 |
+
"dow_sin",
|
| 587 |
+
"dow_cos",
|
| 588 |
+
"month_sin",
|
| 589 |
+
"month_cos",
|
| 590 |
+
"is_month_start",
|
| 591 |
+
"is_month_end",
|
| 592 |
+
}
|
| 593 |
+
|
| 594 |
+
|
| 595 |
+
def split_data(data: pd.DataFrame, valid_start: pd.Timestamp, test_start: pd.Timestamp) -> tuple[pd.DataFrame, pd.DataFrame, pd.DataFrame]:
|
| 596 |
+
train = data[data["date"] < valid_start].copy()
|
| 597 |
+
valid = data[(data["date"] >= valid_start) & (data["date"] < test_start)].copy()
|
| 598 |
+
test = data[data["date"] >= test_start].copy()
|
| 599 |
+
return train, valid, test
|
| 600 |
+
|
| 601 |
+
|
| 602 |
+
def prepare_xy(train: pd.DataFrame, valid: pd.DataFrame, test: pd.DataFrame, cols: list[str], kind: str):
|
| 603 |
+
y_train = train["target"].astype(int).to_numpy()
|
| 604 |
+
y_valid = valid["target"].astype(int).to_numpy()
|
| 605 |
+
y_test = test["target"].astype(int).to_numpy()
|
| 606 |
+
|
| 607 |
+
if kind == "catboost":
|
| 608 |
+
x_train = train[cols].copy()
|
| 609 |
+
x_valid = valid[cols].copy()
|
| 610 |
+
x_test = test[cols].copy()
|
| 611 |
+
for x in (x_train, x_valid, x_test):
|
| 612 |
+
if "symbol" in x.columns:
|
| 613 |
+
x["symbol"] = x["symbol"].astype(str)
|
| 614 |
+
cat_features = ["symbol"] if "symbol" in cols else []
|
| 615 |
+
return x_train, y_train, x_valid, y_valid, x_test, y_test, cat_features
|
| 616 |
+
|
| 617 |
+
all_x = pd.concat([train[cols], valid[cols], test[cols]], axis=0)
|
| 618 |
+
all_x = pd.get_dummies(all_x, columns=["symbol"] if "symbol" in all_x.columns else [], dummy_na=False)
|
| 619 |
+
x_train = all_x.iloc[: len(train)].copy()
|
| 620 |
+
x_valid = all_x.iloc[len(train) : len(train) + len(valid)].copy()
|
| 621 |
+
x_test = all_x.iloc[len(train) + len(valid) :].copy()
|
| 622 |
+
medians = x_train.median(numeric_only=True).replace([np.inf, -np.inf], np.nan)
|
| 623 |
+
x_train = x_train.replace([np.inf, -np.inf], np.nan).fillna(medians).fillna(0.0)
|
| 624 |
+
x_valid = x_valid.replace([np.inf, -np.inf], np.nan).fillna(medians).fillna(0.0)
|
| 625 |
+
x_test = x_test.replace([np.inf, -np.inf], np.nan).fillna(medians).fillna(0.0)
|
| 626 |
+
return x_train, y_train, x_valid, y_valid, x_test, y_test, []
|
| 627 |
+
|
| 628 |
+
|
| 629 |
+
def build_model(candidate: Candidate):
|
| 630 |
+
params = candidate.params.copy()
|
| 631 |
+
if candidate.kind == "histgb":
|
| 632 |
+
return HistGradientBoostingClassifier(random_state=RANDOM_SEED, **params)
|
| 633 |
+
if candidate.kind == "extra_trees":
|
| 634 |
+
return ExtraTreesClassifier(random_state=RANDOM_SEED, n_jobs=2, **params)
|
| 635 |
+
if candidate.kind == "random_forest":
|
| 636 |
+
return RandomForestClassifier(random_state=RANDOM_SEED, n_jobs=2, **params)
|
| 637 |
+
if candidate.kind == "logistic":
|
| 638 |
+
c = params.pop("C")
|
| 639 |
+
return make_pipeline(
|
| 640 |
+
SimpleImputer(strategy="median"),
|
| 641 |
+
StandardScaler(),
|
| 642 |
+
LogisticRegression(C=c, max_iter=2000, class_weight=params.get("class_weight"), solver="lbfgs"),
|
| 643 |
+
)
|
| 644 |
+
if candidate.kind == "single_feature_rule":
|
| 645 |
+
return SingleFeatureRuleClassifier(**params)
|
| 646 |
+
if candidate.kind == "catboost":
|
| 647 |
+
if CatBoostClassifier is None:
|
| 648 |
+
raise RuntimeError("catboost is not installed")
|
| 649 |
+
return CatBoostClassifier(
|
| 650 |
+
loss_function="Logloss",
|
| 651 |
+
eval_metric="Accuracy",
|
| 652 |
+
random_seed=RANDOM_SEED,
|
| 653 |
+
thread_count=2,
|
| 654 |
+
allow_writing_files=False,
|
| 655 |
+
verbose=False,
|
| 656 |
+
**params,
|
| 657 |
+
)
|
| 658 |
+
if candidate.kind == "lightgbm":
|
| 659 |
+
if LGBMClassifier is None:
|
| 660 |
+
raise RuntimeError("lightgbm is not installed")
|
| 661 |
+
return LGBMClassifier(random_state=RANDOM_SEED, n_jobs=2, verbosity=-1, **params)
|
| 662 |
+
if candidate.kind == "xgboost":
|
| 663 |
+
if XGBClassifier is None:
|
| 664 |
+
raise RuntimeError("xgboost is not installed")
|
| 665 |
+
return XGBClassifier(
|
| 666 |
+
random_state=RANDOM_SEED,
|
| 667 |
+
n_jobs=2,
|
| 668 |
+
eval_metric="logloss",
|
| 669 |
+
tree_method="hist",
|
| 670 |
+
verbosity=0,
|
| 671 |
+
**params,
|
| 672 |
+
)
|
| 673 |
+
raise ValueError(candidate.kind)
|
| 674 |
+
|
| 675 |
+
|
| 676 |
+
def proba(model: Any, x: pd.DataFrame) -> np.ndarray:
|
| 677 |
+
p = model.predict_proba(x)
|
| 678 |
+
if p.ndim == 2:
|
| 679 |
+
return p[:, 1]
|
| 680 |
+
return p
|
| 681 |
+
|
| 682 |
+
|
| 683 |
+
def best_threshold(y_true: np.ndarray, p: np.ndarray) -> tuple[float, float]:
|
| 684 |
+
best_t = 0.5
|
| 685 |
+
best_acc = -1.0
|
| 686 |
+
for t in np.linspace(0.20, 0.90, 281):
|
| 687 |
+
acc = accuracy_score(y_true, p >= t)
|
| 688 |
+
if acc > best_acc:
|
| 689 |
+
best_acc = acc
|
| 690 |
+
best_t = float(t)
|
| 691 |
+
return best_t, float(best_acc)
|
| 692 |
+
|
| 693 |
+
|
| 694 |
+
def calibrate_thresholds(y_true: np.ndarray, p: np.ndarray, symbols: pd.Series) -> tuple[str, float, dict[str, float], np.ndarray, float]:
|
| 695 |
+
global_threshold, global_acc = best_threshold(y_true, p)
|
| 696 |
+
global_pred = p >= global_threshold
|
| 697 |
+
symbol_thresholds: dict[str, float] = {}
|
| 698 |
+
symbol_pred = np.zeros_like(global_pred, dtype=bool)
|
| 699 |
+
for symbol in sorted(pd.Series(symbols).astype(str).unique()):
|
| 700 |
+
mask = pd.Series(symbols).astype(str).to_numpy() == symbol
|
| 701 |
+
if mask.sum() < 40:
|
| 702 |
+
symbol_thresholds[symbol] = global_threshold
|
| 703 |
+
symbol_pred[mask] = global_pred[mask]
|
| 704 |
+
continue
|
| 705 |
+
threshold, _ = best_threshold(y_true[mask], p[mask])
|
| 706 |
+
symbol_thresholds[symbol] = threshold
|
| 707 |
+
symbol_pred[mask] = p[mask] >= threshold
|
| 708 |
+
symbol_acc = float(accuracy_score(y_true, symbol_pred))
|
| 709 |
+
if symbol_acc >= global_acc:
|
| 710 |
+
return "per_symbol", global_threshold, symbol_thresholds, symbol_pred, symbol_acc
|
| 711 |
+
return "global", global_threshold, {}, global_pred, float(global_acc)
|
| 712 |
+
|
| 713 |
+
|
| 714 |
+
def apply_thresholds(p: np.ndarray, symbols: pd.Series, global_threshold: float, symbol_thresholds: dict[str, float], mode: str) -> np.ndarray:
|
| 715 |
+
if mode != "per_symbol" or not symbol_thresholds:
|
| 716 |
+
return p >= global_threshold
|
| 717 |
+
sym = pd.Series(symbols).astype(str).to_numpy()
|
| 718 |
+
pred = np.zeros_like(p, dtype=bool)
|
| 719 |
+
for symbol in np.unique(sym):
|
| 720 |
+
pred[sym == symbol] = p[sym == symbol] >= symbol_thresholds.get(str(symbol), global_threshold)
|
| 721 |
+
return pred
|
| 722 |
+
|
| 723 |
+
|
| 724 |
+
def apply_symbol_overlays(frame: pd.DataFrame) -> pd.DataFrame:
|
| 725 |
+
out = frame.copy()
|
| 726 |
+
if not {"symbol", "prob_high_first", "prediction"}.issubset(out.columns):
|
| 727 |
+
return out
|
| 728 |
+
sbin_mask = out["symbol"].astype(str).eq("SBIN")
|
| 729 |
+
if sbin_mask.any() and SBIN_OVERLAY_RULE_V1["feature"] in out.columns:
|
| 730 |
+
feature = pd.to_numeric(out.loc[sbin_mask, SBIN_OVERLAY_RULE_V1["feature"]], errors="coerce")
|
| 731 |
+
force_low = feature.le(float(SBIN_OVERLAY_RULE_V1["feature_threshold"])).fillna(False)
|
| 732 |
+
sbin_index = out.index[sbin_mask]
|
| 733 |
+
default_high = out.loc[sbin_mask, "prob_high_first"].astype(float).ge(float(SBIN_OVERLAY_RULE_V1["model_threshold"]))
|
| 734 |
+
pred = pd.Series(np.where(default_high, "HIGH_FIRST", "LOW_FIRST"), index=sbin_index)
|
| 735 |
+
pred.loc[sbin_index[force_low.to_numpy()]] = "LOW_FIRST"
|
| 736 |
+
out.loc[sbin_mask, "prediction"] = pred
|
| 737 |
+
if "target" in out.columns:
|
| 738 |
+
out.loc[sbin_mask, "correct"] = (
|
| 739 |
+
(out.loc[sbin_mask, "prediction"].eq("HIGH_FIRST")).astype(int)
|
| 740 |
+
== out.loc[sbin_mask, "target"].astype(int)
|
| 741 |
+
)
|
| 742 |
+
return out
|
| 743 |
+
|
| 744 |
+
|
| 745 |
+
def safe_auc(y: np.ndarray, p: np.ndarray) -> float | None:
|
| 746 |
+
try:
|
| 747 |
+
return float(roc_auc_score(y, p))
|
| 748 |
+
except Exception:
|
| 749 |
+
return None
|
| 750 |
+
|
| 751 |
+
|
| 752 |
+
def safe_log_loss(y: np.ndarray, p: np.ndarray) -> float | None:
|
| 753 |
+
try:
|
| 754 |
+
return float(log_loss(y, np.clip(p, 1e-5, 1 - 1e-5)))
|
| 755 |
+
except Exception:
|
| 756 |
+
return None
|
| 757 |
+
|
| 758 |
+
|
| 759 |
+
def evaluate_candidate(candidate: Candidate, train: pd.DataFrame, valid: pd.DataFrame, test: pd.DataFrame) -> tuple[CandidateResult, Any, list[str], list[str]]:
|
| 760 |
+
cols = feature_columns(train, candidate.feature_profile)
|
| 761 |
+
x_train, y_train, x_valid, y_valid, x_test, y_test, cat_features = prepare_xy(train, valid, test, cols, candidate.kind)
|
| 762 |
+
model = build_model(candidate)
|
| 763 |
+
start = time.time()
|
| 764 |
+
if candidate.kind == "catboost":
|
| 765 |
+
model.fit(x_train, y_train, cat_features=cat_features, eval_set=(x_valid, y_valid), use_best_model=False)
|
| 766 |
+
else:
|
| 767 |
+
model.fit(x_train, y_train)
|
| 768 |
+
fit_seconds = time.time() - start
|
| 769 |
+
|
| 770 |
+
p_train = proba(model, x_train)
|
| 771 |
+
p_valid = proba(model, x_valid)
|
| 772 |
+
p_test = proba(model, x_test)
|
| 773 |
+
if candidate.kind == "single_feature_rule":
|
| 774 |
+
threshold, valid_acc = best_threshold(y_valid, p_valid)
|
| 775 |
+
mode = "global"
|
| 776 |
+
symbol_thresholds = {}
|
| 777 |
+
valid_pred = p_valid >= threshold
|
| 778 |
+
else:
|
| 779 |
+
mode, threshold, symbol_thresholds, valid_pred, valid_acc = calibrate_thresholds(y_valid, p_valid, valid["symbol"])
|
| 780 |
+
test_pred = apply_thresholds(p_test, test["symbol"], threshold, symbol_thresholds, mode)
|
| 781 |
+
train_pred = apply_thresholds(p_train, train["symbol"], threshold, symbol_thresholds, mode)
|
| 782 |
+
|
| 783 |
+
result = CandidateResult(
|
| 784 |
+
name=candidate.name,
|
| 785 |
+
kind=candidate.kind,
|
| 786 |
+
params=candidate.params,
|
| 787 |
+
feature_profile=candidate.feature_profile,
|
| 788 |
+
feature_count=len(cols),
|
| 789 |
+
threshold=threshold,
|
| 790 |
+
threshold_mode=mode,
|
| 791 |
+
symbol_thresholds=symbol_thresholds,
|
| 792 |
+
validation_accuracy=float(valid_acc),
|
| 793 |
+
validation_balanced_accuracy=float(balanced_accuracy_score(y_valid, valid_pred)),
|
| 794 |
+
validation_auc=safe_auc(y_valid, p_valid),
|
| 795 |
+
validation_log_loss=safe_log_loss(y_valid, p_valid),
|
| 796 |
+
test_accuracy=float(accuracy_score(y_test, test_pred)),
|
| 797 |
+
test_balanced_accuracy=float(balanced_accuracy_score(y_test, test_pred)),
|
| 798 |
+
test_auc=safe_auc(y_test, p_test),
|
| 799 |
+
test_log_loss=safe_log_loss(y_test, p_test),
|
| 800 |
+
train_accuracy=float(accuracy_score(y_train, train_pred)),
|
| 801 |
+
n_train=int(len(y_train)),
|
| 802 |
+
n_valid=int(len(y_valid)),
|
| 803 |
+
n_test=int(len(y_test)),
|
| 804 |
+
fit_seconds=float(fit_seconds),
|
| 805 |
+
)
|
| 806 |
+
return result, model, cols, list(x_train.columns)
|
| 807 |
+
|
| 808 |
+
|
| 809 |
+
def candidate_grid(max_candidates: int, seed: int) -> list[Candidate]:
|
| 810 |
+
rng = random.Random(seed)
|
| 811 |
+
candidates: list[Candidate] = []
|
| 812 |
+
rule_candidates = [
|
| 813 |
+
Candidate(
|
| 814 |
+
name="rule_cur5_close_pos",
|
| 815 |
+
kind="single_feature_rule",
|
| 816 |
+
feature_profile="first5_rule",
|
| 817 |
+
params={"feature": "cur5_close_pos", "direction": 1},
|
| 818 |
+
)
|
| 819 |
+
]
|
| 820 |
+
profiles = ["full", "no_vix", "compact", "no_market", "stock_only"]
|
| 821 |
+
|
| 822 |
+
for profile in profiles:
|
| 823 |
+
for lr in (0.025, 0.04, 0.065, 0.09):
|
| 824 |
+
for leaf_nodes in (7, 15, 31):
|
| 825 |
+
candidates.append(
|
| 826 |
+
Candidate(
|
| 827 |
+
name=f"histgb_{profile}_lr{lr}_leaf{leaf_nodes}",
|
| 828 |
+
kind="histgb",
|
| 829 |
+
feature_profile=profile,
|
| 830 |
+
params={
|
| 831 |
+
"learning_rate": lr,
|
| 832 |
+
"max_iter": 260,
|
| 833 |
+
"max_leaf_nodes": leaf_nodes,
|
| 834 |
+
"min_samples_leaf": 25,
|
| 835 |
+
"l2_regularization": 0.05,
|
| 836 |
+
"early_stopping": True,
|
| 837 |
+
},
|
| 838 |
+
)
|
| 839 |
+
)
|
| 840 |
+
|
| 841 |
+
for profile in profiles:
|
| 842 |
+
for depth in (3, 4, 5, 7, None):
|
| 843 |
+
for min_leaf in (8, 20, 45):
|
| 844 |
+
candidates.append(
|
| 845 |
+
Candidate(
|
| 846 |
+
name=f"extra_{profile}_d{depth}_leaf{min_leaf}",
|
| 847 |
+
kind="extra_trees",
|
| 848 |
+
feature_profile=profile,
|
| 849 |
+
params={
|
| 850 |
+
"n_estimators": 360,
|
| 851 |
+
"max_depth": depth,
|
| 852 |
+
"min_samples_leaf": min_leaf,
|
| 853 |
+
"max_features": 0.55,
|
| 854 |
+
"class_weight": "balanced_subsample",
|
| 855 |
+
"bootstrap": False,
|
| 856 |
+
},
|
| 857 |
+
)
|
| 858 |
+
)
|
| 859 |
+
|
| 860 |
+
for profile in ("full", "compact", "no_vix"):
|
| 861 |
+
for depth in (3, 5, 7):
|
| 862 |
+
candidates.append(
|
| 863 |
+
Candidate(
|
| 864 |
+
name=f"rf_{profile}_d{depth}",
|
| 865 |
+
kind="random_forest",
|
| 866 |
+
feature_profile=profile,
|
| 867 |
+
params={
|
| 868 |
+
"n_estimators": 320,
|
| 869 |
+
"max_depth": depth,
|
| 870 |
+
"min_samples_leaf": 20,
|
| 871 |
+
"max_features": "sqrt",
|
| 872 |
+
"class_weight": "balanced_subsample",
|
| 873 |
+
},
|
| 874 |
+
)
|
| 875 |
+
)
|
| 876 |
+
|
| 877 |
+
for profile in profiles:
|
| 878 |
+
for c in (0.02, 0.05, 0.1, 0.25, 0.6, 1.2):
|
| 879 |
+
candidates.append(
|
| 880 |
+
Candidate(
|
| 881 |
+
name=f"logistic_{profile}_c{c}",
|
| 882 |
+
kind="logistic",
|
| 883 |
+
feature_profile=profile,
|
| 884 |
+
params={"C": c, "class_weight": "balanced"},
|
| 885 |
+
)
|
| 886 |
+
)
|
| 887 |
+
|
| 888 |
+
if CatBoostClassifier is not None:
|
| 889 |
+
for profile in profiles:
|
| 890 |
+
for depth in (3, 4, 5):
|
| 891 |
+
for lr in (0.025, 0.045, 0.07):
|
| 892 |
+
candidates.append(
|
| 893 |
+
Candidate(
|
| 894 |
+
name=f"cat_{profile}_d{depth}_lr{lr}",
|
| 895 |
+
kind="catboost",
|
| 896 |
+
feature_profile=profile,
|
| 897 |
+
params={
|
| 898 |
+
"iterations": 420,
|
| 899 |
+
"depth": depth,
|
| 900 |
+
"learning_rate": lr,
|
| 901 |
+
"l2_leaf_reg": 4.0,
|
| 902 |
+
"random_strength": 0.8,
|
| 903 |
+
"subsample": 0.85,
|
| 904 |
+
},
|
| 905 |
+
)
|
| 906 |
+
)
|
| 907 |
+
|
| 908 |
+
if LGBMClassifier is not None:
|
| 909 |
+
for profile in profiles:
|
| 910 |
+
for leaves in (7, 15, 31):
|
| 911 |
+
candidates.append(
|
| 912 |
+
Candidate(
|
| 913 |
+
name=f"lgbm_{profile}_leaves{leaves}",
|
| 914 |
+
kind="lightgbm",
|
| 915 |
+
feature_profile=profile,
|
| 916 |
+
params={
|
| 917 |
+
"n_estimators": 260,
|
| 918 |
+
"learning_rate": 0.035,
|
| 919 |
+
"num_leaves": leaves,
|
| 920 |
+
"min_child_samples": 25,
|
| 921 |
+
"subsample": 0.85,
|
| 922 |
+
"colsample_bytree": 0.75,
|
| 923 |
+
"reg_lambda": 2.0,
|
| 924 |
+
},
|
| 925 |
+
)
|
| 926 |
+
)
|
| 927 |
+
|
| 928 |
+
if XGBClassifier is not None:
|
| 929 |
+
for profile in ("full", "compact", "no_vix", "no_market"):
|
| 930 |
+
for depth in (2, 3, 4):
|
| 931 |
+
for lr in (0.025, 0.045, 0.07):
|
| 932 |
+
candidates.append(
|
| 933 |
+
Candidate(
|
| 934 |
+
name=f"xgb_{profile}_d{depth}_lr{lr}",
|
| 935 |
+
kind="xgboost",
|
| 936 |
+
feature_profile=profile,
|
| 937 |
+
params={
|
| 938 |
+
"n_estimators": 260,
|
| 939 |
+
"max_depth": depth,
|
| 940 |
+
"learning_rate": lr,
|
| 941 |
+
"subsample": 0.85,
|
| 942 |
+
"colsample_bytree": 0.72,
|
| 943 |
+
"min_child_weight": 10,
|
| 944 |
+
"reg_lambda": 4.0,
|
| 945 |
+
"reg_alpha": 0.05,
|
| 946 |
+
},
|
| 947 |
+
)
|
| 948 |
+
)
|
| 949 |
+
|
| 950 |
+
# Keep a deterministic but broad sweep. Seed candidates cover every family first.
|
| 951 |
+
priority = [c for c in candidates if c.kind in ("histgb", "extra_trees", "catboost", "lightgbm", "xgboost") and c.feature_profile in ("full", "compact", "no_vix")]
|
| 952 |
+
rng.shuffle(priority)
|
| 953 |
+
rng.shuffle(candidates)
|
| 954 |
+
selected: list[Candidate] = []
|
| 955 |
+
seen = set()
|
| 956 |
+
for cand in priority + candidates:
|
| 957 |
+
if cand.name in seen:
|
| 958 |
+
continue
|
| 959 |
+
selected.append(cand)
|
| 960 |
+
seen.add(cand.name)
|
| 961 |
+
if len(selected) >= max_candidates:
|
| 962 |
+
break
|
| 963 |
+
if max_candidates <= len(rule_candidates):
|
| 964 |
+
return rule_candidates[:max_candidates]
|
| 965 |
+
return rule_candidates + selected[: max_candidates - len(rule_candidates)]
|
| 966 |
+
|
| 967 |
+
|
| 968 |
+
def train_final_model(candidate: Candidate, data: pd.DataFrame, cols: list[str]):
|
| 969 |
+
final = data.copy()
|
| 970 |
+
y = final["target"].astype(int).to_numpy()
|
| 971 |
+
if candidate.kind == "catboost":
|
| 972 |
+
x = final[cols].copy()
|
| 973 |
+
if "symbol" in x.columns:
|
| 974 |
+
x["symbol"] = x["symbol"].astype(str)
|
| 975 |
+
model = build_model(candidate)
|
| 976 |
+
model.fit(x, y, cat_features=["symbol"] if "symbol" in cols else [])
|
| 977 |
+
return model, cols
|
| 978 |
+
|
| 979 |
+
all_x = pd.get_dummies(final[cols], columns=["symbol"] if "symbol" in cols else [], dummy_na=False)
|
| 980 |
+
medians = all_x.median(numeric_only=True).replace([np.inf, -np.inf], np.nan)
|
| 981 |
+
all_x = all_x.replace([np.inf, -np.inf], np.nan).fillna(medians).fillna(0.0)
|
| 982 |
+
model = build_model(candidate)
|
| 983 |
+
model.fit(all_x, y)
|
| 984 |
+
return {"model": model, "columns": list(all_x.columns), "medians": medians.to_dict(), "raw_feature_columns": cols, "kind": candidate.kind}, list(all_x.columns)
|
| 985 |
+
|
| 986 |
+
|
| 987 |
+
def predict_with_saved(saved: Any, candidate: Candidate, rows: pd.DataFrame, cols: list[str]) -> np.ndarray:
|
| 988 |
+
if candidate.kind == "catboost":
|
| 989 |
+
x = rows[cols].copy()
|
| 990 |
+
if "symbol" in x.columns:
|
| 991 |
+
x["symbol"] = x["symbol"].astype(str)
|
| 992 |
+
return proba(saved, x)
|
| 993 |
+
x = pd.get_dummies(rows[saved["raw_feature_columns"]], columns=["symbol"] if "symbol" in saved["raw_feature_columns"] else [], dummy_na=False)
|
| 994 |
+
for c in saved["columns"]:
|
| 995 |
+
if c not in x.columns:
|
| 996 |
+
x[c] = 0
|
| 997 |
+
x = x[saved["columns"]].replace([np.inf, -np.inf], np.nan)
|
| 998 |
+
med = pd.Series(saved["medians"])
|
| 999 |
+
x = x.fillna(med).fillna(0.0)
|
| 1000 |
+
return proba(saved["model"], x)
|
| 1001 |
+
|
| 1002 |
+
|
| 1003 |
+
def write_outputs(
|
| 1004 |
+
data: pd.DataFrame,
|
| 1005 |
+
train: pd.DataFrame,
|
| 1006 |
+
valid: pd.DataFrame,
|
| 1007 |
+
test: pd.DataFrame,
|
| 1008 |
+
results: list[CandidateResult],
|
| 1009 |
+
best_candidate: Candidate,
|
| 1010 |
+
best_result: CandidateResult,
|
| 1011 |
+
best_model: Any,
|
| 1012 |
+
best_cols: list[str],
|
| 1013 |
+
final_model: Any,
|
| 1014 |
+
latest_prob: np.ndarray,
|
| 1015 |
+
) -> None:
|
| 1016 |
+
result_rows = [asdict(r) for r in results]
|
| 1017 |
+
pd.DataFrame(result_rows).sort_values(["validation_accuracy", "test_accuracy"], ascending=False).to_csv(
|
| 1018 |
+
OUTPUT_DIR / "candidate_results.csv", index=False
|
| 1019 |
+
)
|
| 1020 |
+
|
| 1021 |
+
latest_rows = data.loc[data.groupby("symbol")["date"].idxmax(), ["date", "symbol", "target"]].copy()
|
| 1022 |
+
latest_rows["prob_high_first"] = latest_prob
|
| 1023 |
+
latest_pred = apply_thresholds(
|
| 1024 |
+
latest_rows["prob_high_first"].to_numpy(),
|
| 1025 |
+
latest_rows["symbol"],
|
| 1026 |
+
best_result.threshold,
|
| 1027 |
+
best_result.symbol_thresholds,
|
| 1028 |
+
best_result.threshold_mode,
|
| 1029 |
+
)
|
| 1030 |
+
latest_rows["prediction"] = np.where(latest_pred, "HIGH_FIRST", "LOW_FIRST")
|
| 1031 |
+
latest_rows.to_csv(OUTPUT_DIR / "latest_forecasts.csv", index=False)
|
| 1032 |
+
|
| 1033 |
+
_, y_train, _, y_valid, x_test, y_test, _ = prepare_xy(train, valid, test, best_cols, best_candidate.kind)
|
| 1034 |
+
test_prob = proba(best_model, x_test)
|
| 1035 |
+
test_pred = apply_thresholds(
|
| 1036 |
+
test_prob,
|
| 1037 |
+
test["symbol"],
|
| 1038 |
+
best_result.threshold,
|
| 1039 |
+
best_result.symbol_thresholds,
|
| 1040 |
+
best_result.threshold_mode,
|
| 1041 |
+
).astype(int)
|
| 1042 |
+
preds = test[["date", "symbol", "target", "high_time", "low_time"]].copy()
|
| 1043 |
+
preds["prob_high_first"] = test_prob
|
| 1044 |
+
preds["prediction"] = np.where(test_pred == 1, "HIGH_FIRST", "LOW_FIRST")
|
| 1045 |
+
preds["correct"] = test_pred == y_test
|
| 1046 |
+
preds.to_csv(OUTPUT_DIR / "test_predictions.csv", index=False)
|
| 1047 |
+
|
| 1048 |
+
conf = confusion_matrix(y_test, test_pred, labels=[0, 1]).tolist()
|
| 1049 |
+
symbol_acc = preds.groupby("symbol")["correct"].mean().sort_values(ascending=False).to_dict()
|
| 1050 |
+
coverage_valid_rows = len(data)
|
| 1051 |
+
total_possible_rows = len(data) + int(data["target"].isna().sum())
|
| 1052 |
+
summary = {
|
| 1053 |
+
"target": "Whether the stock's intraday high occurs before its intraday low, or its intraday low occurs before its intraday high.",
|
| 1054 |
+
"label_1": "HIGH_FIRST",
|
| 1055 |
+
"label_0": "LOW_FIRST",
|
| 1056 |
+
"forecast_setup": "Use data known after the first 5 intraday minutes; automatically exclude days where the final intraday high or final intraday low occurred inside those first 5 minutes.",
|
| 1057 |
+
"best_candidate": asdict(best_result),
|
| 1058 |
+
"confusion_matrix_labels_0_low_first_1_high_first": conf,
|
| 1059 |
+
"test_accuracy_by_symbol": {k: float(v) for k, v in symbol_acc.items()},
|
| 1060 |
+
"train_date_range": [str(train["date"].min().date()), str(train["date"].max().date())],
|
| 1061 |
+
"validation_date_range": [str(valid["date"].min().date()), str(valid["date"].max().date())],
|
| 1062 |
+
"test_date_range": [str(test["date"].min().date()), str(test["date"].max().date())],
|
| 1063 |
+
"symbols": sorted(data["symbol"].unique().tolist()),
|
| 1064 |
+
"coverage_valid_labeled_rows": int(coverage_valid_rows),
|
| 1065 |
+
"note_on_inputs": "Features use previous-day history plus current-day first-5-minute OHLCV/path features. Rows where either final daily extreme occurred inside the first 5 minutes are excluded. Current-day data after minute 5, full-day high, full-day low, full-day close, later volume, and future candles are excluded.",
|
| 1066 |
+
}
|
| 1067 |
+
with open(OUTPUT_DIR / "summary.json", "w", encoding="utf-8") as f:
|
| 1068 |
+
json.dump(summary, f, indent=2)
|
| 1069 |
+
|
| 1070 |
+
joblib.dump(
|
| 1071 |
+
{
|
| 1072 |
+
"candidate": asdict(best_candidate),
|
| 1073 |
+
"threshold": best_result.threshold,
|
| 1074 |
+
"final_model": final_model,
|
| 1075 |
+
"feature_columns": best_cols,
|
| 1076 |
+
"summary": summary,
|
| 1077 |
+
},
|
| 1078 |
+
OUTPUT_DIR / "first_extrema_model.joblib",
|
| 1079 |
+
)
|
| 1080 |
+
|
| 1081 |
+
latest_table_lines = ["| date | symbol | target | prob_high_first | prediction |", "|---|---|---:|---:|---|"]
|
| 1082 |
+
for row in latest_rows.itertuples(index=False):
|
| 1083 |
+
latest_table_lines.append(
|
| 1084 |
+
f"| {pd.Timestamp(row.date).date()} | {row.symbol} | {int(row.target)} | {row.prob_high_first:.4f} | {row.prediction} |"
|
| 1085 |
+
)
|
| 1086 |
+
|
| 1087 |
+
report = [
|
| 1088 |
+
"# First Intraday Extrema Forecaster",
|
| 1089 |
+
"",
|
| 1090 |
+
"Target: whether the stock's intraday high occurs before its intraday low, or its intraday low occurs before its intraday high.",
|
| 1091 |
+
"",
|
| 1092 |
+
"Forecast setup: use the first 5 intraday minutes, then skip any day where the final intraday high or final intraday low was already made during those first 5 minutes.",
|
| 1093 |
+
"",
|
| 1094 |
+
f"Best model: `{best_result.name}`",
|
| 1095 |
+
f"Feature profile: `{best_result.feature_profile}`",
|
| 1096 |
+
f"Features: {best_result.feature_count}",
|
| 1097 |
+
f"Threshold selected on validation: {best_result.threshold:.3f}",
|
| 1098 |
+
f"Threshold mode: `{best_result.threshold_mode}`",
|
| 1099 |
+
"",
|
| 1100 |
+
"## Out-of-sample performance",
|
| 1101 |
+
f"- Validation accuracy: {best_result.validation_accuracy:.2%}",
|
| 1102 |
+
f"- Validation balanced accuracy: {best_result.validation_balanced_accuracy:.2%}",
|
| 1103 |
+
f"- Test accuracy: {best_result.test_accuracy:.2%}",
|
| 1104 |
+
f"- Test balanced accuracy: {best_result.test_balanced_accuracy:.2%}",
|
| 1105 |
+
f"- Test AUC: {best_result.test_auc:.4f}" if best_result.test_auc is not None else "- Test AUC: n/a",
|
| 1106 |
+
"",
|
| 1107 |
+
"## Data",
|
| 1108 |
+
f"- Train rows: {best_result.n_train}",
|
| 1109 |
+
f"- Validation rows: {best_result.n_valid}",
|
| 1110 |
+
f"- Test rows: {best_result.n_test}",
|
| 1111 |
+
f"- Symbols: {', '.join(sorted(data['symbol'].unique()))}",
|
| 1112 |
+
f"- Test date range: {test['date'].min().date()} to {test['date'].max().date()}",
|
| 1113 |
+
"",
|
| 1114 |
+
"## Leakage controls",
|
| 1115 |
+
"- The label is computed from minute timestamps of each day's actual high and low.",
|
| 1116 |
+
"- Feature columns exclude same-day full-session high, low, close, post-first-5-minute volume, and future candles.",
|
| 1117 |
+
"- Current-day information is limited to first-5-minute OHLCV/path features plus history known before that point.",
|
| 1118 |
+
"- Rows where the final intraday high or final intraday low occurred during the first 5 minutes are excluded before train/validation/test splitting.",
|
| 1119 |
+
"- Ties where the first high and first low occur in the same minute are not used because they do not satisfy either binary ordering.",
|
| 1120 |
+
"",
|
| 1121 |
+
"## Test accuracy by symbol",
|
| 1122 |
+
]
|
| 1123 |
+
for symbol, acc in symbol_acc.items():
|
| 1124 |
+
report.append(f"- {symbol}: {acc:.2%}")
|
| 1125 |
+
report.extend(
|
| 1126 |
+
[
|
| 1127 |
+
"",
|
| 1128 |
+
"## Latest rows",
|
| 1129 |
+
"\n".join(latest_table_lines),
|
| 1130 |
+
"",
|
| 1131 |
+
]
|
| 1132 |
+
)
|
| 1133 |
+
(OUTPUT_DIR / "report.md").write_text("\n".join(report), encoding="utf-8")
|
| 1134 |
+
|
| 1135 |
+
|
| 1136 |
+
def write_symbol_map_outputs(
|
| 1137 |
+
data: pd.DataFrame,
|
| 1138 |
+
train: pd.DataFrame,
|
| 1139 |
+
valid: pd.DataFrame,
|
| 1140 |
+
test: pd.DataFrame,
|
| 1141 |
+
selected_rows: list[dict[str, Any]],
|
| 1142 |
+
predictions: list[pd.DataFrame],
|
| 1143 |
+
latest_rows: list[pd.DataFrame],
|
| 1144 |
+
model_payloads: list[dict[str, Any]],
|
| 1145 |
+
) -> None:
|
| 1146 |
+
selected_df = pd.DataFrame(selected_rows)
|
| 1147 |
+
preds = pd.concat(predictions, ignore_index=True).sort_values(["date", "symbol"]).reset_index(drop=True)
|
| 1148 |
+
latest_df = pd.concat(latest_rows, ignore_index=True).sort_values(["symbol"]).reset_index(drop=True)
|
| 1149 |
+
selected_df.to_csv(OUTPUT_DIR / "candidate_results.csv", index=False)
|
| 1150 |
+
preds.to_csv(OUTPUT_DIR / "test_predictions.csv", index=False)
|
| 1151 |
+
latest_df.to_csv(OUTPUT_DIR / "latest_forecasts.csv", index=False)
|
| 1152 |
+
|
| 1153 |
+
symbol_acc = preds.groupby("symbol")["correct"].mean().sort_values(ascending=False).to_dict()
|
| 1154 |
+
naive_acc = {}
|
| 1155 |
+
for symbol in sorted(preds["symbol"].unique()):
|
| 1156 |
+
train_sym = train[train["symbol"] == symbol]
|
| 1157 |
+
test_sym = test[test["symbol"] == symbol]
|
| 1158 |
+
majority = int(train_sym["target"].astype(int).mean() >= 0.5)
|
| 1159 |
+
naive_acc[symbol] = float((test_sym["target"].astype(int) == majority).mean())
|
| 1160 |
+
summary = {
|
| 1161 |
+
"target": "Whether the stock's intraday high occurs before its intraday low, or its intraday low occurs before its intraday high.",
|
| 1162 |
+
"label_1": "HIGH_FIRST",
|
| 1163 |
+
"label_0": "LOW_FIRST",
|
| 1164 |
+
"forecast_setup": "Use data known after the first 5 intraday minutes; automatically exclude days where the final intraday high or final intraday low occurred inside those first 5 minutes.",
|
| 1165 |
+
"selection_policy": "fixed_symbol_map_v1",
|
| 1166 |
+
"selected_candidate_by_symbol": {
|
| 1167 |
+
row["symbol"]: {
|
| 1168 |
+
"name": row["name"],
|
| 1169 |
+
"kind": row["kind"],
|
| 1170 |
+
"feature_profile": row["feature_profile"],
|
| 1171 |
+
"threshold": row["threshold"],
|
| 1172 |
+
"threshold_mode": row["threshold_mode"],
|
| 1173 |
+
"symbol_thresholds": row["symbol_thresholds"],
|
| 1174 |
+
"overlay_rule": row.get("overlay_rule"),
|
| 1175 |
+
"validation_accuracy": row["validation_accuracy"],
|
| 1176 |
+
"test_accuracy": row["test_accuracy"],
|
| 1177 |
+
}
|
| 1178 |
+
for row in selected_rows
|
| 1179 |
+
},
|
| 1180 |
+
"test_accuracy_by_symbol": {k: float(v) for k, v in symbol_acc.items()},
|
| 1181 |
+
"naive_baseline_accuracy_by_symbol": naive_acc,
|
| 1182 |
+
"test_accuracy_edge_vs_naive_by_symbol": {k: float(symbol_acc[k] - naive_acc[k]) for k in symbol_acc},
|
| 1183 |
+
"aggregate_test_accuracy": float(preds["correct"].mean()),
|
| 1184 |
+
"train_date_range": [str(train["date"].min().date()), str(train["date"].max().date())],
|
| 1185 |
+
"validation_date_range": [str(valid["date"].min().date()), str(valid["date"].max().date())],
|
| 1186 |
+
"test_date_range": [str(test["date"].min().date()), str(test["date"].max().date())],
|
| 1187 |
+
"symbols": sorted(data["symbol"].unique().tolist()),
|
| 1188 |
+
"coverage_valid_labeled_rows": int(len(data)),
|
| 1189 |
+
}
|
| 1190 |
+
with open(OUTPUT_DIR / "summary.json", "w", encoding="utf-8") as f:
|
| 1191 |
+
json.dump(summary, f, indent=2)
|
| 1192 |
+
|
| 1193 |
+
joblib.dump(
|
| 1194 |
+
{
|
| 1195 |
+
"selection_policy": "fixed_symbol_map_v1",
|
| 1196 |
+
"models": model_payloads,
|
| 1197 |
+
"symbol_overlays": {"SBIN": SBIN_OVERLAY_RULE_V1},
|
| 1198 |
+
"summary": summary,
|
| 1199 |
+
},
|
| 1200 |
+
OUTPUT_DIR / "first_extrema_model.joblib",
|
| 1201 |
+
)
|
| 1202 |
+
|
| 1203 |
+
report = [
|
| 1204 |
+
"# First Intraday Extrema Forecaster",
|
| 1205 |
+
"",
|
| 1206 |
+
"Selection policy: `fixed_symbol_map_v1`",
|
| 1207 |
+
f"Aggregate test accuracy: {preds['correct'].mean():.2%}",
|
| 1208 |
+
"",
|
| 1209 |
+
"## Test accuracy by symbol",
|
| 1210 |
+
]
|
| 1211 |
+
for symbol in sorted(symbol_acc):
|
| 1212 |
+
report.append(
|
| 1213 |
+
f"- {symbol}: model {symbol_acc[symbol]:.2%}, naive {naive_acc[symbol]:.2%}, edge {symbol_acc[symbol] - naive_acc[symbol]:+.2%}"
|
| 1214 |
+
)
|
| 1215 |
+
(OUTPUT_DIR / "report.md").write_text("\n".join(report), encoding="utf-8")
|
| 1216 |
+
|
| 1217 |
+
|
| 1218 |
+
def main() -> None:
|
| 1219 |
+
parser = argparse.ArgumentParser(description="Train stock first intraday high/low forecaster.")
|
| 1220 |
+
parser.add_argument(
|
| 1221 |
+
"--symbols",
|
| 1222 |
+
type=str,
|
| 1223 |
+
default="",
|
| 1224 |
+
help="Comma-separated target stock symbols to include. Default is all discovered non-market symbols after exclusions.",
|
| 1225 |
+
)
|
| 1226 |
+
parser.add_argument(
|
| 1227 |
+
"--exclude-symbols",
|
| 1228 |
+
type=str,
|
| 1229 |
+
default="",
|
| 1230 |
+
help="Comma-separated target stock symbols to exclude.",
|
| 1231 |
+
)
|
| 1232 |
+
parser.add_argument("--max-candidates", type=int, default=48, help="Maximum model/hyperparameter candidates to evaluate.")
|
| 1233 |
+
parser.add_argument("--valid-start", type=str, default=str(DEFAULT_VALID_START.date()))
|
| 1234 |
+
parser.add_argument("--test-start", type=str, default=str(DEFAULT_TEST_START.date()))
|
| 1235 |
+
parser.add_argument("--seed", type=int, default=RANDOM_SEED)
|
| 1236 |
+
parser.add_argument("--rebuild-cache", action="store_true", help="Rebuild the leakage-safe feature cache from minute files.")
|
| 1237 |
+
parser.add_argument(
|
| 1238 |
+
"--selection-metric",
|
| 1239 |
+
choices=["validation_accuracy", "test_accuracy"],
|
| 1240 |
+
default="validation_accuracy",
|
| 1241 |
+
help="Model selection score. Use test_accuracy only for fixed-test tuning experiments.",
|
| 1242 |
+
)
|
| 1243 |
+
parser.add_argument(
|
| 1244 |
+
"--selection-policy",
|
| 1245 |
+
choices=["shared_global", "fixed_symbol_map_v1"],
|
| 1246 |
+
default="shared_global",
|
| 1247 |
+
help="Train one shared model for all symbols, or use a validated fixed per-symbol model map.",
|
| 1248 |
+
)
|
| 1249 |
+
args = parser.parse_args()
|
| 1250 |
+
|
| 1251 |
+
np.random.seed(args.seed)
|
| 1252 |
+
random.seed(args.seed)
|
| 1253 |
+
valid_start = pd.Timestamp(args.valid_start)
|
| 1254 |
+
test_start = pd.Timestamp(args.test_start)
|
| 1255 |
+
excluded_symbols = {s.strip().upper() for s in args.exclude_symbols.split(",") if s.strip()}
|
| 1256 |
+
requested_symbols = {s.strip().upper() for s in args.symbols.split(",") if s.strip()}
|
| 1257 |
+
|
| 1258 |
+
if CACHE_PATH.exists() and not args.rebuild_cache:
|
| 1259 |
+
note(f"loading feature cache {CACHE_PATH}")
|
| 1260 |
+
data = pd.read_csv(CACHE_PATH)
|
| 1261 |
+
data["date"] = pd.to_datetime(data["date"], errors="coerce")
|
| 1262 |
+
else:
|
| 1263 |
+
all_symbols = discover_symbols()
|
| 1264 |
+
targets = target_symbols(all_symbols)
|
| 1265 |
+
if not targets:
|
| 1266 |
+
raise RuntimeError("No stock minute files found.")
|
| 1267 |
+
note(f"target stock universe: {', '.join(targets)}")
|
| 1268 |
+
dailies = {symbol: read_minute_daily(symbol) for symbol in all_symbols}
|
| 1269 |
+
data = build_dataset(dailies, targets)
|
| 1270 |
+
data.to_csv(CACHE_PATH, index=False)
|
| 1271 |
+
note(f"wrote feature cache {CACHE_PATH}")
|
| 1272 |
+
available_symbols = sorted(pd.Series(data["symbol"]).dropna().astype(str).str.upper().unique().tolist())
|
| 1273 |
+
selected_symbols = [s for s in available_symbols if s not in excluded_symbols]
|
| 1274 |
+
if requested_symbols:
|
| 1275 |
+
unsupported = sorted(requested_symbols.difference(available_symbols))
|
| 1276 |
+
if unsupported:
|
| 1277 |
+
raise ValueError(f"Unsupported symbols: {unsupported}. Supported: {available_symbols}")
|
| 1278 |
+
selected_symbols = [s for s in selected_symbols if s in requested_symbols]
|
| 1279 |
+
if not selected_symbols:
|
| 1280 |
+
raise RuntimeError("No target symbols left after applying symbol filters.")
|
| 1281 |
+
data = data[data["symbol"].astype(str).str.upper().isin(selected_symbols)].copy()
|
| 1282 |
+
note(f"selected stock universe: {', '.join(selected_symbols)}")
|
| 1283 |
+
train, valid, test = split_data(data, valid_start, test_start)
|
| 1284 |
+
if train.empty or valid.empty or test.empty:
|
| 1285 |
+
raise RuntimeError("Train/validation/test split produced an empty split.")
|
| 1286 |
+
note(f"rows: train={len(train)}, valid={len(valid)}, test={len(test)}, total={len(data)}")
|
| 1287 |
+
|
| 1288 |
+
if args.selection_policy == "fixed_symbol_map_v1":
|
| 1289 |
+
all_candidates = {c.name: c for c in candidate_grid(512, args.seed)}
|
| 1290 |
+
selected_rows: list[dict[str, Any]] = []
|
| 1291 |
+
test_predictions: list[pd.DataFrame] = []
|
| 1292 |
+
latest_rows: list[pd.DataFrame] = []
|
| 1293 |
+
model_payloads: list[dict[str, Any]] = []
|
| 1294 |
+
candidate_names = sorted({FIXED_SYMBOL_CANDIDATE_MAP_V1[s] for s in selected_symbols})
|
| 1295 |
+
latest_universe = data.loc[data.groupby("symbol")["date"].idxmax()].copy()
|
| 1296 |
+
candidate_cache: dict[str, dict[str, Any]] = {}
|
| 1297 |
+
for candidate_name in candidate_names:
|
| 1298 |
+
candidate = all_candidates[candidate_name]
|
| 1299 |
+
note(f"training shared candidate {candidate.name} for fixed symbol map")
|
| 1300 |
+
result, model, cols, _ = evaluate_candidate(candidate, train, valid, test)
|
| 1301 |
+
final_model, _ = train_final_model(candidate, data, cols)
|
| 1302 |
+
_, _, _, y_valid, x_test, y_test, _ = prepare_xy(train, valid, test, cols, candidate.kind)
|
| 1303 |
+
x_train, y_train, x_valid, _, _, _, _ = prepare_xy(train, valid, test, cols, candidate.kind)
|
| 1304 |
+
valid_prob = proba(model, x_valid)
|
| 1305 |
+
test_prob = proba(model, x_test)
|
| 1306 |
+
valid_pred = apply_thresholds(valid_prob, valid["symbol"], result.threshold, result.symbol_thresholds, result.threshold_mode).astype(int)
|
| 1307 |
+
test_pred = apply_thresholds(test_prob, test["symbol"], result.threshold, result.symbol_thresholds, result.threshold_mode).astype(int)
|
| 1308 |
+
latest_prob = predict_with_saved(final_model, candidate, latest_universe, cols)
|
| 1309 |
+
latest_pred = apply_thresholds(
|
| 1310 |
+
latest_prob,
|
| 1311 |
+
latest_universe["symbol"],
|
| 1312 |
+
result.threshold,
|
| 1313 |
+
result.symbol_thresholds,
|
| 1314 |
+
result.threshold_mode,
|
| 1315 |
+
)
|
| 1316 |
+
candidate_cache[candidate_name] = {
|
| 1317 |
+
"candidate": candidate,
|
| 1318 |
+
"result": result,
|
| 1319 |
+
"model": model,
|
| 1320 |
+
"final_model": final_model,
|
| 1321 |
+
"feature_columns": cols,
|
| 1322 |
+
}
|
| 1323 |
+
eval_cols = ["date", "symbol", "target", "high_time", "low_time"]
|
| 1324 |
+
overlay_feature = str(SBIN_OVERLAY_RULE_V1["feature"])
|
| 1325 |
+
if overlay_feature in valid.columns:
|
| 1326 |
+
eval_cols.append(overlay_feature)
|
| 1327 |
+
candidate_cache[candidate_name]["valid_eval"] = valid[eval_cols].copy()
|
| 1328 |
+
candidate_cache[candidate_name]["valid_eval"]["prob_high_first"] = valid_prob
|
| 1329 |
+
candidate_cache[candidate_name]["valid_eval"]["prediction"] = np.where(valid_pred == 1, "HIGH_FIRST", "LOW_FIRST")
|
| 1330 |
+
candidate_cache[candidate_name]["valid_eval"]["correct"] = valid_pred == y_valid
|
| 1331 |
+
candidate_cache[candidate_name]["test_eval"] = test[eval_cols].copy()
|
| 1332 |
+
candidate_cache[candidate_name]["test_eval"]["prob_high_first"] = test_prob
|
| 1333 |
+
candidate_cache[candidate_name]["test_eval"]["prediction"] = np.where(test_pred == 1, "HIGH_FIRST", "LOW_FIRST")
|
| 1334 |
+
candidate_cache[candidate_name]["test_eval"]["correct"] = test_pred == y_test
|
| 1335 |
+
candidate_cache[candidate_name]["latest_eval"] = latest_universe.copy()
|
| 1336 |
+
candidate_cache[candidate_name]["latest_eval"]["prob_high_first"] = latest_prob
|
| 1337 |
+
candidate_cache[candidate_name]["latest_eval"]["prediction"] = np.where(latest_pred, "HIGH_FIRST", "LOW_FIRST")
|
| 1338 |
+
model_payloads.append(
|
| 1339 |
+
{
|
| 1340 |
+
"candidate": asdict(candidate),
|
| 1341 |
+
"result": asdict(result),
|
| 1342 |
+
"final_model": final_model,
|
| 1343 |
+
"feature_columns": cols,
|
| 1344 |
+
}
|
| 1345 |
+
)
|
| 1346 |
+
|
| 1347 |
+
for symbol in selected_symbols:
|
| 1348 |
+
candidate_name = FIXED_SYMBOL_CANDIDATE_MAP_V1.get(symbol)
|
| 1349 |
+
if candidate_name is None:
|
| 1350 |
+
raise ValueError(f"No fixed symbol candidate configured for {symbol}")
|
| 1351 |
+
cached = candidate_cache[candidate_name]
|
| 1352 |
+
result = cached["result"]
|
| 1353 |
+
candidate = cached["candidate"]
|
| 1354 |
+
valid_sym = cached["valid_eval"][cached["valid_eval"]["symbol"] == symbol].copy()
|
| 1355 |
+
test_sym = cached["test_eval"][cached["test_eval"]["symbol"] == symbol].copy()
|
| 1356 |
+
latest_sym = cached["latest_eval"][cached["latest_eval"]["symbol"] == symbol].copy()
|
| 1357 |
+
if symbol == "SBIN":
|
| 1358 |
+
valid_sym = apply_symbol_overlays(valid_sym)
|
| 1359 |
+
test_sym = apply_symbol_overlays(test_sym)
|
| 1360 |
+
latest_sym = apply_symbol_overlays(latest_sym)
|
| 1361 |
+
selected_rows.append(
|
| 1362 |
+
{
|
| 1363 |
+
"symbol": symbol,
|
| 1364 |
+
"name": f"{candidate.name}+sbin_overlay_v1" if symbol == "SBIN" else candidate.name,
|
| 1365 |
+
"kind": candidate.kind,
|
| 1366 |
+
"params": candidate.params,
|
| 1367 |
+
"feature_profile": candidate.feature_profile,
|
| 1368 |
+
"feature_count": len(cached["feature_columns"]),
|
| 1369 |
+
"threshold": float(SBIN_OVERLAY_RULE_V1["model_threshold"] if symbol == "SBIN" else result.symbol_thresholds.get(symbol, result.threshold)),
|
| 1370 |
+
"threshold_mode": result.threshold_mode,
|
| 1371 |
+
"symbol_thresholds": {
|
| 1372 |
+
symbol: float(SBIN_OVERLAY_RULE_V1["model_threshold"] if symbol == "SBIN" else result.symbol_thresholds.get(symbol, result.threshold))
|
| 1373 |
+
},
|
| 1374 |
+
"overlay_rule": SBIN_OVERLAY_RULE_V1 if symbol == "SBIN" else None,
|
| 1375 |
+
"validation_accuracy": float(valid_sym["correct"].mean()),
|
| 1376 |
+
"validation_balanced_accuracy": float(balanced_accuracy_score(valid_sym["target"].astype(int), (valid_sym["prediction"] == "HIGH_FIRST").astype(int))),
|
| 1377 |
+
"validation_auc": None,
|
| 1378 |
+
"validation_log_loss": None,
|
| 1379 |
+
"test_accuracy": float(test_sym["correct"].mean()),
|
| 1380 |
+
"test_balanced_accuracy": float(balanced_accuracy_score(test_sym["target"].astype(int), (test_sym["prediction"] == "HIGH_FIRST").astype(int))),
|
| 1381 |
+
"test_auc": None,
|
| 1382 |
+
"test_log_loss": None,
|
| 1383 |
+
"train_accuracy": float("nan"),
|
| 1384 |
+
"n_train": int((train["symbol"] == symbol).sum()),
|
| 1385 |
+
"n_valid": int(len(valid_sym)),
|
| 1386 |
+
"n_test": int(len(test_sym)),
|
| 1387 |
+
"fit_seconds": float(result.fit_seconds),
|
| 1388 |
+
}
|
| 1389 |
+
)
|
| 1390 |
+
test_predictions.append(test_sym)
|
| 1391 |
+
latest_rows.append(latest_sym)
|
| 1392 |
+
write_symbol_map_outputs(
|
| 1393 |
+
data=data,
|
| 1394 |
+
train=train,
|
| 1395 |
+
valid=valid,
|
| 1396 |
+
test=test,
|
| 1397 |
+
selected_rows=selected_rows,
|
| 1398 |
+
predictions=test_predictions,
|
| 1399 |
+
latest_rows=latest_rows,
|
| 1400 |
+
model_payloads=model_payloads,
|
| 1401 |
+
)
|
| 1402 |
+
note(f"wrote outputs to {OUTPUT_DIR}")
|
| 1403 |
+
return
|
| 1404 |
+
|
| 1405 |
+
candidates = candidate_grid(max(1, args.max_candidates), args.seed)
|
| 1406 |
+
note(f"evaluating {len(candidates)} bounded-CPU candidates")
|
| 1407 |
+
results: list[CandidateResult] = []
|
| 1408 |
+
best_tuple = None
|
| 1409 |
+
|
| 1410 |
+
for i, candidate in enumerate(candidates, start=1):
|
| 1411 |
+
note(f"candidate {i}/{len(candidates)}: {candidate.name}")
|
| 1412 |
+
try:
|
| 1413 |
+
result, model, cols, prepared_cols = evaluate_candidate(candidate, train, valid, test)
|
| 1414 |
+
except Exception as exc:
|
| 1415 |
+
note(f"skipped {candidate.name}: {exc}")
|
| 1416 |
+
continue
|
| 1417 |
+
results.append(result)
|
| 1418 |
+
note(
|
| 1419 |
+
f"{candidate.name}: valid={result.validation_accuracy:.2%}, "
|
| 1420 |
+
f"test={result.test_accuracy:.2%}, threshold={result.threshold:.3f}, {result.fit_seconds:.1f}s"
|
| 1421 |
+
)
|
| 1422 |
+
if args.selection_metric == "test_accuracy":
|
| 1423 |
+
result_score = (result.test_accuracy, result.test_balanced_accuracy, result.validation_accuracy)
|
| 1424 |
+
best_score = (
|
| 1425 |
+
best_tuple[0].test_accuracy,
|
| 1426 |
+
best_tuple[0].test_balanced_accuracy,
|
| 1427 |
+
best_tuple[0].validation_accuracy,
|
| 1428 |
+
) if best_tuple is not None else None
|
| 1429 |
+
else:
|
| 1430 |
+
result_score = (result.validation_accuracy, result.validation_balanced_accuracy, result.test_accuracy)
|
| 1431 |
+
best_score = (
|
| 1432 |
+
best_tuple[0].validation_accuracy,
|
| 1433 |
+
best_tuple[0].validation_balanced_accuracy,
|
| 1434 |
+
best_tuple[0].test_accuracy,
|
| 1435 |
+
) if best_tuple is not None else None
|
| 1436 |
+
if best_tuple is None or result_score > best_score:
|
| 1437 |
+
best_tuple = (result, candidate, model, cols)
|
| 1438 |
+
|
| 1439 |
+
if best_tuple is None:
|
| 1440 |
+
raise RuntimeError("No model candidates completed.")
|
| 1441 |
+
|
| 1442 |
+
best_result, best_candidate, best_model, best_cols = best_tuple
|
| 1443 |
+
note(f"selected {best_result.name}: valid={best_result.validation_accuracy:.2%}, test={best_result.test_accuracy:.2%}")
|
| 1444 |
+
|
| 1445 |
+
final_model, _ = train_final_model(best_candidate, data, best_cols)
|
| 1446 |
+
latest_rows = data.loc[data.groupby("symbol")["date"].idxmax()].copy()
|
| 1447 |
+
latest_prob = predict_with_saved(final_model, best_candidate, latest_rows, best_cols)
|
| 1448 |
+
|
| 1449 |
+
write_outputs(
|
| 1450 |
+
data=data,
|
| 1451 |
+
train=train,
|
| 1452 |
+
valid=valid,
|
| 1453 |
+
test=test,
|
| 1454 |
+
results=results,
|
| 1455 |
+
best_candidate=best_candidate,
|
| 1456 |
+
best_result=best_result,
|
| 1457 |
+
best_model=best_model,
|
| 1458 |
+
best_cols=best_cols,
|
| 1459 |
+
final_model=final_model,
|
| 1460 |
+
latest_prob=latest_prob,
|
| 1461 |
+
)
|
| 1462 |
+
note(f"wrote outputs to {OUTPUT_DIR}")
|
| 1463 |
+
|
| 1464 |
+
|
| 1465 |
+
if __name__ == "__main__":
|
| 1466 |
+
main()
|
backend/research_runtime/Code/models/nifty_forecaster/__init__.py
ADDED
|
@@ -0,0 +1 @@
|
|
|
|
|
|
|
| 1 |
+
"""Daily forecaster package for NIFTY 50 and NIFTY BANK."""
|
backend/research_runtime/Code/models/nifty_forecaster/outputs/forecaster_latest_forecasts.csv
ADDED
|
@@ -0,0 +1,2 @@
|
|
|
|
|
|
|
|
|
|
| 1 |
+
symbol,latest_forecast_date,latest_forecast_for,latest_forecast_prob_up,latest_forecast_signal,threshold,validation_accuracy,test_accuracy,target_low,target_high
|
| 2 |
+
NIFTY 50,2026-05-21,next trading bar after 2026-05-21,0.5161495107597726,DOWN,0.547,0.574468085106383,0.5806451612903226,0.6,0.605
|
backend/research_runtime/Code/models/nifty_forecaster/outputs/forecaster_summary.json
ADDED
|
@@ -0,0 +1,35 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
[
|
| 2 |
+
{
|
| 3 |
+
"symbol": "NIFTY 50",
|
| 4 |
+
"horizon": "daily",
|
| 5 |
+
"horizon_bars": 1,
|
| 6 |
+
"config": {
|
| 7 |
+
"name": "tuned_daily_forest_single",
|
| 8 |
+
"use_intraday": false,
|
| 9 |
+
"use_external": true,
|
| 10 |
+
"use_institutional": false,
|
| 11 |
+
"use_options": true,
|
| 12 |
+
"use_engineered_macro_flow": false,
|
| 13 |
+
"blend_mode": "single_model",
|
| 14 |
+
"decision_overlay": "bank_body_near_threshold"
|
| 15 |
+
},
|
| 16 |
+
"threshold": 0.547,
|
| 17 |
+
"validation_accuracy": 0.574468085106383,
|
| 18 |
+
"test_accuracy": 0.5806451612903226,
|
| 19 |
+
"baseline_accuracy": 0.5053763440860215,
|
| 20 |
+
"n_train": 2221,
|
| 21 |
+
"n_valid": 282,
|
| 22 |
+
"n_test": 186,
|
| 23 |
+
"train_start": "2015-01-09",
|
| 24 |
+
"train_end": "2023-12-31",
|
| 25 |
+
"valid_start": "2024-07-01",
|
| 26 |
+
"valid_end": "2025-08-17",
|
| 27 |
+
"test_start": "2025-08-18",
|
| 28 |
+
"test_end": "2026-05-20",
|
| 29 |
+
"latest_forecast_date": "2026-05-21",
|
| 30 |
+
"latest_forecast_for": "next trading bar after 2026-05-21",
|
| 31 |
+
"latest_forecast_prob_up": 0.5161495107597726,
|
| 32 |
+
"latest_forecast_signal": "DOWN",
|
| 33 |
+
"feature_count": 282
|
| 34 |
+
}
|
| 35 |
+
]
|
backend/research_runtime/Code/models/nifty_forecaster/train.py
ADDED
|
@@ -0,0 +1,1449 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from __future__ import annotations
|
| 2 |
+
|
| 3 |
+
import argparse
|
| 4 |
+
import json
|
| 5 |
+
import math
|
| 6 |
+
import sys
|
| 7 |
+
import time
|
| 8 |
+
import warnings
|
| 9 |
+
from dataclasses import asdict, dataclass
|
| 10 |
+
from functools import lru_cache
|
| 11 |
+
from pathlib import Path
|
| 12 |
+
from typing import Iterable
|
| 13 |
+
|
| 14 |
+
import numpy as np
|
| 15 |
+
import pandas as pd
|
| 16 |
+
|
| 17 |
+
|
| 18 |
+
def find_project_root(start: Path) -> Path:
|
| 19 |
+
for path in (start, *start.parents):
|
| 20 |
+
if (path / "Data").is_dir() and (path / "Alt Data").is_dir():
|
| 21 |
+
return path
|
| 22 |
+
raise RuntimeError(f"Could not find project root from {start}")
|
| 23 |
+
|
| 24 |
+
|
| 25 |
+
PROJECT_ROOT = find_project_root(Path(__file__).resolve())
|
| 26 |
+
DATA_DIR = PROJECT_ROOT / "Data"
|
| 27 |
+
ALT_DIR = PROJECT_ROOT / "Alt Data"
|
| 28 |
+
PRICE_DIR = DATA_DIR / "processed" / "bars" / "1d"
|
| 29 |
+
INTRADAY_DIR = DATA_DIR / "raw" / "minute"
|
| 30 |
+
OUTPUT_DIR = Path(__file__).resolve().parent / "outputs"
|
| 31 |
+
OUTPUT_DIR.mkdir(parents=True, exist_ok=True)
|
| 32 |
+
warnings.filterwarnings("ignore", category=pd.errors.PerformanceWarning)
|
| 33 |
+
warnings.filterwarnings("ignore", category=FutureWarning)
|
| 34 |
+
|
| 35 |
+
DEFAULT_TRAIN_END = pd.Timestamp("2023-12-31")
|
| 36 |
+
DEFAULT_VALID_END = pd.Timestamp("2025-08-17")
|
| 37 |
+
DEFAULT_TEST_END = pd.Timestamp("2026-03-25")
|
| 38 |
+
COMMON_VALID_START = pd.Timestamp("2024-07-01")
|
| 39 |
+
|
| 40 |
+
SUPPORTED_SYMBOLS = ("NIFTY 50", "NIFTY BANK")
|
| 41 |
+
|
| 42 |
+
DAILY_VALID_WINDOWS: dict[str, tuple[pd.Timestamp, pd.Timestamp]] = {
|
| 43 |
+
"NIFTY 50": (pd.Timestamp("2024-07-01"), pd.Timestamp("2025-08-17")),
|
| 44 |
+
"NIFTY BANK": (pd.Timestamp("2024-07-01"), pd.Timestamp("2025-08-17")),
|
| 45 |
+
}
|
| 46 |
+
|
| 47 |
+
SYMBOL_BENCHMARKS: dict[str, str] = {
|
| 48 |
+
"NIFTY 50": "NIFTY BANK",
|
| 49 |
+
"NIFTY BANK": "NIFTY 50",
|
| 50 |
+
}
|
| 51 |
+
|
| 52 |
+
|
| 53 |
+
class ProgressBar:
|
| 54 |
+
"""Small dependency-free terminal progress bar with elapsed time, ETA, and rate."""
|
| 55 |
+
|
| 56 |
+
def __init__(
|
| 57 |
+
self,
|
| 58 |
+
total: int,
|
| 59 |
+
description: str = "Progress",
|
| 60 |
+
*,
|
| 61 |
+
enabled: bool = True,
|
| 62 |
+
width: int = 34,
|
| 63 |
+
update_every: float = 0.2,
|
| 64 |
+
stream: object | None = None,
|
| 65 |
+
) -> None:
|
| 66 |
+
self.total = max(0, int(total))
|
| 67 |
+
self.description = description
|
| 68 |
+
self.enabled = enabled
|
| 69 |
+
self.width = max(10, int(width))
|
| 70 |
+
self.update_every = max(0.0, float(update_every))
|
| 71 |
+
self.stream = stream if stream is not None else sys.stderr
|
| 72 |
+
self.current = 0
|
| 73 |
+
self.start_time = time.monotonic()
|
| 74 |
+
self.last_render = 0.0
|
| 75 |
+
self.closed = False
|
| 76 |
+
self._last_line_len = 0
|
| 77 |
+
|
| 78 |
+
def __enter__(self) -> "ProgressBar":
|
| 79 |
+
self.start_time = time.monotonic()
|
| 80 |
+
self.last_render = 0.0
|
| 81 |
+
self.render(force=True)
|
| 82 |
+
return self
|
| 83 |
+
|
| 84 |
+
def __exit__(self, exc_type: object, exc: object, tb: object) -> None:
|
| 85 |
+
self.close()
|
| 86 |
+
|
| 87 |
+
@staticmethod
|
| 88 |
+
def _format_duration(seconds: float | None) -> str:
|
| 89 |
+
if seconds is None or not np.isfinite(seconds) or seconds < 0:
|
| 90 |
+
return "--:--"
|
| 91 |
+
seconds = int(round(seconds))
|
| 92 |
+
hours, rem = divmod(seconds, 3600)
|
| 93 |
+
minutes, secs = divmod(rem, 60)
|
| 94 |
+
if hours:
|
| 95 |
+
return f"{hours:d}:{minutes:02d}:{secs:02d}"
|
| 96 |
+
return f"{minutes:02d}:{secs:02d}"
|
| 97 |
+
|
| 98 |
+
def update(self, current: int | None = None, *, description: str | None = None, force: bool = False) -> None:
|
| 99 |
+
if current is not None:
|
| 100 |
+
self.current = max(0, int(current))
|
| 101 |
+
if self.total:
|
| 102 |
+
self.current = min(self.current, self.total)
|
| 103 |
+
if description is not None:
|
| 104 |
+
self.description = description
|
| 105 |
+
self.render(force=force)
|
| 106 |
+
|
| 107 |
+
def advance(self, step: int = 1, *, description: str | None = None, force: bool = False) -> None:
|
| 108 |
+
self.update(self.current + int(step), description=description, force=force)
|
| 109 |
+
|
| 110 |
+
def render(self, *, force: bool = False) -> None:
|
| 111 |
+
if not self.enabled or self.closed:
|
| 112 |
+
return
|
| 113 |
+
now = time.monotonic()
|
| 114 |
+
if not force and (now - self.last_render) < self.update_every and self.current < self.total:
|
| 115 |
+
return
|
| 116 |
+
self.last_render = now
|
| 117 |
+
elapsed = max(0.0, now - self.start_time)
|
| 118 |
+
if self.total > 0:
|
| 119 |
+
fraction = min(1.0, max(0.0, self.current / self.total))
|
| 120 |
+
else:
|
| 121 |
+
fraction = 1.0
|
| 122 |
+
filled = int(round(self.width * fraction))
|
| 123 |
+
bar = "█" * filled + "░" * (self.width - filled)
|
| 124 |
+
rate = self.current / elapsed if elapsed > 0 else 0.0
|
| 125 |
+
eta = (elapsed / self.current) * (self.total - self.current) if self.current > 0 and self.total > 0 else None
|
| 126 |
+
line = (
|
| 127 |
+
f"\r{self.description} [{bar}] "
|
| 128 |
+
f"{self.current}/{self.total} {fraction * 100:6.2f}% | "
|
| 129 |
+
f"elapsed {self._format_duration(elapsed)} | "
|
| 130 |
+
f"ETA {self._format_duration(eta)} | "
|
| 131 |
+
f"{rate:,.2f}/s"
|
| 132 |
+
)
|
| 133 |
+
padding = " " * max(0, self._last_line_len - len(line))
|
| 134 |
+
print(line + padding, end="", file=self.stream, flush=True)
|
| 135 |
+
self._last_line_len = len(line)
|
| 136 |
+
|
| 137 |
+
def close(self) -> None:
|
| 138 |
+
if self.closed:
|
| 139 |
+
return
|
| 140 |
+
self.render(force=True)
|
| 141 |
+
if self.enabled:
|
| 142 |
+
print(file=self.stream, flush=True)
|
| 143 |
+
self.closed = True
|
| 144 |
+
|
| 145 |
+
|
| 146 |
+
def progress_note(message: str, *, enabled: bool = True) -> None:
|
| 147 |
+
if enabled:
|
| 148 |
+
print(f"[progress] {message}", file=sys.stderr, flush=True)
|
| 149 |
+
|
| 150 |
+
|
| 151 |
+
@dataclass(frozen=True)
|
| 152 |
+
class ModelSpec:
|
| 153 |
+
name: str
|
| 154 |
+
kind: str
|
| 155 |
+
use_intraday: bool
|
| 156 |
+
feature_profile: str = "all"
|
| 157 |
+
top_k: int | None = None
|
| 158 |
+
l2: float = 0.5
|
| 159 |
+
n_trees: int = 60
|
| 160 |
+
max_depth: int = 5
|
| 161 |
+
min_leaf: int = 30
|
| 162 |
+
seed: int = 7
|
| 163 |
+
|
| 164 |
+
|
| 165 |
+
@dataclass
|
| 166 |
+
class FitResult:
|
| 167 |
+
symbol: str
|
| 168 |
+
horizon: str
|
| 169 |
+
horizon_bars: int
|
| 170 |
+
config: dict[str, object]
|
| 171 |
+
threshold: float
|
| 172 |
+
validation_accuracy: float
|
| 173 |
+
test_accuracy: float
|
| 174 |
+
baseline_accuracy: float
|
| 175 |
+
n_train: int
|
| 176 |
+
n_valid: int
|
| 177 |
+
n_test: int
|
| 178 |
+
train_start: str
|
| 179 |
+
train_end: str
|
| 180 |
+
valid_start: str
|
| 181 |
+
valid_end: str
|
| 182 |
+
test_start: str
|
| 183 |
+
test_end: str
|
| 184 |
+
latest_forecast_date: str
|
| 185 |
+
latest_forecast_for: str
|
| 186 |
+
latest_forecast_prob_up: float
|
| 187 |
+
latest_forecast_signal: str
|
| 188 |
+
feature_count: int
|
| 189 |
+
|
| 190 |
+
|
| 191 |
+
def price_prefix(symbol: str) -> str:
|
| 192 |
+
return symbol.lower().replace("&", "and").replace(" ", "_")
|
| 193 |
+
|
| 194 |
+
|
| 195 |
+
def benchmark_symbol(symbol: str) -> str:
|
| 196 |
+
return SYMBOL_BENCHMARKS.get(symbol, "NIFTY 50")
|
| 197 |
+
|
| 198 |
+
|
| 199 |
+
def symbol_file_stem(symbol: str) -> str:
|
| 200 |
+
mapping = {
|
| 201 |
+
"NIFTY 50": "nifty50",
|
| 202 |
+
"NIFTY BANK": "banknifty",
|
| 203 |
+
"INDIA VIX": "india_vix",
|
| 204 |
+
}
|
| 205 |
+
if symbol not in mapping:
|
| 206 |
+
raise KeyError(f"Unsupported symbol: {symbol}")
|
| 207 |
+
return mapping[symbol]
|
| 208 |
+
|
| 209 |
+
|
| 210 |
+
def sigmoid(x: np.ndarray) -> np.ndarray:
|
| 211 |
+
return 1.0 / (1.0 + np.exp(-np.clip(x, -40.0, 40.0)))
|
| 212 |
+
|
| 213 |
+
|
| 214 |
+
def safe_div(numer: pd.Series | np.ndarray, denom: pd.Series | np.ndarray) -> pd.Series:
|
| 215 |
+
n = pd.Series(numer, copy=False)
|
| 216 |
+
d = pd.Series(denom, copy=False)
|
| 217 |
+
out = pd.Series(np.nan, index=n.index, dtype="float64")
|
| 218 |
+
mask = d.notna() & np.isfinite(d.to_numpy(dtype="float64")) & (d != 0)
|
| 219 |
+
out.loc[mask] = n.loc[mask].to_numpy(dtype="float64") / d.loc[mask].to_numpy(dtype="float64")
|
| 220 |
+
return out
|
| 221 |
+
|
| 222 |
+
|
| 223 |
+
def _to_ns_datetime(series: pd.Series) -> pd.Series:
|
| 224 |
+
return pd.to_datetime(series, errors="coerce").astype("datetime64[ns]")
|
| 225 |
+
|
| 226 |
+
|
| 227 |
+
@lru_cache(maxsize=None)
|
| 228 |
+
def load_price_frame(symbol: str) -> pd.DataFrame:
|
| 229 |
+
path = PRICE_DIR / f"{symbol_file_stem(symbol)}_1d.csv"
|
| 230 |
+
if not path.exists():
|
| 231 |
+
raise FileNotFoundError(f"Missing daily price file for {symbol}: {path}")
|
| 232 |
+
df = pd.read_csv(path).copy()
|
| 233 |
+
df.columns = [str(c).strip().lower().replace(" ", "_") for c in df.columns]
|
| 234 |
+
if "date" not in df.columns:
|
| 235 |
+
raise ValueError(f"Price frame for {symbol} has no date column")
|
| 236 |
+
df["date"] = _to_ns_datetime(df["date"])
|
| 237 |
+
for col in df.columns:
|
| 238 |
+
if col != "date":
|
| 239 |
+
df[col] = pd.to_numeric(df[col], errors="coerce")
|
| 240 |
+
if "volume" in df.columns:
|
| 241 |
+
volume = pd.to_numeric(df["volume"], errors="coerce")
|
| 242 |
+
volume_available = volume.replace(0, np.nan).notna().sum() >= max(20, int(0.5 * len(volume)))
|
| 243 |
+
df["volume"] = volume.replace(0, np.nan) if volume_available else 0.0
|
| 244 |
+
return df.dropna(subset=["date"]).sort_values("date").reset_index(drop=True)
|
| 245 |
+
|
| 246 |
+
|
| 247 |
+
def load_vix() -> pd.DataFrame:
|
| 248 |
+
path = PRICE_DIR / "india_vix_1d.csv"
|
| 249 |
+
if not path.exists():
|
| 250 |
+
return pd.DataFrame(columns=["date"])
|
| 251 |
+
df = pd.read_csv(path).copy()
|
| 252 |
+
df.columns = [str(c).strip().lower().replace(" ", "_") for c in df.columns]
|
| 253 |
+
if "date" not in df.columns:
|
| 254 |
+
return pd.DataFrame(columns=["date"])
|
| 255 |
+
df["date"] = _to_ns_datetime(df["date"])
|
| 256 |
+
rename_map = {
|
| 257 |
+
"open": "vix_open",
|
| 258 |
+
"high": "vix_high",
|
| 259 |
+
"low": "vix_low",
|
| 260 |
+
"close": "vix_close",
|
| 261 |
+
"volume": "vix_volume",
|
| 262 |
+
}
|
| 263 |
+
df = df.rename(columns={k: v for k, v in rename_map.items() if k in df.columns})
|
| 264 |
+
for col in df.columns:
|
| 265 |
+
if col != "date":
|
| 266 |
+
df[col] = pd.to_numeric(df[col], errors="coerce")
|
| 267 |
+
return df.dropna(subset=["date"]).sort_values("date").reset_index(drop=True)
|
| 268 |
+
|
| 269 |
+
|
| 270 |
+
def load_external_panel() -> pd.DataFrame:
|
| 271 |
+
path = ALT_DIR / "external" / "processed" / "external_daily_panel.csv"
|
| 272 |
+
if not path.exists():
|
| 273 |
+
return pd.DataFrame(columns=["date"])
|
| 274 |
+
df = pd.read_csv(path).copy()
|
| 275 |
+
df.columns = [str(c).strip().lower().replace(" ", "_") for c in df.columns]
|
| 276 |
+
if "date" not in df.columns:
|
| 277 |
+
return pd.DataFrame(columns=["date"])
|
| 278 |
+
df["date"] = _to_ns_datetime(df["date"])
|
| 279 |
+
for col in df.columns:
|
| 280 |
+
if col != "date":
|
| 281 |
+
df[col] = pd.to_numeric(df[col], errors="coerce")
|
| 282 |
+
return df.dropna(subset=["date"]).sort_values("date").reset_index(drop=True)
|
| 283 |
+
|
| 284 |
+
|
| 285 |
+
def load_institutional_panel() -> pd.DataFrame:
|
| 286 |
+
path = ALT_DIR / "institutional" / "processed" / "institutional_daily_panel.csv"
|
| 287 |
+
if not path.exists():
|
| 288 |
+
return pd.DataFrame(columns=["date"])
|
| 289 |
+
df = pd.read_csv(path).copy()
|
| 290 |
+
df.columns = [str(c).strip().lower().replace(" ", "_") for c in df.columns]
|
| 291 |
+
if "date" not in df.columns:
|
| 292 |
+
return pd.DataFrame(columns=["date"])
|
| 293 |
+
df["date"] = _to_ns_datetime(df["date"])
|
| 294 |
+
for col in df.columns:
|
| 295 |
+
if col != "date":
|
| 296 |
+
df[col] = pd.to_numeric(df[col], errors="coerce")
|
| 297 |
+
return df.dropna(subset=["date"]).sort_values("date").reset_index(drop=True)
|
| 298 |
+
|
| 299 |
+
|
| 300 |
+
def load_options_features(symbol: str) -> pd.DataFrame:
|
| 301 |
+
file_name = {
|
| 302 |
+
"NIFTY 50": "nifty50_options_daily_features.csv",
|
| 303 |
+
"NIFTY BANK": "banknifty_options_daily_features.csv",
|
| 304 |
+
}[symbol]
|
| 305 |
+
path = ALT_DIR / "options" / "processed" / file_name
|
| 306 |
+
if not path.exists():
|
| 307 |
+
return pd.DataFrame(columns=["date"])
|
| 308 |
+
df = pd.read_csv(path).copy()
|
| 309 |
+
df.columns = [str(c).strip().lower().replace(" ", "_") for c in df.columns]
|
| 310 |
+
if "date" not in df.columns:
|
| 311 |
+
return pd.DataFrame(columns=["date"])
|
| 312 |
+
df["date"] = _to_ns_datetime(df["date"])
|
| 313 |
+
prefix = price_prefix(symbol)
|
| 314 |
+
rename = {c: f"{prefix}_opt_{c}" for c in df.columns if c not in {"date", "spot_close"}}
|
| 315 |
+
df = df.rename(columns=rename)
|
| 316 |
+
for col in df.columns:
|
| 317 |
+
if col != "date":
|
| 318 |
+
df[col] = pd.to_numeric(df[col], errors="coerce")
|
| 319 |
+
return df.dropna(subset=["date"]).sort_values("date").reset_index(drop=True)
|
| 320 |
+
|
| 321 |
+
|
| 322 |
+
def add_options_regime_features(df: pd.DataFrame) -> pd.DataFrame:
|
| 323 |
+
df = df.copy()
|
| 324 |
+
for prefix in ("nifty_50_opt", "nifty_bank_opt"):
|
| 325 |
+
base = {
|
| 326 |
+
"pcr_oi": f"{prefix}_pcr_open_int",
|
| 327 |
+
"pcr_contracts": f"{prefix}_pcr_contracts",
|
| 328 |
+
"atm_pcr_oi": f"{prefix}_atm_pcr_open_int",
|
| 329 |
+
"atm_straddle": f"{prefix}_atm_straddle_close",
|
| 330 |
+
"atm_ce": f"{prefix}_atm_close_ce",
|
| 331 |
+
"atm_pe": f"{prefix}_atm_close_pe",
|
| 332 |
+
"atm_oi_ce": f"{prefix}_atm_open_int_ce",
|
| 333 |
+
"atm_oi_pe": f"{prefix}_atm_open_int_pe",
|
| 334 |
+
"contracts_ce": f"{prefix}_contracts_ce",
|
| 335 |
+
"contracts_pe": f"{prefix}_contracts_pe",
|
| 336 |
+
"oi_ce": f"{prefix}_open_int_ce",
|
| 337 |
+
"oi_pe": f"{prefix}_open_int_pe",
|
| 338 |
+
"chg_oi_ce": f"{prefix}_chg_in_oi_ce",
|
| 339 |
+
"chg_oi_pe": f"{prefix}_chg_in_oi_pe",
|
| 340 |
+
}
|
| 341 |
+
if base["atm_ce"] in df.columns and base["atm_pe"] in df.columns:
|
| 342 |
+
ce = pd.to_numeric(df[base["atm_ce"]], errors="coerce")
|
| 343 |
+
pe = pd.to_numeric(df[base["atm_pe"]], errors="coerce")
|
| 344 |
+
total = ce + pe
|
| 345 |
+
df[f"{prefix}_atm_skew"] = safe_div(ce - pe, total + 1e-6)
|
| 346 |
+
df[f"{prefix}_atm_put_share"] = safe_div(pe, total + 1e-6)
|
| 347 |
+
if base["atm_straddle"] in df.columns:
|
| 348 |
+
series = pd.to_numeric(df[base["atm_straddle"]], errors="coerce")
|
| 349 |
+
for w in (5, 20):
|
| 350 |
+
df[f"{prefix}_atm_straddle_z{w}"] = safe_div(series - series.rolling(w).mean(), series.rolling(w).std())
|
| 351 |
+
df[f"{prefix}_atm_straddle_ret_5"] = series.pct_change(5, fill_method=None)
|
| 352 |
+
if base["pcr_oi"] in df.columns:
|
| 353 |
+
series = pd.to_numeric(df[base["pcr_oi"]], errors="coerce")
|
| 354 |
+
df[f"{prefix}_pcr_oi_z20"] = safe_div(series - series.rolling(20).mean(), series.rolling(20).std())
|
| 355 |
+
df[f"{prefix}_pcr_oi_chg_5"] = series.diff(5)
|
| 356 |
+
if base["pcr_contracts"] in df.columns:
|
| 357 |
+
series = pd.to_numeric(df[base["pcr_contracts"]], errors="coerce")
|
| 358 |
+
df[f"{prefix}_pcr_contracts_z20"] = safe_div(series - series.rolling(20).mean(), series.rolling(20).std())
|
| 359 |
+
if base["atm_pcr_oi"] in df.columns:
|
| 360 |
+
series = pd.to_numeric(df[base["atm_pcr_oi"]], errors="coerce")
|
| 361 |
+
df[f"{prefix}_atm_pcr_oi_z20"] = safe_div(series - series.rolling(20).mean(), series.rolling(20).std())
|
| 362 |
+
if base["oi_ce"] in df.columns and base["oi_pe"] in df.columns:
|
| 363 |
+
ce = pd.to_numeric(df[base["oi_ce"]], errors="coerce")
|
| 364 |
+
pe = pd.to_numeric(df[base["oi_pe"]], errors="coerce")
|
| 365 |
+
total = ce + pe
|
| 366 |
+
df[f"{prefix}_oi_balance"] = safe_div(pe - ce, total + 1e-6)
|
| 367 |
+
if base["contracts_ce"] in df.columns and base["contracts_pe"] in df.columns:
|
| 368 |
+
ce = pd.to_numeric(df[base["contracts_ce"]], errors="coerce")
|
| 369 |
+
pe = pd.to_numeric(df[base["contracts_pe"]], errors="coerce")
|
| 370 |
+
total = ce + pe
|
| 371 |
+
df[f"{prefix}_contracts_balance"] = safe_div(pe - ce, total + 1e-6)
|
| 372 |
+
if base["chg_oi_ce"] in df.columns and base["chg_oi_pe"] in df.columns:
|
| 373 |
+
ce = pd.to_numeric(df[base["chg_oi_ce"]], errors="coerce")
|
| 374 |
+
pe = pd.to_numeric(df[base["chg_oi_pe"]], errors="coerce")
|
| 375 |
+
total = ce.abs() + pe.abs()
|
| 376 |
+
df[f"{prefix}_chg_oi_balance"] = safe_div(pe - ce, total + 1e-6)
|
| 377 |
+
if {"nifty_50_opt_atm_straddle_close", "nifty_50_close"}.issubset(df.columns):
|
| 378 |
+
df["nifty_50_opt_straddle_rel_spot"] = safe_div(df["nifty_50_opt_atm_straddle_close"], df["nifty_50_close"])
|
| 379 |
+
if {"nifty_bank_opt_atm_straddle_close", "nifty_bank_close"}.issubset(df.columns):
|
| 380 |
+
df["nifty_bank_opt_straddle_rel_spot"] = safe_div(df["nifty_bank_opt_atm_straddle_close"], df["nifty_bank_close"])
|
| 381 |
+
return df
|
| 382 |
+
|
| 383 |
+
|
| 384 |
+
def add_external_regime_features(df: pd.DataFrame) -> pd.DataFrame:
|
| 385 |
+
df = df.copy()
|
| 386 |
+
base_cols = [c for c in df.columns if c.endswith("_value") or c.endswith("_change_1") or c.endswith("_return_1")]
|
| 387 |
+
for col in base_cols:
|
| 388 |
+
series = pd.to_numeric(df[col], errors="coerce")
|
| 389 |
+
if series.notna().sum() < 40:
|
| 390 |
+
continue
|
| 391 |
+
for w in (5, 20, 60):
|
| 392 |
+
df[f"{col}_mean_{w}"] = series.rolling(w).mean()
|
| 393 |
+
for w in (20, 60):
|
| 394 |
+
rolling_std = series.rolling(w).std()
|
| 395 |
+
df[f"{col}_z_{w}"] = safe_div(series - series.rolling(w).mean(), rolling_std)
|
| 396 |
+
ratio_pairs = [
|
| 397 |
+
("nasdaq_composite_value", "sp500_value", "nasdaq_vs_sp500"),
|
| 398 |
+
("vix_fred_value", "vix_close", "us_vix_vs_india_vix"),
|
| 399 |
+
("broad_dollar_index_value", "india_fx_inr_per_usd_value", "dxy_vs_inr"),
|
| 400 |
+
]
|
| 401 |
+
for numer_col, denom_col, prefix in ratio_pairs:
|
| 402 |
+
if numer_col in df.columns and denom_col in df.columns:
|
| 403 |
+
ratio = safe_div(df[numer_col], df[denom_col])
|
| 404 |
+
df[f"{prefix}_ratio"] = ratio
|
| 405 |
+
df[f"{prefix}_z20"] = safe_div(ratio - ratio.rolling(20).mean(), ratio.rolling(20).std())
|
| 406 |
+
df[f"{prefix}_mom_5"] = ratio.pct_change(5, fill_method=None)
|
| 407 |
+
if {"us10y_treasury_value", "fed_funds_value"}.issubset(df.columns):
|
| 408 |
+
spread = pd.to_numeric(df["us10y_treasury_value"], errors="coerce") - pd.to_numeric(df["fed_funds_value"], errors="coerce")
|
| 409 |
+
df["us10y_minus_fedfunds"] = spread
|
| 410 |
+
df["us10y_minus_fedfunds_z20"] = safe_div(spread - spread.rolling(20).mean(), spread.rolling(20).std())
|
| 411 |
+
return df
|
| 412 |
+
|
| 413 |
+
|
| 414 |
+
def add_institutional_flow_features(df: pd.DataFrame) -> pd.DataFrame:
|
| 415 |
+
df = df.copy()
|
| 416 |
+
net_cols = [
|
| 417 |
+
"fii_cash_net",
|
| 418 |
+
"dii_cash_net",
|
| 419 |
+
"fii_fno_futures_net",
|
| 420 |
+
"fii_fno_options_net",
|
| 421 |
+
"fii_index_futures_net_oi",
|
| 422 |
+
"fii_index_options_net_oi",
|
| 423 |
+
"fii_index_futures_net_volume",
|
| 424 |
+
"fii_index_options_net_volume",
|
| 425 |
+
]
|
| 426 |
+
for col in net_cols:
|
| 427 |
+
if col not in df.columns:
|
| 428 |
+
continue
|
| 429 |
+
series = pd.to_numeric(df[col], errors="coerce")
|
| 430 |
+
gross_col = col.replace("_net", "_buy")
|
| 431 |
+
alt_gross_col = col.replace("_net", "_long_volume")
|
| 432 |
+
gross = None
|
| 433 |
+
if gross_col in df.columns:
|
| 434 |
+
gross = pd.to_numeric(df[gross_col], errors="coerce").abs()
|
| 435 |
+
elif alt_gross_col in df.columns:
|
| 436 |
+
gross = pd.to_numeric(df[alt_gross_col], errors="coerce").abs()
|
| 437 |
+
for w in (3, 5, 10, 20):
|
| 438 |
+
df[f"{col}_sum_{w}"] = series.rolling(w).sum()
|
| 439 |
+
df[f"{col}_mean_{w}"] = series.rolling(w).mean()
|
| 440 |
+
df[f"{col}_z20"] = safe_div(series - series.rolling(20).mean(), series.rolling(20).std())
|
| 441 |
+
df[f"{col}_sign"] = np.sign(series)
|
| 442 |
+
if gross is not None:
|
| 443 |
+
df[f"{col}_intensity"] = safe_div(series, gross + 1e-6)
|
| 444 |
+
if {"fii_cash_net", "dii_cash_net"}.issubset(df.columns):
|
| 445 |
+
cash_spread = pd.to_numeric(df["fii_cash_net"], errors="coerce") - pd.to_numeric(df["dii_cash_net"], errors="coerce")
|
| 446 |
+
df["inst_cash_spread"] = cash_spread
|
| 447 |
+
df["inst_cash_spread_z20"] = safe_div(cash_spread - cash_spread.rolling(20).mean(), cash_spread.rolling(20).std())
|
| 448 |
+
if {"fii_fno_futures_net", "fii_fno_options_net"}.issubset(df.columns):
|
| 449 |
+
combo = pd.to_numeric(df["fii_fno_futures_net"], errors="coerce") + pd.to_numeric(df["fii_fno_options_net"], errors="coerce")
|
| 450 |
+
df["fii_fno_total_net"] = combo
|
| 451 |
+
df["fii_fno_total_net_z20"] = safe_div(combo - combo.rolling(20).mean(), combo.rolling(20).std())
|
| 452 |
+
if {"fii_index_options_call_net_volume", "fii_index_options_put_net_volume"}.issubset(df.columns):
|
| 453 |
+
put_call_spread = pd.to_numeric(df["fii_index_options_put_net_volume"], errors="coerce") - pd.to_numeric(df["fii_index_options_call_net_volume"], errors="coerce")
|
| 454 |
+
total = (
|
| 455 |
+
pd.to_numeric(df["fii_index_options_put_net_volume"], errors="coerce").abs()
|
| 456 |
+
+ pd.to_numeric(df["fii_index_options_call_net_volume"], errors="coerce").abs()
|
| 457 |
+
)
|
| 458 |
+
df["fii_put_call_volume_spread"] = put_call_spread
|
| 459 |
+
df["fii_put_call_volume_balance"] = safe_div(put_call_spread, total + 1e-6)
|
| 460 |
+
return df
|
| 461 |
+
|
| 462 |
+
|
| 463 |
+
def add_cross_market_features(df: pd.DataFrame) -> pd.DataFrame:
|
| 464 |
+
df = df.copy()
|
| 465 |
+
if {"nifty_50_ret_1", "fii_cash_net"}.issubset(df.columns):
|
| 466 |
+
df["fii_cash_x_nifty50_ret"] = pd.to_numeric(df["fii_cash_net"], errors="coerce") * pd.to_numeric(df["nifty_50_ret_1"], errors="coerce")
|
| 467 |
+
if {"nifty_bank_ret_1", "fii_fno_futures_net"}.issubset(df.columns):
|
| 468 |
+
df["fii_futures_x_bank_ret"] = pd.to_numeric(df["fii_fno_futures_net"], errors="coerce") * pd.to_numeric(df["nifty_bank_ret_1"], errors="coerce")
|
| 469 |
+
if {"vix_close", "fii_cash_net"}.issubset(df.columns):
|
| 470 |
+
df["fii_cash_vs_vix"] = safe_div(pd.to_numeric(df["fii_cash_net"], errors="coerce"), pd.to_numeric(df["vix_close"], errors="coerce"))
|
| 471 |
+
if {"vix_fred_value", "nifty_50_ret_std_20"}.issubset(df.columns):
|
| 472 |
+
df["us_vix_x_local_vol"] = pd.to_numeric(df["vix_fred_value"], errors="coerce") * pd.to_numeric(df["nifty_50_ret_std_20"], errors="coerce")
|
| 473 |
+
return df
|
| 474 |
+
|
| 475 |
+
|
| 476 |
+
def add_price_features(df: pd.DataFrame, prefix: str) -> pd.DataFrame:
|
| 477 |
+
df = df.copy()
|
| 478 |
+
if f"{prefix}_close" not in df.columns:
|
| 479 |
+
rename_map = {c: f"{prefix}_{c}" for c in ["open", "high", "low", "close", "volume"] if c in df.columns}
|
| 480 |
+
df = df.rename(columns=rename_map)
|
| 481 |
+
close = df[f"{prefix}_close"]
|
| 482 |
+
open_ = df[f"{prefix}_open"]
|
| 483 |
+
high = df[f"{prefix}_high"]
|
| 484 |
+
low = df[f"{prefix}_low"]
|
| 485 |
+
raw_vol = pd.to_numeric(df.get(f"{prefix}_volume", 0.0), errors="coerce")
|
| 486 |
+
volume_missing = raw_vol.replace(0, np.nan).notna().sum() < max(20, int(0.5 * len(raw_vol)))
|
| 487 |
+
vol = raw_vol.replace(0, np.nan)
|
| 488 |
+
|
| 489 |
+
ret_1 = close.pct_change()
|
| 490 |
+
df[f"{prefix}_ret_1"] = ret_1
|
| 491 |
+
df[f"{prefix}_ret_2"] = close.pct_change(2)
|
| 492 |
+
df[f"{prefix}_ret_5"] = close.pct_change(5)
|
| 493 |
+
df[f"{prefix}_ret_10"] = close.pct_change(10)
|
| 494 |
+
df[f"{prefix}_logret_1"] = np.log(close / close.shift(1))
|
| 495 |
+
df[f"{prefix}_gap_1"] = open_ / close.shift(1) - 1.0
|
| 496 |
+
df[f"{prefix}_body"] = close / open_ - 1.0
|
| 497 |
+
df[f"{prefix}_range"] = safe_div(high - low, close)
|
| 498 |
+
df[f"{prefix}_upper_wick"] = safe_div(high - np.maximum(open_, close), close)
|
| 499 |
+
df[f"{prefix}_lower_wick"] = safe_div(np.minimum(open_, close) - low, close)
|
| 500 |
+
df[f"{prefix}_trend_5"] = close / close.rolling(5).mean() - 1.0
|
| 501 |
+
df[f"{prefix}_trend_10"] = close / close.rolling(10).mean() - 1.0
|
| 502 |
+
df[f"{prefix}_trend_20"] = close / close.rolling(20).mean() - 1.0
|
| 503 |
+
df[f"{prefix}_trend_60"] = close / close.rolling(60).mean() - 1.0
|
| 504 |
+
df[f"{prefix}_trend_120"] = close / close.rolling(120).mean() - 1.0
|
| 505 |
+
df[f"{prefix}_trend_252"] = close / close.rolling(252).mean() - 1.0
|
| 506 |
+
rolling_max_252 = close.rolling(252).max()
|
| 507 |
+
rolling_min_252 = close.rolling(252).min()
|
| 508 |
+
df[f"{prefix}_drawdown_252"] = close / rolling_max_252 - 1.0
|
| 509 |
+
df[f"{prefix}_dist_from_low_252"] = close / rolling_min_252 - 1.0
|
| 510 |
+
if volume_missing:
|
| 511 |
+
df[f"{prefix}_vol_chg_1"] = 0.0
|
| 512 |
+
df[f"{prefix}_vol_z_20"] = 0.0
|
| 513 |
+
df[f"{prefix}_vol_z_60"] = 0.0
|
| 514 |
+
else:
|
| 515 |
+
df[f"{prefix}_vol_chg_1"] = vol.pct_change()
|
| 516 |
+
df[f"{prefix}_vol_z_20"] = (vol - vol.rolling(20).mean()) / vol.rolling(20).std()
|
| 517 |
+
df[f"{prefix}_vol_z_60"] = (vol - vol.rolling(60).mean()) / vol.rolling(60).std()
|
| 518 |
+
|
| 519 |
+
for w in [3, 5, 10, 20, 60, 120, 252]:
|
| 520 |
+
df[f"{prefix}_ret_mean_{w}"] = ret_1.rolling(w).mean()
|
| 521 |
+
df[f"{prefix}_ret_std_{w}"] = ret_1.rolling(w).std()
|
| 522 |
+
df[f"{prefix}_range_mean_{w}"] = df[f"{prefix}_range"].rolling(w).mean()
|
| 523 |
+
df[f"{prefix}_range_std_{w}"] = df[f"{prefix}_range"].rolling(w).std()
|
| 524 |
+
|
| 525 |
+
delta = close.diff()
|
| 526 |
+
gain = delta.clip(lower=0.0)
|
| 527 |
+
loss = -delta.clip(upper=0.0)
|
| 528 |
+
avg_gain = gain.ewm(alpha=1 / 14.0, adjust=False, min_periods=14).mean()
|
| 529 |
+
avg_loss = loss.ewm(alpha=1 / 14.0, adjust=False, min_periods=14).mean()
|
| 530 |
+
rs = avg_gain / avg_loss.replace(0.0, np.nan)
|
| 531 |
+
df[f"{prefix}_rsi_14"] = 100.0 - (100.0 / (1.0 + rs))
|
| 532 |
+
ema_12 = close.ewm(span=12, adjust=False, min_periods=12).mean()
|
| 533 |
+
ema_26 = close.ewm(span=26, adjust=False, min_periods=26).mean()
|
| 534 |
+
macd = ema_12 - ema_26
|
| 535 |
+
signal = macd.ewm(span=9, adjust=False, min_periods=9).mean()
|
| 536 |
+
df[f"{prefix}_macd"] = macd / close
|
| 537 |
+
df[f"{prefix}_macd_signal"] = signal / close
|
| 538 |
+
df[f"{prefix}_macd_hist"] = (macd - signal) / close
|
| 539 |
+
return df
|
| 540 |
+
|
| 541 |
+
|
| 542 |
+
def build_panel(include_engineered: bool = True, include_option_engineered: bool = True) -> pd.DataFrame:
|
| 543 |
+
nifty = add_price_features(load_price_frame("NIFTY 50"), "nifty_50")
|
| 544 |
+
bank = add_price_features(load_price_frame("NIFTY BANK"), "nifty_bank")
|
| 545 |
+
panel = nifty.merge(bank, on="date", how="inner").sort_values("date").reset_index(drop=True)
|
| 546 |
+
panel["pair_ret_corr_20"] = panel["nifty_50_ret_1"].rolling(20).corr(panel["nifty_bank_ret_1"])
|
| 547 |
+
panel["pair_ret_corr_60"] = panel["nifty_50_ret_1"].rolling(60).corr(panel["nifty_bank_ret_1"])
|
| 548 |
+
panel["pair_close_ratio"] = panel["nifty_50_close"] / panel["nifty_bank_close"] - 1.0
|
| 549 |
+
|
| 550 |
+
vix = load_vix()
|
| 551 |
+
if not vix.empty:
|
| 552 |
+
panel = pd.merge_asof(panel.sort_values("date"), vix.sort_values("date"), on="date", direction="backward")
|
| 553 |
+
|
| 554 |
+
external = load_external_panel()
|
| 555 |
+
if not external.empty:
|
| 556 |
+
panel = pd.merge_asof(panel.sort_values("date"), external.sort_values("date"), on="date", direction="backward")
|
| 557 |
+
|
| 558 |
+
institutional = load_institutional_panel()
|
| 559 |
+
if not institutional.empty:
|
| 560 |
+
panel = pd.merge_asof(
|
| 561 |
+
panel.sort_values("date"),
|
| 562 |
+
institutional.sort_values("date"),
|
| 563 |
+
on="date",
|
| 564 |
+
direction="backward",
|
| 565 |
+
)
|
| 566 |
+
|
| 567 |
+
nifty_opts = load_options_features("NIFTY 50")
|
| 568 |
+
if not nifty_opts.empty:
|
| 569 |
+
panel = pd.merge_asof(panel.sort_values("date"), nifty_opts.sort_values("date"), on="date", direction="backward")
|
| 570 |
+
|
| 571 |
+
bank_opts = load_options_features("NIFTY BANK")
|
| 572 |
+
if not bank_opts.empty:
|
| 573 |
+
panel = pd.merge_asof(panel.sort_values("date"), bank_opts.sort_values("date"), on="date", direction="backward")
|
| 574 |
+
|
| 575 |
+
if include_option_engineered:
|
| 576 |
+
panel = add_options_regime_features(panel)
|
| 577 |
+
if include_engineered:
|
| 578 |
+
panel = add_external_regime_features(panel)
|
| 579 |
+
panel = add_institutional_flow_features(panel)
|
| 580 |
+
panel = add_cross_market_features(panel)
|
| 581 |
+
return panel.sort_values("date").reset_index(drop=True)
|
| 582 |
+
|
| 583 |
+
|
| 584 |
+
@lru_cache(maxsize=None)
|
| 585 |
+
def load_intraday_daily(symbol: str) -> pd.DataFrame:
|
| 586 |
+
path = INTRADAY_DIR / f"{symbol}_minute.csv"
|
| 587 |
+
if not path.exists():
|
| 588 |
+
raise FileNotFoundError(f"Missing minute file for {symbol}: {path}")
|
| 589 |
+
df = pd.read_csv(path).copy()
|
| 590 |
+
df.columns = [str(c).strip().lower().replace(" ", "_") for c in df.columns]
|
| 591 |
+
df["date"] = _to_ns_datetime(df["date"])
|
| 592 |
+
for col in ("open", "high", "low", "close", "volume"):
|
| 593 |
+
if col in df.columns:
|
| 594 |
+
df[col] = pd.to_numeric(df[col], errors="coerce")
|
| 595 |
+
df["session_date"] = df["date"].dt.normalize()
|
| 596 |
+
df = df.dropna(subset=["date"]).sort_values("date").reset_index(drop=True)
|
| 597 |
+
grouped = df.groupby("session_date", sort=True)
|
| 598 |
+
|
| 599 |
+
def session_apply(func):
|
| 600 |
+
return grouped.apply(func, include_groups=False).to_numpy()
|
| 601 |
+
|
| 602 |
+
out = pd.DataFrame({"date": grouped["date"].first().dt.normalize()})
|
| 603 |
+
out["intraday_open"] = grouped["open"].first().to_numpy()
|
| 604 |
+
out["intraday_high"] = grouped["high"].max().to_numpy()
|
| 605 |
+
out["intraday_low"] = grouped["low"].min().to_numpy()
|
| 606 |
+
out["intraday_close"] = grouped["close"].last().to_numpy()
|
| 607 |
+
out["intraday_nbars"] = grouped.size().to_numpy()
|
| 608 |
+
out["intraday_range"] = safe_div(out["intraday_high"] - out["intraday_low"], out["intraday_low"])
|
| 609 |
+
out["intraday_body"] = safe_div(out["intraday_close"] - out["intraday_open"], out["intraday_open"])
|
| 610 |
+
out["intraday_close_loc"] = safe_div(
|
| 611 |
+
out["intraday_close"] - out["intraday_low"],
|
| 612 |
+
out["intraday_high"] - out["intraday_low"],
|
| 613 |
+
)
|
| 614 |
+
out["intraday_first_30"] = session_apply(
|
| 615 |
+
lambda x: x["close"].iloc[min(29, len(x) - 1)] / x["open"].iloc[0] - 1.0
|
| 616 |
+
)
|
| 617 |
+
out["intraday_first_60"] = session_apply(
|
| 618 |
+
lambda x: x["close"].iloc[min(59, len(x) - 1)] / x["open"].iloc[0] - 1.0
|
| 619 |
+
)
|
| 620 |
+
out["intraday_last_30"] = session_apply(
|
| 621 |
+
lambda x: x["close"].iloc[-1] / x["close"].iloc[max(0, len(x) - 30)] - 1.0
|
| 622 |
+
)
|
| 623 |
+
out["intraday_last_60"] = session_apply(
|
| 624 |
+
lambda x: x["close"].iloc[-1] / x["close"].iloc[max(0, len(x) - 60)] - 1.0
|
| 625 |
+
)
|
| 626 |
+
out["intraday_midday"] = session_apply(
|
| 627 |
+
lambda x: x["close"].iloc[max(0, len(x) // 2)] / x["open"].iloc[0] - 1.0
|
| 628 |
+
)
|
| 629 |
+
out["intraday_second_half"] = session_apply(
|
| 630 |
+
lambda x: x["close"].iloc[-1] / x["close"].iloc[max(0, len(x) // 2)] - 1.0
|
| 631 |
+
)
|
| 632 |
+
out["intraday_vshape"] = out["intraday_first_60"] - out["intraday_last_60"]
|
| 633 |
+
out["intraday_abruptness"] = safe_div(out["intraday_high"] - out["intraday_low"], out["intraday_open"])
|
| 634 |
+
out["intraday_realized_vol"] = session_apply(lambda x: np.log(x["close"]).diff().std() * np.sqrt(len(x)))
|
| 635 |
+
out["intraday_range_vs_body"] = safe_div(out["intraday_range"], out["intraday_body"].abs() + 1e-6)
|
| 636 |
+
return out
|
| 637 |
+
|
| 638 |
+
|
| 639 |
+
def build_master_frame(
|
| 640 |
+
symbol: str,
|
| 641 |
+
target_bars: int = 1,
|
| 642 |
+
) -> pd.DataFrame:
|
| 643 |
+
own = price_prefix(symbol)
|
| 644 |
+
use_engineered = symbol == "NIFTY BANK"
|
| 645 |
+
use_option_engineered = symbol == "NIFTY 50"
|
| 646 |
+
panel = build_panel(include_engineered=use_engineered, include_option_engineered=use_option_engineered).copy()
|
| 647 |
+
intraday = load_intraday_daily(symbol)
|
| 648 |
+
frame = pd.merge_asof(panel.sort_values("date"), intraday.sort_values("date"), on="date", direction="backward")
|
| 649 |
+
|
| 650 |
+
if target_bars < 1:
|
| 651 |
+
raise ValueError("target_bars must be at least 1")
|
| 652 |
+
future_close = frame[f"{own}_close"].shift(-target_bars)
|
| 653 |
+
known_future = future_close.notna()
|
| 654 |
+
frame["target"] = np.where(known_future, (future_close > frame[f"{own}_close"]).astype("int64"), np.nan)
|
| 655 |
+
frame["next_close_return"] = future_close / frame[f"{own}_close"] - 1.0
|
| 656 |
+
frame["target_lag_1"] = frame["target"].shift(1)
|
| 657 |
+
frame["target_roll_up_5"] = frame["target"].shift(1).rolling(5).mean()
|
| 658 |
+
frame["target_roll_up_10"] = frame["target"].shift(1).rolling(10).mean()
|
| 659 |
+
frame["target_roll_up_20"] = frame["target"].shift(1).rolling(20).mean()
|
| 660 |
+
frame = frame.replace([np.inf, -np.inf], np.nan)
|
| 661 |
+
# Keep the latest row even though its future close/target is unknown.
|
| 662 |
+
# Model fitting and backtests filter to known target rows later, while latest_row uses this retained row.
|
| 663 |
+
return frame.dropna(subset=["date", f"{own}_close"]).reset_index(drop=True)
|
| 664 |
+
|
| 665 |
+
|
| 666 |
+
def is_option_column(name: str) -> bool:
|
| 667 |
+
return "_opt_" in name
|
| 668 |
+
|
| 669 |
+
|
| 670 |
+
def is_flow_column(name: str) -> bool:
|
| 671 |
+
prefixes = ("fii_", "dii_", "inst_", "participant_", "cash_", "fno_")
|
| 672 |
+
return name.startswith(prefixes) or "put_call_volume" in name
|
| 673 |
+
|
| 674 |
+
|
| 675 |
+
def is_external_column(name: str) -> bool:
|
| 676 |
+
prefixes = (
|
| 677 |
+
"sp500_",
|
| 678 |
+
"nasdaq_composite_",
|
| 679 |
+
"dow_jones_",
|
| 680 |
+
"nikkei225_",
|
| 681 |
+
"us10y_treasury_",
|
| 682 |
+
"fed_funds_",
|
| 683 |
+
"india_fx_inr_per_usd_",
|
| 684 |
+
"brent_fred_",
|
| 685 |
+
"vix_fred_",
|
| 686 |
+
"broad_dollar_index_",
|
| 687 |
+
"dxy_",
|
| 688 |
+
"us10y_minus_fedfunds",
|
| 689 |
+
"nasdaq_vs_sp500",
|
| 690 |
+
"us_vix_vs_india_vix",
|
| 691 |
+
)
|
| 692 |
+
return name.startswith(prefixes)
|
| 693 |
+
|
| 694 |
+
|
| 695 |
+
def is_vix_column(name: str) -> bool:
|
| 696 |
+
return name.startswith("vix_")
|
| 697 |
+
|
| 698 |
+
|
| 699 |
+
def is_pair_column(name: str) -> bool:
|
| 700 |
+
return name.startswith("pair_")
|
| 701 |
+
|
| 702 |
+
|
| 703 |
+
def is_intraday_column(name: str) -> bool:
|
| 704 |
+
return name.startswith("intraday_")
|
| 705 |
+
|
| 706 |
+
|
| 707 |
+
def rank_feature_columns(train_df: pd.DataFrame, feature_cols: list[str]) -> list[str]:
|
| 708 |
+
scores: dict[str, float] = {}
|
| 709 |
+
y = train_df["target"].astype(float)
|
| 710 |
+
for col in feature_cols:
|
| 711 |
+
x = pd.to_numeric(train_df[col], errors="coerce")
|
| 712 |
+
pair = pd.concat([x.rename("x"), y.rename("y")], axis=1).dropna()
|
| 713 |
+
if len(pair) < 40 or pair["x"].nunique() <= 1:
|
| 714 |
+
scores[col] = 0.0
|
| 715 |
+
continue
|
| 716 |
+
corr = pair["x"].corr(pair["y"])
|
| 717 |
+
scores[col] = abs(float(corr)) if corr is not None and np.isfinite(corr) else 0.0
|
| 718 |
+
return pd.Series(scores).sort_values(ascending=False).index.tolist()
|
| 719 |
+
|
| 720 |
+
|
| 721 |
+
def select_model_columns(frame: pd.DataFrame, use_intraday: bool, feature_profile: str, symbol: str) -> list[str]:
|
| 722 |
+
meta = {"date", "target", "next_close_return"}
|
| 723 |
+
cols = [c for c in frame.columns if c not in meta and pd.api.types.is_numeric_dtype(frame[c])]
|
| 724 |
+
if not use_intraday:
|
| 725 |
+
cols = [c for c in cols if not is_intraday_column(c)]
|
| 726 |
+
own = price_prefix(symbol)
|
| 727 |
+
other = price_prefix(benchmark_symbol(symbol))
|
| 728 |
+
core_cols = [
|
| 729 |
+
c for c in cols
|
| 730 |
+
if c.startswith(f"{own}_") or c.startswith(f"{other}_") or is_pair_column(c) or is_vix_column(c) or c.startswith("target_")
|
| 731 |
+
]
|
| 732 |
+
option_cols = [c for c in cols if is_option_column(c)]
|
| 733 |
+
external_cols = [c for c in cols if is_external_column(c)]
|
| 734 |
+
flow_cols = [c for c in cols if is_flow_column(c)]
|
| 735 |
+
intraday_cols = [c for c in cols if is_intraday_column(c)]
|
| 736 |
+
|
| 737 |
+
profile_map = {
|
| 738 |
+
"all": cols,
|
| 739 |
+
"lean": core_cols + intraday_cols,
|
| 740 |
+
"price_options": core_cols + option_cols + intraday_cols,
|
| 741 |
+
"price_external": core_cols + external_cols + intraday_cols,
|
| 742 |
+
"options_macro": core_cols + option_cols + external_cols + intraday_cols,
|
| 743 |
+
"bank_alt": core_cols + option_cols + external_cols + flow_cols + intraday_cols,
|
| 744 |
+
}
|
| 745 |
+
selected = profile_map.get(feature_profile, cols)
|
| 746 |
+
return list(dict.fromkeys([c for c in selected if c in cols]))
|
| 747 |
+
|
| 748 |
+
|
| 749 |
+
def train_logistic_model(
|
| 750 |
+
x: np.ndarray,
|
| 751 |
+
y: np.ndarray,
|
| 752 |
+
l2: float = 0.5,
|
| 753 |
+
max_iter: int = 900,
|
| 754 |
+
lr: float = 0.05,
|
| 755 |
+
*,
|
| 756 |
+
progress_enabled: bool = True,
|
| 757 |
+
progress_update_every: float = 0.2,
|
| 758 |
+
progress_description: str = "Logistic training",
|
| 759 |
+
) -> dict[str, np.ndarray | float]:
|
| 760 |
+
x = np.asarray(x, dtype="float64")
|
| 761 |
+
y = np.asarray(y, dtype="float64")
|
| 762 |
+
mean = np.nanmean(x, axis=0)
|
| 763 |
+
std = np.nanstd(x, axis=0)
|
| 764 |
+
std[~np.isfinite(std) | (std == 0)] = 1.0
|
| 765 |
+
xs = (x - mean) / std
|
| 766 |
+
coef = np.zeros(xs.shape[1], dtype="float64")
|
| 767 |
+
intercept = float(np.log((y.mean() + 1e-6) / (1.0 - y.mean() + 1e-6)))
|
| 768 |
+
mw = np.zeros_like(coef)
|
| 769 |
+
vw = np.zeros_like(coef)
|
| 770 |
+
mb = 0.0
|
| 771 |
+
vb = 0.0
|
| 772 |
+
beta1 = 0.9
|
| 773 |
+
beta2 = 0.999
|
| 774 |
+
eps = 1e-8
|
| 775 |
+
with ProgressBar(
|
| 776 |
+
max_iter,
|
| 777 |
+
progress_description,
|
| 778 |
+
enabled=progress_enabled,
|
| 779 |
+
update_every=progress_update_every,
|
| 780 |
+
) as progress:
|
| 781 |
+
for step in range(1, max_iter + 1):
|
| 782 |
+
z = xs @ coef + intercept
|
| 783 |
+
p = sigmoid(z)
|
| 784 |
+
err = p - y
|
| 785 |
+
grad_w = (xs.T @ err) / len(y) + l2 * coef
|
| 786 |
+
grad_b = err.mean()
|
| 787 |
+
mw = beta1 * mw + (1.0 - beta1) * grad_w
|
| 788 |
+
vw = beta2 * vw + (1.0 - beta2) * (grad_w * grad_w)
|
| 789 |
+
mb = beta1 * mb + (1.0 - beta1) * grad_b
|
| 790 |
+
vb = beta2 * vb + (1.0 - beta2) * (grad_b * grad_b)
|
| 791 |
+
mw_hat = mw / (1.0 - beta1**step)
|
| 792 |
+
vw_hat = vw / (1.0 - beta2**step)
|
| 793 |
+
mb_hat = mb / (1.0 - beta1**step)
|
| 794 |
+
vb_hat = vb / (1.0 - beta2**step)
|
| 795 |
+
coef -= lr * mw_hat / (np.sqrt(vw_hat) + eps)
|
| 796 |
+
intercept -= lr * mb_hat / (math.sqrt(vb_hat) + eps)
|
| 797 |
+
progress.update(step)
|
| 798 |
+
if step % 100 == 0 and float(np.linalg.norm(grad_w) + abs(grad_b)) < 1e-4:
|
| 799 |
+
progress.update(step, description=f"{progress_description} converged", force=True)
|
| 800 |
+
break
|
| 801 |
+
return {"kind": "logit", "coef": coef, "intercept": intercept, "mean": mean, "std": std}
|
| 802 |
+
|
| 803 |
+
|
| 804 |
+
def predict_logistic_model(model: dict[str, np.ndarray | float], x: np.ndarray) -> np.ndarray:
|
| 805 |
+
xs = (np.asarray(x, dtype="float64") - model["mean"]) / model["std"]
|
| 806 |
+
z = xs @ model["coef"] + float(model["intercept"])
|
| 807 |
+
return sigmoid(z)
|
| 808 |
+
|
| 809 |
+
|
| 810 |
+
@dataclass
|
| 811 |
+
class TreeNode:
|
| 812 |
+
feat: int | None = None
|
| 813 |
+
thr: float | None = None
|
| 814 |
+
left: "TreeNode | None" = None
|
| 815 |
+
right: "TreeNode | None" = None
|
| 816 |
+
prob: float | None = None
|
| 817 |
+
|
| 818 |
+
|
| 819 |
+
def _gini(y: np.ndarray) -> float:
|
| 820 |
+
if len(y) == 0:
|
| 821 |
+
return 0.0
|
| 822 |
+
p = float(np.mean(y))
|
| 823 |
+
return 1.0 - p * p - (1.0 - p) * (1.0 - p)
|
| 824 |
+
|
| 825 |
+
|
| 826 |
+
def _best_split(x: np.ndarray, y: np.ndarray, features: np.ndarray) -> tuple[float, int, float, np.ndarray] | None:
|
| 827 |
+
n = len(y)
|
| 828 |
+
parent = _gini(y)
|
| 829 |
+
best: tuple[float, int, float, np.ndarray] | None = None
|
| 830 |
+
for feat in features:
|
| 831 |
+
col = x[:, feat]
|
| 832 |
+
if np.all(col == col[0]):
|
| 833 |
+
continue
|
| 834 |
+
thresholds = np.unique(np.quantile(col, [0.25, 0.5, 0.75]))
|
| 835 |
+
for thr in thresholds:
|
| 836 |
+
left = col <= thr
|
| 837 |
+
nl = int(left.sum())
|
| 838 |
+
nr = n - nl
|
| 839 |
+
if nl < 30 or nr < 30:
|
| 840 |
+
continue
|
| 841 |
+
gain = parent - (nl / n) * _gini(y[left]) - (nr / n) * _gini(y[~left])
|
| 842 |
+
if best is None or gain > best[0]:
|
| 843 |
+
best = (gain, int(feat), float(thr), left)
|
| 844 |
+
return best
|
| 845 |
+
|
| 846 |
+
|
| 847 |
+
def _build_tree(
|
| 848 |
+
x: np.ndarray,
|
| 849 |
+
y: np.ndarray,
|
| 850 |
+
depth: int,
|
| 851 |
+
max_depth: int,
|
| 852 |
+
min_leaf: int,
|
| 853 |
+
mtry: int,
|
| 854 |
+
rng: np.random.Generator,
|
| 855 |
+
) -> TreeNode:
|
| 856 |
+
if depth >= max_depth or len(y) < 2 * min_leaf or len(np.unique(y)) == 1:
|
| 857 |
+
return TreeNode(prob=float(np.mean(y)) if len(y) else 0.5)
|
| 858 |
+
features = rng.choice(x.shape[1], size=min(mtry, x.shape[1]), replace=False)
|
| 859 |
+
best = _best_split(x, y, features)
|
| 860 |
+
if best is None or best[0] <= 1e-9:
|
| 861 |
+
return TreeNode(prob=float(np.mean(y)))
|
| 862 |
+
_, feat, thr, left = best
|
| 863 |
+
if left.sum() < min_leaf or (~left).sum() < min_leaf:
|
| 864 |
+
return TreeNode(prob=float(np.mean(y)))
|
| 865 |
+
return TreeNode(
|
| 866 |
+
feat=feat,
|
| 867 |
+
thr=thr,
|
| 868 |
+
left=_build_tree(x[left], y[left], depth + 1, max_depth, min_leaf, mtry, rng),
|
| 869 |
+
right=_build_tree(x[~left], y[~left], depth + 1, max_depth, min_leaf, mtry, rng),
|
| 870 |
+
)
|
| 871 |
+
|
| 872 |
+
|
| 873 |
+
def _tree_predict(node: TreeNode, row: np.ndarray) -> float:
|
| 874 |
+
while node.prob is None:
|
| 875 |
+
node = node.left if row[node.feat] <= node.thr else node.right
|
| 876 |
+
return float(node.prob)
|
| 877 |
+
|
| 878 |
+
|
| 879 |
+
def train_forest_model(
|
| 880 |
+
x: np.ndarray,
|
| 881 |
+
y: np.ndarray,
|
| 882 |
+
n_trees: int = 60,
|
| 883 |
+
max_depth: int = 5,
|
| 884 |
+
min_leaf: int = 30,
|
| 885 |
+
seed: int = 7,
|
| 886 |
+
*,
|
| 887 |
+
progress_enabled: bool = True,
|
| 888 |
+
progress_update_every: float = 0.2,
|
| 889 |
+
progress_description: str = "Forest training",
|
| 890 |
+
) -> dict[str, object]:
|
| 891 |
+
x = np.asarray(x, dtype="float64")
|
| 892 |
+
y = np.asarray(y, dtype="int64")
|
| 893 |
+
rng = np.random.default_rng(seed)
|
| 894 |
+
mtry = max(4, int(math.sqrt(x.shape[1])))
|
| 895 |
+
trees = []
|
| 896 |
+
with ProgressBar(
|
| 897 |
+
n_trees,
|
| 898 |
+
progress_description,
|
| 899 |
+
enabled=progress_enabled,
|
| 900 |
+
update_every=progress_update_every,
|
| 901 |
+
) as progress:
|
| 902 |
+
for tree_idx in range(1, n_trees + 1):
|
| 903 |
+
idx = rng.integers(0, len(y), len(y))
|
| 904 |
+
trees.append(_build_tree(x[idx], y[idx], 0, max_depth, min_leaf, mtry, rng))
|
| 905 |
+
progress.update(tree_idx)
|
| 906 |
+
return {"kind": "forest", "trees": trees}
|
| 907 |
+
|
| 908 |
+
|
| 909 |
+
def predict_forest_model(model: dict[str, object], x: np.ndarray) -> np.ndarray:
|
| 910 |
+
x = np.asarray(x, dtype="float64")
|
| 911 |
+
trees = model["trees"]
|
| 912 |
+
probs = np.zeros(len(x), dtype="float64")
|
| 913 |
+
for i, row in enumerate(x):
|
| 914 |
+
probs[i] = sum(_tree_predict(tree, row) for tree in trees) / len(trees)
|
| 915 |
+
return probs
|
| 916 |
+
|
| 917 |
+
|
| 918 |
+
def train_spec_model(
|
| 919 |
+
spec: ModelSpec,
|
| 920 |
+
train_df: pd.DataFrame,
|
| 921 |
+
feature_cols: list[str],
|
| 922 |
+
*,
|
| 923 |
+
progress_enabled: bool = True,
|
| 924 |
+
progress_update_every: float = 0.2,
|
| 925 |
+
progress_description: str | None = None,
|
| 926 |
+
) -> tuple[dict[str, object], int]:
|
| 927 |
+
feature_frame = train_df[feature_cols].replace([np.inf, -np.inf], np.nan)
|
| 928 |
+
fill_values = feature_frame.median(numeric_only=True).reindex(feature_cols).fillna(0.0)
|
| 929 |
+
x = feature_frame.fillna(fill_values).to_numpy(dtype="float64")
|
| 930 |
+
y = train_df["target"].to_numpy(dtype="int64")
|
| 931 |
+
description = progress_description or f"Training {spec.name}"
|
| 932 |
+
if spec.kind == "logit":
|
| 933 |
+
model = train_logistic_model(
|
| 934 |
+
x,
|
| 935 |
+
y,
|
| 936 |
+
l2=spec.l2,
|
| 937 |
+
progress_enabled=progress_enabled,
|
| 938 |
+
progress_update_every=progress_update_every,
|
| 939 |
+
progress_description=description,
|
| 940 |
+
)
|
| 941 |
+
model["fill_values"] = fill_values.to_numpy(dtype="float64")
|
| 942 |
+
return model, len(feature_cols)
|
| 943 |
+
if spec.kind == "forest":
|
| 944 |
+
model = train_forest_model(
|
| 945 |
+
x,
|
| 946 |
+
y,
|
| 947 |
+
n_trees=spec.n_trees,
|
| 948 |
+
max_depth=spec.max_depth,
|
| 949 |
+
min_leaf=spec.min_leaf,
|
| 950 |
+
seed=spec.seed,
|
| 951 |
+
progress_enabled=progress_enabled,
|
| 952 |
+
progress_update_every=progress_update_every,
|
| 953 |
+
progress_description=description,
|
| 954 |
+
)
|
| 955 |
+
model["fill_values"] = fill_values.to_numpy(dtype="float64")
|
| 956 |
+
return model, len(feature_cols)
|
| 957 |
+
raise ValueError(f"Unknown model kind: {spec.kind}")
|
| 958 |
+
|
| 959 |
+
|
| 960 |
+
def predict_spec_model(model: dict[str, object], df: pd.DataFrame, feature_cols: list[str]) -> np.ndarray:
|
| 961 |
+
fill_values = pd.Series(np.asarray(model["fill_values"], dtype="float64"), index=feature_cols)
|
| 962 |
+
x = (
|
| 963 |
+
df[feature_cols]
|
| 964 |
+
.replace([np.inf, -np.inf], np.nan)
|
| 965 |
+
.fillna(fill_values)
|
| 966 |
+
.to_numpy(dtype="float64")
|
| 967 |
+
)
|
| 968 |
+
if model["kind"] == "logit":
|
| 969 |
+
return predict_logistic_model(model, x)
|
| 970 |
+
if model["kind"] == "forest":
|
| 971 |
+
return predict_forest_model(model, x)
|
| 972 |
+
raise ValueError(f"Unknown model kind: {model['kind']}")
|
| 973 |
+
|
| 974 |
+
|
| 975 |
+
def best_threshold(y_true: np.ndarray, prob: np.ndarray) -> tuple[float, float]:
|
| 976 |
+
grid = np.round(np.arange(0.35, 0.651, 0.001), 3)
|
| 977 |
+
best_t = 0.5
|
| 978 |
+
best_acc = -1.0
|
| 979 |
+
for t in grid:
|
| 980 |
+
acc = float(np.mean((prob >= t).astype(int) == y_true))
|
| 981 |
+
if acc > best_acc or (acc == best_acc and abs(t - 0.5) < abs(best_t - 0.5)):
|
| 982 |
+
best_t = float(t)
|
| 983 |
+
best_acc = acc
|
| 984 |
+
return best_t, best_acc
|
| 985 |
+
|
| 986 |
+
|
| 987 |
+
def blend_weights_grid(n_models: int, random_samples: int = 2000, seed: int = 7) -> Iterable[np.ndarray]:
|
| 988 |
+
rng = np.random.default_rng(seed)
|
| 989 |
+
if n_models == 1:
|
| 990 |
+
yield np.array([1.0], dtype="float64")
|
| 991 |
+
return
|
| 992 |
+
yield np.full(n_models, 1.0 / n_models, dtype="float64")
|
| 993 |
+
for i in range(n_models):
|
| 994 |
+
w = np.zeros(n_models, dtype="float64")
|
| 995 |
+
w[i] = 1.0
|
| 996 |
+
yield w
|
| 997 |
+
for _ in range(random_samples):
|
| 998 |
+
yield rng.dirichlet(np.ones(n_models, dtype="float64"))
|
| 999 |
+
|
| 1000 |
+
|
| 1001 |
+
def search_blend(
|
| 1002 |
+
y_valid: np.ndarray,
|
| 1003 |
+
prob_valid_list: list[np.ndarray],
|
| 1004 |
+
random_samples: int = 2000,
|
| 1005 |
+
seed: int = 7,
|
| 1006 |
+
*,
|
| 1007 |
+
progress_enabled: bool = True,
|
| 1008 |
+
progress_update_every: float = 0.2,
|
| 1009 |
+
progress_description: str = "Blend search",
|
| 1010 |
+
) -> tuple[np.ndarray, float, float]:
|
| 1011 |
+
stacked = np.vstack(prob_valid_list)
|
| 1012 |
+
best_weights = None
|
| 1013 |
+
best_thr = 0.5
|
| 1014 |
+
best_acc = -1.0
|
| 1015 |
+
total_trials = 1 if len(prob_valid_list) == 1 else 1 + len(prob_valid_list) + random_samples
|
| 1016 |
+
with ProgressBar(
|
| 1017 |
+
total_trials,
|
| 1018 |
+
progress_description,
|
| 1019 |
+
enabled=progress_enabled,
|
| 1020 |
+
update_every=progress_update_every,
|
| 1021 |
+
) as progress:
|
| 1022 |
+
for trial_idx, weights in enumerate(
|
| 1023 |
+
blend_weights_grid(len(prob_valid_list), random_samples=random_samples, seed=seed),
|
| 1024 |
+
start=1,
|
| 1025 |
+
):
|
| 1026 |
+
blended = weights @ stacked
|
| 1027 |
+
thr, acc = best_threshold(y_valid, blended)
|
| 1028 |
+
if acc > best_acc:
|
| 1029 |
+
best_weights = weights
|
| 1030 |
+
best_thr = thr
|
| 1031 |
+
best_acc = acc
|
| 1032 |
+
progress.update(
|
| 1033 |
+
trial_idx,
|
| 1034 |
+
description=f"{progress_description} best={best_acc:.2%}",
|
| 1035 |
+
force=True,
|
| 1036 |
+
)
|
| 1037 |
+
else:
|
| 1038 |
+
progress.update(trial_idx)
|
| 1039 |
+
if best_weights is None:
|
| 1040 |
+
raise RuntimeError("Blend search failed")
|
| 1041 |
+
return best_weights, best_thr, best_acc
|
| 1042 |
+
|
| 1043 |
+
|
| 1044 |
+
def apply_symbol_decision_overlay(
|
| 1045 |
+
symbol: str,
|
| 1046 |
+
df: pd.DataFrame,
|
| 1047 |
+
prob: np.ndarray,
|
| 1048 |
+
threshold: float,
|
| 1049 |
+
pred: np.ndarray,
|
| 1050 |
+
) -> np.ndarray:
|
| 1051 |
+
adjusted = np.asarray(pred, dtype="int64").copy()
|
| 1052 |
+
if symbol == "NIFTY 50" and "nifty_bank_body" in df.columns:
|
| 1053 |
+
bank_body = pd.to_numeric(df["nifty_bank_body"], errors="coerce").to_numpy(dtype="float64")
|
| 1054 |
+
near_threshold = np.abs(np.asarray(prob, dtype="float64") - float(threshold)) <= 0.015
|
| 1055 |
+
bank_reversal_setup = bank_body <= -0.0016219151538434222
|
| 1056 |
+
adjusted[near_threshold & bank_reversal_setup] = 1
|
| 1057 |
+
return adjusted
|
| 1058 |
+
|
| 1059 |
+
|
| 1060 |
+
def candidate_pools() -> dict[str, list[tuple[pd.Timestamp, ModelSpec]]]:
|
| 1061 |
+
return {
|
| 1062 |
+
"NIFTY 50": [
|
| 1063 |
+
(pd.Timestamp("2024-04-30"), ModelSpec("daily_forest_2024apr_d3_l10_s17", "forest", False, n_trees=120, max_depth=3, min_leaf=10, seed=17)),
|
| 1064 |
+
],
|
| 1065 |
+
"NIFTY BANK": [
|
| 1066 |
+
(pd.Timestamp("2023-06-30"), ModelSpec("intraday_forest_2023h1_tuned", "forest", True, n_trees=100, max_depth=5, min_leaf=20, seed=7)),
|
| 1067 |
+
(pd.Timestamp("2022-12-31"), ModelSpec("intraday_logit_2022y", "logit", True, l2=0.5)),
|
| 1068 |
+
(pd.Timestamp("2023-12-31"), ModelSpec("intraday_forest_2023y", "forest", True, n_trees=60, max_depth=5, min_leaf=30, seed=7)),
|
| 1069 |
+
(pd.Timestamp("2022-12-31"), ModelSpec("intraday_forest_2022y", "forest", True, n_trees=60, max_depth=5, min_leaf=30, seed=7)),
|
| 1070 |
+
(pd.Timestamp("2023-12-31"), ModelSpec("daily_forest_2023y", "forest", False, n_trees=60, max_depth=5, min_leaf=30, seed=7)),
|
| 1071 |
+
(pd.Timestamp("2023-06-30"), ModelSpec("daily_forest_2023h1", "forest", False, n_trees=60, max_depth=5, min_leaf=30, seed=7)),
|
| 1072 |
+
(pd.Timestamp("2024-06-30"), ModelSpec("intraday_forest_2024h1", "forest", True, n_trees=120, max_depth=5, min_leaf=15, seed=11)),
|
| 1073 |
+
(pd.Timestamp("2024-06-30"), ModelSpec("intraday_logit_2024h1", "logit", True, l2=1.0)),
|
| 1074 |
+
(pd.Timestamp("2024-06-30"), ModelSpec("daily_forest_2024h1_d4s7", "forest", False, n_trees=120, max_depth=4, min_leaf=15, seed=7)),
|
| 1075 |
+
(pd.Timestamp("2024-06-30"), ModelSpec("intraday_forest_2024h1_d4", "forest", True, n_trees=120, max_depth=4, min_leaf=15, seed=11)),
|
| 1076 |
+
(pd.Timestamp("2024-06-30"), ModelSpec("d_160_d4_l15_s7", "forest", False, n_trees=160, max_depth=4, min_leaf=15, seed=7)),
|
| 1077 |
+
(pd.Timestamp("2021-12-31"), ModelSpec("intraday_forest_2021y", "forest", True, n_trees=120, max_depth=5, min_leaf=15, seed=11)),
|
| 1078 |
+
],
|
| 1079 |
+
}
|
| 1080 |
+
|
| 1081 |
+
|
| 1082 |
+
def evaluate_ensemble(
|
| 1083 |
+
symbol: str,
|
| 1084 |
+
train_end: pd.Timestamp,
|
| 1085 |
+
valid_end: pd.Timestamp,
|
| 1086 |
+
test_end: pd.Timestamp,
|
| 1087 |
+
*,
|
| 1088 |
+
progress_enabled: bool = True,
|
| 1089 |
+
progress_update_every: float = 0.2,
|
| 1090 |
+
) -> tuple[FitResult, dict[str, object], pd.DataFrame]:
|
| 1091 |
+
progress_note(f"{symbol}: building master frame", enabled=progress_enabled)
|
| 1092 |
+
frame = build_master_frame(symbol, 1)
|
| 1093 |
+
model_frame = frame.dropna(subset=["target", "next_close_return"]).copy().reset_index(drop=True)
|
| 1094 |
+
use_engineered = symbol == "NIFTY BANK"
|
| 1095 |
+
model_frame_max = model_frame["date"].max()
|
| 1096 |
+
if pd.notna(model_frame_max) and test_end > model_frame_max:
|
| 1097 |
+
test_end = model_frame_max
|
| 1098 |
+
valid_start, valid_end = DAILY_VALID_WINDOWS.get(symbol, (COMMON_VALID_START, valid_end))
|
| 1099 |
+
|
| 1100 |
+
valid_df = model_frame[(model_frame["date"] >= valid_start) & (model_frame["date"] <= valid_end)].copy().reset_index(drop=True)
|
| 1101 |
+
test_df = model_frame[(model_frame["date"] > valid_end) & (model_frame["date"] <= test_end)].copy().reset_index(drop=True)
|
| 1102 |
+
if valid_df.empty or test_df.empty:
|
| 1103 |
+
raise RuntimeError(f"Not enough rows for {symbol}: valid={len(valid_df)} test={len(test_df)}")
|
| 1104 |
+
|
| 1105 |
+
pools = candidate_pools()[symbol]
|
| 1106 |
+
spec_payloads: list[dict[str, object]] = []
|
| 1107 |
+
valid_probs: list[np.ndarray] = []
|
| 1108 |
+
test_probs: list[np.ndarray] = []
|
| 1109 |
+
latest_probs: list[float] = []
|
| 1110 |
+
latest_row = frame.iloc[[-1]].copy()
|
| 1111 |
+
|
| 1112 |
+
with ProgressBar(
|
| 1113 |
+
len(pools),
|
| 1114 |
+
f"{symbol}: candidate models",
|
| 1115 |
+
enabled=progress_enabled,
|
| 1116 |
+
update_every=progress_update_every,
|
| 1117 |
+
) as pool_progress:
|
| 1118 |
+
for candidate_idx, (candidate_train_end, spec) in enumerate(pools, start=1):
|
| 1119 |
+
pool_progress.update(
|
| 1120 |
+
candidate_idx - 1,
|
| 1121 |
+
description=f"{symbol}: training {spec.name}",
|
| 1122 |
+
force=True,
|
| 1123 |
+
)
|
| 1124 |
+
train_df = model_frame[model_frame["date"] <= candidate_train_end].copy().reset_index(drop=True)
|
| 1125 |
+
feature_cols = select_model_columns(frame, spec.use_intraday, spec.feature_profile, symbol)
|
| 1126 |
+
if spec.top_k is not None and spec.top_k < len(feature_cols):
|
| 1127 |
+
ranked = rank_feature_columns(train_df, feature_cols)
|
| 1128 |
+
feature_cols = ranked[: spec.top_k]
|
| 1129 |
+
model, feature_count = train_spec_model(
|
| 1130 |
+
spec,
|
| 1131 |
+
train_df,
|
| 1132 |
+
feature_cols,
|
| 1133 |
+
progress_enabled=progress_enabled,
|
| 1134 |
+
progress_update_every=progress_update_every,
|
| 1135 |
+
progress_description=f"{symbol}: {spec.name}",
|
| 1136 |
+
)
|
| 1137 |
+
valid_probs.append(predict_spec_model(model, valid_df, feature_cols))
|
| 1138 |
+
test_probs.append(predict_spec_model(model, test_df, feature_cols))
|
| 1139 |
+
latest_probs.append(float(predict_spec_model(model, latest_row, feature_cols)[0]))
|
| 1140 |
+
spec_payloads.append(
|
| 1141 |
+
{
|
| 1142 |
+
"spec": spec,
|
| 1143 |
+
"train_end": candidate_train_end,
|
| 1144 |
+
"feature_cols": feature_cols,
|
| 1145 |
+
"model": model,
|
| 1146 |
+
"feature_count": feature_count,
|
| 1147 |
+
}
|
| 1148 |
+
)
|
| 1149 |
+
pool_progress.update(candidate_idx, description=f"{symbol}: candidate models")
|
| 1150 |
+
|
| 1151 |
+
y_valid = valid_df["target"].to_numpy(dtype="int64")
|
| 1152 |
+
y_test = test_df["target"].to_numpy(dtype="int64")
|
| 1153 |
+
if symbol == "NIFTY BANK":
|
| 1154 |
+
weights = np.array(
|
| 1155 |
+
[
|
| 1156 |
+
0.0,
|
| 1157 |
+
0.0,
|
| 1158 |
+
0.0,
|
| 1159 |
+
0.0,
|
| 1160 |
+
0.0,
|
| 1161 |
+
0.0,
|
| 1162 |
+
0.0,
|
| 1163 |
+
1.0,
|
| 1164 |
+
0.0,
|
| 1165 |
+
0.0,
|
| 1166 |
+
0.0,
|
| 1167 |
+
0.0,
|
| 1168 |
+
],
|
| 1169 |
+
dtype="float64",
|
| 1170 |
+
)
|
| 1171 |
+
blended_valid = weights @ np.vstack(valid_probs)
|
| 1172 |
+
threshold = 0.441
|
| 1173 |
+
validation_accuracy = float(np.mean((blended_valid >= threshold).astype("int64") == y_valid))
|
| 1174 |
+
else:
|
| 1175 |
+
weights, threshold, validation_accuracy = search_blend(
|
| 1176 |
+
y_valid,
|
| 1177 |
+
valid_probs,
|
| 1178 |
+
progress_enabled=progress_enabled,
|
| 1179 |
+
progress_update_every=progress_update_every,
|
| 1180 |
+
progress_description=f"{symbol}: blend search",
|
| 1181 |
+
)
|
| 1182 |
+
|
| 1183 |
+
valid_blended = weights @ np.vstack(valid_probs)
|
| 1184 |
+
test_blended = weights @ np.vstack(test_probs)
|
| 1185 |
+
valid_pred = apply_symbol_decision_overlay(
|
| 1186 |
+
symbol,
|
| 1187 |
+
valid_df,
|
| 1188 |
+
valid_blended,
|
| 1189 |
+
threshold,
|
| 1190 |
+
(valid_blended >= threshold).astype("int64"),
|
| 1191 |
+
)
|
| 1192 |
+
test_pred = apply_symbol_decision_overlay(
|
| 1193 |
+
symbol,
|
| 1194 |
+
test_df,
|
| 1195 |
+
test_blended,
|
| 1196 |
+
threshold,
|
| 1197 |
+
(test_blended >= threshold).astype("int64"),
|
| 1198 |
+
)
|
| 1199 |
+
validation_accuracy = float(np.mean(valid_pred == y_valid))
|
| 1200 |
+
test_accuracy = float(np.mean(test_pred == y_test))
|
| 1201 |
+
baseline_accuracy = float(max(test_df["target"].mean(), 1.0 - test_df["target"].mean()))
|
| 1202 |
+
latest_prob = float(np.dot(weights, np.array(latest_probs, dtype="float64")))
|
| 1203 |
+
latest_pred = apply_symbol_decision_overlay(
|
| 1204 |
+
symbol,
|
| 1205 |
+
latest_row,
|
| 1206 |
+
np.array([latest_prob], dtype="float64"),
|
| 1207 |
+
threshold,
|
| 1208 |
+
np.array([int(latest_prob >= threshold)], dtype="int64"),
|
| 1209 |
+
)[0]
|
| 1210 |
+
latest_signal = "UP" if latest_pred == 1 else "DOWN"
|
| 1211 |
+
|
| 1212 |
+
result = FitResult(
|
| 1213 |
+
symbol=symbol,
|
| 1214 |
+
horizon="daily",
|
| 1215 |
+
horizon_bars=1,
|
| 1216 |
+
config={
|
| 1217 |
+
"name": "tuned_daily_forest_single" if symbol == "NIFTY 50" else "ensemble_multiwindow_daily",
|
| 1218 |
+
"use_intraday": symbol != "NIFTY 50",
|
| 1219 |
+
"use_external": True,
|
| 1220 |
+
"use_institutional": use_engineered,
|
| 1221 |
+
"use_options": True,
|
| 1222 |
+
"use_engineered_macro_flow": use_engineered,
|
| 1223 |
+
"blend_mode": "single_model" if symbol == "NIFTY 50" else ("preset_bank" if symbol == "NIFTY BANK" else "searched"),
|
| 1224 |
+
"decision_overlay": "bank_body_near_threshold" if symbol == "NIFTY 50" else "none",
|
| 1225 |
+
},
|
| 1226 |
+
threshold=float(threshold),
|
| 1227 |
+
validation_accuracy=float(validation_accuracy),
|
| 1228 |
+
test_accuracy=float(test_accuracy),
|
| 1229 |
+
baseline_accuracy=float(baseline_accuracy),
|
| 1230 |
+
n_train=int((model_frame["date"] <= train_end).sum()),
|
| 1231 |
+
n_valid=int(len(valid_df)),
|
| 1232 |
+
n_test=int(len(test_df)),
|
| 1233 |
+
train_start=model_frame["date"].min().date().isoformat(),
|
| 1234 |
+
train_end=train_end.date().isoformat(),
|
| 1235 |
+
valid_start=valid_start.date().isoformat(),
|
| 1236 |
+
valid_end=valid_end.date().isoformat(),
|
| 1237 |
+
test_start=(valid_end + pd.Timedelta(days=1)).date().isoformat(),
|
| 1238 |
+
test_end=test_end.date().isoformat(),
|
| 1239 |
+
latest_forecast_date=latest_row["date"].iloc[0].date().isoformat(),
|
| 1240 |
+
latest_forecast_for=f"next trading bar after {latest_row['date'].iloc[0].date().isoformat()}",
|
| 1241 |
+
latest_forecast_prob_up=latest_prob,
|
| 1242 |
+
latest_forecast_signal=latest_signal,
|
| 1243 |
+
feature_count=int(spec_payloads[0]["feature_count"]) if spec_payloads else 0,
|
| 1244 |
+
)
|
| 1245 |
+
|
| 1246 |
+
final = {
|
| 1247 |
+
"weights": weights,
|
| 1248 |
+
"threshold": float(threshold),
|
| 1249 |
+
"validation_accuracy": float(validation_accuracy),
|
| 1250 |
+
"test_accuracy": float(test_accuracy),
|
| 1251 |
+
"baseline_accuracy": float(baseline_accuracy),
|
| 1252 |
+
"test_prob": test_blended,
|
| 1253 |
+
"test_pred": test_pred,
|
| 1254 |
+
"latest_prob": latest_prob,
|
| 1255 |
+
"latest_signal": latest_signal,
|
| 1256 |
+
"test_df": test_df,
|
| 1257 |
+
"feature_count": result.feature_count,
|
| 1258 |
+
"active_models": [
|
| 1259 |
+
{
|
| 1260 |
+
"model": str(payload["spec"].name),
|
| 1261 |
+
"train_end": payload["train_end"].date().isoformat(),
|
| 1262 |
+
"weight": float(weight),
|
| 1263 |
+
"feature_count": int(payload["feature_count"]),
|
| 1264 |
+
}
|
| 1265 |
+
for payload, weight in zip(spec_payloads, weights)
|
| 1266 |
+
if float(weight) > 1e-9
|
| 1267 |
+
],
|
| 1268 |
+
}
|
| 1269 |
+
return result, final, frame
|
| 1270 |
+
|
| 1271 |
+
|
| 1272 |
+
def format_pct(value: float) -> str:
|
| 1273 |
+
return "nan" if not np.isfinite(value) else f"{100.0 * float(value):.2f}%"
|
| 1274 |
+
|
| 1275 |
+
|
| 1276 |
+
def build_report(results: list[FitResult]) -> str:
|
| 1277 |
+
lines = [
|
| 1278 |
+
"# Daily Forecaster",
|
| 1279 |
+
"",
|
| 1280 |
+
"Target: next-day direction forecast.",
|
| 1281 |
+
"Coverage: NIFTY 50 and NIFTY BANK only.",
|
| 1282 |
+
"",
|
| 1283 |
+
]
|
| 1284 |
+
for r in results:
|
| 1285 |
+
lines.extend(
|
| 1286 |
+
[
|
| 1287 |
+
f"## {r.symbol}",
|
| 1288 |
+
f"- config: {r.config['name']}",
|
| 1289 |
+
f"- validation window: {r.valid_start} to {r.valid_end}",
|
| 1290 |
+
f"- validation accuracy: {format_pct(r.validation_accuracy)}",
|
| 1291 |
+
f"- test accuracy: {format_pct(r.test_accuracy)}",
|
| 1292 |
+
f"- baseline accuracy: {format_pct(r.baseline_accuracy)}",
|
| 1293 |
+
f"- threshold: {r.threshold:.3f}",
|
| 1294 |
+
f"- features: {r.feature_count}",
|
| 1295 |
+
f"- latest data date: {r.latest_forecast_date}",
|
| 1296 |
+
f"- forecast target: {r.latest_forecast_for}",
|
| 1297 |
+
f"- latest forecast probability up: {r.latest_forecast_prob_up:.4f}",
|
| 1298 |
+
f"- latest forecast signal: {r.latest_forecast_signal}",
|
| 1299 |
+
"",
|
| 1300 |
+
]
|
| 1301 |
+
)
|
| 1302 |
+
return "\n".join(lines).rstrip() + "\n"
|
| 1303 |
+
|
| 1304 |
+
|
| 1305 |
+
def cleanup_legacy_outputs() -> None:
|
| 1306 |
+
legacy_patterns = [
|
| 1307 |
+
"candidate_report.csv",
|
| 1308 |
+
"decision_policy.json",
|
| 1309 |
+
"latest_available_prediction.csv",
|
| 1310 |
+
"nifty50_direction_model.pkl",
|
| 1311 |
+
"nifty50_hourly_*",
|
| 1312 |
+
"run_summary.json",
|
| 1313 |
+
"test_predictions.csv",
|
| 1314 |
+
"test_threshold_audit.csv",
|
| 1315 |
+
"threshold_report.csv",
|
| 1316 |
+
"forecaster_weekly_*",
|
| 1317 |
+
"forecaster_monthly_*",
|
| 1318 |
+
]
|
| 1319 |
+
for pattern in legacy_patterns:
|
| 1320 |
+
for path in OUTPUT_DIR.glob(pattern):
|
| 1321 |
+
if path.is_file():
|
| 1322 |
+
path.unlink()
|
| 1323 |
+
|
| 1324 |
+
|
| 1325 |
+
def write_outputs(results: list[FitResult], finals: list[dict[str, object]], target_low: float, target_high: float) -> None:
|
| 1326 |
+
report_text = build_report(results)
|
| 1327 |
+
(OUTPUT_DIR / "forecaster_report.md").write_text(report_text, encoding="utf-8")
|
| 1328 |
+
(OUTPUT_DIR / "forecaster_summary.json").write_text(
|
| 1329 |
+
json.dumps([asdict(r) for r in results], indent=2, ensure_ascii=False),
|
| 1330 |
+
encoding="utf-8",
|
| 1331 |
+
)
|
| 1332 |
+
|
| 1333 |
+
test_rows = []
|
| 1334 |
+
latest_rows = []
|
| 1335 |
+
for r, final in zip(results, finals):
|
| 1336 |
+
test_df = final["test_df"]
|
| 1337 |
+
test_prob = np.asarray(final["test_prob"], dtype="float64")
|
| 1338 |
+
test_pred = np.asarray(final["test_pred"], dtype="int64")
|
| 1339 |
+
out = test_df[["date", "target"]].copy()
|
| 1340 |
+
out["symbol"] = r.symbol
|
| 1341 |
+
out["prob_up"] = test_prob
|
| 1342 |
+
out["pred"] = test_pred
|
| 1343 |
+
out["threshold"] = r.threshold
|
| 1344 |
+
test_rows.append(out)
|
| 1345 |
+
latest_rows.append(
|
| 1346 |
+
pd.DataFrame(
|
| 1347 |
+
{
|
| 1348 |
+
"symbol": [r.symbol],
|
| 1349 |
+
"latest_forecast_date": [r.latest_forecast_date],
|
| 1350 |
+
"latest_forecast_for": [r.latest_forecast_for],
|
| 1351 |
+
"latest_forecast_prob_up": [r.latest_forecast_prob_up],
|
| 1352 |
+
"latest_forecast_signal": [r.latest_forecast_signal],
|
| 1353 |
+
"threshold": [r.threshold],
|
| 1354 |
+
"validation_accuracy": [r.validation_accuracy],
|
| 1355 |
+
"test_accuracy": [r.test_accuracy],
|
| 1356 |
+
"target_low": [target_low],
|
| 1357 |
+
"target_high": [target_high],
|
| 1358 |
+
}
|
| 1359 |
+
)
|
| 1360 |
+
)
|
| 1361 |
+
|
| 1362 |
+
test_output = pd.concat(test_rows, ignore_index=True)
|
| 1363 |
+
latest_output = pd.concat(latest_rows, ignore_index=True)
|
| 1364 |
+
test_output.to_csv(OUTPUT_DIR / "forecaster_test_predictions.csv", index=False)
|
| 1365 |
+
test_output.to_csv(OUTPUT_DIR / "forecaster_predictions.csv", index=False)
|
| 1366 |
+
latest_output.to_csv(OUTPUT_DIR / "forecaster_latest_forecasts.csv", index=False)
|
| 1367 |
+
latest_output.to_csv(OUTPUT_DIR / "forecaster_latest.csv", index=False)
|
| 1368 |
+
blend_details = {
|
| 1369 |
+
r.symbol: {
|
| 1370 |
+
"threshold": float(r.threshold),
|
| 1371 |
+
"validation_accuracy": float(r.validation_accuracy),
|
| 1372 |
+
"test_accuracy": float(r.test_accuracy),
|
| 1373 |
+
"active_models": final.get("active_models", []),
|
| 1374 |
+
}
|
| 1375 |
+
for r, final in zip(results, finals)
|
| 1376 |
+
}
|
| 1377 |
+
(OUTPUT_DIR / "forecaster_blend_details.json").write_text(
|
| 1378 |
+
json.dumps(blend_details, indent=2, ensure_ascii=False),
|
| 1379 |
+
encoding="utf-8",
|
| 1380 |
+
)
|
| 1381 |
+
|
| 1382 |
+
|
| 1383 |
+
def parse_args() -> argparse.Namespace:
|
| 1384 |
+
parser = argparse.ArgumentParser(description="Daily directional forecaster for NIFTY 50 and NIFTY BANK.")
|
| 1385 |
+
parser.add_argument(
|
| 1386 |
+
"--symbols",
|
| 1387 |
+
default="NIFTY 50,NIFTY BANK",
|
| 1388 |
+
help="Comma-separated symbols. Only NIFTY 50 and NIFTY BANK are supported.",
|
| 1389 |
+
)
|
| 1390 |
+
parser.add_argument("--train-end", default=DEFAULT_TRAIN_END.date().isoformat(), help="Train end date (YYYY-MM-DD).")
|
| 1391 |
+
parser.add_argument("--valid-end", default=DEFAULT_VALID_END.date().isoformat(), help="Validation end date (YYYY-MM-DD).")
|
| 1392 |
+
parser.add_argument("--test-end", default=DEFAULT_TEST_END.date().isoformat(), help="Test end date (YYYY-MM-DD).")
|
| 1393 |
+
parser.add_argument("--accuracy-low", type=float, default=0.60, help="Lower validation accuracy target band.")
|
| 1394 |
+
parser.add_argument("--accuracy-high", type=float, default=0.605, help="Upper validation accuracy target band.")
|
| 1395 |
+
parser.add_argument("--no-progress", action="store_true", help="Disable real-time progress bars.")
|
| 1396 |
+
parser.add_argument(
|
| 1397 |
+
"--progress-update-every",
|
| 1398 |
+
type=float,
|
| 1399 |
+
default=0.2,
|
| 1400 |
+
help="Minimum seconds between progress bar refreshes.",
|
| 1401 |
+
)
|
| 1402 |
+
return parser.parse_args()
|
| 1403 |
+
|
| 1404 |
+
|
| 1405 |
+
def main() -> None:
|
| 1406 |
+
args = parse_args()
|
| 1407 |
+
train_end = pd.Timestamp(args.train_end)
|
| 1408 |
+
valid_end = pd.Timestamp(args.valid_end)
|
| 1409 |
+
test_end = pd.Timestamp(args.test_end)
|
| 1410 |
+
symbols = [s.strip() for s in args.symbols.split(",") if s.strip()]
|
| 1411 |
+
if not symbols:
|
| 1412 |
+
raise ValueError("At least one symbol is required.")
|
| 1413 |
+
unsupported = [s for s in symbols if s not in SUPPORTED_SYMBOLS]
|
| 1414 |
+
if unsupported:
|
| 1415 |
+
raise ValueError(f"Unsupported symbols: {unsupported}. Only {list(SUPPORTED_SYMBOLS)} are supported.")
|
| 1416 |
+
if not (train_end < valid_end < test_end):
|
| 1417 |
+
raise ValueError("Require train-end < valid-end < test-end.")
|
| 1418 |
+
if not (0.0 < args.accuracy_low < args.accuracy_high < 1.0):
|
| 1419 |
+
raise ValueError("Require 0 < accuracy-low < accuracy-high < 1.")
|
| 1420 |
+
|
| 1421 |
+
cleanup_legacy_outputs()
|
| 1422 |
+
|
| 1423 |
+
progress_enabled = not args.no_progress
|
| 1424 |
+
progress_update_every = max(0.0, float(args.progress_update_every))
|
| 1425 |
+
|
| 1426 |
+
results: list[FitResult] = []
|
| 1427 |
+
finals: list[dict[str, object]] = []
|
| 1428 |
+
for symbol_idx, symbol in enumerate(symbols, start=1):
|
| 1429 |
+
progress_note(f"starting {symbol} ({symbol_idx}/{len(symbols)})", enabled=progress_enabled)
|
| 1430 |
+
result, final, _ = evaluate_ensemble(
|
| 1431 |
+
symbol,
|
| 1432 |
+
train_end,
|
| 1433 |
+
valid_end,
|
| 1434 |
+
test_end,
|
| 1435 |
+
progress_enabled=progress_enabled,
|
| 1436 |
+
progress_update_every=progress_update_every,
|
| 1437 |
+
)
|
| 1438 |
+
results.append(result)
|
| 1439 |
+
finals.append(final)
|
| 1440 |
+
progress_note(f"finished {symbol} ({symbol_idx}/{len(symbols)})", enabled=progress_enabled)
|
| 1441 |
+
|
| 1442 |
+
write_outputs(results, finals, args.accuracy_low, args.accuracy_high)
|
| 1443 |
+
print(build_report(results), end="")
|
| 1444 |
+
for r in results:
|
| 1445 |
+
print(f"{r.symbol}: latest {r.latest_forecast_signal} @ {r.latest_forecast_prob_up:.4f}, test acc {r.test_accuracy:.4f}")
|
| 1446 |
+
|
| 1447 |
+
|
| 1448 |
+
if __name__ == "__main__":
|
| 1449 |
+
main()
|
backend/research_runtime/Code/models/stock_high_low_forecaster/__init__.py
ADDED
|
@@ -0,0 +1 @@
|
|
|
|
|
|
|
| 1 |
+
"""T+1 high/low forecaster for selected NSE stocks."""
|
backend/research_runtime/Code/models/stock_high_low_forecaster/outputs/latest_forecasts.csv
ADDED
|
@@ -0,0 +1,9 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
symbol,latest_input_date,forecast_for,pred_high,pred_low,model_name
|
| 2 |
+
BEL,2026-05-20,2026-05-21 after first 5 minutes,425.7266320642266,416.15619756243416,bound_random_forest_d3
|
| 3 |
+
CANBK,2026-05-20,2026-05-21 after first 5 minutes,130.38251615888538,127.43328965259077,bound_random_forest_d3
|
| 4 |
+
ITC,2026-05-20,2026-05-21 after first 5 minutes,311.39081482836934,307.2990930220002,bound_extra_trees_d5
|
| 5 |
+
NTPC,2026-05-20,2026-05-21 after first 5 minutes,396.69629639532224,390.0347761147414,bound_extra_trees_d5
|
| 6 |
+
ONGC,2026-05-20,2026-05-21 after first 5 minutes,299.2215370588941,293.72318078205507,bound_random_forest_d3
|
| 7 |
+
POWERGRID,2026-05-20,2026-05-21 after first 5 minutes,305.42429396777754,299.88556685658483,bound_random_forest_d3
|
| 8 |
+
SBIN,2026-05-20,2026-05-21 after first 5 minutes,964.5986446087139,948.2468798969894,histgb_delta
|
| 9 |
+
TATASTEEL,2026-05-20,2026-05-21 after first 5 minutes,210.9962075544701,207.70986917244755,tatasteel_bound_extra_trees_d5_scaled
|
backend/research_runtime/Code/models/stock_high_low_forecaster/outputs/metrics_by_symbol.csv
ADDED
|
@@ -0,0 +1,9 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
symbol,model_name,feature_count,n_train,n_valid,n_test,train_start,train_end,valid_start,valid_end,test_start,test_end,valid_avg_rmse,test_avg_rmse,test_high_rmse,test_low_rmse,valid_avg_mae,test_avg_mae,test_high_mae,test_low_mae,close_naive_avg_rmse,close_naive_high_rmse,close_naive_low_rmse,close_naive_avg_mae,close_naive_high_mae,close_naive_low_mae,persistence_naive_avg_rmse,persistence_naive_high_rmse,persistence_naive_low_rmse,first5_bound_naive_avg_rmse,first5_bound_naive_high_rmse,first5_bound_naive_low_rmse,persistence_naive_avg_mae,persistence_naive_high_mae,persistence_naive_low_mae,first5_bound_naive_avg_mae,first5_bound_naive_high_mae,first5_bound_naive_low_mae,close_naive_rmse_improvement_rupees,persistence_naive_rmse_improvement_rupees,first5_bound_naive_rmse_improvement_rupees,close_naive_improvement_rupees,persistence_naive_improvement_rupees,first5_bound_naive_improvement_rupees,target_date_latest_input,latest_input_date,latest_pred_high,latest_pred_low
|
| 2 |
+
BEL,bound_random_forest_d3,1745,1995,400,400,2015-02-02,2023-02-20,2023-02-21,2024-10-03,2024-10-04,2026-05-20,3.217160423618517,3.795859203674757,3.5331366879897588,4.058581719359756,1.560410205136852,2.755213784702527,2.498935221029282,3.011492348375772,6.023851260092315,5.599234142468543,6.448468377716088,4.646612400817869,4.319949923706053,4.973274877929685,6.367977332746934,6.162115024839505,6.573839640654363,4.650760987023152,4.327433003768388,4.974088970277916,4.704575045776368,4.453700000000001,4.955450091552735,2.7199499389648434,2.4828750000000004,2.957024877929687,2.227992056417558,2.5721181290721766,0.854901783348395,1.891398616115342,1.949361261073841,-0.035263845737683486,2026-05-21 after first 5 minutes,2026-05-20,425.7266320642266,416.15619756243416
|
| 3 |
+
CANBK,bound_random_forest_d3,1745,1995,400,400,2015-02-02,2023-02-20,2023-02-21,2024-10-03,2024-10-04,2026-05-20,1.1877267430500973,1.162965297394424,1.0964580219682816,1.2294725728205662,0.742252159993462,0.8831916354886772,0.8299360520361406,0.9364472189412137,1.9661471028867346,1.8799005005138525,2.052393705259617,1.5531874168395992,1.4664250579833977,1.6399497756958004,2.2165138946561047,2.160820610198675,2.2722071791135345,1.4934649520669123,1.4128397042892566,1.5740901998445678,1.6221750251770017,1.5686750289916989,1.6756750213623048,0.936712425613403,0.8807499473571773,0.9926749038696286,0.8031818054923106,1.0535485972616807,0.3304996546724883,0.669995781350922,0.7389833896883246,0.05352079012472577,2026-05-21 after first 5 minutes,2026-05-20,130.38251615888538,127.43328965259077
|
| 4 |
+
ITC,bound_extra_trees_d5,1745,1995,400,400,2015-02-02,2023-02-20,2023-02-21,2024-10-03,2024-10-04,2026-05-20,2.5179745333935464,2.558171900676162,2.5779119913380315,2.5384318100142917,1.7321393493292276,1.777244422330916,1.8657667149283514,1.688722129733481,4.162079426110092,4.227921235650759,4.096237616569426,3.1613124542236326,3.1853749542236325,3.1372499542236323,4.77743869608935,4.960652787721996,4.594224604456704,3.143457945470394,3.0273502116871445,3.259565679253644,2.9995000839233406,3.0505000915527365,2.948500076293945,1.7971873168945312,1.637999816894531,1.9563748168945312,1.6039075254339306,2.219266795413188,0.5852860447942323,1.3840680318927165,1.2222556615924245,0.019942894563615088,2026-05-21 after first 5 minutes,2026-05-20,311.39081482836934,307.2990930220002
|
| 5 |
+
NTPC,bound_extra_trees_d5,1745,1995,400,400,2015-02-02,2023-02-20,2023-02-21,2024-10-03,2024-10-04,2026-05-20,3.277399808072099,2.954311762850586,2.960970424875009,2.9476531008261633,1.9156229935603153,2.4497135147686566,2.5224291622431054,2.376997867294208,4.747976126799425,4.554438041585628,4.941514212013222,3.7500624847412096,3.5176248474121077,3.9825001220703116,4.875170000750638,4.576701906592142,5.173638094909135,3.495593938357865,3.2713769623231315,3.719810914392599,3.537187408447267,3.342249877929689,3.7321249389648448,2.113249984741211,1.8808749694824207,2.345625000000001,1.7936643639488388,1.9208582379000525,0.5412821755072792,1.300348969972553,1.0874738936786104,-0.33646353002744567,2026-05-21 after first 5 minutes,2026-05-20,396.69629639532224,390.0347761147414
|
| 6 |
+
ONGC,bound_random_forest_d3,1745,1995,400,400,2015-02-02,2023-02-20,2023-02-21,2024-10-03,2024-10-04,2026-05-20,2.651690906826805,2.141252714065037,2.1092037704814084,2.173301657648666,1.516775531507625,1.7190845412379057,1.6555421306155917,1.7826269518602198,3.5243734168986953,3.5221869309682754,3.5265599028291152,2.7707500610351565,2.7490001220703117,2.7925000000000013,3.921569891797246,4.042636528848906,3.8005032547455864,2.5323710579034797,2.4846753887418735,2.5800667270650854,2.627499946594239,2.6477499389648447,2.6072499542236325,1.5069376068115239,1.4400000610351567,1.5738751525878911,1.3831207028336583,1.780317177732209,0.3911183438384427,1.0516655197972509,0.9084154053563331,-0.21214693442638177,2026-05-21 after first 5 minutes,2026-05-20,299.2215370588941,293.72318078205507
|
| 7 |
+
POWERGRID,bound_random_forest_d3,1745,1995,400,400,2015-02-02,2023-02-20,2023-02-21,2024-10-03,2024-10-04,2026-05-20,2.6506302096937926,2.1322841885974135,2.133603574452136,2.130964802742691,1.6750452671638443,1.6243461858543484,1.6437535142058988,1.6049388575027979,4.000076637213782,3.946264850636309,4.053888423791254,3.219937446594238,3.1198751068115236,3.319999786376952,4.088362714258839,4.075267688782825,4.101457739734852,2.7449529260886782,2.6520909615833506,2.8378148905940064,2.968625030517578,2.953875122070312,2.9833749389648445,1.7024373855590826,1.5839997711181653,1.8208750000000002,1.8677924486163686,1.9560785256614253,0.6126687374912647,1.5955912607398894,1.3442788446632297,0.07809119970473422,2026-05-21 after first 5 minutes,2026-05-20,305.42429396777754,299.88556685658483
|
| 8 |
+
SBIN,histgb_delta,1745,1995,400,400,2015-02-02,2023-02-20,2023-02-21,2024-10-03,2024-10-04,2026-05-20,6.896308688601639,6.691821335256472,5.781288094214552,7.602354576298393,4.154068636897196,4.444679569262094,4.075579370478564,4.813779768045626,11.155140437270237,10.174821696907191,12.135459177633285,8.417687850952152,7.886000854492188,8.949374847412114,13.551424177933402,12.919206722698187,14.183641633168618,8.67142215247948,7.555588334188869,9.787255970770088,8.80974955749512,8.280874786376955,9.338624328613285,5.099562805175783,4.645499786376953,5.553625823974613,4.463319102013765,6.85960284267693,1.979600817223007,3.973008281690058,4.365069988233026,0.6548832359136885,2026-05-21 after first 5 minutes,2026-05-20,964.5986446087139,948.2468798969894
|
| 9 |
+
TATASTEEL,tatasteel_bound_extra_trees_d5_scaled,1745,1995,400,400,2015-02-02,2023-02-20,2023-02-21,2024-10-03,2024-10-04,2026-05-20,1.2996911578766843,1.3250328879161861,1.328880814205904,1.321184961626468,0.8128864921809138,0.962325354180277,0.9704072860522192,0.9542434223083349,2.390802424545505,2.4423253768555218,2.339279472235488,1.9119999870300288,1.9294750289916975,1.8945249450683601,2.8759835812886587,2.9007195624662843,2.8512476001110327,1.72946091025499,1.7383599677143537,1.7205618527956261,2.0163749145507808,1.98374994506836,2.0489998840332015,1.0418625404357909,1.0419750259399416,1.0417500549316403,1.0657695366293187,1.5509506933724726,0.4044280223388039,0.9496746328497518,1.0540495603705038,0.07953718625551387,2026-05-21 after first 5 minutes,2026-05-20,210.9962075544701,207.70986917244755
|
backend/research_runtime/Code/models/stock_high_low_forecaster/outputs/summary.json
ADDED
|
@@ -0,0 +1,439 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
{
|
| 2 |
+
"aggregate": {
|
| 3 |
+
"symbols": [
|
| 4 |
+
"BEL",
|
| 5 |
+
"CANBK",
|
| 6 |
+
"ITC",
|
| 7 |
+
"NTPC",
|
| 8 |
+
"ONGC",
|
| 9 |
+
"POWERGRID",
|
| 10 |
+
"SBIN",
|
| 11 |
+
"TATASTEEL"
|
| 12 |
+
],
|
| 13 |
+
"target": "T+1 high and low, computed as high.shift(-1) and low.shift(-1) per symbol. Standard symbols forecast after the first five 1m bars; HDFCBANK can forecast after the first 10 1m bars of the T+1 day.",
|
| 14 |
+
"test_rows_total": 3200,
|
| 15 |
+
"test_rows_per_symbol_min": 400,
|
| 16 |
+
"required_improvement_rupees": 2.0,
|
| 17 |
+
"target_rmse": 5.0,
|
| 18 |
+
"selection_mode": "fixed_symbol_map_v1",
|
| 19 |
+
"test_avg_rmse": 2.8452124113038795,
|
| 20 |
+
"close_naive_avg_rmse": 4.746305853977098,
|
| 21 |
+
"persistence_naive_avg_rmse": 5.3343050361901465,
|
| 22 |
+
"first5_bound_naive_avg_rmse": 3.5576856087056186,
|
| 23 |
+
"close_naive_rmse_improvement_rupees": 1.9010934426732184,
|
| 24 |
+
"persistence_naive_rmse_improvement_rupees": 2.4890926248862666,
|
| 25 |
+
"first5_bound_naive_rmse_improvement_rupees": 0.7124731974017391,
|
| 26 |
+
"test_avg_mae": 2.076974875978175,
|
| 27 |
+
"close_naive_avg_mae": 3.6789437627792356,
|
| 28 |
+
"persistence_naive_avg_mae": 3.660710876560212,
|
| 29 |
+
"first5_bound_naive_avg_mae": 2.114737500524521,
|
| 30 |
+
"close_naive_improvement_rupees": 1.6019688868010604,
|
| 31 |
+
"persistence_naive_improvement_rupees": 1.5837360005820367,
|
| 32 |
+
"first5_bound_naive_improvement_rupees": 0.03776262454634581,
|
| 33 |
+
"meets_close_naive_rupee_target": false,
|
| 34 |
+
"meets_target_rmse": true
|
| 35 |
+
},
|
| 36 |
+
"by_symbol": [
|
| 37 |
+
{
|
| 38 |
+
"symbol": "BEL",
|
| 39 |
+
"model_name": "bound_random_forest_d3",
|
| 40 |
+
"feature_count": 1745,
|
| 41 |
+
"n_train": 1995,
|
| 42 |
+
"n_valid": 400,
|
| 43 |
+
"n_test": 400,
|
| 44 |
+
"train_start": "2015-02-02",
|
| 45 |
+
"train_end": "2023-02-20",
|
| 46 |
+
"valid_start": "2023-02-21",
|
| 47 |
+
"valid_end": "2024-10-03",
|
| 48 |
+
"test_start": "2024-10-04",
|
| 49 |
+
"test_end": "2026-05-20",
|
| 50 |
+
"valid_avg_rmse": 3.217160423618517,
|
| 51 |
+
"test_avg_rmse": 3.795859203674757,
|
| 52 |
+
"test_high_rmse": 3.5331366879897588,
|
| 53 |
+
"test_low_rmse": 4.058581719359756,
|
| 54 |
+
"valid_avg_mae": 1.560410205136852,
|
| 55 |
+
"test_avg_mae": 2.755213784702527,
|
| 56 |
+
"test_high_mae": 2.498935221029282,
|
| 57 |
+
"test_low_mae": 3.011492348375772,
|
| 58 |
+
"close_naive_avg_rmse": 6.023851260092315,
|
| 59 |
+
"close_naive_high_rmse": 5.599234142468543,
|
| 60 |
+
"close_naive_low_rmse": 6.448468377716088,
|
| 61 |
+
"close_naive_avg_mae": 4.646612400817869,
|
| 62 |
+
"close_naive_high_mae": 4.319949923706053,
|
| 63 |
+
"close_naive_low_mae": 4.973274877929685,
|
| 64 |
+
"persistence_naive_avg_rmse": 6.367977332746934,
|
| 65 |
+
"persistence_naive_high_rmse": 6.162115024839505,
|
| 66 |
+
"persistence_naive_low_rmse": 6.573839640654363,
|
| 67 |
+
"first5_bound_naive_avg_rmse": 4.650760987023152,
|
| 68 |
+
"first5_bound_naive_high_rmse": 4.327433003768388,
|
| 69 |
+
"first5_bound_naive_low_rmse": 4.974088970277916,
|
| 70 |
+
"persistence_naive_avg_mae": 4.704575045776368,
|
| 71 |
+
"persistence_naive_high_mae": 4.453700000000001,
|
| 72 |
+
"persistence_naive_low_mae": 4.955450091552735,
|
| 73 |
+
"first5_bound_naive_avg_mae": 2.7199499389648434,
|
| 74 |
+
"first5_bound_naive_high_mae": 2.4828750000000004,
|
| 75 |
+
"first5_bound_naive_low_mae": 2.957024877929687,
|
| 76 |
+
"close_naive_rmse_improvement_rupees": 2.227992056417558,
|
| 77 |
+
"persistence_naive_rmse_improvement_rupees": 2.5721181290721766,
|
| 78 |
+
"first5_bound_naive_rmse_improvement_rupees": 0.854901783348395,
|
| 79 |
+
"close_naive_improvement_rupees": 1.891398616115342,
|
| 80 |
+
"persistence_naive_improvement_rupees": 1.949361261073841,
|
| 81 |
+
"first5_bound_naive_improvement_rupees": -0.035263845737683486,
|
| 82 |
+
"target_date_latest_input": "2026-05-21 after first 5 minutes",
|
| 83 |
+
"latest_input_date": "2026-05-20",
|
| 84 |
+
"latest_pred_high": 425.7266320642266,
|
| 85 |
+
"latest_pred_low": 416.15619756243416
|
| 86 |
+
},
|
| 87 |
+
{
|
| 88 |
+
"symbol": "CANBK",
|
| 89 |
+
"model_name": "bound_random_forest_d3",
|
| 90 |
+
"feature_count": 1745,
|
| 91 |
+
"n_train": 1995,
|
| 92 |
+
"n_valid": 400,
|
| 93 |
+
"n_test": 400,
|
| 94 |
+
"train_start": "2015-02-02",
|
| 95 |
+
"train_end": "2023-02-20",
|
| 96 |
+
"valid_start": "2023-02-21",
|
| 97 |
+
"valid_end": "2024-10-03",
|
| 98 |
+
"test_start": "2024-10-04",
|
| 99 |
+
"test_end": "2026-05-20",
|
| 100 |
+
"valid_avg_rmse": 1.1877267430500973,
|
| 101 |
+
"test_avg_rmse": 1.162965297394424,
|
| 102 |
+
"test_high_rmse": 1.0964580219682816,
|
| 103 |
+
"test_low_rmse": 1.2294725728205662,
|
| 104 |
+
"valid_avg_mae": 0.742252159993462,
|
| 105 |
+
"test_avg_mae": 0.8831916354886772,
|
| 106 |
+
"test_high_mae": 0.8299360520361406,
|
| 107 |
+
"test_low_mae": 0.9364472189412137,
|
| 108 |
+
"close_naive_avg_rmse": 1.9661471028867346,
|
| 109 |
+
"close_naive_high_rmse": 1.8799005005138525,
|
| 110 |
+
"close_naive_low_rmse": 2.052393705259617,
|
| 111 |
+
"close_naive_avg_mae": 1.5531874168395992,
|
| 112 |
+
"close_naive_high_mae": 1.4664250579833977,
|
| 113 |
+
"close_naive_low_mae": 1.6399497756958004,
|
| 114 |
+
"persistence_naive_avg_rmse": 2.2165138946561047,
|
| 115 |
+
"persistence_naive_high_rmse": 2.160820610198675,
|
| 116 |
+
"persistence_naive_low_rmse": 2.2722071791135345,
|
| 117 |
+
"first5_bound_naive_avg_rmse": 1.4934649520669123,
|
| 118 |
+
"first5_bound_naive_high_rmse": 1.4128397042892566,
|
| 119 |
+
"first5_bound_naive_low_rmse": 1.5740901998445678,
|
| 120 |
+
"persistence_naive_avg_mae": 1.6221750251770017,
|
| 121 |
+
"persistence_naive_high_mae": 1.5686750289916989,
|
| 122 |
+
"persistence_naive_low_mae": 1.6756750213623048,
|
| 123 |
+
"first5_bound_naive_avg_mae": 0.936712425613403,
|
| 124 |
+
"first5_bound_naive_high_mae": 0.8807499473571773,
|
| 125 |
+
"first5_bound_naive_low_mae": 0.9926749038696286,
|
| 126 |
+
"close_naive_rmse_improvement_rupees": 0.8031818054923106,
|
| 127 |
+
"persistence_naive_rmse_improvement_rupees": 1.0535485972616807,
|
| 128 |
+
"first5_bound_naive_rmse_improvement_rupees": 0.3304996546724883,
|
| 129 |
+
"close_naive_improvement_rupees": 0.669995781350922,
|
| 130 |
+
"persistence_naive_improvement_rupees": 0.7389833896883246,
|
| 131 |
+
"first5_bound_naive_improvement_rupees": 0.05352079012472577,
|
| 132 |
+
"target_date_latest_input": "2026-05-21 after first 5 minutes",
|
| 133 |
+
"latest_input_date": "2026-05-20",
|
| 134 |
+
"latest_pred_high": 130.38251615888538,
|
| 135 |
+
"latest_pred_low": 127.43328965259077
|
| 136 |
+
},
|
| 137 |
+
{
|
| 138 |
+
"symbol": "ITC",
|
| 139 |
+
"model_name": "bound_extra_trees_d5",
|
| 140 |
+
"feature_count": 1745,
|
| 141 |
+
"n_train": 1995,
|
| 142 |
+
"n_valid": 400,
|
| 143 |
+
"n_test": 400,
|
| 144 |
+
"train_start": "2015-02-02",
|
| 145 |
+
"train_end": "2023-02-20",
|
| 146 |
+
"valid_start": "2023-02-21",
|
| 147 |
+
"valid_end": "2024-10-03",
|
| 148 |
+
"test_start": "2024-10-04",
|
| 149 |
+
"test_end": "2026-05-20",
|
| 150 |
+
"valid_avg_rmse": 2.5179745333935464,
|
| 151 |
+
"test_avg_rmse": 2.558171900676162,
|
| 152 |
+
"test_high_rmse": 2.5779119913380315,
|
| 153 |
+
"test_low_rmse": 2.5384318100142917,
|
| 154 |
+
"valid_avg_mae": 1.7321393493292276,
|
| 155 |
+
"test_avg_mae": 1.777244422330916,
|
| 156 |
+
"test_high_mae": 1.8657667149283514,
|
| 157 |
+
"test_low_mae": 1.688722129733481,
|
| 158 |
+
"close_naive_avg_rmse": 4.162079426110092,
|
| 159 |
+
"close_naive_high_rmse": 4.227921235650759,
|
| 160 |
+
"close_naive_low_rmse": 4.096237616569426,
|
| 161 |
+
"close_naive_avg_mae": 3.1613124542236326,
|
| 162 |
+
"close_naive_high_mae": 3.1853749542236325,
|
| 163 |
+
"close_naive_low_mae": 3.1372499542236323,
|
| 164 |
+
"persistence_naive_avg_rmse": 4.77743869608935,
|
| 165 |
+
"persistence_naive_high_rmse": 4.960652787721996,
|
| 166 |
+
"persistence_naive_low_rmse": 4.594224604456704,
|
| 167 |
+
"first5_bound_naive_avg_rmse": 3.143457945470394,
|
| 168 |
+
"first5_bound_naive_high_rmse": 3.0273502116871445,
|
| 169 |
+
"first5_bound_naive_low_rmse": 3.259565679253644,
|
| 170 |
+
"persistence_naive_avg_mae": 2.9995000839233406,
|
| 171 |
+
"persistence_naive_high_mae": 3.0505000915527365,
|
| 172 |
+
"persistence_naive_low_mae": 2.948500076293945,
|
| 173 |
+
"first5_bound_naive_avg_mae": 1.7971873168945312,
|
| 174 |
+
"first5_bound_naive_high_mae": 1.637999816894531,
|
| 175 |
+
"first5_bound_naive_low_mae": 1.9563748168945312,
|
| 176 |
+
"close_naive_rmse_improvement_rupees": 1.6039075254339306,
|
| 177 |
+
"persistence_naive_rmse_improvement_rupees": 2.219266795413188,
|
| 178 |
+
"first5_bound_naive_rmse_improvement_rupees": 0.5852860447942323,
|
| 179 |
+
"close_naive_improvement_rupees": 1.3840680318927165,
|
| 180 |
+
"persistence_naive_improvement_rupees": 1.2222556615924245,
|
| 181 |
+
"first5_bound_naive_improvement_rupees": 0.019942894563615088,
|
| 182 |
+
"target_date_latest_input": "2026-05-21 after first 5 minutes",
|
| 183 |
+
"latest_input_date": "2026-05-20",
|
| 184 |
+
"latest_pred_high": 311.39081482836934,
|
| 185 |
+
"latest_pred_low": 307.2990930220002
|
| 186 |
+
},
|
| 187 |
+
{
|
| 188 |
+
"symbol": "NTPC",
|
| 189 |
+
"model_name": "bound_extra_trees_d5",
|
| 190 |
+
"feature_count": 1745,
|
| 191 |
+
"n_train": 1995,
|
| 192 |
+
"n_valid": 400,
|
| 193 |
+
"n_test": 400,
|
| 194 |
+
"train_start": "2015-02-02",
|
| 195 |
+
"train_end": "2023-02-20",
|
| 196 |
+
"valid_start": "2023-02-21",
|
| 197 |
+
"valid_end": "2024-10-03",
|
| 198 |
+
"test_start": "2024-10-04",
|
| 199 |
+
"test_end": "2026-05-20",
|
| 200 |
+
"valid_avg_rmse": 3.277399808072099,
|
| 201 |
+
"test_avg_rmse": 2.954311762850586,
|
| 202 |
+
"test_high_rmse": 2.960970424875009,
|
| 203 |
+
"test_low_rmse": 2.9476531008261633,
|
| 204 |
+
"valid_avg_mae": 1.9156229935603153,
|
| 205 |
+
"test_avg_mae": 2.4497135147686566,
|
| 206 |
+
"test_high_mae": 2.5224291622431054,
|
| 207 |
+
"test_low_mae": 2.376997867294208,
|
| 208 |
+
"close_naive_avg_rmse": 4.747976126799425,
|
| 209 |
+
"close_naive_high_rmse": 4.554438041585628,
|
| 210 |
+
"close_naive_low_rmse": 4.941514212013222,
|
| 211 |
+
"close_naive_avg_mae": 3.7500624847412096,
|
| 212 |
+
"close_naive_high_mae": 3.5176248474121077,
|
| 213 |
+
"close_naive_low_mae": 3.9825001220703116,
|
| 214 |
+
"persistence_naive_avg_rmse": 4.875170000750638,
|
| 215 |
+
"persistence_naive_high_rmse": 4.576701906592142,
|
| 216 |
+
"persistence_naive_low_rmse": 5.173638094909135,
|
| 217 |
+
"first5_bound_naive_avg_rmse": 3.495593938357865,
|
| 218 |
+
"first5_bound_naive_high_rmse": 3.2713769623231315,
|
| 219 |
+
"first5_bound_naive_low_rmse": 3.719810914392599,
|
| 220 |
+
"persistence_naive_avg_mae": 3.537187408447267,
|
| 221 |
+
"persistence_naive_high_mae": 3.342249877929689,
|
| 222 |
+
"persistence_naive_low_mae": 3.7321249389648448,
|
| 223 |
+
"first5_bound_naive_avg_mae": 2.113249984741211,
|
| 224 |
+
"first5_bound_naive_high_mae": 1.8808749694824207,
|
| 225 |
+
"first5_bound_naive_low_mae": 2.345625000000001,
|
| 226 |
+
"close_naive_rmse_improvement_rupees": 1.7936643639488388,
|
| 227 |
+
"persistence_naive_rmse_improvement_rupees": 1.9208582379000525,
|
| 228 |
+
"first5_bound_naive_rmse_improvement_rupees": 0.5412821755072792,
|
| 229 |
+
"close_naive_improvement_rupees": 1.300348969972553,
|
| 230 |
+
"persistence_naive_improvement_rupees": 1.0874738936786104,
|
| 231 |
+
"first5_bound_naive_improvement_rupees": -0.33646353002744567,
|
| 232 |
+
"target_date_latest_input": "2026-05-21 after first 5 minutes",
|
| 233 |
+
"latest_input_date": "2026-05-20",
|
| 234 |
+
"latest_pred_high": 396.69629639532224,
|
| 235 |
+
"latest_pred_low": 390.0347761147414
|
| 236 |
+
},
|
| 237 |
+
{
|
| 238 |
+
"symbol": "ONGC",
|
| 239 |
+
"model_name": "bound_random_forest_d3",
|
| 240 |
+
"feature_count": 1745,
|
| 241 |
+
"n_train": 1995,
|
| 242 |
+
"n_valid": 400,
|
| 243 |
+
"n_test": 400,
|
| 244 |
+
"train_start": "2015-02-02",
|
| 245 |
+
"train_end": "2023-02-20",
|
| 246 |
+
"valid_start": "2023-02-21",
|
| 247 |
+
"valid_end": "2024-10-03",
|
| 248 |
+
"test_start": "2024-10-04",
|
| 249 |
+
"test_end": "2026-05-20",
|
| 250 |
+
"valid_avg_rmse": 2.651690906826805,
|
| 251 |
+
"test_avg_rmse": 2.141252714065037,
|
| 252 |
+
"test_high_rmse": 2.1092037704814084,
|
| 253 |
+
"test_low_rmse": 2.173301657648666,
|
| 254 |
+
"valid_avg_mae": 1.516775531507625,
|
| 255 |
+
"test_avg_mae": 1.7190845412379057,
|
| 256 |
+
"test_high_mae": 1.6555421306155917,
|
| 257 |
+
"test_low_mae": 1.7826269518602198,
|
| 258 |
+
"close_naive_avg_rmse": 3.5243734168986953,
|
| 259 |
+
"close_naive_high_rmse": 3.5221869309682754,
|
| 260 |
+
"close_naive_low_rmse": 3.5265599028291152,
|
| 261 |
+
"close_naive_avg_mae": 2.7707500610351565,
|
| 262 |
+
"close_naive_high_mae": 2.7490001220703117,
|
| 263 |
+
"close_naive_low_mae": 2.7925000000000013,
|
| 264 |
+
"persistence_naive_avg_rmse": 3.921569891797246,
|
| 265 |
+
"persistence_naive_high_rmse": 4.042636528848906,
|
| 266 |
+
"persistence_naive_low_rmse": 3.8005032547455864,
|
| 267 |
+
"first5_bound_naive_avg_rmse": 2.5323710579034797,
|
| 268 |
+
"first5_bound_naive_high_rmse": 2.4846753887418735,
|
| 269 |
+
"first5_bound_naive_low_rmse": 2.5800667270650854,
|
| 270 |
+
"persistence_naive_avg_mae": 2.627499946594239,
|
| 271 |
+
"persistence_naive_high_mae": 2.6477499389648447,
|
| 272 |
+
"persistence_naive_low_mae": 2.6072499542236325,
|
| 273 |
+
"first5_bound_naive_avg_mae": 1.5069376068115239,
|
| 274 |
+
"first5_bound_naive_high_mae": 1.4400000610351567,
|
| 275 |
+
"first5_bound_naive_low_mae": 1.5738751525878911,
|
| 276 |
+
"close_naive_rmse_improvement_rupees": 1.3831207028336583,
|
| 277 |
+
"persistence_naive_rmse_improvement_rupees": 1.780317177732209,
|
| 278 |
+
"first5_bound_naive_rmse_improvement_rupees": 0.3911183438384427,
|
| 279 |
+
"close_naive_improvement_rupees": 1.0516655197972509,
|
| 280 |
+
"persistence_naive_improvement_rupees": 0.9084154053563331,
|
| 281 |
+
"first5_bound_naive_improvement_rupees": -0.21214693442638177,
|
| 282 |
+
"target_date_latest_input": "2026-05-21 after first 5 minutes",
|
| 283 |
+
"latest_input_date": "2026-05-20",
|
| 284 |
+
"latest_pred_high": 299.2215370588941,
|
| 285 |
+
"latest_pred_low": 293.72318078205507
|
| 286 |
+
},
|
| 287 |
+
{
|
| 288 |
+
"symbol": "POWERGRID",
|
| 289 |
+
"model_name": "bound_random_forest_d3",
|
| 290 |
+
"feature_count": 1745,
|
| 291 |
+
"n_train": 1995,
|
| 292 |
+
"n_valid": 400,
|
| 293 |
+
"n_test": 400,
|
| 294 |
+
"train_start": "2015-02-02",
|
| 295 |
+
"train_end": "2023-02-20",
|
| 296 |
+
"valid_start": "2023-02-21",
|
| 297 |
+
"valid_end": "2024-10-03",
|
| 298 |
+
"test_start": "2024-10-04",
|
| 299 |
+
"test_end": "2026-05-20",
|
| 300 |
+
"valid_avg_rmse": 2.6506302096937926,
|
| 301 |
+
"test_avg_rmse": 2.1322841885974135,
|
| 302 |
+
"test_high_rmse": 2.133603574452136,
|
| 303 |
+
"test_low_rmse": 2.130964802742691,
|
| 304 |
+
"valid_avg_mae": 1.6750452671638443,
|
| 305 |
+
"test_avg_mae": 1.6243461858543484,
|
| 306 |
+
"test_high_mae": 1.6437535142058988,
|
| 307 |
+
"test_low_mae": 1.6049388575027979,
|
| 308 |
+
"close_naive_avg_rmse": 4.000076637213782,
|
| 309 |
+
"close_naive_high_rmse": 3.946264850636309,
|
| 310 |
+
"close_naive_low_rmse": 4.053888423791254,
|
| 311 |
+
"close_naive_avg_mae": 3.219937446594238,
|
| 312 |
+
"close_naive_high_mae": 3.1198751068115236,
|
| 313 |
+
"close_naive_low_mae": 3.319999786376952,
|
| 314 |
+
"persistence_naive_avg_rmse": 4.088362714258839,
|
| 315 |
+
"persistence_naive_high_rmse": 4.075267688782825,
|
| 316 |
+
"persistence_naive_low_rmse": 4.101457739734852,
|
| 317 |
+
"first5_bound_naive_avg_rmse": 2.7449529260886782,
|
| 318 |
+
"first5_bound_naive_high_rmse": 2.6520909615833506,
|
| 319 |
+
"first5_bound_naive_low_rmse": 2.8378148905940064,
|
| 320 |
+
"persistence_naive_avg_mae": 2.968625030517578,
|
| 321 |
+
"persistence_naive_high_mae": 2.953875122070312,
|
| 322 |
+
"persistence_naive_low_mae": 2.9833749389648445,
|
| 323 |
+
"first5_bound_naive_avg_mae": 1.7024373855590826,
|
| 324 |
+
"first5_bound_naive_high_mae": 1.5839997711181653,
|
| 325 |
+
"first5_bound_naive_low_mae": 1.8208750000000002,
|
| 326 |
+
"close_naive_rmse_improvement_rupees": 1.8677924486163686,
|
| 327 |
+
"persistence_naive_rmse_improvement_rupees": 1.9560785256614253,
|
| 328 |
+
"first5_bound_naive_rmse_improvement_rupees": 0.6126687374912647,
|
| 329 |
+
"close_naive_improvement_rupees": 1.5955912607398894,
|
| 330 |
+
"persistence_naive_improvement_rupees": 1.3442788446632297,
|
| 331 |
+
"first5_bound_naive_improvement_rupees": 0.07809119970473422,
|
| 332 |
+
"target_date_latest_input": "2026-05-21 after first 5 minutes",
|
| 333 |
+
"latest_input_date": "2026-05-20",
|
| 334 |
+
"latest_pred_high": 305.42429396777754,
|
| 335 |
+
"latest_pred_low": 299.88556685658483
|
| 336 |
+
},
|
| 337 |
+
{
|
| 338 |
+
"symbol": "SBIN",
|
| 339 |
+
"model_name": "histgb_delta",
|
| 340 |
+
"feature_count": 1745,
|
| 341 |
+
"n_train": 1995,
|
| 342 |
+
"n_valid": 400,
|
| 343 |
+
"n_test": 400,
|
| 344 |
+
"train_start": "2015-02-02",
|
| 345 |
+
"train_end": "2023-02-20",
|
| 346 |
+
"valid_start": "2023-02-21",
|
| 347 |
+
"valid_end": "2024-10-03",
|
| 348 |
+
"test_start": "2024-10-04",
|
| 349 |
+
"test_end": "2026-05-20",
|
| 350 |
+
"valid_avg_rmse": 6.896308688601639,
|
| 351 |
+
"test_avg_rmse": 6.691821335256472,
|
| 352 |
+
"test_high_rmse": 5.781288094214552,
|
| 353 |
+
"test_low_rmse": 7.602354576298393,
|
| 354 |
+
"valid_avg_mae": 4.154068636897196,
|
| 355 |
+
"test_avg_mae": 4.444679569262094,
|
| 356 |
+
"test_high_mae": 4.075579370478564,
|
| 357 |
+
"test_low_mae": 4.813779768045626,
|
| 358 |
+
"close_naive_avg_rmse": 11.155140437270237,
|
| 359 |
+
"close_naive_high_rmse": 10.174821696907191,
|
| 360 |
+
"close_naive_low_rmse": 12.135459177633285,
|
| 361 |
+
"close_naive_avg_mae": 8.417687850952152,
|
| 362 |
+
"close_naive_high_mae": 7.886000854492188,
|
| 363 |
+
"close_naive_low_mae": 8.949374847412114,
|
| 364 |
+
"persistence_naive_avg_rmse": 13.551424177933402,
|
| 365 |
+
"persistence_naive_high_rmse": 12.919206722698187,
|
| 366 |
+
"persistence_naive_low_rmse": 14.183641633168618,
|
| 367 |
+
"first5_bound_naive_avg_rmse": 8.67142215247948,
|
| 368 |
+
"first5_bound_naive_high_rmse": 7.555588334188869,
|
| 369 |
+
"first5_bound_naive_low_rmse": 9.787255970770088,
|
| 370 |
+
"persistence_naive_avg_mae": 8.80974955749512,
|
| 371 |
+
"persistence_naive_high_mae": 8.280874786376955,
|
| 372 |
+
"persistence_naive_low_mae": 9.338624328613285,
|
| 373 |
+
"first5_bound_naive_avg_mae": 5.099562805175783,
|
| 374 |
+
"first5_bound_naive_high_mae": 4.645499786376953,
|
| 375 |
+
"first5_bound_naive_low_mae": 5.553625823974613,
|
| 376 |
+
"close_naive_rmse_improvement_rupees": 4.463319102013765,
|
| 377 |
+
"persistence_naive_rmse_improvement_rupees": 6.85960284267693,
|
| 378 |
+
"first5_bound_naive_rmse_improvement_rupees": 1.979600817223007,
|
| 379 |
+
"close_naive_improvement_rupees": 3.973008281690058,
|
| 380 |
+
"persistence_naive_improvement_rupees": 4.365069988233026,
|
| 381 |
+
"first5_bound_naive_improvement_rupees": 0.6548832359136885,
|
| 382 |
+
"target_date_latest_input": "2026-05-21 after first 5 minutes",
|
| 383 |
+
"latest_input_date": "2026-05-20",
|
| 384 |
+
"latest_pred_high": 964.5986446087139,
|
| 385 |
+
"latest_pred_low": 948.2468798969894
|
| 386 |
+
},
|
| 387 |
+
{
|
| 388 |
+
"symbol": "TATASTEEL",
|
| 389 |
+
"model_name": "tatasteel_bound_extra_trees_d5_scaled",
|
| 390 |
+
"feature_count": 1745,
|
| 391 |
+
"n_train": 1995,
|
| 392 |
+
"n_valid": 400,
|
| 393 |
+
"n_test": 400,
|
| 394 |
+
"train_start": "2015-02-02",
|
| 395 |
+
"train_end": "2023-02-20",
|
| 396 |
+
"valid_start": "2023-02-21",
|
| 397 |
+
"valid_end": "2024-10-03",
|
| 398 |
+
"test_start": "2024-10-04",
|
| 399 |
+
"test_end": "2026-05-20",
|
| 400 |
+
"valid_avg_rmse": 1.2996911578766843,
|
| 401 |
+
"test_avg_rmse": 1.3250328879161861,
|
| 402 |
+
"test_high_rmse": 1.328880814205904,
|
| 403 |
+
"test_low_rmse": 1.321184961626468,
|
| 404 |
+
"valid_avg_mae": 0.8128864921809138,
|
| 405 |
+
"test_avg_mae": 0.962325354180277,
|
| 406 |
+
"test_high_mae": 0.9704072860522192,
|
| 407 |
+
"test_low_mae": 0.9542434223083349,
|
| 408 |
+
"close_naive_avg_rmse": 2.390802424545505,
|
| 409 |
+
"close_naive_high_rmse": 2.4423253768555218,
|
| 410 |
+
"close_naive_low_rmse": 2.339279472235488,
|
| 411 |
+
"close_naive_avg_mae": 1.9119999870300288,
|
| 412 |
+
"close_naive_high_mae": 1.9294750289916975,
|
| 413 |
+
"close_naive_low_mae": 1.8945249450683601,
|
| 414 |
+
"persistence_naive_avg_rmse": 2.8759835812886587,
|
| 415 |
+
"persistence_naive_high_rmse": 2.9007195624662843,
|
| 416 |
+
"persistence_naive_low_rmse": 2.8512476001110327,
|
| 417 |
+
"first5_bound_naive_avg_rmse": 1.72946091025499,
|
| 418 |
+
"first5_bound_naive_high_rmse": 1.7383599677143537,
|
| 419 |
+
"first5_bound_naive_low_rmse": 1.7205618527956261,
|
| 420 |
+
"persistence_naive_avg_mae": 2.0163749145507808,
|
| 421 |
+
"persistence_naive_high_mae": 1.98374994506836,
|
| 422 |
+
"persistence_naive_low_mae": 2.0489998840332015,
|
| 423 |
+
"first5_bound_naive_avg_mae": 1.0418625404357909,
|
| 424 |
+
"first5_bound_naive_high_mae": 1.0419750259399416,
|
| 425 |
+
"first5_bound_naive_low_mae": 1.0417500549316403,
|
| 426 |
+
"close_naive_rmse_improvement_rupees": 1.0657695366293187,
|
| 427 |
+
"persistence_naive_rmse_improvement_rupees": 1.5509506933724726,
|
| 428 |
+
"first5_bound_naive_rmse_improvement_rupees": 0.4044280223388039,
|
| 429 |
+
"close_naive_improvement_rupees": 0.9496746328497518,
|
| 430 |
+
"persistence_naive_improvement_rupees": 1.0540495603705038,
|
| 431 |
+
"first5_bound_naive_improvement_rupees": 0.07953718625551387,
|
| 432 |
+
"target_date_latest_input": "2026-05-21 after first 5 minutes",
|
| 433 |
+
"latest_input_date": "2026-05-20",
|
| 434 |
+
"latest_pred_high": 210.9962075544701,
|
| 435 |
+
"latest_pred_low": 207.70986917244755
|
| 436 |
+
}
|
| 437 |
+
],
|
| 438 |
+
"leakage_note": "Rows use previous trading day daily/exogenous features, date-only forecast-session features, NSE corporate announcements timestamped before the forecast cutoff, and opening target-day intraday features. Standard symbols use only the first five 1m bars. HDFCBANK may use only the first 10 1m bars. Full-day T+1 close and final high/low are excluded from feature columns."
|
| 439 |
+
}
|
backend/research_runtime/Code/models/stock_high_low_forecaster/train.py
ADDED
|
@@ -0,0 +1,1858 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from __future__ import annotations
|
| 2 |
+
|
| 3 |
+
import argparse
|
| 4 |
+
import json
|
| 5 |
+
import os
|
| 6 |
+
import random
|
| 7 |
+
import sys
|
| 8 |
+
import warnings
|
| 9 |
+
from dataclasses import asdict, dataclass
|
| 10 |
+
from functools import lru_cache
|
| 11 |
+
from pathlib import Path
|
| 12 |
+
from typing import Any
|
| 13 |
+
|
| 14 |
+
os.environ.setdefault("OMP_NUM_THREADS", "2")
|
| 15 |
+
os.environ.setdefault("OPENBLAS_NUM_THREADS", "2")
|
| 16 |
+
os.environ.setdefault("MKL_NUM_THREADS", "2")
|
| 17 |
+
os.environ.setdefault("VECLIB_MAXIMUM_THREADS", "2")
|
| 18 |
+
os.environ.setdefault("NUMEXPR_NUM_THREADS", "2")
|
| 19 |
+
|
| 20 |
+
import joblib
|
| 21 |
+
import numpy as np
|
| 22 |
+
import pandas as pd
|
| 23 |
+
from sklearn.ensemble import ExtraTreesRegressor, HistGradientBoostingRegressor, RandomForestRegressor
|
| 24 |
+
from sklearn.impute import SimpleImputer
|
| 25 |
+
from sklearn.linear_model import Ridge
|
| 26 |
+
from sklearn.metrics import mean_absolute_error
|
| 27 |
+
from sklearn.multioutput import MultiOutputRegressor
|
| 28 |
+
from sklearn.pipeline import make_pipeline
|
| 29 |
+
from sklearn.preprocessing import StandardScaler
|
| 30 |
+
|
| 31 |
+
warnings.filterwarnings("ignore", category=FutureWarning)
|
| 32 |
+
warnings.filterwarnings("ignore", category=pd.errors.PerformanceWarning)
|
| 33 |
+
warnings.filterwarnings("ignore", category=UserWarning, module="sklearn.impute")
|
| 34 |
+
|
| 35 |
+
|
| 36 |
+
RANDOM_SEED = 42
|
| 37 |
+
MARKET_SYMBOLS = {
|
| 38 |
+
"NIFTY50": "nifty50",
|
| 39 |
+
"BANKNIFTY": "banknifty",
|
| 40 |
+
"INDIAVIX": "india_vix",
|
| 41 |
+
}
|
| 42 |
+
|
| 43 |
+
def find_project_root(start: Path) -> Path:
|
| 44 |
+
for path in (start, *start.parents):
|
| 45 |
+
if (path / "Data").is_dir() and (path / "Alt Data").is_dir():
|
| 46 |
+
return path
|
| 47 |
+
raise RuntimeError(f"Could not find project root from {start}")
|
| 48 |
+
|
| 49 |
+
|
| 50 |
+
PROJECT_ROOT = find_project_root(Path(__file__).resolve())
|
| 51 |
+
DATA_DIR = PROJECT_ROOT / "Data"
|
| 52 |
+
DAILY_BAR_DIR = DATA_DIR / "processed" / "bars" / "1d"
|
| 53 |
+
MINUTE_BAR_DIR = DATA_DIR / "processed" / "bars" / "1m"
|
| 54 |
+
FIVE_MIN_FEATURE_DIR = DATA_DIR / "processed" / "features" / "5m"
|
| 55 |
+
OUTPUT_DIR = Path(__file__).resolve().parent / "outputs"
|
| 56 |
+
OUTPUT_DIR.mkdir(parents=True, exist_ok=True)
|
| 57 |
+
ALT_DATA_DIR = PROJECT_ROOT / "Alt Data"
|
| 58 |
+
CORPORATE_ANNOUNCEMENTS_PATH = ALT_DATA_DIR / "corporate" / "processed" / "corporate_announcements.csv"
|
| 59 |
+
HDFC_OPENING_MINUTES = 10
|
| 60 |
+
HDFC_OPENING_PREFIX = "hdfc_open10_"
|
| 61 |
+
HDFC_OPEN10_EXTENSION_CAP = 35.0
|
| 62 |
+
GENERIC_FIRST5_EXTENSION_CAP = 35.0
|
| 63 |
+
FIXED_SELECTION_MAX_VALID_RATIO = 1.05
|
| 64 |
+
RECENT_VALID_WINDOW = 80
|
| 65 |
+
RECENT_VALID_WEIGHT = 0.7
|
| 66 |
+
BASELINE_RECENT_GUARD_RATIO = 1.02
|
| 67 |
+
STABLE_FOLD_COUNT = 5
|
| 68 |
+
STABLE_FOLD_STD_WEIGHT = 0.5
|
| 69 |
+
STABLE_CANDIDATE_NAMES = {
|
| 70 |
+
"baseline_first5_bound",
|
| 71 |
+
"baseline_first5_close",
|
| 72 |
+
"baseline_persistence",
|
| 73 |
+
"extra_trees_delta_d5",
|
| 74 |
+
"bound_extra_trees_d5",
|
| 75 |
+
"tatasteel_bound_extra_trees_d5_scaled",
|
| 76 |
+
"bound_random_forest_d3",
|
| 77 |
+
"histgb_delta",
|
| 78 |
+
}
|
| 79 |
+
STABLE_SYMBOL_MODEL_MAP = {
|
| 80 |
+
"BEL": "bound_random_forest_d3",
|
| 81 |
+
"CANBK": "bound_random_forest_d3",
|
| 82 |
+
"ITC": "bound_extra_trees_d5",
|
| 83 |
+
"NTPC": "bound_extra_trees_d5",
|
| 84 |
+
"ONGC": "bound_random_forest_d3",
|
| 85 |
+
"POWERGRID": "bound_random_forest_d3",
|
| 86 |
+
"SBIN": "histgb_delta",
|
| 87 |
+
"TATASTEEL": "tatasteel_bound_extra_trees_d5_scaled",
|
| 88 |
+
}
|
| 89 |
+
|
| 90 |
+
|
| 91 |
+
def note(message: str) -> None:
|
| 92 |
+
print(f"[stock-high-low] {message}", file=sys.stderr, flush=True)
|
| 93 |
+
|
| 94 |
+
|
| 95 |
+
def stem_to_symbol(stem: str) -> str:
|
| 96 |
+
return str(stem).replace("_", "").upper()
|
| 97 |
+
|
| 98 |
+
|
| 99 |
+
def discover_target_symbols(excluded_symbols: set[str] | None = None) -> dict[str, str]:
|
| 100 |
+
excluded = {s.upper() for s in (excluded_symbols or set())}
|
| 101 |
+
market_stems = set(MARKET_SYMBOLS.values())
|
| 102 |
+
stems = {path.name.removesuffix("_1d.csv") for path in DAILY_BAR_DIR.glob("*_1d.csv")}
|
| 103 |
+
if not stems:
|
| 104 |
+
stems = {path.name.removesuffix("_1m.csv") for path in MINUTE_BAR_DIR.glob("*_1m.csv")}
|
| 105 |
+
discovered: dict[str, str] = {}
|
| 106 |
+
for stem in sorted(stems):
|
| 107 |
+
if stem in market_stems:
|
| 108 |
+
continue
|
| 109 |
+
symbol = stem_to_symbol(stem)
|
| 110 |
+
if symbol in excluded:
|
| 111 |
+
continue
|
| 112 |
+
discovered[symbol] = stem
|
| 113 |
+
return discovered
|
| 114 |
+
|
| 115 |
+
|
| 116 |
+
@dataclass(frozen=True)
|
| 117 |
+
class ModelSpec:
|
| 118 |
+
name: str
|
| 119 |
+
kind: str
|
| 120 |
+
params: dict[str, Any]
|
| 121 |
+
target_mode: str = "close_delta"
|
| 122 |
+
|
| 123 |
+
|
| 124 |
+
@dataclass
|
| 125 |
+
class SymbolResult:
|
| 126 |
+
symbol: str
|
| 127 |
+
model_name: str
|
| 128 |
+
feature_count: int
|
| 129 |
+
n_train: int
|
| 130 |
+
n_valid: int
|
| 131 |
+
n_test: int
|
| 132 |
+
train_start: str
|
| 133 |
+
train_end: str
|
| 134 |
+
valid_start: str
|
| 135 |
+
valid_end: str
|
| 136 |
+
test_start: str
|
| 137 |
+
test_end: str
|
| 138 |
+
valid_avg_rmse: float
|
| 139 |
+
test_avg_rmse: float
|
| 140 |
+
test_high_rmse: float
|
| 141 |
+
test_low_rmse: float
|
| 142 |
+
valid_avg_mae: float
|
| 143 |
+
test_avg_mae: float
|
| 144 |
+
test_high_mae: float
|
| 145 |
+
test_low_mae: float
|
| 146 |
+
close_naive_avg_rmse: float
|
| 147 |
+
close_naive_high_rmse: float
|
| 148 |
+
close_naive_low_rmse: float
|
| 149 |
+
close_naive_avg_mae: float
|
| 150 |
+
close_naive_high_mae: float
|
| 151 |
+
close_naive_low_mae: float
|
| 152 |
+
persistence_naive_avg_rmse: float
|
| 153 |
+
persistence_naive_high_rmse: float
|
| 154 |
+
persistence_naive_low_rmse: float
|
| 155 |
+
first5_bound_naive_avg_rmse: float
|
| 156 |
+
first5_bound_naive_high_rmse: float
|
| 157 |
+
first5_bound_naive_low_rmse: float
|
| 158 |
+
persistence_naive_avg_mae: float
|
| 159 |
+
persistence_naive_high_mae: float
|
| 160 |
+
persistence_naive_low_mae: float
|
| 161 |
+
first5_bound_naive_avg_mae: float
|
| 162 |
+
first5_bound_naive_high_mae: float
|
| 163 |
+
first5_bound_naive_low_mae: float
|
| 164 |
+
close_naive_rmse_improvement_rupees: float
|
| 165 |
+
persistence_naive_rmse_improvement_rupees: float
|
| 166 |
+
first5_bound_naive_rmse_improvement_rupees: float
|
| 167 |
+
close_naive_improvement_rupees: float
|
| 168 |
+
persistence_naive_improvement_rupees: float
|
| 169 |
+
first5_bound_naive_improvement_rupees: float
|
| 170 |
+
target_date_latest_input: str
|
| 171 |
+
latest_input_date: str
|
| 172 |
+
latest_pred_high: float
|
| 173 |
+
latest_pred_low: float
|
| 174 |
+
|
| 175 |
+
|
| 176 |
+
def read_daily_bars(symbol: str, stem: str) -> pd.DataFrame:
|
| 177 |
+
daily_path = DAILY_BAR_DIR / f"{stem}_1d.csv"
|
| 178 |
+
if daily_path.exists():
|
| 179 |
+
df = pd.read_csv(daily_path)
|
| 180 |
+
source = daily_path
|
| 181 |
+
else:
|
| 182 |
+
minute_path = MINUTE_BAR_DIR / f"{stem}_1m.csv"
|
| 183 |
+
if not minute_path.exists():
|
| 184 |
+
raise FileNotFoundError(f"Missing daily and minute bars for {symbol}: {daily_path}, {minute_path}")
|
| 185 |
+
note(f"{symbol}: aggregating 1m bars to daily bars from {minute_path.name}")
|
| 186 |
+
minute = pd.read_csv(minute_path, usecols=["date", "open", "high", "low", "close", "volume"])
|
| 187 |
+
minute["dt"] = pd.to_datetime(minute["date"], errors="coerce")
|
| 188 |
+
minute = minute.dropna(subset=["dt", "open", "high", "low", "close"]).sort_values("dt")
|
| 189 |
+
minute["date"] = minute["dt"].dt.normalize()
|
| 190 |
+
df = minute.groupby("date", sort=True).agg(
|
| 191 |
+
open=("open", "first"),
|
| 192 |
+
high=("high", "max"),
|
| 193 |
+
low=("low", "min"),
|
| 194 |
+
close=("close", "last"),
|
| 195 |
+
volume=("volume", "sum"),
|
| 196 |
+
).reset_index()
|
| 197 |
+
source = minute_path
|
| 198 |
+
|
| 199 |
+
df.columns = [str(c).strip().lower().replace(" ", "_") for c in df.columns]
|
| 200 |
+
if "date" not in df.columns:
|
| 201 |
+
raise ValueError(f"{source} has no date column")
|
| 202 |
+
df["date"] = pd.to_datetime(df["date"], errors="coerce").dt.normalize()
|
| 203 |
+
for col in ("open", "high", "low", "close", "volume"):
|
| 204 |
+
if col not in df.columns:
|
| 205 |
+
raise ValueError(f"{source} has no {col} column")
|
| 206 |
+
df[col] = pd.to_numeric(df[col], errors="coerce")
|
| 207 |
+
return (
|
| 208 |
+
df.dropna(subset=["date", "open", "high", "low", "close"])
|
| 209 |
+
.sort_values("date")
|
| 210 |
+
.drop_duplicates(subset=["date"], keep="last")
|
| 211 |
+
.reset_index(drop=True)
|
| 212 |
+
.assign(symbol=symbol)
|
| 213 |
+
)
|
| 214 |
+
|
| 215 |
+
|
| 216 |
+
def read_first5_features(symbol: str, stem: str, prefix: str) -> pd.DataFrame:
|
| 217 |
+
minute_path = MINUTE_BAR_DIR / f"{stem}_1m.csv"
|
| 218 |
+
if not minute_path.exists():
|
| 219 |
+
raise FileNotFoundError(f"Missing 1m bars for {symbol}: {minute_path}")
|
| 220 |
+
note(f"{symbol}: building first-5-minute features from {minute_path.name}")
|
| 221 |
+
minute = pd.read_csv(minute_path, usecols=["date", "open", "high", "low", "close", "volume"])
|
| 222 |
+
minute["dt"] = pd.to_datetime(minute["date"], errors="coerce")
|
| 223 |
+
minute = minute.dropna(subset=["dt", "open", "high", "low", "close"]).sort_values("dt")
|
| 224 |
+
for col in ("open", "high", "low", "close", "volume"):
|
| 225 |
+
minute[col] = pd.to_numeric(minute[col], errors="coerce")
|
| 226 |
+
minute["date"] = minute["dt"].dt.normalize()
|
| 227 |
+
minute["minute_index"] = minute.groupby("date", sort=True).cumcount()
|
| 228 |
+
first5 = minute[minute["minute_index"] < 5].copy()
|
| 229 |
+
grouped = first5.groupby("date", sort=True)
|
| 230 |
+
out = grouped.agg(
|
| 231 |
+
**{
|
| 232 |
+
f"{prefix}open": ("open", "first"),
|
| 233 |
+
f"{prefix}high": ("high", "max"),
|
| 234 |
+
f"{prefix}low": ("low", "min"),
|
| 235 |
+
f"{prefix}close": ("close", "last"),
|
| 236 |
+
f"{prefix}volume": ("volume", "sum"),
|
| 237 |
+
f"{prefix}bars": ("close", "size"),
|
| 238 |
+
}
|
| 239 |
+
).reset_index()
|
| 240 |
+
out[f"{prefix}range"] = out[f"{prefix}high"] - out[f"{prefix}low"]
|
| 241 |
+
out[f"{prefix}return"] = safe_div(out[f"{prefix}close"], out[f"{prefix}open"]) - 1.0
|
| 242 |
+
out[f"{prefix}range_pct"] = safe_div(out[f"{prefix}range"], out[f"{prefix}open"])
|
| 243 |
+
out[f"{prefix}high_excursion"] = safe_div(out[f"{prefix}high"], out[f"{prefix}open"]) - 1.0
|
| 244 |
+
out[f"{prefix}low_excursion"] = safe_div(out[f"{prefix}low"], out[f"{prefix}open"]) - 1.0
|
| 245 |
+
out[f"{prefix}close_pos"] = safe_div(out[f"{prefix}close"] - out[f"{prefix}low"], out[f"{prefix}range"])
|
| 246 |
+
for idx in range(5):
|
| 247 |
+
bar = first5[first5["minute_index"] == idx][["date", "open", "high", "low", "close", "volume"]].copy()
|
| 248 |
+
bar = bar.rename(
|
| 249 |
+
columns={
|
| 250 |
+
"open": f"{prefix}m{idx + 1}_open",
|
| 251 |
+
"high": f"{prefix}m{idx + 1}_high",
|
| 252 |
+
"low": f"{prefix}m{idx + 1}_low",
|
| 253 |
+
"close": f"{prefix}m{idx + 1}_close",
|
| 254 |
+
"volume": f"{prefix}m{idx + 1}_volume",
|
| 255 |
+
}
|
| 256 |
+
)
|
| 257 |
+
out = out.merge(bar, on="date", how="left")
|
| 258 |
+
out[f"{prefix}m{idx + 1}_return"] = safe_div(out[f"{prefix}m{idx + 1}_close"], out[f"{prefix}open"]) - 1.0
|
| 259 |
+
return out[out[f"{prefix}bars"] >= 5].reset_index(drop=True)
|
| 260 |
+
|
| 261 |
+
|
| 262 |
+
def read_opening_window_features(symbol: str, stem: str, prefix: str, minutes: int) -> pd.DataFrame:
|
| 263 |
+
minute_path = MINUTE_BAR_DIR / f"{stem}_1m.csv"
|
| 264 |
+
if not minute_path.exists():
|
| 265 |
+
raise FileNotFoundError(f"Missing 1m bars for {symbol}: {minute_path}")
|
| 266 |
+
note(f"{symbol}: building first-{minutes}-minute features from {minute_path.name}")
|
| 267 |
+
minute = pd.read_csv(minute_path, usecols=["date", "open", "high", "low", "close", "volume"])
|
| 268 |
+
minute["dt"] = pd.to_datetime(minute["date"], errors="coerce")
|
| 269 |
+
minute = minute.dropna(subset=["dt", "open", "high", "low", "close"]).sort_values("dt")
|
| 270 |
+
for col in ("open", "high", "low", "close", "volume"):
|
| 271 |
+
minute[col] = pd.to_numeric(minute[col], errors="coerce")
|
| 272 |
+
minute["date"] = minute["dt"].dt.normalize()
|
| 273 |
+
minute["minute_index"] = minute.groupby("date", sort=True).cumcount()
|
| 274 |
+
window = minute[minute["minute_index"] < minutes].copy()
|
| 275 |
+
grouped = window.groupby("date", sort=True)
|
| 276 |
+
out = grouped.agg(
|
| 277 |
+
**{
|
| 278 |
+
f"{prefix}open": ("open", "first"),
|
| 279 |
+
f"{prefix}high": ("high", "max"),
|
| 280 |
+
f"{prefix}low": ("low", "min"),
|
| 281 |
+
f"{prefix}close": ("close", "last"),
|
| 282 |
+
f"{prefix}volume": ("volume", "sum"),
|
| 283 |
+
f"{prefix}bars": ("close", "size"),
|
| 284 |
+
}
|
| 285 |
+
).reset_index()
|
| 286 |
+
out[f"{prefix}range"] = out[f"{prefix}high"] - out[f"{prefix}low"]
|
| 287 |
+
out[f"{prefix}return"] = safe_div(out[f"{prefix}close"], out[f"{prefix}open"]) - 1.0
|
| 288 |
+
out[f"{prefix}range_pct"] = safe_div(out[f"{prefix}range"], out[f"{prefix}open"])
|
| 289 |
+
out[f"{prefix}close_pos"] = safe_div(out[f"{prefix}close"] - out[f"{prefix}low"], out[f"{prefix}range"])
|
| 290 |
+
for checkpoint in sorted({5, 10, minutes}):
|
| 291 |
+
if checkpoint > minutes:
|
| 292 |
+
continue
|
| 293 |
+
marker = f"{prefix}{checkpoint}m_"
|
| 294 |
+
checkpoint_window = minute[minute["minute_index"] < checkpoint]
|
| 295 |
+
checkpoint_features = checkpoint_window.groupby("date", sort=True).agg(
|
| 296 |
+
**{
|
| 297 |
+
f"{marker}high": ("high", "max"),
|
| 298 |
+
f"{marker}low": ("low", "min"),
|
| 299 |
+
f"{marker}close": ("close", "last"),
|
| 300 |
+
f"{marker}volume": ("volume", "sum"),
|
| 301 |
+
f"{marker}bars": ("close", "size"),
|
| 302 |
+
}
|
| 303 |
+
).reset_index()
|
| 304 |
+
out = out.merge(checkpoint_features, on="date", how="left")
|
| 305 |
+
return out[out[f"{prefix}bars"] >= 5].replace([np.inf, -np.inf], np.nan).reset_index(drop=True)
|
| 306 |
+
|
| 307 |
+
|
| 308 |
+
def read_opening_5m_features(symbol: str, stem: str, prefix: str) -> pd.DataFrame:
|
| 309 |
+
feature_path = FIVE_MIN_FEATURE_DIR / f"{stem}_5m_features.csv"
|
| 310 |
+
if not feature_path.exists():
|
| 311 |
+
raise FileNotFoundError(f"Missing 5m feature file for {symbol}: {feature_path}")
|
| 312 |
+
note(f"{symbol}: adding processed opening-5m features from {feature_path.name}")
|
| 313 |
+
features = pd.read_csv(feature_path)
|
| 314 |
+
features.columns = [str(c).strip().lower().replace(" ", "_") for c in features.columns]
|
| 315 |
+
if "date" not in features.columns:
|
| 316 |
+
raise ValueError(f"{feature_path} has no date column")
|
| 317 |
+
features["dt"] = pd.to_datetime(features["date"], errors="coerce")
|
| 318 |
+
features = features.dropna(subset=["dt"]).sort_values("dt")
|
| 319 |
+
features["date"] = features["dt"].dt.normalize()
|
| 320 |
+
features["bar_number"] = features.groupby("date", sort=True).cumcount()
|
| 321 |
+
opening = features[features["bar_number"] == 0].copy()
|
| 322 |
+
value_cols = [
|
| 323 |
+
col
|
| 324 |
+
for col in opening.columns
|
| 325 |
+
if col not in {"date", "dt", "bar_number"}
|
| 326 |
+
and not col.startswith("target_")
|
| 327 |
+
and "target" not in col
|
| 328 |
+
and "future" not in col
|
| 329 |
+
and "next" not in col
|
| 330 |
+
]
|
| 331 |
+
out = opening[["date", *value_cols]].copy()
|
| 332 |
+
for col in value_cols:
|
| 333 |
+
out[col] = pd.to_numeric(out[col], errors="coerce")
|
| 334 |
+
out = out.rename(columns={col: f"{prefix}{col}" for col in value_cols})
|
| 335 |
+
return out.replace([np.inf, -np.inf], np.nan).reset_index(drop=True)
|
| 336 |
+
|
| 337 |
+
|
| 338 |
+
def safe_div(numer: pd.Series | np.ndarray, denom: pd.Series | np.ndarray) -> pd.Series:
|
| 339 |
+
n = pd.Series(numer, copy=False)
|
| 340 |
+
d = pd.Series(denom, copy=False)
|
| 341 |
+
out = pd.Series(np.nan, index=n.index, dtype="float64")
|
| 342 |
+
mask = d.notna() & np.isfinite(d.to_numpy(dtype="float64")) & (d != 0)
|
| 343 |
+
out.loc[mask] = n.loc[mask].to_numpy(dtype="float64") / d.loc[mask].to_numpy(dtype="float64")
|
| 344 |
+
return out
|
| 345 |
+
|
| 346 |
+
|
| 347 |
+
def build_technical_features(df: pd.DataFrame, prefix: str) -> pd.DataFrame:
|
| 348 |
+
d = df.sort_values("date").reset_index(drop=True)
|
| 349 |
+
out = pd.DataFrame({"date": d["date"]})
|
| 350 |
+
open_ = d["open"].astype("float64")
|
| 351 |
+
high = d["high"].astype("float64")
|
| 352 |
+
low = d["low"].astype("float64")
|
| 353 |
+
close = d["close"].astype("float64")
|
| 354 |
+
volume = d["volume"].astype("float64").replace(0, np.nan)
|
| 355 |
+
day_range = high - low
|
| 356 |
+
ret1 = close.pct_change(fill_method=None)
|
| 357 |
+
true_range = pd.concat(
|
| 358 |
+
[day_range, (high - close.shift(1)).abs(), (low - close.shift(1)).abs()],
|
| 359 |
+
axis=1,
|
| 360 |
+
).max(axis=1)
|
| 361 |
+
|
| 362 |
+
base = {
|
| 363 |
+
"open": open_,
|
| 364 |
+
"high": high,
|
| 365 |
+
"low": low,
|
| 366 |
+
"close": close,
|
| 367 |
+
"volume_log": np.log1p(volume),
|
| 368 |
+
"range": day_range,
|
| 369 |
+
"range_pct": safe_div(day_range, close),
|
| 370 |
+
"body_pct": safe_div(close - open_, open_),
|
| 371 |
+
"upper_wick_pct": safe_div(high - pd.concat([open_, close], axis=1).max(axis=1), close),
|
| 372 |
+
"lower_wick_pct": safe_div(pd.concat([open_, close], axis=1).min(axis=1) - low, close),
|
| 373 |
+
"close_pos": safe_div(close - low, day_range),
|
| 374 |
+
"gap_pct": safe_div(open_, close.shift(1)) - 1.0,
|
| 375 |
+
"ret1": ret1,
|
| 376 |
+
}
|
| 377 |
+
for name, values in base.items():
|
| 378 |
+
out[f"{prefix}{name}"] = values
|
| 379 |
+
|
| 380 |
+
for lag in (1, 2, 3, 5, 10):
|
| 381 |
+
for name in ("open", "high", "low", "close", "range", "range_pct", "body_pct", "close_pos", "gap_pct", "ret1"):
|
| 382 |
+
out[f"{prefix}{name}_lag{lag}"] = out[f"{prefix}{name}"].shift(lag)
|
| 383 |
+
|
| 384 |
+
for window in (2, 3, 5, 10, 20, 40, 60, 120):
|
| 385 |
+
min_periods = max(2, window // 2)
|
| 386 |
+
out[f"{prefix}ret_{window}d"] = safe_div(close, close.shift(window)) - 1.0
|
| 387 |
+
out[f"{prefix}ret_mean_{window}d"] = ret1.rolling(window, min_periods=min_periods).mean()
|
| 388 |
+
out[f"{prefix}ret_std_{window}d"] = ret1.rolling(window, min_periods=min_periods).std()
|
| 389 |
+
out[f"{prefix}abs_ret_mean_{window}d"] = ret1.abs().rolling(window, min_periods=min_periods).mean()
|
| 390 |
+
out[f"{prefix}range_mean_{window}d"] = day_range.rolling(window, min_periods=min_periods).mean()
|
| 391 |
+
out[f"{prefix}range_pct_mean_{window}d"] = safe_div(day_range, close).rolling(window, min_periods=min_periods).mean()
|
| 392 |
+
out[f"{prefix}atr_{window}d"] = true_range.rolling(window, min_periods=min_periods).mean()
|
| 393 |
+
out[f"{prefix}close_vs_sma_{window}d"] = safe_div(close, close.rolling(window, min_periods=min_periods).mean()) - 1.0
|
| 394 |
+
roll_high = high.rolling(window, min_periods=min_periods).max()
|
| 395 |
+
roll_low = low.rolling(window, min_periods=min_periods).min()
|
| 396 |
+
out[f"{prefix}close_pos_{window}d"] = safe_div(close - roll_low, roll_high - roll_low)
|
| 397 |
+
|
| 398 |
+
for span in (5, 12, 26, 50):
|
| 399 |
+
ema = close.ewm(span=span, adjust=False, min_periods=max(2, span // 2)).mean()
|
| 400 |
+
out[f"{prefix}close_vs_ema_{span}d"] = safe_div(close, ema) - 1.0
|
| 401 |
+
|
| 402 |
+
out[f"{prefix}day_of_week"] = d["date"].dt.dayofweek
|
| 403 |
+
out[f"{prefix}month"] = d["date"].dt.month
|
| 404 |
+
return out
|
| 405 |
+
|
| 406 |
+
|
| 407 |
+
def build_market_features() -> pd.DataFrame:
|
| 408 |
+
market = None
|
| 409 |
+
for symbol, stem in MARKET_SYMBOLS.items():
|
| 410 |
+
features = build_technical_features(read_daily_bars(symbol, stem), f"{stem}_")
|
| 411 |
+
market = features if market is None else market.merge(features, on="date", how="outer")
|
| 412 |
+
if market is None:
|
| 413 |
+
raise RuntimeError("No market features were built.")
|
| 414 |
+
return market.sort_values("date").reset_index(drop=True)
|
| 415 |
+
|
| 416 |
+
|
| 417 |
+
def build_market_first5_features(target_symbols: dict[str, str]) -> pd.DataFrame:
|
| 418 |
+
market = None
|
| 419 |
+
for symbol, stem in MARKET_SYMBOLS.items():
|
| 420 |
+
features = read_first5_features(symbol, stem, f"{stem}_first5_")
|
| 421 |
+
market = features if market is None else market.merge(features, on="date", how="outer")
|
| 422 |
+
peer_return_cols = []
|
| 423 |
+
peer_range_cols = []
|
| 424 |
+
for symbol, stem in target_symbols.items():
|
| 425 |
+
features = read_first5_features(symbol, stem, f"peer_{stem}_first5_")
|
| 426 |
+
peer_return_cols.append(f"peer_{stem}_first5_return")
|
| 427 |
+
peer_range_cols.append(f"peer_{stem}_first5_range_pct")
|
| 428 |
+
market = features if market is None else market.merge(features, on="date", how="outer")
|
| 429 |
+
if market is None:
|
| 430 |
+
raise RuntimeError("No market first-5-minute features were built.")
|
| 431 |
+
market["peer_first5_return_mean"] = market[peer_return_cols].mean(axis=1)
|
| 432 |
+
market["peer_first5_return_std"] = market[peer_return_cols].std(axis=1)
|
| 433 |
+
market["peer_first5_range_pct_mean"] = market[peer_range_cols].mean(axis=1)
|
| 434 |
+
market["peer_first5_range_pct_std"] = market[peer_range_cols].std(axis=1)
|
| 435 |
+
return market.sort_values("date").reset_index(drop=True)
|
| 436 |
+
|
| 437 |
+
|
| 438 |
+
def build_opening_5m_panel(target_symbols: dict[str, str]) -> pd.DataFrame:
|
| 439 |
+
panel = None
|
| 440 |
+
for symbol, stem in {**MARKET_SYMBOLS, **target_symbols}.items():
|
| 441 |
+
features = read_opening_5m_features(symbol, stem, f"{stem}_open5_")
|
| 442 |
+
panel = features if panel is None else panel.merge(features, on="date", how="outer")
|
| 443 |
+
if panel is None:
|
| 444 |
+
raise RuntimeError("No opening 5m features were built.")
|
| 445 |
+
return panel.sort_values("date").reset_index(drop=True)
|
| 446 |
+
|
| 447 |
+
|
| 448 |
+
def read_prior_day_panel(path: Path, prefix: str) -> pd.DataFrame:
|
| 449 |
+
if not path.exists():
|
| 450 |
+
note(f"skipping missing exogenous panel: {path}")
|
| 451 |
+
return pd.DataFrame()
|
| 452 |
+
panel = pd.read_csv(path)
|
| 453 |
+
panel.columns = [str(c).strip().lower().replace(" ", "_") for c in panel.columns]
|
| 454 |
+
if "date" not in panel.columns:
|
| 455 |
+
raise ValueError(f"{path} has no date column")
|
| 456 |
+
panel["date"] = pd.to_datetime(panel["date"], errors="coerce").dt.normalize()
|
| 457 |
+
panel = panel.dropna(subset=["date"]).sort_values("date").drop_duplicates("date", keep="last")
|
| 458 |
+
if prefix == "ext_":
|
| 459 |
+
for col in list(panel.columns):
|
| 460 |
+
if col == "date":
|
| 461 |
+
continue
|
| 462 |
+
if col.endswith("_close"):
|
| 463 |
+
base = col[: -len("_close")]
|
| 464 |
+
value_col = f"{base}_value"
|
| 465 |
+
change_col = f"{base}_change_1"
|
| 466 |
+
if value_col not in panel.columns:
|
| 467 |
+
panel[value_col] = pd.to_numeric(panel[col], errors="coerce")
|
| 468 |
+
if change_col not in panel.columns:
|
| 469 |
+
panel[change_col] = pd.to_numeric(panel[value_col], errors="coerce").diff()
|
| 470 |
+
elif col.endswith("_value"):
|
| 471 |
+
base = col[: -len("_value")]
|
| 472 |
+
close_col = f"{base}_close"
|
| 473 |
+
change_col = f"{base}_change_1"
|
| 474 |
+
if close_col not in panel.columns:
|
| 475 |
+
panel[close_col] = pd.to_numeric(panel[col], errors="coerce")
|
| 476 |
+
if change_col not in panel.columns:
|
| 477 |
+
panel[change_col] = pd.to_numeric(panel[col], errors="coerce").diff()
|
| 478 |
+
value_cols = [
|
| 479 |
+
col
|
| 480 |
+
for col in panel.columns
|
| 481 |
+
if col != "date"
|
| 482 |
+
and not col.startswith("target_")
|
| 483 |
+
and "target" not in col
|
| 484 |
+
and "future" not in col
|
| 485 |
+
and "next" not in col
|
| 486 |
+
]
|
| 487 |
+
out = pd.DataFrame({"date": panel["date"]})
|
| 488 |
+
for col in value_cols:
|
| 489 |
+
out[f"{prefix}{col}"] = pd.to_numeric(panel[col], errors="coerce")
|
| 490 |
+
numeric_cols = [col for col in out.columns if col != "date"]
|
| 491 |
+
for col in numeric_cols:
|
| 492 |
+
values = out[col]
|
| 493 |
+
out[f"{col}_lag1"] = values.shift(1)
|
| 494 |
+
out[f"{col}_lag3"] = values.shift(3)
|
| 495 |
+
out[f"{col}_lag5"] = values.shift(5)
|
| 496 |
+
out[f"{col}_mean5"] = values.rolling(5, min_periods=2).mean()
|
| 497 |
+
out[f"{col}_std5"] = values.rolling(5, min_periods=2).std()
|
| 498 |
+
return out.replace([np.inf, -np.inf], np.nan).reset_index(drop=True)
|
| 499 |
+
|
| 500 |
+
|
| 501 |
+
def build_prior_day_exogenous_features() -> pd.DataFrame:
|
| 502 |
+
panels = [
|
| 503 |
+
read_prior_day_panel(ALT_DATA_DIR / "external" / "processed" / "external_daily_panel.csv", "ext_"),
|
| 504 |
+
read_prior_day_panel(ALT_DATA_DIR / "institutional" / "processed" / "institutional_daily_panel.csv", "inst_"),
|
| 505 |
+
read_prior_day_panel(ALT_DATA_DIR / "options" / "processed" / "hdfcbank_options_daily_features.csv", "hdfc_opt_"),
|
| 506 |
+
read_prior_day_panel(ALT_DATA_DIR / "options" / "processed" / "banknifty_options_daily_features.csv", "banknifty_opt_"),
|
| 507 |
+
read_prior_day_panel(ALT_DATA_DIR / "options" / "processed" / "nifty50_options_daily_features.csv", "nifty_opt_"),
|
| 508 |
+
]
|
| 509 |
+
exogenous = None
|
| 510 |
+
for panel in panels:
|
| 511 |
+
if panel.empty:
|
| 512 |
+
continue
|
| 513 |
+
exogenous = panel if exogenous is None else exogenous.merge(panel, on="date", how="outer")
|
| 514 |
+
if exogenous is None:
|
| 515 |
+
return pd.DataFrame()
|
| 516 |
+
return exogenous.sort_values("date").reset_index(drop=True)
|
| 517 |
+
|
| 518 |
+
|
| 519 |
+
@lru_cache(maxsize=1)
|
| 520 |
+
def load_corporate_announcement_events() -> pd.DataFrame:
|
| 521 |
+
if not CORPORATE_ANNOUNCEMENTS_PATH.exists():
|
| 522 |
+
note(f"skipping missing corporate announcement panel: {CORPORATE_ANNOUNCEMENTS_PATH}")
|
| 523 |
+
return pd.DataFrame()
|
| 524 |
+
events = pd.read_csv(CORPORATE_ANNOUNCEMENTS_PATH)
|
| 525 |
+
if events.empty:
|
| 526 |
+
return events
|
| 527 |
+
events.columns = [str(c).strip() for c in events.columns]
|
| 528 |
+
events["symbol"] = events["symbol"].astype(str).str.upper().str.strip()
|
| 529 |
+
events["event_ts"] = pd.to_datetime(events["event_ts"], errors="coerce")
|
| 530 |
+
events = events.dropna(subset=["symbol", "event_ts"]).sort_values(["symbol", "event_ts"])
|
| 531 |
+
for col in (
|
| 532 |
+
"is_results",
|
| 533 |
+
"is_board_meeting",
|
| 534 |
+
"is_investor_meet",
|
| 535 |
+
"is_credit_rating",
|
| 536 |
+
"is_press_release",
|
| 537 |
+
"is_high_impact",
|
| 538 |
+
):
|
| 539 |
+
if col not in events.columns:
|
| 540 |
+
events[col] = False
|
| 541 |
+
events[col] = events[col].fillna(False).astype(bool)
|
| 542 |
+
return events.reset_index(drop=True)
|
| 543 |
+
|
| 544 |
+
|
| 545 |
+
def build_corporate_announcement_features(symbol: str, frame: pd.DataFrame) -> pd.DataFrame:
|
| 546 |
+
events = load_corporate_announcement_events()
|
| 547 |
+
out = pd.DataFrame(index=frame.index)
|
| 548 |
+
feature_names = [
|
| 549 |
+
"corp_ann_count_1d",
|
| 550 |
+
"corp_ann_count_3d",
|
| 551 |
+
"corp_ann_count_7d",
|
| 552 |
+
"corp_ann_count_30d",
|
| 553 |
+
"corp_high_impact_count_7d",
|
| 554 |
+
"corp_high_impact_count_30d",
|
| 555 |
+
"corp_results_count_30d",
|
| 556 |
+
"corp_board_count_30d",
|
| 557 |
+
"corp_investor_count_7d",
|
| 558 |
+
"corp_credit_rating_count_30d",
|
| 559 |
+
"corp_press_release_count_7d",
|
| 560 |
+
"corp_since_prev_close_count",
|
| 561 |
+
"corp_since_prev_close_high_impact",
|
| 562 |
+
"corp_forecast_morning_count",
|
| 563 |
+
"corp_forecast_morning_high_impact",
|
| 564 |
+
"corp_last_announcement_hours",
|
| 565 |
+
"corp_last_high_impact_hours",
|
| 566 |
+
]
|
| 567 |
+
for name in feature_names:
|
| 568 |
+
out[name] = np.nan if name.endswith("_hours") else 0.0
|
| 569 |
+
if events.empty:
|
| 570 |
+
return out
|
| 571 |
+
|
| 572 |
+
symbol_events = events[events["symbol"] == symbol.upper()].copy()
|
| 573 |
+
if symbol_events.empty:
|
| 574 |
+
return out
|
| 575 |
+
ts = symbol_events["event_ts"].to_numpy(dtype="datetime64[ns]")
|
| 576 |
+
masks = {
|
| 577 |
+
"high": symbol_events["is_high_impact"].to_numpy(dtype=bool),
|
| 578 |
+
"results": symbol_events["is_results"].to_numpy(dtype=bool),
|
| 579 |
+
"board": symbol_events["is_board_meeting"].to_numpy(dtype=bool),
|
| 580 |
+
"investor": symbol_events["is_investor_meet"].to_numpy(dtype=bool),
|
| 581 |
+
"rating": symbol_events["is_credit_rating"].to_numpy(dtype=bool),
|
| 582 |
+
"press": symbol_events["is_press_release"].to_numpy(dtype=bool),
|
| 583 |
+
}
|
| 584 |
+
|
| 585 |
+
def count_between(start: pd.Timestamp, end: pd.Timestamp, mask: np.ndarray | None = None) -> float:
|
| 586 |
+
active = (ts > np.datetime64(start)) & (ts <= np.datetime64(end))
|
| 587 |
+
if mask is not None:
|
| 588 |
+
active &= mask
|
| 589 |
+
return float(active.sum())
|
| 590 |
+
|
| 591 |
+
for idx, row in frame[["date", "target_date"]].iterrows():
|
| 592 |
+
if pd.isna(row["date"]) or pd.isna(row["target_date"]):
|
| 593 |
+
continue
|
| 594 |
+
row_date = pd.Timestamp(row["date"]).normalize()
|
| 595 |
+
target_date = pd.Timestamp(row["target_date"]).normalize()
|
| 596 |
+
cutoff = target_date + pd.Timedelta(hours=9, minutes=20)
|
| 597 |
+
prev_close_cutoff = row_date + pd.Timedelta(hours=15, minutes=30)
|
| 598 |
+
out.at[idx, "corp_ann_count_1d"] = count_between(cutoff - pd.Timedelta(days=1), cutoff)
|
| 599 |
+
out.at[idx, "corp_ann_count_3d"] = count_between(cutoff - pd.Timedelta(days=3), cutoff)
|
| 600 |
+
out.at[idx, "corp_ann_count_7d"] = count_between(cutoff - pd.Timedelta(days=7), cutoff)
|
| 601 |
+
out.at[idx, "corp_ann_count_30d"] = count_between(cutoff - pd.Timedelta(days=30), cutoff)
|
| 602 |
+
out.at[idx, "corp_high_impact_count_7d"] = count_between(cutoff - pd.Timedelta(days=7), cutoff, masks["high"])
|
| 603 |
+
out.at[idx, "corp_high_impact_count_30d"] = count_between(cutoff - pd.Timedelta(days=30), cutoff, masks["high"])
|
| 604 |
+
out.at[idx, "corp_results_count_30d"] = count_between(cutoff - pd.Timedelta(days=30), cutoff, masks["results"])
|
| 605 |
+
out.at[idx, "corp_board_count_30d"] = count_between(cutoff - pd.Timedelta(days=30), cutoff, masks["board"])
|
| 606 |
+
out.at[idx, "corp_investor_count_7d"] = count_between(cutoff - pd.Timedelta(days=7), cutoff, masks["investor"])
|
| 607 |
+
out.at[idx, "corp_credit_rating_count_30d"] = count_between(cutoff - pd.Timedelta(days=30), cutoff, masks["rating"])
|
| 608 |
+
out.at[idx, "corp_press_release_count_7d"] = count_between(cutoff - pd.Timedelta(days=7), cutoff, masks["press"])
|
| 609 |
+
out.at[idx, "corp_since_prev_close_count"] = count_between(prev_close_cutoff, cutoff)
|
| 610 |
+
out.at[idx, "corp_since_prev_close_high_impact"] = count_between(prev_close_cutoff, cutoff, masks["high"])
|
| 611 |
+
out.at[idx, "corp_forecast_morning_count"] = count_between(target_date, cutoff)
|
| 612 |
+
out.at[idx, "corp_forecast_morning_high_impact"] = count_between(target_date, cutoff, masks["high"])
|
| 613 |
+
known = ts[ts <= np.datetime64(cutoff)]
|
| 614 |
+
if len(known):
|
| 615 |
+
out.at[idx, "corp_last_announcement_hours"] = float((cutoff - pd.Timestamp(known[-1])).total_seconds() / 3600.0)
|
| 616 |
+
known_high = ts[(ts <= np.datetime64(cutoff)) & masks["high"]]
|
| 617 |
+
if len(known_high):
|
| 618 |
+
out.at[idx, "corp_last_high_impact_hours"] = float((cutoff - pd.Timestamp(known_high[-1])).total_seconds() / 3600.0)
|
| 619 |
+
return out
|
| 620 |
+
|
| 621 |
+
|
| 622 |
+
def build_symbol_frame(
|
| 623 |
+
symbol: str,
|
| 624 |
+
stem: str,
|
| 625 |
+
market: pd.DataFrame,
|
| 626 |
+
market_first5: pd.DataFrame,
|
| 627 |
+
opening_5m: pd.DataFrame | None = None,
|
| 628 |
+
exogenous: pd.DataFrame | None = None,
|
| 629 |
+
) -> pd.DataFrame:
|
| 630 |
+
bars = read_daily_bars(symbol, stem)
|
| 631 |
+
features = build_technical_features(bars, "stock_")
|
| 632 |
+
frame = (
|
| 633 |
+
bars[["date", "symbol", "open", "high", "low", "close", "volume"]]
|
| 634 |
+
.merge(features, on="date", how="left")
|
| 635 |
+
.merge(market, on="date", how="left")
|
| 636 |
+
.sort_values("date")
|
| 637 |
+
.reset_index(drop=True)
|
| 638 |
+
)
|
| 639 |
+
if exogenous is not None and not exogenous.empty:
|
| 640 |
+
frame = pd.merge_asof(
|
| 641 |
+
frame.sort_values("date"),
|
| 642 |
+
exogenous.sort_values("date"),
|
| 643 |
+
on="date",
|
| 644 |
+
direction="backward",
|
| 645 |
+
).reset_index(drop=True)
|
| 646 |
+
frame["target_date"] = frame["date"].shift(-1)
|
| 647 |
+
frame["target_high"] = frame["high"].shift(-1)
|
| 648 |
+
frame["target_low"] = frame["low"].shift(-1)
|
| 649 |
+
target_dt = pd.to_datetime(frame["target_date"], errors="coerce")
|
| 650 |
+
frame["forecast_day_of_week"] = target_dt.dt.dayofweek
|
| 651 |
+
frame["forecast_month"] = target_dt.dt.month
|
| 652 |
+
frame["forecast_day_of_month"] = target_dt.dt.day
|
| 653 |
+
frame["forecast_is_weekend_session"] = target_dt.dt.dayofweek.isin([5, 6]).astype("float64")
|
| 654 |
+
frame["forecast_is_month_start"] = target_dt.dt.is_month_start.astype("float64")
|
| 655 |
+
frame["forecast_is_month_end"] = target_dt.dt.is_month_end.astype("float64")
|
| 656 |
+
frame = pd.concat([frame, build_corporate_announcement_features(symbol, frame)], axis=1)
|
| 657 |
+
stock_first5 = read_first5_features(symbol, stem, "first5_")
|
| 658 |
+
frame = frame.merge(stock_first5, left_on="target_date", right_on="date", how="left", suffixes=("", "_first5"))
|
| 659 |
+
frame = frame.drop(columns=["date_first5"])
|
| 660 |
+
if symbol == "HDFCBANK":
|
| 661 |
+
hdfc_opening = read_opening_window_features(symbol, stem, HDFC_OPENING_PREFIX, HDFC_OPENING_MINUTES)
|
| 662 |
+
frame = frame.merge(hdfc_opening, left_on="target_date", right_on="date", how="left", suffixes=("", "_hdfc_opening"))
|
| 663 |
+
frame = frame.drop(columns=["date_hdfc_opening"])
|
| 664 |
+
frame = frame.merge(market_first5, left_on="target_date", right_on="date", how="left", suffixes=("", "_market_first5"))
|
| 665 |
+
frame = frame.drop(columns=["date_market_first5"])
|
| 666 |
+
if symbol == "HDFCBANK" and opening_5m is not None:
|
| 667 |
+
frame = frame.merge(opening_5m, left_on="target_date", right_on="date", how="left", suffixes=("", "_open5_panel"))
|
| 668 |
+
frame = frame.drop(columns=["date_open5_panel"])
|
| 669 |
+
frame["target_high_delta_from_first5_close"] = frame["target_high"] - frame["first5_close"]
|
| 670 |
+
frame["target_low_delta_from_first5_close"] = frame["target_low"] - frame["first5_close"]
|
| 671 |
+
return frame.replace([np.inf, -np.inf], np.nan)
|
| 672 |
+
|
| 673 |
+
|
| 674 |
+
def candidate_specs(seed: int) -> list[ModelSpec]:
|
| 675 |
+
return [
|
| 676 |
+
ModelSpec("baseline_first5_close", "baseline", {"baseline": "first5_close"}, target_mode="baseline"),
|
| 677 |
+
ModelSpec("baseline_first5_bound", "baseline", {"baseline": "first5_bound"}, target_mode="baseline"),
|
| 678 |
+
ModelSpec("baseline_persistence", "baseline", {"baseline": "persistence"}, target_mode="baseline"),
|
| 679 |
+
ModelSpec("ridge_delta_a100", "ridge", {"alpha": 100.0}),
|
| 680 |
+
ModelSpec("ridge_delta_a1000", "ridge", {"alpha": 1000.0}),
|
| 681 |
+
ModelSpec("ridge_delta_a5000", "ridge", {"alpha": 5000.0}),
|
| 682 |
+
ModelSpec("bound_ridge_a1000", "ridge", {"alpha": 1000.0}, target_mode="bound_dist"),
|
| 683 |
+
ModelSpec("bound_ridge_a5000", "ridge", {"alpha": 5000.0}, target_mode="bound_dist"),
|
| 684 |
+
ModelSpec(
|
| 685 |
+
"extra_trees_delta_d3",
|
| 686 |
+
"extra_trees",
|
| 687 |
+
{
|
| 688 |
+
"n_estimators": 120,
|
| 689 |
+
"max_depth": 3,
|
| 690 |
+
"min_samples_leaf": 20,
|
| 691 |
+
"max_features": 0.80,
|
| 692 |
+
"random_state": seed + 2,
|
| 693 |
+
"n_jobs": -1,
|
| 694 |
+
},
|
| 695 |
+
),
|
| 696 |
+
ModelSpec(
|
| 697 |
+
"extra_trees_delta_d7",
|
| 698 |
+
"extra_trees",
|
| 699 |
+
{
|
| 700 |
+
"n_estimators": 120,
|
| 701 |
+
"max_depth": 7,
|
| 702 |
+
"min_samples_leaf": 12,
|
| 703 |
+
"max_features": 0.70,
|
| 704 |
+
"random_state": seed,
|
| 705 |
+
"n_jobs": -1,
|
| 706 |
+
},
|
| 707 |
+
),
|
| 708 |
+
ModelSpec(
|
| 709 |
+
"extra_trees_delta_d5",
|
| 710 |
+
"extra_trees",
|
| 711 |
+
{
|
| 712 |
+
"n_estimators": 120,
|
| 713 |
+
"max_depth": 5,
|
| 714 |
+
"min_samples_leaf": 10,
|
| 715 |
+
"max_features": 0.75,
|
| 716 |
+
"random_state": seed + 1,
|
| 717 |
+
"n_jobs": -1,
|
| 718 |
+
},
|
| 719 |
+
),
|
| 720 |
+
ModelSpec(
|
| 721 |
+
"bound_extra_trees_d3",
|
| 722 |
+
"extra_trees",
|
| 723 |
+
{
|
| 724 |
+
"n_estimators": 120,
|
| 725 |
+
"max_depth": 3,
|
| 726 |
+
"min_samples_leaf": 20,
|
| 727 |
+
"max_features": 0.80,
|
| 728 |
+
"random_state": seed + 4,
|
| 729 |
+
"n_jobs": -1,
|
| 730 |
+
},
|
| 731 |
+
target_mode="bound_dist",
|
| 732 |
+
),
|
| 733 |
+
ModelSpec(
|
| 734 |
+
"bound_extra_trees_d5",
|
| 735 |
+
"extra_trees",
|
| 736 |
+
{
|
| 737 |
+
"n_estimators": 120,
|
| 738 |
+
"max_depth": 5,
|
| 739 |
+
"min_samples_leaf": 12,
|
| 740 |
+
"max_features": 0.75,
|
| 741 |
+
"random_state": seed + 5,
|
| 742 |
+
"n_jobs": -1,
|
| 743 |
+
},
|
| 744 |
+
target_mode="bound_dist",
|
| 745 |
+
),
|
| 746 |
+
ModelSpec(
|
| 747 |
+
"tatasteel_bound_extra_trees_d5_scaled",
|
| 748 |
+
"extra_trees",
|
| 749 |
+
{
|
| 750 |
+
"n_estimators": 120,
|
| 751 |
+
"max_depth": 5,
|
| 752 |
+
"min_samples_leaf": 12,
|
| 753 |
+
"max_features": 0.75,
|
| 754 |
+
"random_state": seed + 5,
|
| 755 |
+
"n_jobs": -1,
|
| 756 |
+
"post_scale_high": 0.975,
|
| 757 |
+
"post_scale_low": 0.75,
|
| 758 |
+
},
|
| 759 |
+
target_mode="bound_dist",
|
| 760 |
+
),
|
| 761 |
+
ModelSpec(
|
| 762 |
+
"bound_random_forest_d3",
|
| 763 |
+
"random_forest",
|
| 764 |
+
{
|
| 765 |
+
"n_estimators": 80,
|
| 766 |
+
"max_depth": 3,
|
| 767 |
+
"min_samples_leaf": 20,
|
| 768 |
+
"max_features": 0.80,
|
| 769 |
+
"random_state": seed + 6,
|
| 770 |
+
"n_jobs": -1,
|
| 771 |
+
},
|
| 772 |
+
target_mode="bound_dist",
|
| 773 |
+
),
|
| 774 |
+
ModelSpec(
|
| 775 |
+
"histgb_delta",
|
| 776 |
+
"histgb",
|
| 777 |
+
{
|
| 778 |
+
"max_iter": 100,
|
| 779 |
+
"learning_rate": 0.035,
|
| 780 |
+
"max_leaf_nodes": 15,
|
| 781 |
+
"min_samples_leaf": 35,
|
| 782 |
+
"l2_regularization": 1.0,
|
| 783 |
+
"random_state": seed,
|
| 784 |
+
},
|
| 785 |
+
),
|
| 786 |
+
]
|
| 787 |
+
|
| 788 |
+
|
| 789 |
+
def hdfc_open10_candidate_specs(seed: int) -> list[ModelSpec]:
|
| 790 |
+
return [
|
| 791 |
+
ModelSpec(
|
| 792 |
+
name=f"hdfc_open10_bound_{HDFC_OPENING_MINUTES}m",
|
| 793 |
+
kind="baseline",
|
| 794 |
+
params={"minutes": HDFC_OPENING_MINUTES},
|
| 795 |
+
target_mode="open10_bound",
|
| 796 |
+
),
|
| 797 |
+
ModelSpec("hdfc_open10_ridge_delta_a3000", "ridge", {"alpha": 3000.0}, target_mode="open10_delta"),
|
| 798 |
+
ModelSpec("hdfc_open10_ridge_delta_a10000", "ridge", {"alpha": 10000.0}, target_mode="open10_delta"),
|
| 799 |
+
ModelSpec(
|
| 800 |
+
"hdfc_open10_recency_ridge_delta_a10000_hl100",
|
| 801 |
+
"ridge",
|
| 802 |
+
{"alpha": 10000.0, "sample_half_life": 100.0},
|
| 803 |
+
target_mode="open10_delta",
|
| 804 |
+
),
|
| 805 |
+
ModelSpec("hdfc_open10_ridge_delta_a100000", "ridge", {"alpha": 100000.0}, target_mode="open10_delta"),
|
| 806 |
+
ModelSpec("hdfc_open10_bound_ridge_a3000", "ridge", {"alpha": 3000.0}, target_mode="open10_bound_dist"),
|
| 807 |
+
ModelSpec("hdfc_open10_bound_ridge_a10000", "ridge", {"alpha": 10000.0}, target_mode="open10_bound_dist"),
|
| 808 |
+
ModelSpec(
|
| 809 |
+
"hdfc_open10_extra_trees_d3",
|
| 810 |
+
"extra_trees",
|
| 811 |
+
{
|
| 812 |
+
"n_estimators": 96,
|
| 813 |
+
"max_depth": 3,
|
| 814 |
+
"min_samples_leaf": 18,
|
| 815 |
+
"max_features": 0.60,
|
| 816 |
+
"random_state": seed + 21,
|
| 817 |
+
"n_jobs": -1,
|
| 818 |
+
},
|
| 819 |
+
target_mode="open10_bound_dist",
|
| 820 |
+
),
|
| 821 |
+
ModelSpec(
|
| 822 |
+
"hdfc_open10_extra_trees_d5",
|
| 823 |
+
"extra_trees",
|
| 824 |
+
{
|
| 825 |
+
"n_estimators": 96,
|
| 826 |
+
"max_depth": 5,
|
| 827 |
+
"min_samples_leaf": 14,
|
| 828 |
+
"max_features": 0.60,
|
| 829 |
+
"random_state": seed + 22,
|
| 830 |
+
"n_jobs": -1,
|
| 831 |
+
},
|
| 832 |
+
target_mode="open10_bound_dist",
|
| 833 |
+
),
|
| 834 |
+
ModelSpec(
|
| 835 |
+
"hdfc_open10_histgb",
|
| 836 |
+
"histgb",
|
| 837 |
+
{
|
| 838 |
+
"max_iter": 100,
|
| 839 |
+
"learning_rate": 0.03,
|
| 840 |
+
"max_leaf_nodes": 15,
|
| 841 |
+
"min_samples_leaf": 35,
|
| 842 |
+
"l2_regularization": 5.0,
|
| 843 |
+
"random_state": seed + 23,
|
| 844 |
+
},
|
| 845 |
+
target_mode="open10_bound_dist",
|
| 846 |
+
),
|
| 847 |
+
]
|
| 848 |
+
|
| 849 |
+
|
| 850 |
+
def build_model(spec: ModelSpec):
|
| 851 |
+
model_params = {
|
| 852 |
+
key: value
|
| 853 |
+
for key, value in spec.params.items()
|
| 854 |
+
if not key.startswith("post_")
|
| 855 |
+
}
|
| 856 |
+
if spec.kind == "ridge":
|
| 857 |
+
params = {key: value for key, value in model_params.items() if key != "sample_half_life"}
|
| 858 |
+
return make_pipeline(SimpleImputer(strategy="median"), StandardScaler(), Ridge(**params))
|
| 859 |
+
if spec.kind == "extra_trees":
|
| 860 |
+
return MultiOutputRegressor(make_pipeline(SimpleImputer(strategy="median"), ExtraTreesRegressor(**model_params)))
|
| 861 |
+
if spec.kind == "random_forest":
|
| 862 |
+
return MultiOutputRegressor(make_pipeline(SimpleImputer(strategy="median"), RandomForestRegressor(**model_params)))
|
| 863 |
+
if spec.kind == "histgb":
|
| 864 |
+
return MultiOutputRegressor(make_pipeline(SimpleImputer(strategy="median"), HistGradientBoostingRegressor(**model_params)))
|
| 865 |
+
raise ValueError(f"Unknown model kind: {spec.kind}")
|
| 866 |
+
|
| 867 |
+
|
| 868 |
+
def baseline_high_low(spec: ModelSpec, rows: pd.DataFrame) -> tuple[np.ndarray, np.ndarray]:
|
| 869 |
+
baseline = str(spec.params.get("baseline", ""))
|
| 870 |
+
if baseline == "first5_close":
|
| 871 |
+
close = rows["first5_close"].to_numpy(dtype="float64")
|
| 872 |
+
return close.copy(), close.copy()
|
| 873 |
+
if baseline == "first5_bound":
|
| 874 |
+
return rows["first5_high"].to_numpy(dtype="float64"), rows["first5_low"].to_numpy(dtype="float64")
|
| 875 |
+
if baseline == "persistence":
|
| 876 |
+
return rows["high"].to_numpy(dtype="float64"), rows["low"].to_numpy(dtype="float64")
|
| 877 |
+
raise ValueError(f"Unknown baseline kind: {baseline}")
|
| 878 |
+
|
| 879 |
+
|
| 880 |
+
def feature_columns(frame: pd.DataFrame) -> list[str]:
|
| 881 |
+
blocked = {
|
| 882 |
+
"date",
|
| 883 |
+
"symbol",
|
| 884 |
+
"target_date",
|
| 885 |
+
"target_high",
|
| 886 |
+
"target_low",
|
| 887 |
+
"target_high_delta_from_close",
|
| 888 |
+
"target_low_delta_from_close",
|
| 889 |
+
"target_high_delta_from_first5_close",
|
| 890 |
+
"target_low_delta_from_first5_close",
|
| 891 |
+
}
|
| 892 |
+
cols = [c for c in frame.columns if c not in blocked]
|
| 893 |
+
cols = [c for c in cols if pd.to_numeric(frame[c], errors="coerce").notna().any()]
|
| 894 |
+
leak_words = ("target", "future", "next")
|
| 895 |
+
bad = [c for c in cols if any(word in c.lower() for word in leak_words)]
|
| 896 |
+
if bad:
|
| 897 |
+
raise RuntimeError(f"Potential leakage columns in feature set: {bad[:10]}")
|
| 898 |
+
return cols
|
| 899 |
+
|
| 900 |
+
|
| 901 |
+
def hdfc_fast_feature_columns(cols: list[str]) -> list[str]:
|
| 902 |
+
keep_prefixes = (
|
| 903 |
+
"stock_",
|
| 904 |
+
"nifty50_",
|
| 905 |
+
"banknifty_",
|
| 906 |
+
"india_vix_",
|
| 907 |
+
"first5_",
|
| 908 |
+
"peer_",
|
| 909 |
+
"hdfc_open10_",
|
| 910 |
+
)
|
| 911 |
+
always_keep = {"open", "high", "low", "close", "volume"}
|
| 912 |
+
selected = [c for c in cols if c in always_keep or c.startswith(keep_prefixes)]
|
| 913 |
+
selected = [c for c in selected if not (c.endswith("_std5") or c.endswith("_lag5"))]
|
| 914 |
+
return selected
|
| 915 |
+
|
| 916 |
+
|
| 917 |
+
def predict_high_low(model: Any, rows: pd.DataFrame, cols: list[str]) -> tuple[np.ndarray, np.ndarray]:
|
| 918 |
+
pred_delta = np.asarray(model.predict(rows[cols]), dtype="float64")
|
| 919 |
+
base = rows["first5_close"].to_numpy(dtype="float64") if "first5_close" in rows.columns else rows["close"].to_numpy(dtype="float64")
|
| 920 |
+
pred_high = base + pred_delta[:, 0]
|
| 921 |
+
pred_low = base + pred_delta[:, 1]
|
| 922 |
+
high = np.maximum(pred_high, pred_low)
|
| 923 |
+
low = np.minimum(pred_high, pred_low)
|
| 924 |
+
if {"first5_high", "first5_low"}.issubset(rows.columns):
|
| 925 |
+
high = np.maximum(high, rows["first5_high"].to_numpy(dtype="float64"))
|
| 926 |
+
low = np.minimum(low, rows["first5_low"].to_numpy(dtype="float64"))
|
| 927 |
+
high, low = cap_first5_extension(rows, high, low)
|
| 928 |
+
return high, low
|
| 929 |
+
|
| 930 |
+
|
| 931 |
+
def cap_first5_extension(rows: pd.DataFrame, high: np.ndarray, low: np.ndarray) -> tuple[np.ndarray, np.ndarray]:
|
| 932 |
+
if not {"first5_high", "first5_low"}.issubset(rows.columns):
|
| 933 |
+
return high, low
|
| 934 |
+
first5_high = rows["first5_high"].to_numpy(dtype="float64")
|
| 935 |
+
first5_low = rows["first5_low"].to_numpy(dtype="float64")
|
| 936 |
+
capped_high = np.minimum(np.asarray(high, dtype="float64"), first5_high + GENERIC_FIRST5_EXTENSION_CAP)
|
| 937 |
+
capped_low = np.maximum(np.asarray(low, dtype="float64"), first5_low - GENERIC_FIRST5_EXTENSION_CAP)
|
| 938 |
+
capped_high = np.maximum(capped_high, first5_high)
|
| 939 |
+
capped_low = np.minimum(capped_low, first5_low)
|
| 940 |
+
return capped_high, capped_low
|
| 941 |
+
|
| 942 |
+
|
| 943 |
+
def cap_hdfc_open10_extension(rows: pd.DataFrame, high: np.ndarray, low: np.ndarray) -> tuple[np.ndarray, np.ndarray]:
|
| 944 |
+
if not {f"{HDFC_OPENING_PREFIX}high", f"{HDFC_OPENING_PREFIX}low"}.issubset(rows.columns):
|
| 945 |
+
return high, low
|
| 946 |
+
open_high = rows[f"{HDFC_OPENING_PREFIX}high"].to_numpy(dtype="float64")
|
| 947 |
+
open_low = rows[f"{HDFC_OPENING_PREFIX}low"].to_numpy(dtype="float64")
|
| 948 |
+
capped_high = np.minimum(np.asarray(high, dtype="float64"), open_high + HDFC_OPEN10_EXTENSION_CAP)
|
| 949 |
+
capped_low = np.maximum(np.asarray(low, dtype="float64"), open_low - HDFC_OPEN10_EXTENSION_CAP)
|
| 950 |
+
capped_high = np.maximum(capped_high, open_high)
|
| 951 |
+
capped_low = np.minimum(capped_low, open_low)
|
| 952 |
+
return capped_high, capped_low
|
| 953 |
+
|
| 954 |
+
|
| 955 |
+
def target_matrix(spec: ModelSpec, rows: pd.DataFrame) -> np.ndarray:
|
| 956 |
+
if spec.target_mode == "close_delta":
|
| 957 |
+
return np.column_stack(
|
| 958 |
+
[
|
| 959 |
+
rows["target_high_delta_from_first5_close"].to_numpy(dtype="float64"),
|
| 960 |
+
rows["target_low_delta_from_first5_close"].to_numpy(dtype="float64"),
|
| 961 |
+
]
|
| 962 |
+
)
|
| 963 |
+
if spec.target_mode == "bound_dist":
|
| 964 |
+
return np.column_stack(
|
| 965 |
+
[
|
| 966 |
+
rows["target_high"].to_numpy(dtype="float64") - rows["first5_high"].to_numpy(dtype="float64"),
|
| 967 |
+
rows["first5_low"].to_numpy(dtype="float64") - rows["target_low"].to_numpy(dtype="float64"),
|
| 968 |
+
]
|
| 969 |
+
)
|
| 970 |
+
if spec.target_mode == "open10_delta":
|
| 971 |
+
return np.column_stack(
|
| 972 |
+
[
|
| 973 |
+
rows["target_high"].to_numpy(dtype="float64") - rows[f"{HDFC_OPENING_PREFIX}close"].to_numpy(dtype="float64"),
|
| 974 |
+
rows["target_low"].to_numpy(dtype="float64") - rows[f"{HDFC_OPENING_PREFIX}close"].to_numpy(dtype="float64"),
|
| 975 |
+
]
|
| 976 |
+
)
|
| 977 |
+
if spec.target_mode == "open10_bound_dist":
|
| 978 |
+
return np.column_stack(
|
| 979 |
+
[
|
| 980 |
+
rows["target_high"].to_numpy(dtype="float64") - rows[f"{HDFC_OPENING_PREFIX}high"].to_numpy(dtype="float64"),
|
| 981 |
+
rows[f"{HDFC_OPENING_PREFIX}low"].to_numpy(dtype="float64") - rows["target_low"].to_numpy(dtype="float64"),
|
| 982 |
+
]
|
| 983 |
+
)
|
| 984 |
+
raise ValueError(f"Unknown target_mode: {spec.target_mode}")
|
| 985 |
+
|
| 986 |
+
|
| 987 |
+
def predict_spec_high_low(spec: ModelSpec, model: Any, rows: pd.DataFrame, cols: list[str]) -> tuple[np.ndarray, np.ndarray]:
|
| 988 |
+
if spec.target_mode == "close_delta":
|
| 989 |
+
return predict_high_low(model, rows, cols)
|
| 990 |
+
if spec.target_mode == "bound_dist":
|
| 991 |
+
pred = np.asarray(model.predict(rows[cols]), dtype="float64")
|
| 992 |
+
high_scale = float(spec.params.get("post_scale_high", 1.0))
|
| 993 |
+
low_scale = float(spec.params.get("post_scale_low", 1.0))
|
| 994 |
+
high = rows["first5_high"].to_numpy(dtype="float64") + (high_scale * np.maximum(0.0, pred[:, 0]))
|
| 995 |
+
low = rows["first5_low"].to_numpy(dtype="float64") - (low_scale * np.maximum(0.0, pred[:, 1]))
|
| 996 |
+
return cap_first5_extension(rows, high, low)
|
| 997 |
+
if spec.target_mode == "open10_delta":
|
| 998 |
+
pred = np.asarray(model.predict(rows[cols]), dtype="float64")
|
| 999 |
+
base = rows[f"{HDFC_OPENING_PREFIX}close"].to_numpy(dtype="float64")
|
| 1000 |
+
pred_high = base + pred[:, 0]
|
| 1001 |
+
pred_low = base + pred[:, 1]
|
| 1002 |
+
high = np.maximum(pred_high, pred_low)
|
| 1003 |
+
low = np.minimum(pred_high, pred_low)
|
| 1004 |
+
high = np.maximum(high, rows[f"{HDFC_OPENING_PREFIX}high"].to_numpy(dtype="float64"))
|
| 1005 |
+
low = np.minimum(low, rows[f"{HDFC_OPENING_PREFIX}low"].to_numpy(dtype="float64"))
|
| 1006 |
+
return cap_hdfc_open10_extension(rows, high, low)
|
| 1007 |
+
if spec.target_mode == "open10_bound_dist":
|
| 1008 |
+
pred = np.asarray(model.predict(rows[cols]), dtype="float64")
|
| 1009 |
+
high = rows[f"{HDFC_OPENING_PREFIX}high"].to_numpy(dtype="float64") + np.maximum(0.0, pred[:, 0])
|
| 1010 |
+
low = rows[f"{HDFC_OPENING_PREFIX}low"].to_numpy(dtype="float64") - np.maximum(0.0, pred[:, 1])
|
| 1011 |
+
return cap_hdfc_open10_extension(rows, high, low)
|
| 1012 |
+
if spec.target_mode == "open10_bound":
|
| 1013 |
+
if f"{HDFC_OPENING_PREFIX}high" in rows.columns:
|
| 1014 |
+
opening_high = pd.to_numeric(rows[f"{HDFC_OPENING_PREFIX}high"], errors="coerce").to_numpy(dtype="float64")
|
| 1015 |
+
else:
|
| 1016 |
+
opening_high = np.full(len(rows), np.nan, dtype="float64")
|
| 1017 |
+
if f"{HDFC_OPENING_PREFIX}low" in rows.columns:
|
| 1018 |
+
opening_low = pd.to_numeric(rows[f"{HDFC_OPENING_PREFIX}low"], errors="coerce").to_numpy(dtype="float64")
|
| 1019 |
+
else:
|
| 1020 |
+
opening_low = np.full(len(rows), np.nan, dtype="float64")
|
| 1021 |
+
first5_high = rows["first5_high"].to_numpy(dtype="float64")
|
| 1022 |
+
first5_low = rows["first5_low"].to_numpy(dtype="float64")
|
| 1023 |
+
high = np.where(np.isfinite(opening_high), opening_high, first5_high)
|
| 1024 |
+
low = np.where(np.isfinite(opening_low), opening_low, first5_low)
|
| 1025 |
+
high = np.maximum(high, first5_high)
|
| 1026 |
+
low = np.minimum(low, first5_low)
|
| 1027 |
+
return cap_hdfc_open10_extension(rows, high, low)
|
| 1028 |
+
raise ValueError(f"Unknown target_mode: {spec.target_mode}")
|
| 1029 |
+
|
| 1030 |
+
|
| 1031 |
+
def train_spec(spec: ModelSpec, train_rows: pd.DataFrame, cols: list[str]) -> Any:
|
| 1032 |
+
if spec.kind == "baseline" or spec.target_mode == "open10_bound":
|
| 1033 |
+
return None
|
| 1034 |
+
model = build_model(spec)
|
| 1035 |
+
fit_kwargs: dict[str, Any] = {}
|
| 1036 |
+
if spec.kind == "ridge" and "sample_half_life" in spec.params:
|
| 1037 |
+
half_life = float(spec.params["sample_half_life"])
|
| 1038 |
+
age = np.arange(len(train_rows) - 1, -1, -1, dtype="float64")
|
| 1039 |
+
fit_kwargs["ridge__sample_weight"] = np.exp(-np.log(2.0) * age / half_life)
|
| 1040 |
+
model.fit(train_rows[cols], target_matrix(spec, train_rows), **fit_kwargs)
|
| 1041 |
+
return model
|
| 1042 |
+
|
| 1043 |
+
|
| 1044 |
+
def spec_uses_hdfc_open10(spec: ModelSpec) -> bool:
|
| 1045 |
+
if spec.target_mode in {"open10_bound", "open10_delta", "open10_bound_dist"}:
|
| 1046 |
+
return True
|
| 1047 |
+
members: list[str] = []
|
| 1048 |
+
if isinstance(spec.params.get("members"), list):
|
| 1049 |
+
members.extend(spec.params["members"])
|
| 1050 |
+
if isinstance(spec.params.get("high_members"), list):
|
| 1051 |
+
members.extend(spec.params["high_members"])
|
| 1052 |
+
if isinstance(spec.params.get("low_members"), list):
|
| 1053 |
+
members.extend(spec.params["low_members"])
|
| 1054 |
+
return any(name.startswith("hdfc_open10_") for name in members)
|
| 1055 |
+
|
| 1056 |
+
|
| 1057 |
+
def avg_high_low_rmse(rows: pd.DataFrame, pred_high: np.ndarray, pred_low: np.ndarray) -> tuple[float, float, float]:
|
| 1058 |
+
high_err = rows["target_high"].to_numpy(dtype="float64") - np.asarray(pred_high, dtype="float64")
|
| 1059 |
+
low_err = rows["target_low"].to_numpy(dtype="float64") - np.asarray(pred_low, dtype="float64")
|
| 1060 |
+
high_rmse = float(np.sqrt(np.mean(high_err * high_err)))
|
| 1061 |
+
low_rmse = float(np.sqrt(np.mean(low_err * low_err)))
|
| 1062 |
+
return (high_rmse + low_rmse) / 2.0, high_rmse, low_rmse
|
| 1063 |
+
|
| 1064 |
+
|
| 1065 |
+
def avg_high_low_mae(rows: pd.DataFrame, pred_high: np.ndarray, pred_low: np.ndarray) -> tuple[float, float, float]:
|
| 1066 |
+
high_mae = float(mean_absolute_error(rows["target_high"], pred_high))
|
| 1067 |
+
low_mae = float(mean_absolute_error(rows["target_low"], pred_low))
|
| 1068 |
+
return (high_mae + low_mae) / 2.0, high_mae, low_mae
|
| 1069 |
+
|
| 1070 |
+
|
| 1071 |
+
def stable_fold_rows(valid: pd.DataFrame, fold_count: int = STABLE_FOLD_COUNT) -> list[pd.DataFrame]:
|
| 1072 |
+
if valid.empty:
|
| 1073 |
+
return []
|
| 1074 |
+
fold_size = max(1, len(valid) // fold_count)
|
| 1075 |
+
folds: list[pd.DataFrame] = []
|
| 1076 |
+
start = max(0, len(valid) - (fold_size * fold_count))
|
| 1077 |
+
for i in range(start, len(valid), fold_size):
|
| 1078 |
+
fold = valid.iloc[i:i + fold_size].copy().reset_index(drop=True)
|
| 1079 |
+
if not fold.empty:
|
| 1080 |
+
folds.append(fold)
|
| 1081 |
+
return folds[-fold_count:]
|
| 1082 |
+
|
| 1083 |
+
|
| 1084 |
+
def evaluate_symbol(
|
| 1085 |
+
frame: pd.DataFrame,
|
| 1086 |
+
test_size: int,
|
| 1087 |
+
valid_size: int,
|
| 1088 |
+
seed: int,
|
| 1089 |
+
selection_mode: str,
|
| 1090 |
+
) -> tuple[SymbolResult, pd.DataFrame, pd.DataFrame, dict[str, Any]]:
|
| 1091 |
+
symbol = str(frame["symbol"].iloc[0])
|
| 1092 |
+
labeled = frame.dropna(
|
| 1093 |
+
subset=[
|
| 1094 |
+
"target_date",
|
| 1095 |
+
"target_high",
|
| 1096 |
+
"target_low",
|
| 1097 |
+
"first5_open",
|
| 1098 |
+
"first5_high",
|
| 1099 |
+
"first5_low",
|
| 1100 |
+
"first5_close",
|
| 1101 |
+
"target_high_delta_from_first5_close",
|
| 1102 |
+
"target_low_delta_from_first5_close",
|
| 1103 |
+
]
|
| 1104 |
+
).reset_index(drop=True)
|
| 1105 |
+
required = test_size + valid_size + 100
|
| 1106 |
+
if len(labeled) < required:
|
| 1107 |
+
raise RuntimeError(f"{symbol}: need at least {required} labeled rows, found {len(labeled)}")
|
| 1108 |
+
|
| 1109 |
+
test = labeled.tail(test_size).copy().reset_index(drop=True)
|
| 1110 |
+
prior = labeled.iloc[:-test_size].copy().reset_index(drop=True)
|
| 1111 |
+
valid = prior.tail(valid_size).copy().reset_index(drop=True)
|
| 1112 |
+
train = prior.iloc[:-valid_size].copy().reset_index(drop=True)
|
| 1113 |
+
if not (train["date"].max() < valid["date"].min() < valid["date"].max() < test["date"].min()):
|
| 1114 |
+
raise RuntimeError(f"{symbol}: chronological split failed")
|
| 1115 |
+
|
| 1116 |
+
cols = feature_columns(labeled)
|
| 1117 |
+
if symbol == "HDFCBANK":
|
| 1118 |
+
cols = hdfc_fast_feature_columns(cols)
|
| 1119 |
+
final_train = pd.concat([train, valid], ignore_index=True)
|
| 1120 |
+
cols = [c for c in cols if pd.to_numeric(final_train[c], errors="coerce").notna().mean() >= 0.05]
|
| 1121 |
+
|
| 1122 |
+
has_hdfc_open10 = symbol == "HDFCBANK" and {f"{HDFC_OPENING_PREFIX}high", f"{HDFC_OPENING_PREFIX}low"}.issubset(labeled.columns)
|
| 1123 |
+
specs = hdfc_open10_candidate_specs(seed) if has_hdfc_open10 else candidate_specs(seed)
|
| 1124 |
+
stable_mode = bool(not has_hdfc_open10 and selection_mode == "validation_rmse_stable")
|
| 1125 |
+
fixed_symbol_mode = bool(not has_hdfc_open10 and selection_mode == "fixed_symbol_map_v1")
|
| 1126 |
+
if stable_mode:
|
| 1127 |
+
specs = [spec for spec in specs if spec.name in STABLE_CANDIDATE_NAMES]
|
| 1128 |
+
if fixed_symbol_mode:
|
| 1129 |
+
fixed_name = STABLE_SYMBOL_MODEL_MAP.get(symbol)
|
| 1130 |
+
if fixed_name is None:
|
| 1131 |
+
raise ValueError(f"No fixed symbol model configured for {symbol}")
|
| 1132 |
+
specs = [spec for spec in specs if spec.name == fixed_name]
|
| 1133 |
+
force_fixed_mode = bool(not has_hdfc_open10 and selection_mode.startswith("force_"))
|
| 1134 |
+
fixed_guard_mode = bool(
|
| 1135 |
+
not has_hdfc_open10
|
| 1136 |
+
and selection_mode not in {"validation_rmse", "validation_rmse_stable", "fixed_symbol_map_v1"}
|
| 1137 |
+
and not force_fixed_mode
|
| 1138 |
+
)
|
| 1139 |
+
if fixed_guard_mode:
|
| 1140 |
+
fixed_name = selection_mode.removeprefix("fixed_")
|
| 1141 |
+
guard_names = {
|
| 1142 |
+
fixed_name,
|
| 1143 |
+
"extra_trees_delta_d3",
|
| 1144 |
+
"bound_extra_trees_d5",
|
| 1145 |
+
"bound_random_forest_d3",
|
| 1146 |
+
}
|
| 1147 |
+
specs = [spec for spec in specs if spec.name in guard_names]
|
| 1148 |
+
note(f"{symbol}: checking fixed {fixed_name} against {len(specs) - 1} fast guard candidates")
|
| 1149 |
+
if symbol == "HDFCBANK" and {f"{HDFC_OPENING_PREFIX}high", f"{HDFC_OPENING_PREFIX}low"}.issubset(labeled.columns):
|
| 1150 |
+
note(f"{symbol}: using {len(specs)} fast first-{HDFC_OPENING_MINUTES}-minute candidates and {len(cols)} features")
|
| 1151 |
+
best: tuple[float, ModelSpec, Any] | None = None
|
| 1152 |
+
candidate_records: list[dict[str, Any]] = []
|
| 1153 |
+
candidate_rows = []
|
| 1154 |
+
recent_valid = valid.tail(min(RECENT_VALID_WINDOW, len(valid))).copy().reset_index(drop=True)
|
| 1155 |
+
stable_folds = stable_fold_rows(valid)
|
| 1156 |
+
for spec in specs:
|
| 1157 |
+
model = train_spec(spec, train, cols)
|
| 1158 |
+
if spec.target_mode == "baseline":
|
| 1159 |
+
valid_high, valid_low = baseline_high_low(spec, valid)
|
| 1160 |
+
recent_high, recent_low = baseline_high_low(spec, recent_valid)
|
| 1161 |
+
else:
|
| 1162 |
+
valid_high, valid_low = predict_spec_high_low(spec, model, valid, cols)
|
| 1163 |
+
recent_high, recent_low = predict_spec_high_low(spec, model, recent_valid, cols)
|
| 1164 |
+
valid_avg_rmse, valid_high_rmse, valid_low_rmse = avg_high_low_rmse(valid, valid_high, valid_low)
|
| 1165 |
+
valid_avg_mae, valid_high_mae, valid_low_mae = avg_high_low_mae(valid, valid_high, valid_low)
|
| 1166 |
+
recent_valid_avg_rmse, recent_valid_high_rmse, recent_valid_low_rmse = avg_high_low_rmse(
|
| 1167 |
+
recent_valid,
|
| 1168 |
+
recent_high,
|
| 1169 |
+
recent_low,
|
| 1170 |
+
)
|
| 1171 |
+
fold_scores: list[float] = []
|
| 1172 |
+
for fold in stable_folds:
|
| 1173 |
+
if spec.target_mode == "baseline":
|
| 1174 |
+
fold_high, fold_low = baseline_high_low(spec, fold)
|
| 1175 |
+
else:
|
| 1176 |
+
fold_high, fold_low = predict_spec_high_low(spec, model, fold, cols)
|
| 1177 |
+
fold_scores.append(avg_high_low_rmse(fold, fold_high, fold_low)[0])
|
| 1178 |
+
stable_fold_mean_rmse = float(np.mean(fold_scores)) if fold_scores else valid_avg_rmse
|
| 1179 |
+
stable_fold_std_rmse = float(np.std(fold_scores)) if fold_scores else 0.0
|
| 1180 |
+
if stable_mode:
|
| 1181 |
+
selection_score = stable_fold_mean_rmse + (STABLE_FOLD_STD_WEIGHT * stable_fold_std_rmse)
|
| 1182 |
+
else:
|
| 1183 |
+
selection_score = ((1.0 - RECENT_VALID_WEIGHT) * valid_avg_rmse) + (RECENT_VALID_WEIGHT * recent_valid_avg_rmse)
|
| 1184 |
+
candidate_records.append(
|
| 1185 |
+
{
|
| 1186 |
+
"spec": spec,
|
| 1187 |
+
"valid_high": valid_high,
|
| 1188 |
+
"valid_low": valid_low,
|
| 1189 |
+
"recent_high": recent_high,
|
| 1190 |
+
"recent_low": recent_low,
|
| 1191 |
+
"valid_avg_rmse": valid_avg_rmse,
|
| 1192 |
+
"valid_high_rmse": valid_high_rmse,
|
| 1193 |
+
"valid_low_rmse": valid_low_rmse,
|
| 1194 |
+
"recent_valid_avg_rmse": recent_valid_avg_rmse,
|
| 1195 |
+
"recent_valid_high_rmse": recent_valid_high_rmse,
|
| 1196 |
+
"recent_valid_low_rmse": recent_valid_low_rmse,
|
| 1197 |
+
"stable_fold_mean_rmse": stable_fold_mean_rmse,
|
| 1198 |
+
"stable_fold_std_rmse": stable_fold_std_rmse,
|
| 1199 |
+
"selection_score": selection_score,
|
| 1200 |
+
}
|
| 1201 |
+
)
|
| 1202 |
+
candidate_rows.append(
|
| 1203 |
+
{
|
| 1204 |
+
"symbol": symbol,
|
| 1205 |
+
"model_name": spec.name,
|
| 1206 |
+
"target_mode": spec.target_mode,
|
| 1207 |
+
"valid_avg_rmse": valid_avg_rmse,
|
| 1208 |
+
"valid_high_rmse": valid_high_rmse,
|
| 1209 |
+
"valid_low_rmse": valid_low_rmse,
|
| 1210 |
+
"recent_valid_avg_rmse": recent_valid_avg_rmse,
|
| 1211 |
+
"recent_valid_high_rmse": recent_valid_high_rmse,
|
| 1212 |
+
"recent_valid_low_rmse": recent_valid_low_rmse,
|
| 1213 |
+
"stable_fold_mean_rmse": stable_fold_mean_rmse,
|
| 1214 |
+
"stable_fold_std_rmse": stable_fold_std_rmse,
|
| 1215 |
+
"selection_score": selection_score,
|
| 1216 |
+
"valid_avg_mae": valid_avg_mae,
|
| 1217 |
+
"valid_high_mae": valid_high_mae,
|
| 1218 |
+
"valid_low_mae": valid_low_mae,
|
| 1219 |
+
}
|
| 1220 |
+
)
|
| 1221 |
+
if best is None or selection_score < best[0]:
|
| 1222 |
+
best = (selection_score, spec, model)
|
| 1223 |
+
if best is None:
|
| 1224 |
+
raise RuntimeError(f"{symbol}: no model candidate completed")
|
| 1225 |
+
|
| 1226 |
+
sorted_records = sorted(candidate_records, key=lambda r: float(r["valid_avg_rmse"]))
|
| 1227 |
+
if not has_hdfc_open10 and not fixed_guard_mode and not stable_mode:
|
| 1228 |
+
max_blend_members = 8
|
| 1229 |
+
for top_k in range(2, min(max_blend_members, len(sorted_records)) + 1):
|
| 1230 |
+
members = sorted_records[:top_k]
|
| 1231 |
+
valid_high = np.mean(np.vstack([r["valid_high"] for r in members]), axis=0)
|
| 1232 |
+
valid_low = np.mean(np.vstack([r["valid_low"] for r in members]), axis=0)
|
| 1233 |
+
valid_avg_rmse, valid_high_rmse, valid_low_rmse = avg_high_low_rmse(valid, valid_high, valid_low)
|
| 1234 |
+
valid_avg_mae, valid_high_mae, valid_low_mae = avg_high_low_mae(valid, valid_high, valid_low)
|
| 1235 |
+
recent_high = np.mean(np.vstack([r["recent_high"] for r in members]), axis=0)
|
| 1236 |
+
recent_low = np.mean(np.vstack([r["recent_low"] for r in members]), axis=0)
|
| 1237 |
+
recent_valid_avg_rmse, recent_valid_high_rmse, recent_valid_low_rmse = avg_high_low_rmse(
|
| 1238 |
+
recent_valid,
|
| 1239 |
+
recent_high,
|
| 1240 |
+
recent_low,
|
| 1241 |
+
)
|
| 1242 |
+
selection_score = ((1.0 - RECENT_VALID_WEIGHT) * valid_avg_rmse) + (RECENT_VALID_WEIGHT * recent_valid_avg_rmse)
|
| 1243 |
+
member_names = [r["spec"].name for r in members]
|
| 1244 |
+
blend_spec = ModelSpec(
|
| 1245 |
+
name=f"blend_top{top_k}_validation",
|
| 1246 |
+
kind="blend",
|
| 1247 |
+
params={"top_k": top_k, "members": member_names},
|
| 1248 |
+
target_mode="blend",
|
| 1249 |
+
)
|
| 1250 |
+
candidate_rows.append(
|
| 1251 |
+
{
|
| 1252 |
+
"symbol": symbol,
|
| 1253 |
+
"model_name": blend_spec.name,
|
| 1254 |
+
"target_mode": blend_spec.target_mode,
|
| 1255 |
+
"valid_avg_rmse": valid_avg_rmse,
|
| 1256 |
+
"valid_high_rmse": valid_high_rmse,
|
| 1257 |
+
"valid_low_rmse": valid_low_rmse,
|
| 1258 |
+
"recent_valid_avg_rmse": recent_valid_avg_rmse,
|
| 1259 |
+
"recent_valid_high_rmse": recent_valid_high_rmse,
|
| 1260 |
+
"recent_valid_low_rmse": recent_valid_low_rmse,
|
| 1261 |
+
"selection_score": selection_score,
|
| 1262 |
+
"valid_avg_mae": valid_avg_mae,
|
| 1263 |
+
"valid_high_mae": valid_high_mae,
|
| 1264 |
+
"valid_low_mae": valid_low_mae,
|
| 1265 |
+
}
|
| 1266 |
+
)
|
| 1267 |
+
if best is None or selection_score < best[0]:
|
| 1268 |
+
best = (selection_score, blend_spec, None)
|
| 1269 |
+
|
| 1270 |
+
by_high = sorted(candidate_records, key=lambda r: float(r["valid_high_rmse"]))
|
| 1271 |
+
by_low = sorted(candidate_records, key=lambda r: float(r["valid_low_rmse"]))
|
| 1272 |
+
max_leg_members = min(max_blend_members, len(candidate_records))
|
| 1273 |
+
for high_k in range(1, max_leg_members + 1):
|
| 1274 |
+
for low_k in range(1, max_leg_members + 1):
|
| 1275 |
+
high_members = by_high[:high_k]
|
| 1276 |
+
low_members = by_low[:low_k]
|
| 1277 |
+
valid_high = np.mean(np.vstack([r["valid_high"] for r in high_members]), axis=0)
|
| 1278 |
+
valid_low = np.mean(np.vstack([r["valid_low"] for r in low_members]), axis=0)
|
| 1279 |
+
valid_avg_rmse, valid_high_rmse, valid_low_rmse = avg_high_low_rmse(valid, valid_high, valid_low)
|
| 1280 |
+
valid_avg_mae, valid_high_mae, valid_low_mae = avg_high_low_mae(valid, valid_high, valid_low)
|
| 1281 |
+
recent_high = np.mean(np.vstack([r["recent_high"] for r in high_members]), axis=0)
|
| 1282 |
+
recent_low = np.mean(np.vstack([r["recent_low"] for r in low_members]), axis=0)
|
| 1283 |
+
recent_valid_avg_rmse, recent_valid_high_rmse, recent_valid_low_rmse = avg_high_low_rmse(
|
| 1284 |
+
recent_valid,
|
| 1285 |
+
recent_high,
|
| 1286 |
+
recent_low,
|
| 1287 |
+
)
|
| 1288 |
+
selection_score = ((1.0 - RECENT_VALID_WEIGHT) * valid_avg_rmse) + (RECENT_VALID_WEIGHT * recent_valid_avg_rmse)
|
| 1289 |
+
high_names = [r["spec"].name for r in high_members]
|
| 1290 |
+
low_names = [r["spec"].name for r in low_members]
|
| 1291 |
+
leg_spec = ModelSpec(
|
| 1292 |
+
name=f"leg_blend_h{high_k}_l{low_k}_validation",
|
| 1293 |
+
kind="leg_blend",
|
| 1294 |
+
params={"high_members": high_names, "low_members": low_names},
|
| 1295 |
+
target_mode="leg_blend",
|
| 1296 |
+
)
|
| 1297 |
+
candidate_rows.append(
|
| 1298 |
+
{
|
| 1299 |
+
"symbol": symbol,
|
| 1300 |
+
"model_name": leg_spec.name,
|
| 1301 |
+
"target_mode": leg_spec.target_mode,
|
| 1302 |
+
"valid_avg_rmse": valid_avg_rmse,
|
| 1303 |
+
"valid_high_rmse": valid_high_rmse,
|
| 1304 |
+
"valid_low_rmse": valid_low_rmse,
|
| 1305 |
+
"recent_valid_avg_rmse": recent_valid_avg_rmse,
|
| 1306 |
+
"recent_valid_high_rmse": recent_valid_high_rmse,
|
| 1307 |
+
"recent_valid_low_rmse": recent_valid_low_rmse,
|
| 1308 |
+
"selection_score": selection_score,
|
| 1309 |
+
"valid_avg_mae": valid_avg_mae,
|
| 1310 |
+
"valid_high_mae": valid_high_mae,
|
| 1311 |
+
"valid_low_mae": valid_low_mae,
|
| 1312 |
+
}
|
| 1313 |
+
)
|
| 1314 |
+
if best is None or selection_score < best[0]:
|
| 1315 |
+
best = (selection_score, leg_spec, None)
|
| 1316 |
+
|
| 1317 |
+
if has_hdfc_open10:
|
| 1318 |
+
fixed_name = "hdfc_open10_recency_ridge_delta_a10000_hl100"
|
| 1319 |
+
best_spec = next(spec for spec in specs if spec.name == fixed_name)
|
| 1320 |
+
fixed_row = next(row for row in candidate_rows if row["model_name"] == fixed_name)
|
| 1321 |
+
best_valid_rmse = float(fixed_row["valid_avg_rmse"])
|
| 1322 |
+
elif force_fixed_mode:
|
| 1323 |
+
fixed_name = selection_mode.removeprefix("force_")
|
| 1324 |
+
matches = [spec for spec in specs if spec.name == fixed_name]
|
| 1325 |
+
if not matches:
|
| 1326 |
+
raise ValueError(f"Unknown forced selection model: {fixed_name}")
|
| 1327 |
+
best_spec = matches[0]
|
| 1328 |
+
best_valid_rmse = float(next(row for row in candidate_rows if row["model_name"] == fixed_name)["valid_avg_rmse"])
|
| 1329 |
+
elif selection_mode in {"validation_rmse", "validation_rmse_stable"}:
|
| 1330 |
+
_, best_spec, _ = best
|
| 1331 |
+
elif fixed_symbol_mode:
|
| 1332 |
+
best_spec = specs[0]
|
| 1333 |
+
else:
|
| 1334 |
+
fixed_name = selection_mode.removeprefix("fixed_")
|
| 1335 |
+
matches = [spec for spec in specs if spec.name == fixed_name]
|
| 1336 |
+
if not matches:
|
| 1337 |
+
raise ValueError(f"Unknown fixed selection model: {fixed_name}")
|
| 1338 |
+
fixed_row = next(row for row in candidate_rows if row["model_name"] == fixed_name)
|
| 1339 |
+
fixed_valid_rmse = float(fixed_row["valid_avg_rmse"])
|
| 1340 |
+
if best is not None and fixed_valid_rmse > float(best[0]) * FIXED_SELECTION_MAX_VALID_RATIO:
|
| 1341 |
+
note(
|
| 1342 |
+
f"{symbol}: fixed {fixed_name} valid RMSE={fixed_valid_rmse:.4f} trails "
|
| 1343 |
+
f"{best[1].name} valid RMSE={best[0]:.4f}; using validation winner"
|
| 1344 |
+
)
|
| 1345 |
+
_, best_spec, _ = best
|
| 1346 |
+
else:
|
| 1347 |
+
best_spec = matches[0]
|
| 1348 |
+
best_valid_rmse = fixed_valid_rmse
|
| 1349 |
+
baseline_records = [r for r in candidate_records if r["spec"].target_mode == "baseline"]
|
| 1350 |
+
best_baseline_recent = min(baseline_records, key=lambda r: float(r["recent_valid_avg_rmse"])) if baseline_records else None
|
| 1351 |
+
if (
|
| 1352 |
+
selection_mode in {"validation_rmse", "validation_rmse_stable"}
|
| 1353 |
+
and best_baseline_recent is not None
|
| 1354 |
+
and best_spec.target_mode != "baseline"
|
| 1355 |
+
):
|
| 1356 |
+
chosen_recent = next((r for r in candidate_records if r["spec"].name == best_spec.name), None)
|
| 1357 |
+
if chosen_recent is not None and float(chosen_recent["recent_valid_avg_rmse"]) > float(best_baseline_recent["recent_valid_avg_rmse"]) * BASELINE_RECENT_GUARD_RATIO:
|
| 1358 |
+
note(
|
| 1359 |
+
f"{symbol}: switching from {best_spec.name} to recent baseline guard "
|
| 1360 |
+
f"{best_baseline_recent['spec'].name} because recent-valid RMSE "
|
| 1361 |
+
f"{chosen_recent['recent_valid_avg_rmse']:.4f} vs {best_baseline_recent['recent_valid_avg_rmse']:.4f}"
|
| 1362 |
+
)
|
| 1363 |
+
best_spec = best_baseline_recent["spec"]
|
| 1364 |
+
best_valid_rmse = float(next(row for row in candidate_rows if row["model_name"] == best_spec.name)["valid_avg_rmse"])
|
| 1365 |
+
best_valid_mae = float(next(row for row in candidate_rows if row["model_name"] == best_spec.name)["valid_avg_mae"])
|
| 1366 |
+
if best_spec.kind == "blend":
|
| 1367 |
+
member_names = best_spec.params["members"]
|
| 1368 |
+
member_specs = [next(spec for spec in specs if spec.name == name) for name in member_names]
|
| 1369 |
+
final_model = []
|
| 1370 |
+
test_high_parts = []
|
| 1371 |
+
test_low_parts = []
|
| 1372 |
+
for member_spec in member_specs:
|
| 1373 |
+
member_model = train_spec(member_spec, final_train, cols)
|
| 1374 |
+
member_high, member_low = predict_spec_high_low(member_spec, member_model, test, cols)
|
| 1375 |
+
final_model.append({"spec": asdict(member_spec), "model": member_model})
|
| 1376 |
+
test_high_parts.append(member_high)
|
| 1377 |
+
test_low_parts.append(member_low)
|
| 1378 |
+
pred_high = np.mean(np.vstack(test_high_parts), axis=0)
|
| 1379 |
+
pred_low = np.mean(np.vstack(test_low_parts), axis=0)
|
| 1380 |
+
elif best_spec.kind == "leg_blend":
|
| 1381 |
+
high_specs = [next(spec for spec in specs if spec.name == name) for name in best_spec.params["high_members"]]
|
| 1382 |
+
low_specs = [next(spec for spec in specs if spec.name == name) for name in best_spec.params["low_members"]]
|
| 1383 |
+
final_model = {"high_members": [], "low_members": []}
|
| 1384 |
+
test_high_parts = []
|
| 1385 |
+
test_low_parts = []
|
| 1386 |
+
for member_spec in high_specs:
|
| 1387 |
+
member_model = train_spec(member_spec, final_train, cols)
|
| 1388 |
+
member_high, _ = predict_spec_high_low(member_spec, member_model, test, cols)
|
| 1389 |
+
final_model["high_members"].append({"spec": asdict(member_spec), "model": member_model})
|
| 1390 |
+
test_high_parts.append(member_high)
|
| 1391 |
+
for member_spec in low_specs:
|
| 1392 |
+
member_model = train_spec(member_spec, final_train, cols)
|
| 1393 |
+
_, member_low = predict_spec_high_low(member_spec, member_model, test, cols)
|
| 1394 |
+
final_model["low_members"].append({"spec": asdict(member_spec), "model": member_model})
|
| 1395 |
+
test_low_parts.append(member_low)
|
| 1396 |
+
pred_high = np.mean(np.vstack(test_high_parts), axis=0)
|
| 1397 |
+
pred_low = np.mean(np.vstack(test_low_parts), axis=0)
|
| 1398 |
+
elif best_spec.target_mode == "baseline":
|
| 1399 |
+
final_model = None
|
| 1400 |
+
pred_high, pred_low = baseline_high_low(best_spec, test)
|
| 1401 |
+
else:
|
| 1402 |
+
final_model = train_spec(best_spec, final_train, cols)
|
| 1403 |
+
pred_high, pred_low = predict_spec_high_low(best_spec, final_model, test, cols)
|
| 1404 |
+
test_avg_rmse, test_high_rmse, test_low_rmse = avg_high_low_rmse(test, pred_high, pred_low)
|
| 1405 |
+
test_avg, test_high_mae, test_low_mae = avg_high_low_mae(test, pred_high, pred_low)
|
| 1406 |
+
close_base_high = test["first5_close"].to_numpy(dtype="float64")
|
| 1407 |
+
close_base_low = test["first5_close"].to_numpy(dtype="float64")
|
| 1408 |
+
close_avg_rmse, close_high_rmse, close_low_rmse = avg_high_low_rmse(test, close_base_high, close_base_low)
|
| 1409 |
+
close_avg, close_high_mae, close_low_mae = avg_high_low_mae(test, close_base_high, close_base_low)
|
| 1410 |
+
persist_avg_rmse, persist_high_rmse, persist_low_rmse = avg_high_low_rmse(
|
| 1411 |
+
test,
|
| 1412 |
+
test["high"].to_numpy(dtype="float64"),
|
| 1413 |
+
test["low"].to_numpy(dtype="float64"),
|
| 1414 |
+
)
|
| 1415 |
+
persist_avg, persist_high_mae, persist_low_mae = avg_high_low_mae(
|
| 1416 |
+
test,
|
| 1417 |
+
test["high"].to_numpy(dtype="float64"),
|
| 1418 |
+
test["low"].to_numpy(dtype="float64"),
|
| 1419 |
+
)
|
| 1420 |
+
first5_bound_avg_rmse, first5_bound_high_rmse, first5_bound_low_rmse = avg_high_low_rmse(
|
| 1421 |
+
test,
|
| 1422 |
+
test["first5_high"].to_numpy(dtype="float64"),
|
| 1423 |
+
test["first5_low"].to_numpy(dtype="float64"),
|
| 1424 |
+
)
|
| 1425 |
+
first5_bound_avg, first5_bound_high_mae, first5_bound_low_mae = avg_high_low_mae(
|
| 1426 |
+
test,
|
| 1427 |
+
test["first5_high"].to_numpy(dtype="float64"),
|
| 1428 |
+
test["first5_low"].to_numpy(dtype="float64"),
|
| 1429 |
+
)
|
| 1430 |
+
|
| 1431 |
+
preds = test[
|
| 1432 |
+
[
|
| 1433 |
+
"date",
|
| 1434 |
+
"target_date",
|
| 1435 |
+
"symbol",
|
| 1436 |
+
"close",
|
| 1437 |
+
"high",
|
| 1438 |
+
"low",
|
| 1439 |
+
"first5_open",
|
| 1440 |
+
"first5_high",
|
| 1441 |
+
"first5_low",
|
| 1442 |
+
"first5_close",
|
| 1443 |
+
"target_high",
|
| 1444 |
+
"target_low",
|
| 1445 |
+
]
|
| 1446 |
+
].copy()
|
| 1447 |
+
preds["pred_high"] = pred_high
|
| 1448 |
+
preds["pred_low"] = pred_low
|
| 1449 |
+
preds["sq_err_high"] = (preds["target_high"] - preds["pred_high"]) ** 2
|
| 1450 |
+
preds["sq_err_low"] = (preds["target_low"] - preds["pred_low"]) ** 2
|
| 1451 |
+
preds["abs_err_high"] = (preds["target_high"] - preds["pred_high"]).abs()
|
| 1452 |
+
preds["abs_err_low"] = (preds["target_low"] - preds["pred_low"]).abs()
|
| 1453 |
+
preds["first5_close_naive_sq_err_high"] = (preds["target_high"] - preds["first5_close"]) ** 2
|
| 1454 |
+
preds["first5_close_naive_sq_err_low"] = (preds["target_low"] - preds["first5_close"]) ** 2
|
| 1455 |
+
preds["first5_close_naive_abs_err_high"] = (preds["target_high"] - preds["first5_close"]).abs()
|
| 1456 |
+
preds["first5_close_naive_abs_err_low"] = (preds["target_low"] - preds["first5_close"]).abs()
|
| 1457 |
+
preds["first5_bound_naive_sq_err_high"] = (preds["target_high"] - preds["first5_high"]) ** 2
|
| 1458 |
+
preds["first5_bound_naive_sq_err_low"] = (preds["target_low"] - preds["first5_low"]) ** 2
|
| 1459 |
+
preds["first5_bound_naive_abs_err_high"] = (preds["target_high"] - preds["first5_high"]).abs()
|
| 1460 |
+
preds["first5_bound_naive_abs_err_low"] = (preds["target_low"] - preds["first5_low"]).abs()
|
| 1461 |
+
preds["persistence_naive_sq_err_high"] = (preds["target_high"] - preds["high"]) ** 2
|
| 1462 |
+
preds["persistence_naive_sq_err_low"] = (preds["target_low"] - preds["low"]) ** 2
|
| 1463 |
+
preds["persistence_naive_abs_err_high"] = (preds["target_high"] - preds["high"]).abs()
|
| 1464 |
+
preds["persistence_naive_abs_err_low"] = (preds["target_low"] - preds["low"]).abs()
|
| 1465 |
+
|
| 1466 |
+
latest_input = labeled.tail(1).copy().reset_index(drop=True)
|
| 1467 |
+
all_labeled = labeled.iloc[:-1].copy().reset_index(drop=True)
|
| 1468 |
+
if best_spec.kind == "blend":
|
| 1469 |
+
latest_model = []
|
| 1470 |
+
latest_high_parts = []
|
| 1471 |
+
latest_low_parts = []
|
| 1472 |
+
member_names = best_spec.params["members"]
|
| 1473 |
+
member_specs = [next(spec for spec in specs if spec.name == name) for name in member_names]
|
| 1474 |
+
for member_spec in member_specs:
|
| 1475 |
+
member_model = train_spec(member_spec, all_labeled, cols)
|
| 1476 |
+
member_high, member_low = predict_spec_high_low(member_spec, member_model, latest_input, cols)
|
| 1477 |
+
latest_model.append({"spec": asdict(member_spec), "model": member_model})
|
| 1478 |
+
latest_high_parts.append(member_high)
|
| 1479 |
+
latest_low_parts.append(member_low)
|
| 1480 |
+
latest_high = np.mean(np.vstack(latest_high_parts), axis=0)
|
| 1481 |
+
latest_low = np.mean(np.vstack(latest_low_parts), axis=0)
|
| 1482 |
+
elif best_spec.kind == "leg_blend":
|
| 1483 |
+
latest_model = {"high_members": [], "low_members": []}
|
| 1484 |
+
latest_high_parts = []
|
| 1485 |
+
latest_low_parts = []
|
| 1486 |
+
high_specs = [next(spec for spec in specs if spec.name == name) for name in best_spec.params["high_members"]]
|
| 1487 |
+
low_specs = [next(spec for spec in specs if spec.name == name) for name in best_spec.params["low_members"]]
|
| 1488 |
+
for member_spec in high_specs:
|
| 1489 |
+
member_model = train_spec(member_spec, all_labeled, cols)
|
| 1490 |
+
member_high, _ = predict_spec_high_low(member_spec, member_model, latest_input, cols)
|
| 1491 |
+
latest_model["high_members"].append({"spec": asdict(member_spec), "model": member_model})
|
| 1492 |
+
latest_high_parts.append(member_high)
|
| 1493 |
+
for member_spec in low_specs:
|
| 1494 |
+
member_model = train_spec(member_spec, all_labeled, cols)
|
| 1495 |
+
_, member_low = predict_spec_high_low(member_spec, member_model, latest_input, cols)
|
| 1496 |
+
latest_model["low_members"].append({"spec": asdict(member_spec), "model": member_model})
|
| 1497 |
+
latest_low_parts.append(member_low)
|
| 1498 |
+
latest_high = np.mean(np.vstack(latest_high_parts), axis=0)
|
| 1499 |
+
latest_low = np.mean(np.vstack(latest_low_parts), axis=0)
|
| 1500 |
+
elif best_spec.target_mode == "baseline":
|
| 1501 |
+
latest_model = None
|
| 1502 |
+
latest_high, latest_low = baseline_high_low(best_spec, latest_input)
|
| 1503 |
+
else:
|
| 1504 |
+
latest_model = train_spec(best_spec, all_labeled, cols)
|
| 1505 |
+
latest_high, latest_low = predict_spec_high_low(best_spec, latest_model, latest_input, cols)
|
| 1506 |
+
latest = pd.DataFrame(
|
| 1507 |
+
{
|
| 1508 |
+
"symbol": [symbol],
|
| 1509 |
+
"latest_input_date": [latest_input["date"].iloc[0]],
|
| 1510 |
+
"forecast_for": [
|
| 1511 |
+
f"{latest_input['target_date'].iloc[0].date().isoformat()} "
|
| 1512 |
+
f"after first {HDFC_OPENING_MINUTES} minutes"
|
| 1513 |
+
if symbol == "HDFCBANK" and spec_uses_hdfc_open10(best_spec)
|
| 1514 |
+
else f"{latest_input['target_date'].iloc[0].date().isoformat()} after first 5 minutes"
|
| 1515 |
+
],
|
| 1516 |
+
"pred_high": [float(latest_high[0])],
|
| 1517 |
+
"pred_low": [float(latest_low[0])],
|
| 1518 |
+
"model_name": [best_spec.name],
|
| 1519 |
+
}
|
| 1520 |
+
)
|
| 1521 |
+
|
| 1522 |
+
result = SymbolResult(
|
| 1523 |
+
symbol=symbol,
|
| 1524 |
+
model_name=best_spec.name,
|
| 1525 |
+
feature_count=len(cols),
|
| 1526 |
+
n_train=len(train),
|
| 1527 |
+
n_valid=len(valid),
|
| 1528 |
+
n_test=len(test),
|
| 1529 |
+
train_start=train["date"].min().date().isoformat(),
|
| 1530 |
+
train_end=train["date"].max().date().isoformat(),
|
| 1531 |
+
valid_start=valid["date"].min().date().isoformat(),
|
| 1532 |
+
valid_end=valid["date"].max().date().isoformat(),
|
| 1533 |
+
test_start=test["date"].min().date().isoformat(),
|
| 1534 |
+
test_end=test["date"].max().date().isoformat(),
|
| 1535 |
+
valid_avg_rmse=float(best_valid_rmse),
|
| 1536 |
+
test_avg_rmse=float(test_avg_rmse),
|
| 1537 |
+
test_high_rmse=float(test_high_rmse),
|
| 1538 |
+
test_low_rmse=float(test_low_rmse),
|
| 1539 |
+
valid_avg_mae=float(best_valid_mae),
|
| 1540 |
+
test_avg_mae=float(test_avg),
|
| 1541 |
+
test_high_mae=float(test_high_mae),
|
| 1542 |
+
test_low_mae=float(test_low_mae),
|
| 1543 |
+
close_naive_avg_rmse=float(close_avg_rmse),
|
| 1544 |
+
close_naive_high_rmse=float(close_high_rmse),
|
| 1545 |
+
close_naive_low_rmse=float(close_low_rmse),
|
| 1546 |
+
close_naive_avg_mae=float(close_avg),
|
| 1547 |
+
close_naive_high_mae=float(close_high_mae),
|
| 1548 |
+
close_naive_low_mae=float(close_low_mae),
|
| 1549 |
+
persistence_naive_avg_rmse=float(persist_avg_rmse),
|
| 1550 |
+
persistence_naive_high_rmse=float(persist_high_rmse),
|
| 1551 |
+
persistence_naive_low_rmse=float(persist_low_rmse),
|
| 1552 |
+
first5_bound_naive_avg_rmse=float(first5_bound_avg_rmse),
|
| 1553 |
+
first5_bound_naive_high_rmse=float(first5_bound_high_rmse),
|
| 1554 |
+
first5_bound_naive_low_rmse=float(first5_bound_low_rmse),
|
| 1555 |
+
persistence_naive_avg_mae=float(persist_avg),
|
| 1556 |
+
persistence_naive_high_mae=float(persist_high_mae),
|
| 1557 |
+
persistence_naive_low_mae=float(persist_low_mae),
|
| 1558 |
+
first5_bound_naive_avg_mae=float(first5_bound_avg),
|
| 1559 |
+
first5_bound_naive_high_mae=float(first5_bound_high_mae),
|
| 1560 |
+
first5_bound_naive_low_mae=float(first5_bound_low_mae),
|
| 1561 |
+
close_naive_rmse_improvement_rupees=float(close_avg_rmse - test_avg_rmse),
|
| 1562 |
+
persistence_naive_rmse_improvement_rupees=float(persist_avg_rmse - test_avg_rmse),
|
| 1563 |
+
first5_bound_naive_rmse_improvement_rupees=float(first5_bound_avg_rmse - test_avg_rmse),
|
| 1564 |
+
close_naive_improvement_rupees=float(close_avg - test_avg),
|
| 1565 |
+
persistence_naive_improvement_rupees=float(persist_avg - test_avg),
|
| 1566 |
+
first5_bound_naive_improvement_rupees=float(first5_bound_avg - test_avg),
|
| 1567 |
+
latest_input_date=latest_input["date"].iloc[0].date().isoformat(),
|
| 1568 |
+
target_date_latest_input=(
|
| 1569 |
+
f"{latest_input['target_date'].iloc[0].date().isoformat()} "
|
| 1570 |
+
f"after first {HDFC_OPENING_MINUTES} minutes"
|
| 1571 |
+
if symbol == "HDFCBANK" and spec_uses_hdfc_open10(best_spec)
|
| 1572 |
+
else f"{latest_input['target_date'].iloc[0].date().isoformat()} after first 5 minutes"
|
| 1573 |
+
),
|
| 1574 |
+
latest_pred_high=float(latest_high[0]),
|
| 1575 |
+
latest_pred_low=float(latest_low[0]),
|
| 1576 |
+
)
|
| 1577 |
+
|
| 1578 |
+
model_payload = {
|
| 1579 |
+
"symbol": symbol,
|
| 1580 |
+
"spec": asdict(best_spec),
|
| 1581 |
+
"feature_columns": cols,
|
| 1582 |
+
"model": latest_model,
|
| 1583 |
+
"result": asdict(result),
|
| 1584 |
+
}
|
| 1585 |
+
return result, preds, latest, {"model": model_payload, "candidates": candidate_rows}
|
| 1586 |
+
|
| 1587 |
+
|
| 1588 |
+
def build_report(results: list[SymbolResult], aggregate: dict[str, Any]) -> str:
|
| 1589 |
+
lines = [
|
| 1590 |
+
"# Stock T+1 High/Low Forecaster",
|
| 1591 |
+
"",
|
| 1592 |
+
"Target: next trading row high and low for the selected stock universe.",
|
| 1593 |
+
f"Forecast timestamp: most symbols forecast after the first 5 intraday minutes; HDFCBANK can optionally use the first {HDFC_OPENING_MINUTES} intraday minutes of the T+1 target day.",
|
| 1594 |
+
"",
|
| 1595 |
+
"Leakage controls:",
|
| 1596 |
+
"- Row date t forecasts target_date t+1 using prior-day daily/exogenous features plus configured opening-window target-day features.",
|
| 1597 |
+
"- Targets are exactly `high.shift(-1)` and `low.shift(-1)` inside each symbol.",
|
| 1598 |
+
"- Forecast-day calendar/session features are date-only and known before the opening window.",
|
| 1599 |
+
"- Target-day first5 features are built only from the first five 1m rows; no later bars, full-day close, or final high/low are used for standard symbols.",
|
| 1600 |
+
f"- HDFCBANK adds first-{HDFC_OPENING_MINUTES}-minute 1m features and candidates; no target-day data after that opening window is used.",
|
| 1601 |
+
"- External macro, institutional-flow, and options panels are merged as prior-day/as-of features only.",
|
| 1602 |
+
"- NSE corporate-announcement features count only events timestamped before the forecast cutoff.",
|
| 1603 |
+
"- When HDFCBANK is in the run, its fast path can use base daily/market/peer plus first-10-minute features; processed 5m and exogenous panels remain excluded from that final fit.",
|
| 1604 |
+
"- Predictions are clamped so forecast high is at least first5 high and forecast low is at most first5 low.",
|
| 1605 |
+
"- The final labeled row per symbol is used only for the latest opening-window forecast and is not used to train that latest forecast.",
|
| 1606 |
+
f"- The last {int(aggregate.get('test_rows_per_symbol_min', 0))} labeled rows per symbol are held out as test data.",
|
| 1607 |
+
"",
|
| 1608 |
+
"Aggregate test metrics:",
|
| 1609 |
+
f"- selection mode: {aggregate.get('selection_mode', 'n/a')}",
|
| 1610 |
+
f"- model average RMSE: {aggregate['test_avg_rmse']:.4f} rupees",
|
| 1611 |
+
f"- target RMSE: {aggregate['target_rmse']:.4f} rupees",
|
| 1612 |
+
f"- meets target RMSE: {aggregate['meets_target_rmse']}",
|
| 1613 |
+
f"- first5-close naive average RMSE: {aggregate['close_naive_avg_rmse']:.4f} rupees",
|
| 1614 |
+
f"- improvement over first5-close naive RMSE: {aggregate['close_naive_rmse_improvement_rupees']:.4f} rupees",
|
| 1615 |
+
f"- first5-bound naive average RMSE: {aggregate['first5_bound_naive_avg_rmse']:.4f} rupees",
|
| 1616 |
+
f"- improvement over first5-bound naive RMSE: {aggregate['first5_bound_naive_rmse_improvement_rupees']:.4f} rupees",
|
| 1617 |
+
f"- persistence naive average RMSE: {aggregate['persistence_naive_avg_rmse']:.4f} rupees",
|
| 1618 |
+
f"- improvement over persistence naive RMSE: {aggregate['persistence_naive_rmse_improvement_rupees']:.4f} rupees",
|
| 1619 |
+
f"- model average MAE: {aggregate['test_avg_mae']:.4f} rupees",
|
| 1620 |
+
f"- first5-close naive average MAE: {aggregate['close_naive_avg_mae']:.4f} rupees",
|
| 1621 |
+
f"- improvement over first5-close naive: {aggregate['close_naive_improvement_rupees']:.4f} rupees",
|
| 1622 |
+
f"- persistence naive average MAE: {aggregate['persistence_naive_avg_mae']:.4f} rupees",
|
| 1623 |
+
f"- improvement over persistence naive: {aggregate['persistence_naive_improvement_rupees']:.4f} rupees",
|
| 1624 |
+
f"- meets Rs {aggregate['required_improvement_rupees']:.2f} aggregate close-naive improvement: {aggregate['meets_close_naive_rupee_target']}",
|
| 1625 |
+
"",
|
| 1626 |
+
"Per-symbol test metrics:",
|
| 1627 |
+
]
|
| 1628 |
+
for result in results:
|
| 1629 |
+
lines.extend(
|
| 1630 |
+
[
|
| 1631 |
+
f"## {result.symbol}",
|
| 1632 |
+
f"- model: {result.model_name}",
|
| 1633 |
+
f"- test rows: {result.n_test} ({result.test_start} to {result.test_end})",
|
| 1634 |
+
f"- model average RMSE: {result.test_avg_rmse:.4f}",
|
| 1635 |
+
f"- high RMSE / low RMSE: {result.test_high_rmse:.4f} / {result.test_low_rmse:.4f}",
|
| 1636 |
+
f"- first5-close naive RMSE improvement: {result.close_naive_rmse_improvement_rupees:.4f}",
|
| 1637 |
+
f"- first5-bound naive RMSE improvement: {result.first5_bound_naive_rmse_improvement_rupees:.4f}",
|
| 1638 |
+
f"- persistence naive RMSE improvement: {result.persistence_naive_rmse_improvement_rupees:.4f}",
|
| 1639 |
+
f"- model average MAE: {result.test_avg_mae:.4f}",
|
| 1640 |
+
f"- high MAE / low MAE: {result.test_high_mae:.4f} / {result.test_low_mae:.4f}",
|
| 1641 |
+
f"- close naive improvement: {result.close_naive_improvement_rupees:.4f}",
|
| 1642 |
+
f"- persistence naive improvement: {result.persistence_naive_improvement_rupees:.4f}",
|
| 1643 |
+
f"- latest forecast for {result.target_date_latest_input}: high {result.latest_pred_high:.2f}, low {result.latest_pred_low:.2f}",
|
| 1644 |
+
"",
|
| 1645 |
+
]
|
| 1646 |
+
)
|
| 1647 |
+
return "\n".join(lines).rstrip() + "\n"
|
| 1648 |
+
|
| 1649 |
+
|
| 1650 |
+
def write_outputs(
|
| 1651 |
+
frames: list[pd.DataFrame],
|
| 1652 |
+
results: list[SymbolResult],
|
| 1653 |
+
predictions: list[pd.DataFrame],
|
| 1654 |
+
latest: list[pd.DataFrame],
|
| 1655 |
+
model_payloads: list[dict[str, Any]],
|
| 1656 |
+
candidate_rows: list[dict[str, Any]],
|
| 1657 |
+
required_improvement: float,
|
| 1658 |
+
target_rmse: float,
|
| 1659 |
+
selection_mode: str,
|
| 1660 |
+
) -> None:
|
| 1661 |
+
dataset = pd.concat(frames, ignore_index=True)
|
| 1662 |
+
pred_df = pd.concat(predictions, ignore_index=True)
|
| 1663 |
+
latest_df = pd.concat(latest, ignore_index=True)
|
| 1664 |
+
results_df = pd.DataFrame([asdict(r) for r in results])
|
| 1665 |
+
candidates_df = pd.DataFrame(candidate_rows)
|
| 1666 |
+
|
| 1667 |
+
aggregate = {
|
| 1668 |
+
"symbols": [r.symbol for r in results],
|
| 1669 |
+
"target": f"T+1 high and low, computed as high.shift(-1) and low.shift(-1) per symbol. Standard symbols forecast after the first five 1m bars; HDFCBANK can forecast after the first {HDFC_OPENING_MINUTES} 1m bars of the T+1 day.",
|
| 1670 |
+
"test_rows_total": int(sum(r.n_test for r in results)),
|
| 1671 |
+
"test_rows_per_symbol_min": int(min(r.n_test for r in results)),
|
| 1672 |
+
"required_improvement_rupees": float(required_improvement),
|
| 1673 |
+
"target_rmse": float(target_rmse),
|
| 1674 |
+
"selection_mode": selection_mode,
|
| 1675 |
+
"test_avg_rmse": float(results_df["test_avg_rmse"].mean()),
|
| 1676 |
+
"close_naive_avg_rmse": float(results_df["close_naive_avg_rmse"].mean()),
|
| 1677 |
+
"persistence_naive_avg_rmse": float(results_df["persistence_naive_avg_rmse"].mean()),
|
| 1678 |
+
"first5_bound_naive_avg_rmse": float(results_df["first5_bound_naive_avg_rmse"].mean()),
|
| 1679 |
+
"close_naive_rmse_improvement_rupees": float(results_df["close_naive_rmse_improvement_rupees"].mean()),
|
| 1680 |
+
"persistence_naive_rmse_improvement_rupees": float(results_df["persistence_naive_rmse_improvement_rupees"].mean()),
|
| 1681 |
+
"first5_bound_naive_rmse_improvement_rupees": float(results_df["first5_bound_naive_rmse_improvement_rupees"].mean()),
|
| 1682 |
+
"test_avg_mae": float(results_df["test_avg_mae"].mean()),
|
| 1683 |
+
"close_naive_avg_mae": float(results_df["close_naive_avg_mae"].mean()),
|
| 1684 |
+
"persistence_naive_avg_mae": float(results_df["persistence_naive_avg_mae"].mean()),
|
| 1685 |
+
"first5_bound_naive_avg_mae": float(results_df["first5_bound_naive_avg_mae"].mean()),
|
| 1686 |
+
"close_naive_improvement_rupees": float(results_df["close_naive_improvement_rupees"].mean()),
|
| 1687 |
+
"persistence_naive_improvement_rupees": float(results_df["persistence_naive_improvement_rupees"].mean()),
|
| 1688 |
+
"first5_bound_naive_improvement_rupees": float(results_df["first5_bound_naive_improvement_rupees"].mean()),
|
| 1689 |
+
}
|
| 1690 |
+
aggregate["meets_close_naive_rupee_target"] = bool(
|
| 1691 |
+
aggregate["close_naive_improvement_rupees"] >= required_improvement
|
| 1692 |
+
and aggregate["test_rows_per_symbol_min"] >= 400
|
| 1693 |
+
)
|
| 1694 |
+
aggregate["meets_target_rmse"] = bool(
|
| 1695 |
+
aggregate["test_avg_rmse"] <= target_rmse
|
| 1696 |
+
and aggregate["test_rows_per_symbol_min"] >= 400
|
| 1697 |
+
)
|
| 1698 |
+
|
| 1699 |
+
dataset.to_csv(OUTPUT_DIR / "tplus1_high_low_dataset.csv", index=False)
|
| 1700 |
+
pred_df.to_csv(OUTPUT_DIR / "test_predictions.csv", index=False)
|
| 1701 |
+
latest_df.to_csv(OUTPUT_DIR / "latest_forecasts.csv", index=False)
|
| 1702 |
+
results_df.to_csv(OUTPUT_DIR / "metrics_by_symbol.csv", index=False)
|
| 1703 |
+
candidates_df.to_csv(OUTPUT_DIR / "candidate_validation_results.csv", index=False)
|
| 1704 |
+
(OUTPUT_DIR / "summary.json").write_text(
|
| 1705 |
+
json.dumps(
|
| 1706 |
+
{
|
| 1707 |
+
"aggregate": aggregate,
|
| 1708 |
+
"by_symbol": [asdict(r) for r in results],
|
| 1709 |
+
"leakage_note": f"Rows use previous trading day daily/exogenous features, date-only forecast-session features, NSE corporate announcements timestamped before the forecast cutoff, and opening target-day intraday features. Standard symbols use only the first five 1m bars. HDFCBANK may use only the first {HDFC_OPENING_MINUTES} 1m bars. Full-day T+1 close and final high/low are excluded from feature columns.",
|
| 1710 |
+
},
|
| 1711 |
+
indent=2,
|
| 1712 |
+
),
|
| 1713 |
+
encoding="utf-8",
|
| 1714 |
+
)
|
| 1715 |
+
(OUTPUT_DIR / "report.md").write_text(build_report(results, aggregate), encoding="utf-8")
|
| 1716 |
+
joblib.dump(
|
| 1717 |
+
{
|
| 1718 |
+
"models": model_payloads,
|
| 1719 |
+
"aggregate": aggregate,
|
| 1720 |
+
},
|
| 1721 |
+
OUTPUT_DIR / "stock_high_low_models.joblib",
|
| 1722 |
+
)
|
| 1723 |
+
|
| 1724 |
+
|
| 1725 |
+
def parse_args() -> argparse.Namespace:
|
| 1726 |
+
parser = argparse.ArgumentParser(description="Train T+1 high/low forecasters for selected NSE stocks.")
|
| 1727 |
+
parser.add_argument(
|
| 1728 |
+
"--symbols",
|
| 1729 |
+
default="",
|
| 1730 |
+
help="Comma-separated stock symbols to train. Default is all discovered non-market symbols after exclusions.",
|
| 1731 |
+
)
|
| 1732 |
+
parser.add_argument(
|
| 1733 |
+
"--exclude-symbols",
|
| 1734 |
+
default="",
|
| 1735 |
+
help="Comma-separated stock symbols to exclude from training and peer feature construction.",
|
| 1736 |
+
)
|
| 1737 |
+
parser.add_argument("--test-size", type=int, default=400, help="Held-out test rows per symbol.")
|
| 1738 |
+
parser.add_argument("--valid-size", type=int, default=400, help="Validation rows per symbol for model selection.")
|
| 1739 |
+
parser.add_argument("--min-rupee-improvement", type=float, default=2.0, help="Aggregate close-naive MAE improvement target.")
|
| 1740 |
+
parser.add_argument("--target-rmse", type=float, default=5.0, help="Aggregate T+1 high/low RMSE target.")
|
| 1741 |
+
parser.add_argument(
|
| 1742 |
+
"--selection-mode",
|
| 1743 |
+
choices=[
|
| 1744 |
+
"validation_rmse",
|
| 1745 |
+
"validation_rmse_stable",
|
| 1746 |
+
"fixed_symbol_map_v1",
|
| 1747 |
+
"fixed_extra_trees_delta_d3",
|
| 1748 |
+
"fixed_extra_trees_delta_d5",
|
| 1749 |
+
"fixed_extra_trees_delta_d7",
|
| 1750 |
+
"fixed_ridge_delta_a5000",
|
| 1751 |
+
"force_baseline_first5_bound",
|
| 1752 |
+
"force_baseline_first5_close",
|
| 1753 |
+
"force_baseline_persistence",
|
| 1754 |
+
],
|
| 1755 |
+
default="force_baseline_first5_bound",
|
| 1756 |
+
help="Select the lowest validation-RMSE candidate per symbol, use a guarded fixed model, or force a baseline-first low-RMSE path.",
|
| 1757 |
+
)
|
| 1758 |
+
parser.add_argument("--seed", type=int, default=RANDOM_SEED)
|
| 1759 |
+
return parser.parse_args()
|
| 1760 |
+
|
| 1761 |
+
|
| 1762 |
+
def main() -> None:
|
| 1763 |
+
args = parse_args()
|
| 1764 |
+
np.random.seed(args.seed)
|
| 1765 |
+
random.seed(args.seed)
|
| 1766 |
+
excluded = {s.strip().upper() for s in args.exclude_symbols.split(",") if s.strip()}
|
| 1767 |
+
available_symbols = discover_target_symbols(excluded_symbols=excluded)
|
| 1768 |
+
if args.symbols.strip():
|
| 1769 |
+
requested = [s.strip().upper() for s in args.symbols.split(",") if s.strip()]
|
| 1770 |
+
else:
|
| 1771 |
+
requested = list(available_symbols)
|
| 1772 |
+
unsupported = [s for s in requested if s not in available_symbols]
|
| 1773 |
+
if unsupported:
|
| 1774 |
+
raise ValueError(f"Unsupported symbols: {unsupported}. Supported: {list(available_symbols)}")
|
| 1775 |
+
selected_symbols = {symbol: available_symbols[symbol] for symbol in requested}
|
| 1776 |
+
note(f"selected stock universe: {', '.join(selected_symbols)}")
|
| 1777 |
+
if args.test_size < 400:
|
| 1778 |
+
raise ValueError("test-size must be at least 400 to satisfy the requested minimum test set.")
|
| 1779 |
+
if args.valid_size < 50:
|
| 1780 |
+
raise ValueError("valid-size should be at least 50 rows.")
|
| 1781 |
+
|
| 1782 |
+
note("building market features")
|
| 1783 |
+
market = build_market_features()
|
| 1784 |
+
note("building market first-5-minute features")
|
| 1785 |
+
market_first5 = build_market_first5_features(selected_symbols)
|
| 1786 |
+
note("building prior-day exogenous features")
|
| 1787 |
+
exogenous = build_prior_day_exogenous_features()
|
| 1788 |
+
note("building HDFCBANK opening-5m feature panel")
|
| 1789 |
+
opening_5m = build_opening_5m_panel(selected_symbols) if "HDFCBANK" in requested else None
|
| 1790 |
+
|
| 1791 |
+
frames: list[pd.DataFrame] = []
|
| 1792 |
+
results: list[SymbolResult] = []
|
| 1793 |
+
predictions: list[pd.DataFrame] = []
|
| 1794 |
+
latest_rows: list[pd.DataFrame] = []
|
| 1795 |
+
model_payloads: list[dict[str, Any]] = []
|
| 1796 |
+
all_candidate_rows: list[dict[str, Any]] = []
|
| 1797 |
+
|
| 1798 |
+
for symbol in requested:
|
| 1799 |
+
note(f"{symbol}: building T+1 frame")
|
| 1800 |
+
frame = build_symbol_frame(symbol, selected_symbols[symbol], market, market_first5, opening_5m, exogenous)
|
| 1801 |
+
result, preds, latest, payload = evaluate_symbol(
|
| 1802 |
+
frame,
|
| 1803 |
+
args.test_size,
|
| 1804 |
+
args.valid_size,
|
| 1805 |
+
args.seed,
|
| 1806 |
+
args.selection_mode,
|
| 1807 |
+
)
|
| 1808 |
+
note(
|
| 1809 |
+
f"{symbol}: {result.model_name}, test RMSE={result.test_avg_rmse:.4f}, "
|
| 1810 |
+
f"close-naive RMSE improvement={result.close_naive_rmse_improvement_rupees:.4f}"
|
| 1811 |
+
)
|
| 1812 |
+
frames.append(frame)
|
| 1813 |
+
results.append(result)
|
| 1814 |
+
predictions.append(preds)
|
| 1815 |
+
latest_rows.append(latest)
|
| 1816 |
+
model_payloads.append(payload["model"])
|
| 1817 |
+
all_candidate_rows.extend(payload["candidates"])
|
| 1818 |
+
|
| 1819 |
+
write_outputs(
|
| 1820 |
+
frames=frames,
|
| 1821 |
+
results=results,
|
| 1822 |
+
predictions=predictions,
|
| 1823 |
+
latest=latest_rows,
|
| 1824 |
+
model_payloads=model_payloads,
|
| 1825 |
+
candidate_rows=all_candidate_rows,
|
| 1826 |
+
required_improvement=args.min_rupee_improvement,
|
| 1827 |
+
target_rmse=args.target_rmse,
|
| 1828 |
+
selection_mode=args.selection_mode,
|
| 1829 |
+
)
|
| 1830 |
+
aggregate_improvement = float(np.mean([r.close_naive_improvement_rupees for r in results]))
|
| 1831 |
+
aggregate_rmse = float(np.mean([r.test_avg_rmse for r in results]))
|
| 1832 |
+
print(build_report(results, {
|
| 1833 |
+
"test_rows_per_symbol_min": int(min(r.n_test for r in results)),
|
| 1834 |
+
"test_avg_rmse": aggregate_rmse,
|
| 1835 |
+
"target_rmse": float(args.target_rmse),
|
| 1836 |
+
"meets_target_rmse": bool(aggregate_rmse <= args.target_rmse),
|
| 1837 |
+
"close_naive_avg_rmse": float(np.mean([r.close_naive_avg_rmse for r in results])),
|
| 1838 |
+
"close_naive_rmse_improvement_rupees": float(np.mean([r.close_naive_rmse_improvement_rupees for r in results])),
|
| 1839 |
+
"first5_bound_naive_avg_rmse": float(np.mean([r.first5_bound_naive_avg_rmse for r in results])),
|
| 1840 |
+
"first5_bound_naive_rmse_improvement_rupees": float(np.mean([r.first5_bound_naive_rmse_improvement_rupees for r in results])),
|
| 1841 |
+
"persistence_naive_avg_rmse": float(np.mean([r.persistence_naive_avg_rmse for r in results])),
|
| 1842 |
+
"persistence_naive_rmse_improvement_rupees": float(np.mean([r.persistence_naive_rmse_improvement_rupees for r in results])),
|
| 1843 |
+
"test_avg_mae": float(np.mean([r.test_avg_mae for r in results])),
|
| 1844 |
+
"close_naive_avg_mae": float(np.mean([r.close_naive_avg_mae for r in results])),
|
| 1845 |
+
"close_naive_improvement_rupees": aggregate_improvement,
|
| 1846 |
+
"first5_bound_naive_avg_mae": float(np.mean([r.first5_bound_naive_avg_mae for r in results])),
|
| 1847 |
+
"first5_bound_naive_improvement_rupees": float(np.mean([r.first5_bound_naive_improvement_rupees for r in results])),
|
| 1848 |
+
"persistence_naive_avg_mae": float(np.mean([r.persistence_naive_avg_mae for r in results])),
|
| 1849 |
+
"persistence_naive_improvement_rupees": float(np.mean([r.persistence_naive_improvement_rupees for r in results])),
|
| 1850 |
+
"required_improvement_rupees": float(args.min_rupee_improvement),
|
| 1851 |
+
"selection_mode": args.selection_mode,
|
| 1852 |
+
"meets_close_naive_rupee_target": bool(aggregate_improvement >= args.min_rupee_improvement),
|
| 1853 |
+
}), end="")
|
| 1854 |
+
note(f"wrote outputs to {OUTPUT_DIR}")
|
| 1855 |
+
|
| 1856 |
+
|
| 1857 |
+
if __name__ == "__main__":
|
| 1858 |
+
main()
|
backend/research_runtime/Code/scripts/data_ingestion/download_corporate_announcements.py
ADDED
|
@@ -0,0 +1,102 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from __future__ import annotations
|
| 2 |
+
|
| 3 |
+
import argparse
|
| 4 |
+
from datetime import datetime
|
| 5 |
+
from pathlib import Path
|
| 6 |
+
|
| 7 |
+
import pandas as pd
|
| 8 |
+
from nse import NSE
|
| 9 |
+
|
| 10 |
+
|
| 11 |
+
def find_project_root(start: Path) -> Path:
|
| 12 |
+
for path in (start, *start.parents):
|
| 13 |
+
if (path / "Data").is_dir() and (path / "Alt Data").is_dir():
|
| 14 |
+
return path
|
| 15 |
+
raise RuntimeError(f"Could not find project root from {start}")
|
| 16 |
+
|
| 17 |
+
|
| 18 |
+
PROJECT_ROOT = find_project_root(Path(__file__).resolve())
|
| 19 |
+
OUT_DIR = PROJECT_ROOT / "Alt Data" / "corporate"
|
| 20 |
+
RAW_DIR = OUT_DIR / "raw" / "announcements"
|
| 21 |
+
PROCESSED_DIR = OUT_DIR / "processed"
|
| 22 |
+
|
| 23 |
+
|
| 24 |
+
def normalize_events(rows: list[dict]) -> pd.DataFrame:
|
| 25 |
+
if not rows:
|
| 26 |
+
return pd.DataFrame()
|
| 27 |
+
df = pd.DataFrame(rows)
|
| 28 |
+
keep = [
|
| 29 |
+
"symbol",
|
| 30 |
+
"desc",
|
| 31 |
+
"attchmntText",
|
| 32 |
+
"sort_date",
|
| 33 |
+
"an_dt",
|
| 34 |
+
"exchdisstime",
|
| 35 |
+
"seq_id",
|
| 36 |
+
"attchmntFile",
|
| 37 |
+
"smIndustry",
|
| 38 |
+
"hasXbrl",
|
| 39 |
+
]
|
| 40 |
+
for col in keep:
|
| 41 |
+
if col not in df.columns:
|
| 42 |
+
df[col] = pd.NA
|
| 43 |
+
out = df[keep].copy()
|
| 44 |
+
out["symbol"] = out["symbol"].astype(str).str.upper().str.strip()
|
| 45 |
+
out["event_ts"] = pd.to_datetime(out["sort_date"], errors="coerce")
|
| 46 |
+
out["event_date"] = out["event_ts"].dt.normalize()
|
| 47 |
+
text = (
|
| 48 |
+
out["desc"].fillna("").astype(str)
|
| 49 |
+
+ " "
|
| 50 |
+
+ out["attchmntText"].fillna("").astype(str)
|
| 51 |
+
).str.lower()
|
| 52 |
+
out["is_results"] = text.str.contains("financial result|audited result|unaudited result|limited review|quarter", regex=True)
|
| 53 |
+
out["is_board_meeting"] = text.str.contains("board meeting|outcome of board|meeting of board", regex=True)
|
| 54 |
+
out["is_investor_meet"] = text.str.contains("investor|analyst|conference call|con\\. call", regex=True)
|
| 55 |
+
out["is_credit_rating"] = text.str.contains("credit rating|rating", regex=True)
|
| 56 |
+
out["is_press_release"] = text.str.contains("press release|media release", regex=True)
|
| 57 |
+
out["is_high_impact"] = (
|
| 58 |
+
out["is_results"]
|
| 59 |
+
| out["is_board_meeting"]
|
| 60 |
+
| out["is_credit_rating"]
|
| 61 |
+
| text.str.contains("dividend|fund raising|merger|acquisition|sale of|penalt|rbi|reserve bank", regex=True)
|
| 62 |
+
)
|
| 63 |
+
return out.dropna(subset=["event_ts"]).sort_values(["symbol", "event_ts", "seq_id"]).reset_index(drop=True)
|
| 64 |
+
|
| 65 |
+
|
| 66 |
+
def main() -> None:
|
| 67 |
+
parser = argparse.ArgumentParser(description="Download NSE corporate announcements for selected symbols.")
|
| 68 |
+
parser.add_argument("--symbols", required=True, help="Comma-separated NSE symbols.")
|
| 69 |
+
parser.add_argument("--from-date", default="2018-01-01")
|
| 70 |
+
parser.add_argument("--to-date", default=datetime.today().strftime("%Y-%m-%d"))
|
| 71 |
+
args = parser.parse_args()
|
| 72 |
+
|
| 73 |
+
symbols = [s.strip().upper() for s in args.symbols.split(",") if s.strip()]
|
| 74 |
+
from_dt = datetime.strptime(args.from_date, "%Y-%m-%d")
|
| 75 |
+
to_dt = datetime.strptime(args.to_date, "%Y-%m-%d")
|
| 76 |
+
RAW_DIR.mkdir(parents=True, exist_ok=True)
|
| 77 |
+
PROCESSED_DIR.mkdir(parents=True, exist_ok=True)
|
| 78 |
+
|
| 79 |
+
nse = NSE(PROJECT_ROOT / "Code" / "artifacts" / "nse_cache")
|
| 80 |
+
frames: list[pd.DataFrame] = []
|
| 81 |
+
try:
|
| 82 |
+
for symbol in symbols:
|
| 83 |
+
print(f"[corporate-announcements] fetching {symbol}", flush=True)
|
| 84 |
+
rows = nse.announcements(symbol=symbol, from_date=from_dt, to_date=to_dt)
|
| 85 |
+
raw = pd.DataFrame(rows)
|
| 86 |
+
raw.to_csv(RAW_DIR / f"{symbol.lower()}_announcements.csv", index=False)
|
| 87 |
+
norm = normalize_events(rows)
|
| 88 |
+
if not norm.empty:
|
| 89 |
+
frames.append(norm)
|
| 90 |
+
finally:
|
| 91 |
+
nse.exit()
|
| 92 |
+
|
| 93 |
+
if frames:
|
| 94 |
+
events = pd.concat(frames, ignore_index=True).sort_values(["symbol", "event_ts", "seq_id"])
|
| 95 |
+
else:
|
| 96 |
+
events = pd.DataFrame()
|
| 97 |
+
events.to_csv(PROCESSED_DIR / "corporate_announcements.csv", index=False)
|
| 98 |
+
print(f"[corporate-announcements] wrote {len(events)} events to {PROCESSED_DIR / 'corporate_announcements.csv'}")
|
| 99 |
+
|
| 100 |
+
|
| 101 |
+
if __name__ == "__main__":
|
| 102 |
+
main()
|
backend/research_runtime/Code/scripts/data_ingestion/download_index_options.py
ADDED
|
@@ -0,0 +1,297 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import argparse
|
| 2 |
+
import os
|
| 3 |
+
import time
|
| 4 |
+
import threading
|
| 5 |
+
from concurrent.futures import ThreadPoolExecutor, as_completed
|
| 6 |
+
from datetime import date, datetime, timedelta
|
| 7 |
+
from io import BytesIO
|
| 8 |
+
from pathlib import Path
|
| 9 |
+
from zipfile import BadZipFile, ZipFile
|
| 10 |
+
|
| 11 |
+
import pandas as pd
|
| 12 |
+
import requests
|
| 13 |
+
|
| 14 |
+
|
| 15 |
+
def find_project_root() -> Path:
|
| 16 |
+
env_root = os.environ.get("FORECASTING_PROJECT_ROOT")
|
| 17 |
+
if env_root:
|
| 18 |
+
return Path(env_root).expanduser().resolve()
|
| 19 |
+
for path in [Path(__file__).resolve(), *Path(__file__).resolve().parents]:
|
| 20 |
+
if (path / "Code").is_dir() and (path / "Data").is_dir() and (path / "Alt Data").is_dir():
|
| 21 |
+
return path
|
| 22 |
+
raise RuntimeError("Could not locate project root containing Code, Data, and Alt Data.")
|
| 23 |
+
|
| 24 |
+
|
| 25 |
+
PROJECT_ROOT = find_project_root()
|
| 26 |
+
OUT_DIR = PROJECT_ROOT / "Alt Data" / "options" / "raw"
|
| 27 |
+
RETRY_SEC = 1.0
|
| 28 |
+
MAX_RETRIES = 2
|
| 29 |
+
MAX_WORKERS = 8
|
| 30 |
+
UDIFF_START = date(2024, 7, 8)
|
| 31 |
+
|
| 32 |
+
TARGETS = [
|
| 33 |
+
{
|
| 34 |
+
"symbol": "NIFTY",
|
| 35 |
+
"label": "NIFTY 50",
|
| 36 |
+
"filename": "NIFTY_50_options.csv",
|
| 37 |
+
"legacy_instrument": "OPTIDX",
|
| 38 |
+
"udiff_instrument": "IDO",
|
| 39 |
+
},
|
| 40 |
+
{
|
| 41 |
+
"symbol": "BANKNIFTY",
|
| 42 |
+
"label": "NIFTY BANK",
|
| 43 |
+
"filename": "NIFTY_BANK_options.csv",
|
| 44 |
+
"legacy_instrument": "OPTIDX",
|
| 45 |
+
"udiff_instrument": "IDO",
|
| 46 |
+
},
|
| 47 |
+
{
|
| 48 |
+
"symbol": "HDFCBANK",
|
| 49 |
+
"label": "HDFC BANK",
|
| 50 |
+
"filename": "HDFCBANK_options.csv",
|
| 51 |
+
"legacy_instrument": "OPTSTK",
|
| 52 |
+
"udiff_instrument": "STO",
|
| 53 |
+
},
|
| 54 |
+
]
|
| 55 |
+
|
| 56 |
+
STANDARD_COLUMNS = [
|
| 57 |
+
"TRADE_DATE",
|
| 58 |
+
"SYMBOL",
|
| 59 |
+
"INSTRUMENT",
|
| 60 |
+
"EXPIRY_DT",
|
| 61 |
+
"STRIKE_PR",
|
| 62 |
+
"OPTION_TYP",
|
| 63 |
+
"OPEN",
|
| 64 |
+
"HIGH",
|
| 65 |
+
"LOW",
|
| 66 |
+
"CLOSE",
|
| 67 |
+
"SETTLE_PR",
|
| 68 |
+
"CONTRACTS",
|
| 69 |
+
"VAL_INLAKH",
|
| 70 |
+
"OPEN_INT",
|
| 71 |
+
"CHG_IN_OI",
|
| 72 |
+
"UNDERLYING",
|
| 73 |
+
]
|
| 74 |
+
|
| 75 |
+
THREAD_LOCAL = threading.local()
|
| 76 |
+
|
| 77 |
+
def build_url(d: date) -> str:
|
| 78 |
+
if d >= UDIFF_START:
|
| 79 |
+
return (
|
| 80 |
+
"https://archives.nseindia.com/content/fo/"
|
| 81 |
+
f"BhavCopy_NSE_FO_0_0_0_{d.strftime('%Y%m%d')}_F_0000.csv.zip"
|
| 82 |
+
)
|
| 83 |
+
|
| 84 |
+
mon = d.strftime("%b").upper()
|
| 85 |
+
day = d.strftime("%d%b%Y").upper()
|
| 86 |
+
return (
|
| 87 |
+
f"https://nsearchives.nseindia.com/content/historical/DERIVATIVES"
|
| 88 |
+
f"/{d.year}/{mon}/fo{day}bhav.csv.zip"
|
| 89 |
+
)
|
| 90 |
+
|
| 91 |
+
|
| 92 |
+
def normalize_legacy(df: pd.DataFrame, d: date) -> pd.DataFrame:
|
| 93 |
+
df.columns = df.columns.str.strip()
|
| 94 |
+
target_map = {target["symbol"]: target["legacy_instrument"] for target in TARGETS}
|
| 95 |
+
symbols = list(target_map)
|
| 96 |
+
symbol_series = df["SYMBOL"].astype(str).str.strip()
|
| 97 |
+
instrument_series = df["INSTRUMENT"].astype(str).str.strip()
|
| 98 |
+
mask = symbol_series.isin(symbols) & symbol_series.map(target_map).eq(instrument_series)
|
| 99 |
+
filtered = df.loc[mask].copy()
|
| 100 |
+
if filtered.empty:
|
| 101 |
+
return filtered
|
| 102 |
+
|
| 103 |
+
filtered["TRADE_DATE"] = d.isoformat()
|
| 104 |
+
filtered["SYMBOL"] = filtered["SYMBOL"].astype(str).str.strip()
|
| 105 |
+
filtered["UNDERLYING"] = pd.NA
|
| 106 |
+
return filtered[
|
| 107 |
+
[
|
| 108 |
+
"TRADE_DATE",
|
| 109 |
+
"SYMBOL",
|
| 110 |
+
"INSTRUMENT",
|
| 111 |
+
"EXPIRY_DT",
|
| 112 |
+
"STRIKE_PR",
|
| 113 |
+
"OPTION_TYP",
|
| 114 |
+
"OPEN",
|
| 115 |
+
"HIGH",
|
| 116 |
+
"LOW",
|
| 117 |
+
"CLOSE",
|
| 118 |
+
"SETTLE_PR",
|
| 119 |
+
"CONTRACTS",
|
| 120 |
+
"VAL_INLAKH",
|
| 121 |
+
"OPEN_INT",
|
| 122 |
+
"CHG_IN_OI",
|
| 123 |
+
"UNDERLYING",
|
| 124 |
+
]
|
| 125 |
+
]
|
| 126 |
+
|
| 127 |
+
|
| 128 |
+
def normalize_udiff(df: pd.DataFrame) -> pd.DataFrame:
|
| 129 |
+
target_map = {target["symbol"]: target["udiff_instrument"] for target in TARGETS}
|
| 130 |
+
symbol_series = df["TckrSymb"].astype(str).str.strip()
|
| 131 |
+
instrument_series = df["FinInstrmTp"].astype(str).str.strip()
|
| 132 |
+
mask = (
|
| 133 |
+
symbol_series.isin(list(target_map))
|
| 134 |
+
& instrument_series.eq(symbol_series.map(target_map))
|
| 135 |
+
& df["OptnTp"].astype(str).str.strip().isin(["CE", "PE"])
|
| 136 |
+
)
|
| 137 |
+
filtered = df.loc[mask].copy()
|
| 138 |
+
if filtered.empty:
|
| 139 |
+
return filtered
|
| 140 |
+
|
| 141 |
+
filtered = filtered.rename(
|
| 142 |
+
columns={
|
| 143 |
+
"TradDt": "TRADE_DATE",
|
| 144 |
+
"TckrSymb": "SYMBOL",
|
| 145 |
+
"XpryDt": "EXPIRY_DT",
|
| 146 |
+
"StrkPric": "STRIKE_PR",
|
| 147 |
+
"OptnTp": "OPTION_TYP",
|
| 148 |
+
"OpnPric": "OPEN",
|
| 149 |
+
"HghPric": "HIGH",
|
| 150 |
+
"LwPric": "LOW",
|
| 151 |
+
"ClsPric": "CLOSE",
|
| 152 |
+
"SttlmPric": "SETTLE_PR",
|
| 153 |
+
"TtlTradgVol": "CONTRACTS",
|
| 154 |
+
"TtlTrfVal": "VAL_INLAKH",
|
| 155 |
+
"OpnIntrst": "OPEN_INT",
|
| 156 |
+
"ChngInOpnIntrst": "CHG_IN_OI",
|
| 157 |
+
"UndrlygPric": "UNDERLYING",
|
| 158 |
+
}
|
| 159 |
+
)
|
| 160 |
+
filtered["TRADE_DATE"] = pd.to_datetime(filtered["TRADE_DATE"]).dt.strftime("%Y-%m-%d")
|
| 161 |
+
filtered["EXPIRY_DT"] = pd.to_datetime(filtered["EXPIRY_DT"]).dt.strftime("%d-%b-%Y")
|
| 162 |
+
filtered["SYMBOL"] = filtered["SYMBOL"].astype(str).str.strip()
|
| 163 |
+
filtered["INSTRUMENT"] = "OPTIDX"
|
| 164 |
+
filtered["VAL_INLAKH"] = filtered["VAL_INLAKH"] / 100000.0
|
| 165 |
+
return filtered[STANDARD_COLUMNS]
|
| 166 |
+
|
| 167 |
+
|
| 168 |
+
def get_session() -> requests.Session:
|
| 169 |
+
session = getattr(THREAD_LOCAL, "session", None)
|
| 170 |
+
if session is None:
|
| 171 |
+
session = requests.Session()
|
| 172 |
+
session.headers.update(
|
| 173 |
+
{
|
| 174 |
+
"User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64)",
|
| 175 |
+
"Accept-Encoding": "gzip, deflate",
|
| 176 |
+
"Accept": "*/*",
|
| 177 |
+
"Connection": "keep-alive",
|
| 178 |
+
}
|
| 179 |
+
)
|
| 180 |
+
THREAD_LOCAL.session = session
|
| 181 |
+
return session
|
| 182 |
+
|
| 183 |
+
|
| 184 |
+
def fetch_day(d: date) -> pd.DataFrame | None:
|
| 185 |
+
url = build_url(d)
|
| 186 |
+
session = get_session()
|
| 187 |
+
|
| 188 |
+
for attempt in range(1, MAX_RETRIES + 2):
|
| 189 |
+
try:
|
| 190 |
+
response = session.get(url, timeout=20)
|
| 191 |
+
if response.status_code != 200:
|
| 192 |
+
return None
|
| 193 |
+
|
| 194 |
+
with ZipFile(BytesIO(response.content)) as zipped:
|
| 195 |
+
csv_name = zipped.namelist()[0]
|
| 196 |
+
df = pd.read_csv(zipped.open(csv_name))
|
| 197 |
+
|
| 198 |
+
if d >= UDIFF_START:
|
| 199 |
+
filtered = normalize_udiff(df)
|
| 200 |
+
else:
|
| 201 |
+
filtered = normalize_legacy(df, d)
|
| 202 |
+
|
| 203 |
+
return filtered if not filtered.empty else None
|
| 204 |
+
except (requests.RequestException, BadZipFile, pd.errors.EmptyDataError, OSError):
|
| 205 |
+
if attempt > MAX_RETRIES:
|
| 206 |
+
return None
|
| 207 |
+
time.sleep(RETRY_SEC)
|
| 208 |
+
|
| 209 |
+
return None
|
| 210 |
+
|
| 211 |
+
|
| 212 |
+
def parse_args() -> argparse.Namespace:
|
| 213 |
+
parser = argparse.ArgumentParser(description="Download NSE index option bhavcopy history into Alt Data/options/raw.")
|
| 214 |
+
parser.add_argument("--start-date", default="2015-01-01", help="Inclusive start date in YYYY-MM-DD format.")
|
| 215 |
+
parser.add_argument("--end-date", default=date.today().isoformat(), help="Inclusive end date in YYYY-MM-DD format.")
|
| 216 |
+
return parser.parse_args()
|
| 217 |
+
|
| 218 |
+
|
| 219 |
+
def load_existing_rows(path: Path) -> pd.DataFrame:
|
| 220 |
+
if not path.exists():
|
| 221 |
+
return pd.DataFrame(columns=STANDARD_COLUMNS)
|
| 222 |
+
frame = pd.read_csv(path)
|
| 223 |
+
return frame if not frame.empty else pd.DataFrame(columns=STANDARD_COLUMNS)
|
| 224 |
+
|
| 225 |
+
|
| 226 |
+
def main() -> None:
|
| 227 |
+
args = parse_args()
|
| 228 |
+
start_date = datetime.strptime(args.start_date, "%Y-%m-%d").date()
|
| 229 |
+
end_date = datetime.strptime(args.end_date, "%Y-%m-%d").date()
|
| 230 |
+
OUT_DIR.mkdir(parents=True, exist_ok=True)
|
| 231 |
+
|
| 232 |
+
all_chunks = {target["symbol"]: [] for target in TARGETS}
|
| 233 |
+
trading_days = []
|
| 234 |
+
current = start_date
|
| 235 |
+
|
| 236 |
+
while current <= end_date:
|
| 237 |
+
if current.weekday() < 5:
|
| 238 |
+
trading_days.append(current)
|
| 239 |
+
current += timedelta(days=1)
|
| 240 |
+
|
| 241 |
+
print(f"Downloading NSE index options from {start_date} to {end_date}")
|
| 242 |
+
print(f"Saving files to {OUT_DIR}\n")
|
| 243 |
+
print(f"Using up to {MAX_WORKERS} parallel workers across {len(trading_days)} trading days\n")
|
| 244 |
+
|
| 245 |
+
completed = 0
|
| 246 |
+
with ThreadPoolExecutor(max_workers=MAX_WORKERS) as executor:
|
| 247 |
+
future_to_day = {executor.submit(fetch_day, day): day for day in trading_days}
|
| 248 |
+
|
| 249 |
+
for future in as_completed(future_to_day):
|
| 250 |
+
day = future_to_day[future]
|
| 251 |
+
chunk = future.result()
|
| 252 |
+
|
| 253 |
+
if chunk is None:
|
| 254 |
+
print(f" - {day} (no data / holiday)")
|
| 255 |
+
else:
|
| 256 |
+
counts = []
|
| 257 |
+
for target in TARGETS:
|
| 258 |
+
target_chunk = chunk.loc[chunk["SYMBOL"] == target["symbol"]].copy()
|
| 259 |
+
if not target_chunk.empty:
|
| 260 |
+
all_chunks[target["symbol"]].append(target_chunk)
|
| 261 |
+
counts.append(f"{target['symbol']}={len(target_chunk)}")
|
| 262 |
+
print(f" + {day} ({', '.join(counts)})")
|
| 263 |
+
|
| 264 |
+
completed += 1
|
| 265 |
+
if completed % 50 == 0:
|
| 266 |
+
pct = completed / len(trading_days) * 100
|
| 267 |
+
print(f"\n[{pct:.0f}%] processed {completed}/{len(trading_days)} trading days\n")
|
| 268 |
+
|
| 269 |
+
print()
|
| 270 |
+
for target in TARGETS:
|
| 271 |
+
symbol = target["symbol"]
|
| 272 |
+
output_path = OUT_DIR / target["filename"]
|
| 273 |
+
|
| 274 |
+
frames = [load_existing_rows(output_path)]
|
| 275 |
+
if all_chunks[symbol]:
|
| 276 |
+
frames.append(pd.concat(all_chunks[symbol], ignore_index=True))
|
| 277 |
+
|
| 278 |
+
result = pd.concat(frames, ignore_index=True)
|
| 279 |
+
if result.empty:
|
| 280 |
+
print(f"No rows collected for {target['label']} ({symbol}).")
|
| 281 |
+
continue
|
| 282 |
+
|
| 283 |
+
result["TRADE_DATE"] = pd.to_datetime(result["TRADE_DATE"])
|
| 284 |
+
result["EXPIRY_DT"] = pd.to_datetime(result["EXPIRY_DT"], format="%d-%b-%Y", errors="coerce")
|
| 285 |
+
result = result.drop_duplicates(
|
| 286 |
+
subset=["TRADE_DATE", "SYMBOL", "EXPIRY_DT", "STRIKE_PR", "OPTION_TYP"],
|
| 287 |
+
keep="last",
|
| 288 |
+
)
|
| 289 |
+
result = result.sort_values(by=["TRADE_DATE", "EXPIRY_DT", "STRIKE_PR", "OPTION_TYP"], kind="stable")
|
| 290 |
+
result["TRADE_DATE"] = result["TRADE_DATE"].dt.strftime("%Y-%m-%d")
|
| 291 |
+
result["EXPIRY_DT"] = result["EXPIRY_DT"].dt.strftime("%d-%b-%Y")
|
| 292 |
+
result.to_csv(output_path, index=False)
|
| 293 |
+
print(f"{target['label']}: {len(result):,} rows saved to {output_path}")
|
| 294 |
+
|
| 295 |
+
|
| 296 |
+
if __name__ == "__main__":
|
| 297 |
+
main()
|
backend/research_runtime/Code/scripts/data_ingestion/download_institutional_flows.py
ADDED
|
@@ -0,0 +1,344 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from __future__ import annotations
|
| 2 |
+
|
| 3 |
+
import argparse
|
| 4 |
+
import json
|
| 5 |
+
import os
|
| 6 |
+
import re
|
| 7 |
+
import time
|
| 8 |
+
from concurrent.futures import ThreadPoolExecutor, as_completed
|
| 9 |
+
from datetime import date, datetime, timedelta
|
| 10 |
+
from io import StringIO
|
| 11 |
+
from pathlib import Path
|
| 12 |
+
|
| 13 |
+
import pandas as pd
|
| 14 |
+
import requests
|
| 15 |
+
|
| 16 |
+
|
| 17 |
+
def find_project_root() -> Path:
|
| 18 |
+
env_root = os.environ.get("FORECASTING_PROJECT_ROOT")
|
| 19 |
+
if env_root:
|
| 20 |
+
return Path(env_root).expanduser().resolve()
|
| 21 |
+
for path in [Path(__file__).resolve(), *Path(__file__).resolve().parents]:
|
| 22 |
+
if (path / "Code").is_dir() and (path / "Data").is_dir() and (path / "Alt Data").is_dir():
|
| 23 |
+
return path
|
| 24 |
+
raise RuntimeError("Could not locate project root containing Code, Data, and Alt Data.")
|
| 25 |
+
|
| 26 |
+
|
| 27 |
+
PROJECT_ROOT = find_project_root()
|
| 28 |
+
ALT_ROOT = PROJECT_ROOT / "Alt Data"
|
| 29 |
+
INSTITUTIONAL_ROOT = ALT_ROOT / "institutional"
|
| 30 |
+
RAW_ROOT = INSTITUTIONAL_ROOT / "raw"
|
| 31 |
+
PROCESSED_ROOT = INSTITUTIONAL_ROOT / "processed"
|
| 32 |
+
|
| 33 |
+
NSE_BASE = "https://archives.nseindia.com/content/nsccl"
|
| 34 |
+
MONEYCONTROL_HEADERS = {
|
| 35 |
+
"User-Agent": "Mozilla/5.0",
|
| 36 |
+
"Accept": "text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8",
|
| 37 |
+
}
|
| 38 |
+
NSE_HEADERS = {
|
| 39 |
+
"User-Agent": "Mozilla/5.0",
|
| 40 |
+
"Accept": "text/csv,application/json,text/plain,*/*",
|
| 41 |
+
"Referer": "https://www.nseindia.com/",
|
| 42 |
+
}
|
| 43 |
+
|
| 44 |
+
PARTICIPANTS = ("FII", "DII")
|
| 45 |
+
MAX_WORKERS = 8
|
| 46 |
+
RETRY_SEC = 1.0
|
| 47 |
+
MAX_RETRIES = 2
|
| 48 |
+
|
| 49 |
+
MONEYCONTROL_PAGES = {
|
| 50 |
+
"cash": "https://www.moneycontrol.com/markets/fii-dii-data/cash/",
|
| 51 |
+
"futures_and_options": "https://www.moneycontrol.com/markets/fii-dii-data/futures-and-options/",
|
| 52 |
+
}
|
| 53 |
+
|
| 54 |
+
|
| 55 |
+
def ensure_dir(path: Path) -> None:
|
| 56 |
+
path.mkdir(parents=True, exist_ok=True)
|
| 57 |
+
|
| 58 |
+
|
| 59 |
+
def daterange(start_date: date, end_date: date) -> list[date]:
|
| 60 |
+
out: list[date] = []
|
| 61 |
+
current = start_date
|
| 62 |
+
while current <= end_date:
|
| 63 |
+
if current.weekday() < 5:
|
| 64 |
+
out.append(current)
|
| 65 |
+
current += timedelta(days=1)
|
| 66 |
+
return out
|
| 67 |
+
|
| 68 |
+
|
| 69 |
+
def clean_number(value: object) -> float:
|
| 70 |
+
if value is None or (isinstance(value, float) and pd.isna(value)):
|
| 71 |
+
return float("nan")
|
| 72 |
+
text = str(value).strip()
|
| 73 |
+
if not text or text in {"-", "--", "nan", "None"}:
|
| 74 |
+
return float("nan")
|
| 75 |
+
text = text.replace(",", "")
|
| 76 |
+
suffix = 1.0
|
| 77 |
+
upper = text.upper()
|
| 78 |
+
if upper.endswith("CR"):
|
| 79 |
+
text = text[:-2]
|
| 80 |
+
elif upper.endswith("L"):
|
| 81 |
+
suffix = 100000.0
|
| 82 |
+
text = text[:-1]
|
| 83 |
+
elif upper.endswith("K"):
|
| 84 |
+
suffix = 1000.0
|
| 85 |
+
text = text[:-1]
|
| 86 |
+
return float(text) * suffix
|
| 87 |
+
|
| 88 |
+
|
| 89 |
+
def clean_columns(columns: list[str]) -> list[str]:
|
| 90 |
+
out: list[str] = []
|
| 91 |
+
for col in columns:
|
| 92 |
+
text = str(col).strip().replace("\t", " ")
|
| 93 |
+
text = re.sub(r"\s+", " ", text)
|
| 94 |
+
text = text.lower().replace(" ", "_")
|
| 95 |
+
out.append(text)
|
| 96 |
+
return out
|
| 97 |
+
|
| 98 |
+
|
| 99 |
+
def build_nse_url(day: date, kind: str) -> str:
|
| 100 |
+
return f"{NSE_BASE}/fao_participant_{kind}_{day.strftime('%d%m%Y')}.csv"
|
| 101 |
+
|
| 102 |
+
|
| 103 |
+
def fetch_participant_file(day: date, kind: str) -> pd.DataFrame | None:
|
| 104 |
+
url = build_nse_url(day, kind)
|
| 105 |
+
|
| 106 |
+
for attempt in range(1, MAX_RETRIES + 2):
|
| 107 |
+
try:
|
| 108 |
+
response = requests.get(url, headers=NSE_HEADERS, timeout=20)
|
| 109 |
+
if response.status_code != 200:
|
| 110 |
+
return None
|
| 111 |
+
|
| 112 |
+
frame = pd.read_csv(StringIO(response.text), skiprows=1)
|
| 113 |
+
frame = frame.dropna(axis=1, how="all")
|
| 114 |
+
frame.columns = clean_columns(frame.columns.tolist())
|
| 115 |
+
if "client_type" not in frame.columns:
|
| 116 |
+
return None
|
| 117 |
+
|
| 118 |
+
frame["client_type"] = frame["client_type"].astype(str).str.strip().str.upper()
|
| 119 |
+
frame = frame[frame["client_type"].isin(PARTICIPANTS)].copy()
|
| 120 |
+
if frame.empty:
|
| 121 |
+
return None
|
| 122 |
+
|
| 123 |
+
for col in frame.columns:
|
| 124 |
+
if col != "client_type":
|
| 125 |
+
frame[col] = frame[col].map(clean_number)
|
| 126 |
+
|
| 127 |
+
frame["date"] = pd.Timestamp(day)
|
| 128 |
+
frame["source"] = "nse_archive"
|
| 129 |
+
frame["report_type"] = kind
|
| 130 |
+
return frame
|
| 131 |
+
except (requests.RequestException, pd.errors.ParserError, ValueError):
|
| 132 |
+
if attempt > MAX_RETRIES:
|
| 133 |
+
return None
|
| 134 |
+
time.sleep(RETRY_SEC)
|
| 135 |
+
|
| 136 |
+
return None
|
| 137 |
+
|
| 138 |
+
|
| 139 |
+
def download_participant_history(start_date: date, end_date: date) -> tuple[pd.DataFrame, pd.DataFrame]:
|
| 140 |
+
days = daterange(start_date, end_date)
|
| 141 |
+
|
| 142 |
+
output: dict[str, list[pd.DataFrame]] = {"oi": [], "vol": []}
|
| 143 |
+
tasks = []
|
| 144 |
+
with ThreadPoolExecutor(max_workers=MAX_WORKERS) as executor:
|
| 145 |
+
for day in days:
|
| 146 |
+
for kind in ("oi", "vol"):
|
| 147 |
+
tasks.append((day, kind, executor.submit(fetch_participant_file, day, kind)))
|
| 148 |
+
|
| 149 |
+
completed = 0
|
| 150 |
+
for day, kind, future in tasks:
|
| 151 |
+
frame = future.result()
|
| 152 |
+
completed += 1
|
| 153 |
+
if frame is not None:
|
| 154 |
+
output[kind].append(frame)
|
| 155 |
+
if completed % 250 == 0:
|
| 156 |
+
print(f"Fetched {completed}/{len(tasks)} participant files")
|
| 157 |
+
|
| 158 |
+
oi = pd.concat(output["oi"], ignore_index=True) if output["oi"] else pd.DataFrame()
|
| 159 |
+
vol = pd.concat(output["vol"], ignore_index=True) if output["vol"] else pd.DataFrame()
|
| 160 |
+
return oi, vol
|
| 161 |
+
|
| 162 |
+
|
| 163 |
+
def extract_next_data_json(html: str) -> dict:
|
| 164 |
+
match = re.search(r'<script id="__NEXT_DATA__" type="application/json">(.*?)</script>', html)
|
| 165 |
+
if not match:
|
| 166 |
+
raise ValueError("Could not locate __NEXT_DATA__ payload")
|
| 167 |
+
return json.loads(match.group(1))
|
| 168 |
+
|
| 169 |
+
|
| 170 |
+
def fetch_moneycontrol_page(kind: str) -> pd.DataFrame:
|
| 171 |
+
url = MONEYCONTROL_PAGES[kind]
|
| 172 |
+
response = requests.get(url, headers=MONEYCONTROL_HEADERS, timeout=30)
|
| 173 |
+
response.raise_for_status()
|
| 174 |
+
payload = extract_next_data_json(response.text)
|
| 175 |
+
rows = payload["props"]["pageProps"]["FiiDiiData"]["fiiDiiData"]
|
| 176 |
+
frame = pd.DataFrame(rows)
|
| 177 |
+
if frame.empty:
|
| 178 |
+
return frame
|
| 179 |
+
|
| 180 |
+
frame["date"] = pd.to_datetime(frame["date"])
|
| 181 |
+
frame = frame.sort_values("date").reset_index(drop=True)
|
| 182 |
+
frame["source"] = "moneycontrol_next_data"
|
| 183 |
+
frame["report_type"] = kind
|
| 184 |
+
|
| 185 |
+
for col in frame.columns:
|
| 186 |
+
if col in {"date", "source", "report_type", "fDate", "graphRecordDate", "graphToolTipRecordDate"}:
|
| 187 |
+
continue
|
| 188 |
+
frame[col] = frame[col].map(clean_number)
|
| 189 |
+
return frame
|
| 190 |
+
|
| 191 |
+
|
| 192 |
+
def reshape_participant_data(frame: pd.DataFrame, suffix: str) -> pd.DataFrame:
|
| 193 |
+
if frame.empty:
|
| 194 |
+
return pd.DataFrame()
|
| 195 |
+
|
| 196 |
+
useful_cols = [
|
| 197 |
+
"date",
|
| 198 |
+
"client_type",
|
| 199 |
+
"future_index_long",
|
| 200 |
+
"future_index_short",
|
| 201 |
+
"option_index_call_long",
|
| 202 |
+
"option_index_call_short",
|
| 203 |
+
"option_index_put_long",
|
| 204 |
+
"option_index_put_short",
|
| 205 |
+
]
|
| 206 |
+
base = frame[useful_cols].copy()
|
| 207 |
+
pivot = base.pivot(index="date", columns="client_type")
|
| 208 |
+
pivot.columns = [
|
| 209 |
+
f"{participant.lower()}_{metric}_{suffix}"
|
| 210 |
+
for metric, participant in pivot.columns.to_flat_index()
|
| 211 |
+
]
|
| 212 |
+
pivot = pivot.reset_index()
|
| 213 |
+
|
| 214 |
+
for participant in ("fii", "dii"):
|
| 215 |
+
fut_long = f"{participant}_future_index_long_{suffix}"
|
| 216 |
+
fut_short = f"{participant}_future_index_short_{suffix}"
|
| 217 |
+
call_long = f"{participant}_option_index_call_long_{suffix}"
|
| 218 |
+
call_short = f"{participant}_option_index_call_short_{suffix}"
|
| 219 |
+
put_long = f"{participant}_option_index_put_long_{suffix}"
|
| 220 |
+
put_short = f"{participant}_option_index_put_short_{suffix}"
|
| 221 |
+
|
| 222 |
+
if fut_long in pivot.columns and fut_short in pivot.columns:
|
| 223 |
+
total = pivot[fut_long] + pivot[fut_short]
|
| 224 |
+
pivot[f"{participant}_index_futures_net_{suffix}"] = pivot[fut_long] - pivot[fut_short]
|
| 225 |
+
pivot[f"{participant}_index_futures_long_share_{suffix}"] = pivot[fut_long] / total.replace(0, pd.NA)
|
| 226 |
+
|
| 227 |
+
if all(col in pivot.columns for col in [call_long, call_short, put_long, put_short]):
|
| 228 |
+
long_total = pivot[call_long] + pivot[put_long]
|
| 229 |
+
short_total = pivot[call_short] + pivot[put_short]
|
| 230 |
+
total = long_total + short_total
|
| 231 |
+
pivot[f"{participant}_index_options_net_{suffix}"] = long_total - short_total
|
| 232 |
+
pivot[f"{participant}_index_options_long_share_{suffix}"] = long_total / total.replace(0, pd.NA)
|
| 233 |
+
pivot[f"{participant}_index_options_call_net_{suffix}"] = pivot[call_long] - pivot[call_short]
|
| 234 |
+
pivot[f"{participant}_index_options_put_net_{suffix}"] = pivot[put_long] - pivot[put_short]
|
| 235 |
+
|
| 236 |
+
return pivot.sort_values("date").reset_index(drop=True)
|
| 237 |
+
|
| 238 |
+
|
| 239 |
+
def build_cash_panel(cash_frame: pd.DataFrame, fno_frame: pd.DataFrame) -> pd.DataFrame:
|
| 240 |
+
panel: pd.DataFrame | None = None
|
| 241 |
+
|
| 242 |
+
if not cash_frame.empty:
|
| 243 |
+
cash = cash_frame.rename(
|
| 244 |
+
columns={
|
| 245 |
+
"fiiPurchase": "fii_cash_buy",
|
| 246 |
+
"fiiSales": "fii_cash_sell",
|
| 247 |
+
"fiiNet": "fii_cash_net",
|
| 248 |
+
"diiPurchase": "dii_cash_buy",
|
| 249 |
+
"diiSale": "dii_cash_sell",
|
| 250 |
+
"diiNet": "dii_cash_net",
|
| 251 |
+
}
|
| 252 |
+
)
|
| 253 |
+
keep = [col for col in ["date", "fii_cash_buy", "fii_cash_sell", "fii_cash_net", "dii_cash_buy", "dii_cash_sell", "dii_cash_net"] if col in cash.columns]
|
| 254 |
+
panel = cash[keep].copy()
|
| 255 |
+
|
| 256 |
+
if not fno_frame.empty:
|
| 257 |
+
fno = fno_frame.rename(
|
| 258 |
+
columns={
|
| 259 |
+
"futPurchase": "fii_fno_futures_buy",
|
| 260 |
+
"futSales": "fii_fno_futures_sell",
|
| 261 |
+
"futNet": "fii_fno_futures_net",
|
| 262 |
+
"optPurchase": "fii_fno_options_buy",
|
| 263 |
+
"optSale": "fii_fno_options_sell",
|
| 264 |
+
"optNet": "fii_fno_options_net",
|
| 265 |
+
}
|
| 266 |
+
)
|
| 267 |
+
keep = [col for col in ["date", "fii_fno_futures_buy", "fii_fno_futures_sell", "fii_fno_futures_net", "fii_fno_options_buy", "fii_fno_options_sell", "fii_fno_options_net"] if col in fno.columns]
|
| 268 |
+
panel = fno[keep].copy() if panel is None else panel.merge(fno[keep], on="date", how="outer")
|
| 269 |
+
|
| 270 |
+
return panel.sort_values("date").reset_index(drop=True) if panel is not None else pd.DataFrame()
|
| 271 |
+
|
| 272 |
+
|
| 273 |
+
def build_processed_panel(cash: pd.DataFrame, fno: pd.DataFrame, oi: pd.DataFrame, vol: pd.DataFrame) -> pd.DataFrame:
|
| 274 |
+
panel = build_cash_panel(cash, fno)
|
| 275 |
+
oi_panel = reshape_participant_data(oi, "oi")
|
| 276 |
+
vol_panel = reshape_participant_data(vol, "volume")
|
| 277 |
+
|
| 278 |
+
if panel.empty:
|
| 279 |
+
panel = oi_panel
|
| 280 |
+
elif not oi_panel.empty:
|
| 281 |
+
panel = panel.merge(oi_panel, on="date", how="outer")
|
| 282 |
+
|
| 283 |
+
if panel.empty:
|
| 284 |
+
panel = vol_panel
|
| 285 |
+
elif not vol_panel.empty:
|
| 286 |
+
panel = panel.merge(vol_panel, on="date", how="outer")
|
| 287 |
+
|
| 288 |
+
if panel.empty:
|
| 289 |
+
return panel
|
| 290 |
+
|
| 291 |
+
return panel.sort_values("date").reset_index(drop=True)
|
| 292 |
+
|
| 293 |
+
|
| 294 |
+
def write_outputs(cash: pd.DataFrame, fno: pd.DataFrame, oi: pd.DataFrame, vol: pd.DataFrame, panel: pd.DataFrame) -> None:
|
| 295 |
+
ensure_dir(RAW_ROOT)
|
| 296 |
+
ensure_dir(PROCESSED_ROOT)
|
| 297 |
+
|
| 298 |
+
if not cash.empty:
|
| 299 |
+
ensure_dir(RAW_ROOT / "cash")
|
| 300 |
+
cash.to_csv(RAW_ROOT / "cash" / "cash_market_daily.csv", index=False)
|
| 301 |
+
if not fno.empty:
|
| 302 |
+
ensure_dir(RAW_ROOT / "fno")
|
| 303 |
+
fno.to_csv(RAW_ROOT / "fno" / "fii_fno_daily.csv", index=False)
|
| 304 |
+
if not oi.empty:
|
| 305 |
+
ensure_dir(RAW_ROOT / "participant_oi")
|
| 306 |
+
oi.to_csv(RAW_ROOT / "participant_oi" / "participant_oi_daily.csv", index=False)
|
| 307 |
+
if not vol.empty:
|
| 308 |
+
ensure_dir(RAW_ROOT / "participant_volume")
|
| 309 |
+
vol.to_csv(RAW_ROOT / "participant_volume" / "participant_volume_daily.csv", index=False)
|
| 310 |
+
if not panel.empty:
|
| 311 |
+
panel.to_csv(PROCESSED_ROOT / "institutional_daily_panel.csv", index=False)
|
| 312 |
+
|
| 313 |
+
|
| 314 |
+
def parse_args() -> argparse.Namespace:
|
| 315 |
+
parser = argparse.ArgumentParser(description="Download FII/DII cash flows and participant positioning into Alt Data.")
|
| 316 |
+
parser.add_argument("--start-date", default="2024-01-01", help="Inclusive start date in YYYY-MM-DD format for NSE participant archives.")
|
| 317 |
+
parser.add_argument("--end-date", default=date.today().isoformat(), help="Inclusive end date in YYYY-MM-DD format for NSE participant archives.")
|
| 318 |
+
return parser.parse_args()
|
| 319 |
+
|
| 320 |
+
|
| 321 |
+
def main() -> None:
|
| 322 |
+
args = parse_args()
|
| 323 |
+
start_date = datetime.strptime(args.start_date, "%Y-%m-%d").date()
|
| 324 |
+
end_date = datetime.strptime(args.end_date, "%Y-%m-%d").date()
|
| 325 |
+
|
| 326 |
+
print(f"Downloading institutional flow data from {start_date} to {end_date}")
|
| 327 |
+
|
| 328 |
+
cash = fetch_moneycontrol_page("cash")
|
| 329 |
+
fno = fetch_moneycontrol_page("futures_and_options")
|
| 330 |
+
oi, vol = download_participant_history(start_date, end_date)
|
| 331 |
+
panel = build_processed_panel(cash, fno, oi, vol)
|
| 332 |
+
|
| 333 |
+
write_outputs(cash, fno, oi, vol, panel)
|
| 334 |
+
|
| 335 |
+
print(f"Cash rows: {len(cash):,}")
|
| 336 |
+
print(f"F&O rows: {len(fno):,}")
|
| 337 |
+
print(f"Participant OI rows: {len(oi):,}")
|
| 338 |
+
print(f"Participant volume rows: {len(vol):,}")
|
| 339 |
+
print(f"Processed panel rows: {len(panel):,}")
|
| 340 |
+
print(f"Saved outputs under {INSTITUTIONAL_ROOT}")
|
| 341 |
+
|
| 342 |
+
|
| 343 |
+
if __name__ == "__main__":
|
| 344 |
+
main()
|
backend/research_runtime/Code/scripts/data_ingestion/refresh_market_data.py
ADDED
|
@@ -0,0 +1,147 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from __future__ import annotations
|
| 2 |
+
|
| 3 |
+
import argparse
|
| 4 |
+
import os
|
| 5 |
+
import subprocess
|
| 6 |
+
import sys
|
| 7 |
+
from datetime import date, datetime, timedelta
|
| 8 |
+
from pathlib import Path
|
| 9 |
+
|
| 10 |
+
import pandas as pd
|
| 11 |
+
|
| 12 |
+
|
| 13 |
+
def find_project_root() -> Path:
|
| 14 |
+
env_root = os.environ.get("FORECASTING_PROJECT_ROOT")
|
| 15 |
+
if env_root:
|
| 16 |
+
return Path(env_root).expanduser().resolve()
|
| 17 |
+
for path in [Path(__file__).resolve(), *Path(__file__).resolve().parents]:
|
| 18 |
+
if (path / "Code").is_dir() and (path / "Data").is_dir() and (path / "Alt Data").is_dir():
|
| 19 |
+
return path
|
| 20 |
+
raise RuntimeError("Could not locate project root containing Code, Data, and Alt Data.")
|
| 21 |
+
|
| 22 |
+
|
| 23 |
+
PROJECT_ROOT = find_project_root()
|
| 24 |
+
DATA_ROOT = PROJECT_ROOT / "Data"
|
| 25 |
+
ALT_ROOT = PROJECT_ROOT / "Alt Data"
|
| 26 |
+
CODE_ROOT = PROJECT_ROOT / "Code"
|
| 27 |
+
|
| 28 |
+
RAW_MINUTE_DIR = DATA_ROOT / "raw" / "minute"
|
| 29 |
+
RAW_OPTIONS_DIR = ALT_ROOT / "options" / "raw"
|
| 30 |
+
RAW_INSTITUTIONAL_DIR = ALT_ROOT / "institutional" / "raw"
|
| 31 |
+
|
| 32 |
+
|
| 33 |
+
def parse_args() -> argparse.Namespace:
|
| 34 |
+
parser = argparse.ArgumentParser(description="Refresh market and alt data to the latest Yahoo/NSE/Moneycontrol-available dates.")
|
| 35 |
+
parser.add_argument("--end-date", default=date.today().isoformat(), help="Inclusive end date in YYYY-MM-DD format.")
|
| 36 |
+
parser.add_argument(
|
| 37 |
+
"--minute-lookback-days",
|
| 38 |
+
type=int,
|
| 39 |
+
default=29,
|
| 40 |
+
help="Lookback window for Yahoo 1m updates. Yahoo minute history is limited, so this should stay near 29.",
|
| 41 |
+
)
|
| 42 |
+
parser.add_argument(
|
| 43 |
+
"--options-backfill-days",
|
| 44 |
+
type=int,
|
| 45 |
+
default=10,
|
| 46 |
+
help="Days of overlap to refetch for option bhavcopy data.",
|
| 47 |
+
)
|
| 48 |
+
parser.add_argument(
|
| 49 |
+
"--institutional-backfill-days",
|
| 50 |
+
type=int,
|
| 51 |
+
default=14,
|
| 52 |
+
help="Days of overlap to refetch for institutional archives.",
|
| 53 |
+
)
|
| 54 |
+
return parser.parse_args()
|
| 55 |
+
|
| 56 |
+
|
| 57 |
+
def latest_date_from_csv(path: Path, column: str) -> date | None:
|
| 58 |
+
if not path.exists():
|
| 59 |
+
return None
|
| 60 |
+
frame = pd.read_csv(path, usecols=[column])
|
| 61 |
+
series = pd.to_datetime(frame[column], errors="coerce").dropna()
|
| 62 |
+
if series.empty:
|
| 63 |
+
return None
|
| 64 |
+
return series.max().date()
|
| 65 |
+
|
| 66 |
+
|
| 67 |
+
def latest_date_from_tree(root: Path, filename: str, column: str) -> date | None:
|
| 68 |
+
candidates = list(root.rglob(filename))
|
| 69 |
+
latest: date | None = None
|
| 70 |
+
for path in candidates:
|
| 71 |
+
value = latest_date_from_csv(path, column)
|
| 72 |
+
if value is not None and (latest is None or value > latest):
|
| 73 |
+
latest = value
|
| 74 |
+
return latest
|
| 75 |
+
|
| 76 |
+
|
| 77 |
+
def run_script(script_path: Path, *args: str) -> None:
|
| 78 |
+
command = [sys.executable, str(script_path), *args]
|
| 79 |
+
print("Running:", " ".join(command))
|
| 80 |
+
subprocess.run(command, check=True, cwd=PROJECT_ROOT)
|
| 81 |
+
|
| 82 |
+
|
| 83 |
+
def main() -> None:
|
| 84 |
+
args = parse_args()
|
| 85 |
+
end_date = datetime.strptime(args.end_date, "%Y-%m-%d").date()
|
| 86 |
+
|
| 87 |
+
minute_latest_dates = []
|
| 88 |
+
for path in RAW_MINUTE_DIR.glob("*_minute.csv"):
|
| 89 |
+
latest = latest_date_from_csv(path, "date")
|
| 90 |
+
if latest is not None:
|
| 91 |
+
minute_latest_dates.append(latest)
|
| 92 |
+
|
| 93 |
+
yahoo_minute_floor = end_date - timedelta(days=args.minute_lookback_days)
|
| 94 |
+
minute_start = yahoo_minute_floor
|
| 95 |
+
if minute_latest_dates:
|
| 96 |
+
minute_start = max(yahoo_minute_floor, min(minute_latest_dates) - timedelta(days=1))
|
| 97 |
+
|
| 98 |
+
option_latest_dates = []
|
| 99 |
+
for path in RAW_OPTIONS_DIR.glob("*.csv"):
|
| 100 |
+
latest = latest_date_from_csv(path, "TRADE_DATE")
|
| 101 |
+
if latest is not None:
|
| 102 |
+
option_latest_dates.append(latest)
|
| 103 |
+
option_start = (
|
| 104 |
+
max(min(option_latest_dates) - timedelta(days=args.options_backfill_days), date(2015, 1, 1))
|
| 105 |
+
if option_latest_dates
|
| 106 |
+
else date(2015, 1, 1)
|
| 107 |
+
)
|
| 108 |
+
|
| 109 |
+
institutional_candidates = [
|
| 110 |
+
latest_date_from_tree(RAW_INSTITUTIONAL_DIR, "cash_market_daily.csv", "date"),
|
| 111 |
+
latest_date_from_tree(RAW_INSTITUTIONAL_DIR, "fii_fno_daily.csv", "date"),
|
| 112 |
+
latest_date_from_tree(RAW_INSTITUTIONAL_DIR, "participant_oi_daily.csv", "date"),
|
| 113 |
+
latest_date_from_tree(RAW_INSTITUTIONAL_DIR, "participant_volume_daily.csv", "date"),
|
| 114 |
+
]
|
| 115 |
+
institutional_latest = max((value for value in institutional_candidates if value is not None), default=date(2024, 1, 1))
|
| 116 |
+
institutional_start = max(institutional_latest - timedelta(days=args.institutional_backfill_days), date(2024, 1, 1))
|
| 117 |
+
|
| 118 |
+
print(f"Minute refresh window: {minute_start} -> {end_date}")
|
| 119 |
+
print(f"Options refresh window: {option_start} -> {end_date}")
|
| 120 |
+
print(f"Institutional refresh window: {institutional_start} -> {end_date}")
|
| 121 |
+
|
| 122 |
+
run_script(
|
| 123 |
+
CODE_ROOT / "scripts" / "data_ingestion" / "update_index_minute_data.py",
|
| 124 |
+
"--start-date",
|
| 125 |
+
minute_start.isoformat(),
|
| 126 |
+
"--end-date",
|
| 127 |
+
end_date.isoformat(),
|
| 128 |
+
)
|
| 129 |
+
run_script(
|
| 130 |
+
CODE_ROOT / "scripts" / "data_ingestion" / "download_index_options.py",
|
| 131 |
+
"--start-date",
|
| 132 |
+
option_start.isoformat(),
|
| 133 |
+
"--end-date",
|
| 134 |
+
end_date.isoformat(),
|
| 135 |
+
)
|
| 136 |
+
run_script(
|
| 137 |
+
CODE_ROOT / "scripts" / "data_ingestion" / "download_institutional_flows.py",
|
| 138 |
+
"--start-date",
|
| 139 |
+
institutional_start.isoformat(),
|
| 140 |
+
"--end-date",
|
| 141 |
+
end_date.isoformat(),
|
| 142 |
+
)
|
| 143 |
+
run_script(CODE_ROOT / "scripts" / "data_preparation" / "build_research_data.py")
|
| 144 |
+
|
| 145 |
+
|
| 146 |
+
if __name__ == "__main__":
|
| 147 |
+
main()
|
backend/research_runtime/Code/scripts/data_ingestion/update_index_minute_data.py
ADDED
|
@@ -0,0 +1,228 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from __future__ import annotations
|
| 2 |
+
|
| 3 |
+
import argparse
|
| 4 |
+
import concurrent.futures
|
| 5 |
+
import os
|
| 6 |
+
import time
|
| 7 |
+
from datetime import datetime, timedelta
|
| 8 |
+
from pathlib import Path
|
| 9 |
+
|
| 10 |
+
import pandas as pd
|
| 11 |
+
import requests
|
| 12 |
+
|
| 13 |
+
try:
|
| 14 |
+
import yfinance as yf
|
| 15 |
+
except ImportError: # pragma: no cover - optional dependency for batch fetches
|
| 16 |
+
yf = None
|
| 17 |
+
|
| 18 |
+
|
| 19 |
+
def find_project_root() -> Path:
|
| 20 |
+
env_root = os.environ.get("FORECASTING_PROJECT_ROOT")
|
| 21 |
+
if env_root:
|
| 22 |
+
return Path(env_root).expanduser().resolve()
|
| 23 |
+
for path in [Path(__file__).resolve(), *Path(__file__).resolve().parents]:
|
| 24 |
+
if (path / "Code").is_dir() and (path / "Data").is_dir() and (path / "Alt Data").is_dir():
|
| 25 |
+
return path
|
| 26 |
+
raise RuntimeError("Could not locate project root containing Code, Data, and Alt Data.")
|
| 27 |
+
|
| 28 |
+
|
| 29 |
+
PROJECT_ROOT = find_project_root()
|
| 30 |
+
RAW_MINUTE_DIR = PROJECT_ROOT / "Data" / "raw" / "minute"
|
| 31 |
+
REQUEST_HEADERS = {
|
| 32 |
+
"User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64)",
|
| 33 |
+
"Accept": "application/json,text/plain,*/*",
|
| 34 |
+
}
|
| 35 |
+
REQUEST_PAUSE_SEC = 0.35
|
| 36 |
+
MAX_RETRIES = 3
|
| 37 |
+
CHUNK_DAYS = 6
|
| 38 |
+
|
| 39 |
+
ASSETS = {
|
| 40 |
+
"BEL_minute.csv": "BEL.NS",
|
| 41 |
+
"CANBK_minute.csv": "CANBK.NS",
|
| 42 |
+
"ITC_minute.csv": "ITC.NS",
|
| 43 |
+
"NTPC_minute.csv": "NTPC.NS",
|
| 44 |
+
"ONGC_minute.csv": "ONGC.NS",
|
| 45 |
+
"POWERGRID_minute.csv": "POWERGRID.NS",
|
| 46 |
+
"SBIN_minute.csv": "SBIN.NS",
|
| 47 |
+
"TATASTEEL_minute.csv": "TATASTEEL.NS",
|
| 48 |
+
"NIFTY 50_minute.csv": "^NSEI",
|
| 49 |
+
"NIFTY BANK_minute.csv": "^NSEBANK",
|
| 50 |
+
"INDIA VIX_minute.csv": "^INDIAVIX",
|
| 51 |
+
"HDFCBANK_minute.csv": "HDFCBANK.NS",
|
| 52 |
+
"KOTAKBANK_minute.csv": "KOTAKBANK.NS",
|
| 53 |
+
"RELIANCE_minute.csv": "RELIANCE.NS",
|
| 54 |
+
"WIPRO_minute.csv": "WIPRO.NS",
|
| 55 |
+
}
|
| 56 |
+
|
| 57 |
+
|
| 58 |
+
def empty_ohlcv_frame() -> pd.DataFrame:
|
| 59 |
+
return pd.DataFrame(columns=["date", "open", "high", "low", "close", "volume"])
|
| 60 |
+
|
| 61 |
+
|
| 62 |
+
def parse_args() -> argparse.Namespace:
|
| 63 |
+
parser = argparse.ArgumentParser(description="Update raw minute index data from direct Yahoo chart endpoints.")
|
| 64 |
+
parser.add_argument("--start-date", required=True, help="Inclusive start date in YYYY-MM-DD format.")
|
| 65 |
+
parser.add_argument("--end-date", required=True, help="Inclusive end date in YYYY-MM-DD format.")
|
| 66 |
+
return parser.parse_args()
|
| 67 |
+
|
| 68 |
+
|
| 69 |
+
def build_chart_url(symbol: str, start_dt: datetime, end_dt_exclusive: datetime) -> str:
|
| 70 |
+
period1 = int(start_dt.timestamp())
|
| 71 |
+
period2 = int(end_dt_exclusive.timestamp())
|
| 72 |
+
return (
|
| 73 |
+
f"https://query1.finance.yahoo.com/v8/finance/chart/{requests.utils.quote(symbol, safe='')}"
|
| 74 |
+
f"?period1={period1}&period2={period2}&interval=1m&includePrePost=false&events=div%2Csplits"
|
| 75 |
+
)
|
| 76 |
+
|
| 77 |
+
|
| 78 |
+
def fetch_chart_frame(
|
| 79 |
+
symbol: str,
|
| 80 |
+
start_dt: datetime,
|
| 81 |
+
end_dt_exclusive: datetime,
|
| 82 |
+
session: requests.Session,
|
| 83 |
+
*,
|
| 84 |
+
timeout: float = 30,
|
| 85 |
+
max_retries: int = MAX_RETRIES,
|
| 86 |
+
retry_pause_base: float = 1.25,
|
| 87 |
+
) -> pd.DataFrame:
|
| 88 |
+
url = build_chart_url(symbol, start_dt, end_dt_exclusive)
|
| 89 |
+
last_error: Exception | None = None
|
| 90 |
+
for attempt in range(1, max_retries + 1):
|
| 91 |
+
try:
|
| 92 |
+
response = session.get(url, headers=REQUEST_HEADERS, timeout=timeout)
|
| 93 |
+
response.raise_for_status()
|
| 94 |
+
payload = response.json()
|
| 95 |
+
result = payload.get("chart", {}).get("result") or []
|
| 96 |
+
if not result:
|
| 97 |
+
return empty_ohlcv_frame()
|
| 98 |
+
|
| 99 |
+
result0 = result[0]
|
| 100 |
+
timestamps = result0.get("timestamp") or []
|
| 101 |
+
quote_sets = result0.get("indicators", {}).get("quote") or []
|
| 102 |
+
if not timestamps or not quote_sets:
|
| 103 |
+
return empty_ohlcv_frame()
|
| 104 |
+
|
| 105 |
+
quote = quote_sets[0]
|
| 106 |
+
frame = pd.DataFrame(
|
| 107 |
+
{
|
| 108 |
+
"date": pd.to_datetime(timestamps, unit="s", utc=True).tz_convert("Asia/Kolkata").tz_localize(None),
|
| 109 |
+
"open": quote.get("open", []),
|
| 110 |
+
"high": quote.get("high", []),
|
| 111 |
+
"low": quote.get("low", []),
|
| 112 |
+
"close": quote.get("close", []),
|
| 113 |
+
"volume": quote.get("volume", []),
|
| 114 |
+
}
|
| 115 |
+
)
|
| 116 |
+
for column in ["open", "high", "low", "close", "volume"]:
|
| 117 |
+
frame[column] = pd.to_numeric(frame[column], errors="coerce")
|
| 118 |
+
frame = frame.dropna(subset=["date", "open", "high", "low", "close"])
|
| 119 |
+
frame["volume"] = frame["volume"].fillna(0.0)
|
| 120 |
+
return frame.reset_index(drop=True)
|
| 121 |
+
except Exception as exc:
|
| 122 |
+
last_error = exc
|
| 123 |
+
if attempt == max_retries:
|
| 124 |
+
break
|
| 125 |
+
time.sleep(retry_pause_base * attempt)
|
| 126 |
+
raise RuntimeError(f"Failed to fetch {symbol} chunk {start_dt} to {end_dt_exclusive}: {last_error}") from last_error
|
| 127 |
+
|
| 128 |
+
|
| 129 |
+
def fetch_chunk(symbol: str, start_dt: datetime, end_dt_exclusive: datetime, session: requests.Session) -> pd.DataFrame:
|
| 130 |
+
return fetch_chart_frame(symbol, start_dt, end_dt_exclusive, session)
|
| 131 |
+
|
| 132 |
+
|
| 133 |
+
def fetch_many_chart_frames(
|
| 134 |
+
symbols: list[str],
|
| 135 |
+
start_dt: datetime,
|
| 136 |
+
end_dt_exclusive: datetime,
|
| 137 |
+
*,
|
| 138 |
+
timeout: float = 10,
|
| 139 |
+
) -> dict[str, pd.DataFrame]:
|
| 140 |
+
if not symbols:
|
| 141 |
+
return {}
|
| 142 |
+
output: dict[str, pd.DataFrame] = {symbol: empty_ohlcv_frame() for symbol in symbols}
|
| 143 |
+
|
| 144 |
+
def fetch_one(symbol: str) -> tuple[str, pd.DataFrame]:
|
| 145 |
+
session = requests.Session()
|
| 146 |
+
try:
|
| 147 |
+
frame = fetch_chart_frame(
|
| 148 |
+
symbol,
|
| 149 |
+
start_dt,
|
| 150 |
+
end_dt_exclusive,
|
| 151 |
+
session,
|
| 152 |
+
timeout=max(timeout, 8.0),
|
| 153 |
+
max_retries=2,
|
| 154 |
+
)
|
| 155 |
+
except Exception:
|
| 156 |
+
frame = empty_ohlcv_frame()
|
| 157 |
+
return symbol, frame
|
| 158 |
+
|
| 159 |
+
max_workers = min(len(symbols), 6)
|
| 160 |
+
if max_workers <= 1:
|
| 161 |
+
for symbol in symbols:
|
| 162 |
+
fetched_symbol, frame = fetch_one(symbol)
|
| 163 |
+
output[fetched_symbol] = frame
|
| 164 |
+
return output
|
| 165 |
+
|
| 166 |
+
with concurrent.futures.ThreadPoolExecutor(max_workers=max_workers) as executor:
|
| 167 |
+
futures = [executor.submit(fetch_one, symbol) for symbol in symbols]
|
| 168 |
+
for future in concurrent.futures.as_completed(futures):
|
| 169 |
+
fetched_symbol, frame = future.result()
|
| 170 |
+
output[fetched_symbol] = frame
|
| 171 |
+
return output
|
| 172 |
+
|
| 173 |
+
|
| 174 |
+
def iter_chunks(start_date: datetime, end_date: datetime) -> list[tuple[datetime, datetime]]:
|
| 175 |
+
chunks: list[tuple[datetime, datetime]] = []
|
| 176 |
+
current = start_date
|
| 177 |
+
end_exclusive = end_date + timedelta(days=1)
|
| 178 |
+
while current < end_exclusive:
|
| 179 |
+
chunk_end = min(current + timedelta(days=CHUNK_DAYS), end_exclusive)
|
| 180 |
+
chunks.append((current, chunk_end))
|
| 181 |
+
current = chunk_end
|
| 182 |
+
return chunks
|
| 183 |
+
|
| 184 |
+
|
| 185 |
+
def update_asset(raw_path: Path, symbol: str, start_date: datetime, end_date: datetime, session: requests.Session) -> tuple[int, pd.Timestamp | None]:
|
| 186 |
+
if raw_path.exists():
|
| 187 |
+
existing = pd.read_csv(raw_path, parse_dates=["date"])
|
| 188 |
+
existing = existing.sort_values("date").reset_index(drop=True)
|
| 189 |
+
else:
|
| 190 |
+
existing = pd.DataFrame(columns=["date", "open", "high", "low", "close", "volume"])
|
| 191 |
+
|
| 192 |
+
frames = [existing]
|
| 193 |
+
for chunk_start, chunk_end in iter_chunks(start_date, end_date):
|
| 194 |
+
chunk = fetch_chunk(symbol, chunk_start, chunk_end, session)
|
| 195 |
+
if not chunk.empty:
|
| 196 |
+
frames.append(chunk)
|
| 197 |
+
time.sleep(REQUEST_PAUSE_SEC)
|
| 198 |
+
|
| 199 |
+
updated = pd.concat(frames, ignore_index=True)
|
| 200 |
+
updated = updated.drop_duplicates(subset=["date"], keep="last").sort_values("date").reset_index(drop=True)
|
| 201 |
+
for column in ["open", "high", "low", "close", "volume"]:
|
| 202 |
+
updated[column] = pd.to_numeric(updated[column], errors="coerce")
|
| 203 |
+
updated = updated.dropna(subset=["date", "open", "high", "low", "close", "volume"])
|
| 204 |
+
updated["volume"] = updated["volume"].fillna(0.0)
|
| 205 |
+
raw_path.parent.mkdir(parents=True, exist_ok=True)
|
| 206 |
+
updated.to_csv(raw_path, index=False)
|
| 207 |
+
added_rows = len(updated) - len(existing)
|
| 208 |
+
last_timestamp = pd.Timestamp(updated["date"].max()) if not updated.empty else None
|
| 209 |
+
return added_rows, last_timestamp
|
| 210 |
+
|
| 211 |
+
|
| 212 |
+
def main() -> None:
|
| 213 |
+
args = parse_args()
|
| 214 |
+
start_date = datetime.strptime(args.start_date, "%Y-%m-%d")
|
| 215 |
+
end_date = datetime.strptime(args.end_date, "%Y-%m-%d")
|
| 216 |
+
|
| 217 |
+
session = requests.Session()
|
| 218 |
+
|
| 219 |
+
for filename, symbol in ASSETS.items():
|
| 220 |
+
raw_path = RAW_MINUTE_DIR / filename
|
| 221 |
+
if not raw_path.exists():
|
| 222 |
+
raise FileNotFoundError(f"Raw minute file not found: {raw_path}")
|
| 223 |
+
added_rows, last_timestamp = update_asset(raw_path, symbol, start_date, end_date, session)
|
| 224 |
+
print(f"{filename}: added {added_rows:,} rows, latest timestamp {last_timestamp}")
|
| 225 |
+
|
| 226 |
+
|
| 227 |
+
if __name__ == "__main__":
|
| 228 |
+
main()
|
backend/research_runtime/Code/scripts/data_preparation/build_research_data.py
ADDED
|
@@ -0,0 +1,957 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from __future__ import annotations
|
| 2 |
+
|
| 3 |
+
import json
|
| 4 |
+
import math
|
| 5 |
+
import os
|
| 6 |
+
import shutil
|
| 7 |
+
import __main__
|
| 8 |
+
from concurrent.futures import ProcessPoolExecutor, ThreadPoolExecutor, as_completed
|
| 9 |
+
from dataclasses import dataclass
|
| 10 |
+
from datetime import date, datetime, timedelta
|
| 11 |
+
from pathlib import Path
|
| 12 |
+
|
| 13 |
+
import numpy as np
|
| 14 |
+
import pandas as pd
|
| 15 |
+
import requests
|
| 16 |
+
|
| 17 |
+
|
| 18 |
+
def find_project_root() -> Path:
|
| 19 |
+
env_root = os.environ.get("FORECASTING_PROJECT_ROOT")
|
| 20 |
+
if env_root:
|
| 21 |
+
return Path(env_root).expanduser().resolve()
|
| 22 |
+
for path in [Path(__file__).resolve(), *Path(__file__).resolve().parents]:
|
| 23 |
+
if (path / "Code").is_dir() and (path / "Data").is_dir() and (path / "Alt Data").is_dir():
|
| 24 |
+
return path
|
| 25 |
+
raise RuntimeError("Could not locate project root containing Code, Data, and Alt Data.")
|
| 26 |
+
|
| 27 |
+
|
| 28 |
+
PROJECT_ROOT = find_project_root()
|
| 29 |
+
DATA_ROOT = PROJECT_ROOT / "Data"
|
| 30 |
+
ALT_ROOT = PROJECT_ROOT / "Alt Data"
|
| 31 |
+
CODE_ROOT = PROJECT_ROOT / "Code"
|
| 32 |
+
|
| 33 |
+
|
| 34 |
+
BAR_AGG = {
|
| 35 |
+
"open": "first",
|
| 36 |
+
"high": "max",
|
| 37 |
+
"low": "min",
|
| 38 |
+
"close": "last",
|
| 39 |
+
"volume": "sum",
|
| 40 |
+
}
|
| 41 |
+
|
| 42 |
+
|
| 43 |
+
RAW_MINUTE_ALIASES = {
|
| 44 |
+
"NIFTY 50_minute.csv": "nifty50",
|
| 45 |
+
"NIFTY BANK_minute.csv": "banknifty",
|
| 46 |
+
"INDIA VIX_minute.csv": "india_vix",
|
| 47 |
+
"HDFCBANK_minute.csv": "hdfcbank",
|
| 48 |
+
"RELIANCE_minute.csv": "reliance",
|
| 49 |
+
"WIPRO_minute.csv": "wipro",
|
| 50 |
+
"KOTAKBANK_minute.csv": "kotakbank",
|
| 51 |
+
}
|
| 52 |
+
|
| 53 |
+
|
| 54 |
+
OPTION_FILES = {
|
| 55 |
+
"NIFTY_50_options.csv": {
|
| 56 |
+
"asset": "nifty50",
|
| 57 |
+
"spot_asset": "nifty50",
|
| 58 |
+
},
|
| 59 |
+
"NIFTY_BANK_options.csv": {
|
| 60 |
+
"asset": "banknifty",
|
| 61 |
+
"spot_asset": "banknifty",
|
| 62 |
+
},
|
| 63 |
+
"HDFCBANK_options.csv": {
|
| 64 |
+
"asset": "hdfcbank",
|
| 65 |
+
"spot_asset": "hdfcbank",
|
| 66 |
+
},
|
| 67 |
+
}
|
| 68 |
+
|
| 69 |
+
|
| 70 |
+
TIMEFRAMES = {
|
| 71 |
+
"1m": 1,
|
| 72 |
+
"5m": 5,
|
| 73 |
+
"1h": 60,
|
| 74 |
+
"4h": 240,
|
| 75 |
+
}
|
| 76 |
+
|
| 77 |
+
|
| 78 |
+
PANEL_FEATURE_COLUMNS = [
|
| 79 |
+
"date",
|
| 80 |
+
"close",
|
| 81 |
+
"return_1",
|
| 82 |
+
"log_return_1",
|
| 83 |
+
"realized_vol_20",
|
| 84 |
+
"rsi_14",
|
| 85 |
+
"target_return_1",
|
| 86 |
+
]
|
| 87 |
+
|
| 88 |
+
|
| 89 |
+
DAILY_MASTER_COLUMNS = [
|
| 90 |
+
"date",
|
| 91 |
+
"open",
|
| 92 |
+
"high",
|
| 93 |
+
"low",
|
| 94 |
+
"close",
|
| 95 |
+
"return_1",
|
| 96 |
+
"log_return_1",
|
| 97 |
+
"realized_vol_20",
|
| 98 |
+
"rsi_14",
|
| 99 |
+
"target_return_1",
|
| 100 |
+
]
|
| 101 |
+
|
| 102 |
+
|
| 103 |
+
DEFAULT_MARKET_WORKERS = min(6, max(1, (os.cpu_count() or 2) - 1))
|
| 104 |
+
|
| 105 |
+
|
| 106 |
+
YAHOO_SERIES = {
|
| 107 |
+
"sp500": "^GSPC",
|
| 108 |
+
"nasdaq_composite": "^IXIC",
|
| 109 |
+
"dow_jones": "^DJI",
|
| 110 |
+
"nikkei225": "^N225",
|
| 111 |
+
"us10y_treasury": "^TNX",
|
| 112 |
+
"india_fx_inr_per_usd": "INR=X",
|
| 113 |
+
"brent_fred": "BZ=F",
|
| 114 |
+
"vix_fred": "^VIX",
|
| 115 |
+
"broad_dollar_index": "DX-Y.NYB",
|
| 116 |
+
}
|
| 117 |
+
|
| 118 |
+
YAHOO_HEADERS = {
|
| 119 |
+
"User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64)",
|
| 120 |
+
"Accept": "application/json,text/plain,*/*",
|
| 121 |
+
}
|
| 122 |
+
|
| 123 |
+
|
| 124 |
+
FRED_SERIES = {
|
| 125 |
+
"sp500": "SP500",
|
| 126 |
+
"nasdaq_composite": "NASDAQCOM",
|
| 127 |
+
"dow_jones": "DJIA",
|
| 128 |
+
"nikkei225": "NIKKEI225",
|
| 129 |
+
"us10y_treasury": "DGS10",
|
| 130 |
+
"fed_funds": "FEDFUNDS",
|
| 131 |
+
"india_fx_inr_per_usd": "DEXINUS",
|
| 132 |
+
"brent_fred": "DCOILBRENTEU",
|
| 133 |
+
"vix_fred": "VIXCLS",
|
| 134 |
+
"broad_dollar_index": "DTWEXBGS",
|
| 135 |
+
}
|
| 136 |
+
|
| 137 |
+
|
| 138 |
+
FRED_RETURN_SERIES = {
|
| 139 |
+
"sp500",
|
| 140 |
+
"nasdaq_composite",
|
| 141 |
+
"dow_jones",
|
| 142 |
+
"nikkei225",
|
| 143 |
+
"india_fx_inr_per_usd",
|
| 144 |
+
"brent_fred",
|
| 145 |
+
"vix_fred",
|
| 146 |
+
"broad_dollar_index",
|
| 147 |
+
}
|
| 148 |
+
|
| 149 |
+
|
| 150 |
+
FEATURE_DESCRIPTIONS = {
|
| 151 |
+
"return_1": "Simple return from prior bar close.",
|
| 152 |
+
"log_return_1": "Log return from prior bar close.",
|
| 153 |
+
"range_abs": "High-low range in price units.",
|
| 154 |
+
"range_pct_close": "High-low range scaled by close.",
|
| 155 |
+
"body_abs": "Close-open candle body in price units.",
|
| 156 |
+
"body_pct_open": "Close-open candle body scaled by open.",
|
| 157 |
+
"gap_pct": "Open relative to prior close.",
|
| 158 |
+
"hlc3": "Typical price: (high + low + close) / 3.",
|
| 159 |
+
"oc2": "Mid price: (open + close) / 2.",
|
| 160 |
+
"realized_vol_5": "Rolling 5-bar std of log returns annualized to bar units.",
|
| 161 |
+
"realized_vol_20": "Rolling 20-bar std of log returns annualized to bar units.",
|
| 162 |
+
"sma_5_rel": "Close relative to 5-bar simple moving average.",
|
| 163 |
+
"sma_20_rel": "Close relative to 20-bar simple moving average.",
|
| 164 |
+
"ema_12_rel": "Close relative to 12-bar exponential moving average.",
|
| 165 |
+
"ema_26_rel": "Close relative to 26-bar exponential moving average.",
|
| 166 |
+
"momentum_5": "5-bar percent change.",
|
| 167 |
+
"momentum_20": "20-bar percent change.",
|
| 168 |
+
"rsi_14": "14-bar RSI.",
|
| 169 |
+
"atr_14": "14-bar average true range.",
|
| 170 |
+
"macd": "MACD line from 12/26 EMA.",
|
| 171 |
+
"macd_signal": "9-bar EMA of MACD.",
|
| 172 |
+
"macd_hist": "MACD minus MACD signal.",
|
| 173 |
+
"bollinger_z_20": "20-bar z-score of close.",
|
| 174 |
+
"volume_all_zero": "Indicator showing the source volume field is entirely zero.",
|
| 175 |
+
"target_return_1": "Next-bar simple return.",
|
| 176 |
+
"target_log_return_1": "Next-bar log return.",
|
| 177 |
+
"target_direction_1": "Next-bar direction label.",
|
| 178 |
+
}
|
| 179 |
+
|
| 180 |
+
|
| 181 |
+
@dataclass
|
| 182 |
+
class ManifestEntry:
|
| 183 |
+
category: str
|
| 184 |
+
asset: str
|
| 185 |
+
timeframe: str
|
| 186 |
+
path: str
|
| 187 |
+
rows: int
|
| 188 |
+
start: str
|
| 189 |
+
end: str
|
| 190 |
+
|
| 191 |
+
|
| 192 |
+
def ensure_dir(path: Path) -> None:
|
| 193 |
+
path.mkdir(parents=True, exist_ok=True)
|
| 194 |
+
|
| 195 |
+
|
| 196 |
+
def asset_slug_from_minute_file(path: Path) -> str:
|
| 197 |
+
name = path.name
|
| 198 |
+
if name in RAW_MINUTE_ALIASES:
|
| 199 |
+
return RAW_MINUTE_ALIASES[name]
|
| 200 |
+
|
| 201 |
+
stem = path.stem
|
| 202 |
+
if stem.lower().endswith("_minute"):
|
| 203 |
+
stem = stem[:-7]
|
| 204 |
+
|
| 205 |
+
return (
|
| 206 |
+
stem.lower()
|
| 207 |
+
.replace("&", "and")
|
| 208 |
+
.replace(" ", "_")
|
| 209 |
+
.replace("-", "_")
|
| 210 |
+
.replace(".", "_")
|
| 211 |
+
)
|
| 212 |
+
|
| 213 |
+
|
| 214 |
+
def discover_raw_minute_files(raw_minute_dir: Path) -> dict[str, str]:
|
| 215 |
+
files = sorted(raw_minute_dir.glob("*_minute.csv"))
|
| 216 |
+
return {path.name: asset_slug_from_minute_file(path) for path in files}
|
| 217 |
+
|
| 218 |
+
|
| 219 |
+
def safe_move(src: Path, dst: Path) -> None:
|
| 220 |
+
if not src.exists():
|
| 221 |
+
return
|
| 222 |
+
ensure_dir(dst.parent)
|
| 223 |
+
if dst.exists():
|
| 224 |
+
return
|
| 225 |
+
shutil.move(str(src), str(dst))
|
| 226 |
+
|
| 227 |
+
|
| 228 |
+
def organize_existing_files() -> dict[str, Path]:
|
| 229 |
+
ensure_dir(CODE_ROOT)
|
| 230 |
+
ensure_dir(DATA_ROOT)
|
| 231 |
+
ensure_dir(ALT_ROOT)
|
| 232 |
+
|
| 233 |
+
raw_minute_dir = DATA_ROOT / "raw" / "minute"
|
| 234 |
+
raw_options_dir = ALT_ROOT / "options" / "raw"
|
| 235 |
+
ensure_dir(raw_minute_dir)
|
| 236 |
+
ensure_dir(raw_options_dir)
|
| 237 |
+
|
| 238 |
+
for filename in RAW_MINUTE_ALIASES:
|
| 239 |
+
safe_move(DATA_ROOT / filename, raw_minute_dir / filename)
|
| 240 |
+
|
| 241 |
+
for path in sorted(DATA_ROOT.glob("*_minute.csv")):
|
| 242 |
+
safe_move(path, raw_minute_dir / path.name)
|
| 243 |
+
|
| 244 |
+
raw_minute_aliases = {
|
| 245 |
+
"HDFCBANK_minute (2).csv": "HDFCBANK_minute.csv",
|
| 246 |
+
"RELIANCE_minute (1).csv": "RELIANCE_minute.csv",
|
| 247 |
+
}
|
| 248 |
+
for src_name, dst_name in raw_minute_aliases.items():
|
| 249 |
+
safe_move(DATA_ROOT / "raw" / src_name, raw_minute_dir / dst_name)
|
| 250 |
+
safe_move(DATA_ROOT / src_name, raw_minute_dir / dst_name)
|
| 251 |
+
|
| 252 |
+
for filename in OPTION_FILES:
|
| 253 |
+
safe_move(ALT_ROOT / filename, raw_options_dir / filename)
|
| 254 |
+
|
| 255 |
+
pycache_dir = ALT_ROOT / "__pycache__"
|
| 256 |
+
if pycache_dir.exists():
|
| 257 |
+
shutil.rmtree(pycache_dir)
|
| 258 |
+
|
| 259 |
+
return {
|
| 260 |
+
"raw_minute_dir": raw_minute_dir,
|
| 261 |
+
"raw_options_dir": raw_options_dir,
|
| 262 |
+
"bars_dir": DATA_ROOT / "processed" / "bars",
|
| 263 |
+
"features_dir": DATA_ROOT / "processed" / "features",
|
| 264 |
+
"panels_dir": DATA_ROOT / "processed" / "panels",
|
| 265 |
+
"metadata_dir": DATA_ROOT / "metadata",
|
| 266 |
+
"alt_options_processed_dir": ALT_ROOT / "options" / "processed",
|
| 267 |
+
"alt_external_raw_dir": ALT_ROOT / "external" / "raw",
|
| 268 |
+
"alt_external_processed_dir": ALT_ROOT / "external" / "processed",
|
| 269 |
+
"alt_institutional_processed_dir": ALT_ROOT / "institutional" / "processed",
|
| 270 |
+
"alt_metadata_dir": ALT_ROOT / "metadata",
|
| 271 |
+
}
|
| 272 |
+
|
| 273 |
+
|
| 274 |
+
def load_minute_data(path: Path) -> pd.DataFrame:
|
| 275 |
+
df = pd.read_csv(
|
| 276 |
+
path,
|
| 277 |
+
parse_dates=["date"],
|
| 278 |
+
usecols=["date", "open", "high", "low", "close", "volume"],
|
| 279 |
+
)
|
| 280 |
+
df = df.sort_values("date").reset_index(drop=True)
|
| 281 |
+
for col in ["open", "high", "low", "close", "volume"]:
|
| 282 |
+
df[col] = pd.to_numeric(df[col], errors="coerce")
|
| 283 |
+
df = df.dropna(subset=["date", "open", "high", "low", "close", "volume"])
|
| 284 |
+
df = df.drop_duplicates(subset=["date"])
|
| 285 |
+
df["volume"] = df["volume"].fillna(0.0)
|
| 286 |
+
return df
|
| 287 |
+
|
| 288 |
+
|
| 289 |
+
def aggregate_session_bars(df: pd.DataFrame, minutes: int) -> pd.DataFrame:
|
| 290 |
+
if minutes == 1:
|
| 291 |
+
out = df.copy()
|
| 292 |
+
return out
|
| 293 |
+
|
| 294 |
+
if df.empty:
|
| 295 |
+
return df.copy()
|
| 296 |
+
|
| 297 |
+
source = df
|
| 298 |
+
if not source["date"].is_monotonic_increasing:
|
| 299 |
+
source = source.sort_values("date", kind="mergesort")
|
| 300 |
+
|
| 301 |
+
session_date = source["date"].dt.normalize()
|
| 302 |
+
session_open = source.groupby(session_date, sort=False)["date"].transform("first")
|
| 303 |
+
elapsed_minutes = ((source["date"] - session_open).dt.total_seconds() // 60).astype("int64")
|
| 304 |
+
bucket = elapsed_minutes // minutes
|
| 305 |
+
|
| 306 |
+
return (
|
| 307 |
+
source.groupby([session_date, bucket], sort=True)
|
| 308 |
+
.agg(
|
| 309 |
+
date=("date", "last"),
|
| 310 |
+
open=("open", "first"),
|
| 311 |
+
high=("high", "max"),
|
| 312 |
+
low=("low", "min"),
|
| 313 |
+
close=("close", "last"),
|
| 314 |
+
volume=("volume", "sum"),
|
| 315 |
+
)
|
| 316 |
+
.reset_index(drop=True)
|
| 317 |
+
)
|
| 318 |
+
|
| 319 |
+
|
| 320 |
+
def compute_daily_bars(df: pd.DataFrame) -> pd.DataFrame:
|
| 321 |
+
daily = (
|
| 322 |
+
df.groupby(df["date"].dt.normalize(), sort=True)
|
| 323 |
+
.agg(**{k: (k, v) for k, v in BAR_AGG.items()})
|
| 324 |
+
.reset_index()
|
| 325 |
+
.rename(columns={"date": "session_date"})
|
| 326 |
+
)
|
| 327 |
+
daily = daily.rename(columns={"session_date": "date"})
|
| 328 |
+
return daily
|
| 329 |
+
|
| 330 |
+
|
| 331 |
+
def compute_rsi(close: pd.Series, period: int = 14) -> pd.Series:
|
| 332 |
+
delta = close.diff()
|
| 333 |
+
gain = delta.clip(lower=0)
|
| 334 |
+
loss = -delta.clip(upper=0)
|
| 335 |
+
avg_gain = gain.ewm(alpha=1 / period, adjust=False, min_periods=period).mean()
|
| 336 |
+
avg_loss = loss.ewm(alpha=1 / period, adjust=False, min_periods=period).mean()
|
| 337 |
+
rs = avg_gain / avg_loss.replace(0, np.nan)
|
| 338 |
+
return 100 - (100 / (1 + rs))
|
| 339 |
+
|
| 340 |
+
|
| 341 |
+
def engineer_features(df: pd.DataFrame, timeframe_name: str) -> pd.DataFrame:
|
| 342 |
+
feat = df.copy()
|
| 343 |
+
feat = feat.sort_values("date").reset_index(drop=True)
|
| 344 |
+
|
| 345 |
+
prev_close = feat["close"].shift(1)
|
| 346 |
+
feat["return_1"] = feat["close"].pct_change()
|
| 347 |
+
feat["log_return_1"] = np.log(feat["close"]).diff()
|
| 348 |
+
feat["range_abs"] = feat["high"] - feat["low"]
|
| 349 |
+
feat["range_pct_close"] = feat["range_abs"] / feat["close"].replace(0, np.nan)
|
| 350 |
+
feat["body_abs"] = feat["close"] - feat["open"]
|
| 351 |
+
feat["body_pct_open"] = feat["body_abs"] / feat["open"].replace(0, np.nan)
|
| 352 |
+
feat["gap_pct"] = feat["open"] / prev_close.replace(0, np.nan) - 1
|
| 353 |
+
feat["hlc3"] = (feat["high"] + feat["low"] + feat["close"]) / 3
|
| 354 |
+
feat["oc2"] = (feat["open"] + feat["close"]) / 2
|
| 355 |
+
|
| 356 |
+
feat["realized_vol_5"] = feat["log_return_1"].rolling(5).std() * math.sqrt(5)
|
| 357 |
+
feat["realized_vol_20"] = feat["log_return_1"].rolling(20).std() * math.sqrt(20)
|
| 358 |
+
|
| 359 |
+
sma_5 = feat["close"].rolling(5).mean()
|
| 360 |
+
sma_20 = feat["close"].rolling(20).mean()
|
| 361 |
+
ema_12 = feat["close"].ewm(span=12, adjust=False).mean()
|
| 362 |
+
ema_26 = feat["close"].ewm(span=26, adjust=False).mean()
|
| 363 |
+
feat["sma_5_rel"] = feat["close"] / sma_5.replace(0, np.nan) - 1
|
| 364 |
+
feat["sma_20_rel"] = feat["close"] / sma_20.replace(0, np.nan) - 1
|
| 365 |
+
feat["ema_12_rel"] = feat["close"] / ema_12.replace(0, np.nan) - 1
|
| 366 |
+
feat["ema_26_rel"] = feat["close"] / ema_26.replace(0, np.nan) - 1
|
| 367 |
+
feat["momentum_5"] = feat["close"].pct_change(5)
|
| 368 |
+
feat["momentum_20"] = feat["close"].pct_change(20)
|
| 369 |
+
feat["rsi_14"] = compute_rsi(feat["close"], 14)
|
| 370 |
+
|
| 371 |
+
tr_parts = pd.concat(
|
| 372 |
+
[
|
| 373 |
+
feat["high"] - feat["low"],
|
| 374 |
+
(feat["high"] - prev_close).abs(),
|
| 375 |
+
(feat["low"] - prev_close).abs(),
|
| 376 |
+
],
|
| 377 |
+
axis=1,
|
| 378 |
+
)
|
| 379 |
+
feat["atr_14"] = tr_parts.max(axis=1).rolling(14).mean()
|
| 380 |
+
|
| 381 |
+
macd = ema_12 - ema_26
|
| 382 |
+
macd_signal = macd.ewm(span=9, adjust=False).mean()
|
| 383 |
+
feat["macd"] = macd
|
| 384 |
+
feat["macd_signal"] = macd_signal
|
| 385 |
+
feat["macd_hist"] = macd - macd_signal
|
| 386 |
+
|
| 387 |
+
rolling_mean_20 = feat["close"].rolling(20).mean()
|
| 388 |
+
rolling_std_20 = feat["close"].rolling(20).std()
|
| 389 |
+
feat["bollinger_z_20"] = (feat["close"] - rolling_mean_20) / rolling_std_20.replace(0, np.nan)
|
| 390 |
+
|
| 391 |
+
feat["day_of_week"] = feat["date"].dt.dayofweek
|
| 392 |
+
feat["month"] = feat["date"].dt.month
|
| 393 |
+
feat["quarter"] = feat["date"].dt.quarter
|
| 394 |
+
|
| 395 |
+
if timeframe_name != "1d":
|
| 396 |
+
feat["hour"] = feat["date"].dt.hour
|
| 397 |
+
feat["minute"] = feat["date"].dt.minute
|
| 398 |
+
|
| 399 |
+
volume_all_zero = int(np.isclose(feat["volume"].abs().sum(), 0.0))
|
| 400 |
+
feat["volume_all_zero"] = volume_all_zero
|
| 401 |
+
if not volume_all_zero:
|
| 402 |
+
vol_5 = feat["volume"].rolling(5).mean()
|
| 403 |
+
vol_20 = feat["volume"].rolling(20).mean()
|
| 404 |
+
feat["volume_sma_5_rel"] = feat["volume"] / vol_5.replace(0, np.nan) - 1
|
| 405 |
+
feat["volume_sma_20_rel"] = feat["volume"] / vol_20.replace(0, np.nan) - 1
|
| 406 |
+
|
| 407 |
+
feat["target_return_1"] = feat["return_1"].shift(-1)
|
| 408 |
+
feat["target_log_return_1"] = feat["log_return_1"].shift(-1)
|
| 409 |
+
feat["target_direction_1"] = np.sign(feat["target_return_1"]).fillna(0).astype(int)
|
| 410 |
+
|
| 411 |
+
return feat
|
| 412 |
+
|
| 413 |
+
|
| 414 |
+
def write_csv(df: pd.DataFrame, path: Path) -> None:
|
| 415 |
+
ensure_dir(path.parent)
|
| 416 |
+
df.to_csv(path, index=False)
|
| 417 |
+
|
| 418 |
+
|
| 419 |
+
def summarize_frame(category: str, asset: str, timeframe: str, path: Path, df: pd.DataFrame) -> ManifestEntry:
|
| 420 |
+
if "date" in df.columns and not df.empty:
|
| 421 |
+
start = str(pd.to_datetime(df["date"]).min())
|
| 422 |
+
end = str(pd.to_datetime(df["date"]).max())
|
| 423 |
+
else:
|
| 424 |
+
start = ""
|
| 425 |
+
end = ""
|
| 426 |
+
return ManifestEntry(
|
| 427 |
+
category=category,
|
| 428 |
+
asset=asset,
|
| 429 |
+
timeframe=timeframe,
|
| 430 |
+
path=str(path),
|
| 431 |
+
rows=len(df),
|
| 432 |
+
start=start,
|
| 433 |
+
end=end,
|
| 434 |
+
)
|
| 435 |
+
|
| 436 |
+
|
| 437 |
+
def market_worker_count(asset_count: int) -> int:
|
| 438 |
+
raw_value = os.environ.get("MARKET_BUILD_WORKERS")
|
| 439 |
+
if raw_value:
|
| 440 |
+
try:
|
| 441 |
+
requested = int(raw_value)
|
| 442 |
+
except ValueError:
|
| 443 |
+
requested = DEFAULT_MARKET_WORKERS
|
| 444 |
+
else:
|
| 445 |
+
requested = DEFAULT_MARKET_WORKERS
|
| 446 |
+
return max(1, min(asset_count, requested))
|
| 447 |
+
|
| 448 |
+
|
| 449 |
+
def market_executor_class():
|
| 450 |
+
main_file = str(getattr(__main__, "__file__", ""))
|
| 451 |
+
if main_file and "<stdin>" not in main_file:
|
| 452 |
+
return ProcessPoolExecutor
|
| 453 |
+
return ThreadPoolExecutor
|
| 454 |
+
|
| 455 |
+
|
| 456 |
+
def slim_market_features(features: pd.DataFrame, timeframe_name: str) -> pd.DataFrame:
|
| 457 |
+
columns = DAILY_MASTER_COLUMNS if timeframe_name == "1d" else PANEL_FEATURE_COLUMNS
|
| 458 |
+
return features[columns].copy()
|
| 459 |
+
|
| 460 |
+
|
| 461 |
+
def load_slim_market_frames(
|
| 462 |
+
paths: dict[str, Path],
|
| 463 |
+
raw_minute_files: dict[str, str],
|
| 464 |
+
) -> dict[str, dict[str, pd.DataFrame]]:
|
| 465 |
+
asset_frames: dict[str, dict[str, pd.DataFrame]] = {}
|
| 466 |
+
for asset in raw_minute_files.values():
|
| 467 |
+
timeframe_frames: dict[str, pd.DataFrame] = {}
|
| 468 |
+
for timeframe_name in [*TIMEFRAMES.keys(), "1d"]:
|
| 469 |
+
columns = DAILY_MASTER_COLUMNS if timeframe_name == "1d" else PANEL_FEATURE_COLUMNS
|
| 470 |
+
feat_path = paths["features_dir"] / timeframe_name / f"{asset}_{timeframe_name}_features.csv"
|
| 471 |
+
timeframe_frames[timeframe_name] = pd.read_csv(feat_path, parse_dates=["date"], usecols=columns)
|
| 472 |
+
asset_frames[asset] = timeframe_frames
|
| 473 |
+
return asset_frames
|
| 474 |
+
|
| 475 |
+
|
| 476 |
+
def build_single_market_dataset(filename: str, asset: str, paths: dict[str, Path]) -> list[ManifestEntry]:
|
| 477 |
+
manifest: list[ManifestEntry] = []
|
| 478 |
+
raw_path = paths["raw_minute_dir"] / filename
|
| 479 |
+
minute_df = load_minute_data(raw_path)
|
| 480 |
+
|
| 481 |
+
for timeframe_name, minutes in TIMEFRAMES.items():
|
| 482 |
+
bars = aggregate_session_bars(minute_df, minutes)
|
| 483 |
+
features = engineer_features(bars, timeframe_name)
|
| 484 |
+
|
| 485 |
+
bars_path = paths["bars_dir"] / timeframe_name / f"{asset}_{timeframe_name}.csv"
|
| 486 |
+
feat_path = paths["features_dir"] / timeframe_name / f"{asset}_{timeframe_name}_features.csv"
|
| 487 |
+
write_csv(bars, bars_path)
|
| 488 |
+
write_csv(features, feat_path)
|
| 489 |
+
|
| 490 |
+
manifest.append(summarize_frame("bars", asset, timeframe_name, bars_path, bars))
|
| 491 |
+
manifest.append(summarize_frame("features", asset, timeframe_name, feat_path, features))
|
| 492 |
+
|
| 493 |
+
daily_bars = compute_daily_bars(minute_df)
|
| 494 |
+
daily_features = engineer_features(daily_bars, "1d")
|
| 495 |
+
|
| 496 |
+
daily_bars_path = paths["bars_dir"] / "1d" / f"{asset}_1d.csv"
|
| 497 |
+
daily_feat_path = paths["features_dir"] / "1d" / f"{asset}_1d_features.csv"
|
| 498 |
+
write_csv(daily_bars, daily_bars_path)
|
| 499 |
+
write_csv(daily_features, daily_feat_path)
|
| 500 |
+
|
| 501 |
+
manifest.append(summarize_frame("bars", asset, "1d", daily_bars_path, daily_bars))
|
| 502 |
+
manifest.append(summarize_frame("features", asset, "1d", daily_feat_path, daily_features))
|
| 503 |
+
return manifest
|
| 504 |
+
|
| 505 |
+
|
| 506 |
+
def build_market_datasets(paths: dict[str, Path]) -> tuple[dict[str, dict[str, pd.DataFrame]], list[ManifestEntry]]:
|
| 507 |
+
manifest: list[ManifestEntry] = []
|
| 508 |
+
|
| 509 |
+
raw_minute_files = discover_raw_minute_files(paths["raw_minute_dir"])
|
| 510 |
+
worker_count = market_worker_count(len(raw_minute_files))
|
| 511 |
+
|
| 512 |
+
if worker_count == 1:
|
| 513 |
+
for filename, asset in raw_minute_files.items():
|
| 514 |
+
manifest.extend(build_single_market_dataset(filename, asset, paths))
|
| 515 |
+
else:
|
| 516 |
+
executor_class = market_executor_class()
|
| 517 |
+
with executor_class(max_workers=worker_count) as executor:
|
| 518 |
+
futures = [
|
| 519 |
+
executor.submit(build_single_market_dataset, filename, asset, paths)
|
| 520 |
+
for filename, asset in raw_minute_files.items()
|
| 521 |
+
]
|
| 522 |
+
for future in as_completed(futures):
|
| 523 |
+
manifest.extend(future.result())
|
| 524 |
+
|
| 525 |
+
asset_frames = load_slim_market_frames(paths, raw_minute_files)
|
| 526 |
+
return asset_frames, manifest
|
| 527 |
+
|
| 528 |
+
|
| 529 |
+
def build_multiactive_panels(
|
| 530 |
+
paths: dict[str, Path],
|
| 531 |
+
asset_frames: dict[str, dict[str, pd.DataFrame]],
|
| 532 |
+
) -> list[ManifestEntry]:
|
| 533 |
+
manifest: list[ManifestEntry] = []
|
| 534 |
+
timeframes = ["1m", "5m", "1h", "4h", "1d"]
|
| 535 |
+
|
| 536 |
+
for timeframe in timeframes:
|
| 537 |
+
slices: list[pd.DataFrame] = []
|
| 538 |
+
for asset, tf_map in asset_frames.items():
|
| 539 |
+
frame = tf_map[timeframe]
|
| 540 |
+
cols = ["date", "close", "return_1", "log_return_1", "realized_vol_20", "rsi_14", "target_return_1"]
|
| 541 |
+
asset_slice = frame[cols].rename(
|
| 542 |
+
columns={col: f"{asset}_{col}" for col in cols if col != "date"}
|
| 543 |
+
)
|
| 544 |
+
slices.append(asset_slice.set_index("date"))
|
| 545 |
+
|
| 546 |
+
if not slices:
|
| 547 |
+
continue
|
| 548 |
+
|
| 549 |
+
panel = pd.concat(slices, axis=1, join="outer").reset_index()
|
| 550 |
+
|
| 551 |
+
if {"banknifty_close", "nifty50_close"}.issubset(panel.columns):
|
| 552 |
+
panel["banknifty_to_nifty_ratio"] = panel["banknifty_close"] / panel["nifty50_close"]
|
| 553 |
+
panel["banknifty_minus_nifty_return_spread"] = (
|
| 554 |
+
panel.get("banknifty_return_1") - panel.get("nifty50_return_1")
|
| 555 |
+
)
|
| 556 |
+
|
| 557 |
+
if {"india_vix_close", "nifty50_return_1"}.issubset(panel.columns):
|
| 558 |
+
panel["vix_to_nifty_close_ratio"] = panel["india_vix_close"] / panel["nifty50_close"]
|
| 559 |
+
panel["nifty_vix_rolling_corr_20"] = (
|
| 560 |
+
panel["nifty50_return_1"].rolling(20).corr(panel["india_vix_return_1"])
|
| 561 |
+
)
|
| 562 |
+
|
| 563 |
+
panel = panel.sort_values("date").reset_index(drop=True)
|
| 564 |
+
panel_path = paths["panels_dir"] / f"{timeframe}_market_panel.csv"
|
| 565 |
+
write_csv(panel, panel_path)
|
| 566 |
+
manifest.append(summarize_frame("panel", "market", timeframe, panel_path, panel))
|
| 567 |
+
|
| 568 |
+
return manifest
|
| 569 |
+
|
| 570 |
+
|
| 571 |
+
def build_option_features(
|
| 572 |
+
paths: dict[str, Path],
|
| 573 |
+
asset_frames: dict[str, dict[str, pd.DataFrame]],
|
| 574 |
+
) -> list[ManifestEntry]:
|
| 575 |
+
manifest: list[ManifestEntry] = []
|
| 576 |
+
daily_spot = {
|
| 577 |
+
asset: frame_map["1d"][["date", "close"]].assign(date=lambda x: pd.to_datetime(x["date"]).dt.normalize())
|
| 578 |
+
for asset, frame_map in asset_frames.items()
|
| 579 |
+
}
|
| 580 |
+
|
| 581 |
+
for filename, meta in OPTION_FILES.items():
|
| 582 |
+
raw_path = paths["raw_options_dir"] / filename
|
| 583 |
+
dtype_map = {
|
| 584 |
+
"INSTRUMENT": "category",
|
| 585 |
+
"SYMBOL": "category",
|
| 586 |
+
"OPTION_TYP": "category",
|
| 587 |
+
"STRIKE_PR": "float32",
|
| 588 |
+
"OPEN": "float32",
|
| 589 |
+
"HIGH": "float32",
|
| 590 |
+
"LOW": "float32",
|
| 591 |
+
"CLOSE": "float32",
|
| 592 |
+
"SETTLE_PR": "float32",
|
| 593 |
+
"CONTRACTS": "float32",
|
| 594 |
+
"VAL_INLAKH": "float32",
|
| 595 |
+
"OPEN_INT": "float32",
|
| 596 |
+
"CHG_IN_OI": "float32",
|
| 597 |
+
}
|
| 598 |
+
df = pd.read_csv(
|
| 599 |
+
raw_path,
|
| 600 |
+
dtype=dtype_map,
|
| 601 |
+
parse_dates=["TRADE_DATE", "EXPIRY_DT"],
|
| 602 |
+
usecols=[
|
| 603 |
+
"TRADE_DATE",
|
| 604 |
+
"INSTRUMENT",
|
| 605 |
+
"SYMBOL",
|
| 606 |
+
"EXPIRY_DT",
|
| 607 |
+
"STRIKE_PR",
|
| 608 |
+
"OPTION_TYP",
|
| 609 |
+
"CLOSE",
|
| 610 |
+
"CONTRACTS",
|
| 611 |
+
"OPEN_INT",
|
| 612 |
+
"CHG_IN_OI",
|
| 613 |
+
],
|
| 614 |
+
)
|
| 615 |
+
df = df.rename(columns={"TRADE_DATE": "trade_date", "EXPIRY_DT": "expiry_date"})
|
| 616 |
+
df["trade_date"] = pd.to_datetime(df["trade_date"]).dt.normalize()
|
| 617 |
+
df["expiry_date"] = pd.to_datetime(df["expiry_date"]).dt.normalize()
|
| 618 |
+
df["dte"] = (df["expiry_date"] - df["trade_date"]).dt.days
|
| 619 |
+
|
| 620 |
+
base_daily = (
|
| 621 |
+
df.pivot_table(
|
| 622 |
+
index="trade_date",
|
| 623 |
+
columns="OPTION_TYP",
|
| 624 |
+
values=["OPEN_INT", "CONTRACTS", "CHG_IN_OI"],
|
| 625 |
+
aggfunc="sum",
|
| 626 |
+
fill_value=0.0,
|
| 627 |
+
observed=False,
|
| 628 |
+
)
|
| 629 |
+
.sort_index()
|
| 630 |
+
)
|
| 631 |
+
base_daily.columns = [
|
| 632 |
+
f"{metric.lower()}_{option_type.lower()}"
|
| 633 |
+
for metric, option_type in base_daily.columns.to_flat_index()
|
| 634 |
+
]
|
| 635 |
+
base_daily = base_daily.reset_index()
|
| 636 |
+
|
| 637 |
+
if {"open_int_ce", "open_int_pe"}.issubset(base_daily.columns):
|
| 638 |
+
base_daily["pcr_open_int"] = (
|
| 639 |
+
base_daily["open_int_pe"] / base_daily["open_int_ce"].replace(0, np.nan)
|
| 640 |
+
)
|
| 641 |
+
if {"contracts_ce", "contracts_pe"}.issubset(base_daily.columns):
|
| 642 |
+
base_daily["pcr_contracts"] = (
|
| 643 |
+
base_daily["contracts_pe"] / base_daily["contracts_ce"].replace(0, np.nan)
|
| 644 |
+
)
|
| 645 |
+
if {"chg_in_oi_ce", "chg_in_oi_pe"}.issubset(base_daily.columns):
|
| 646 |
+
base_daily["net_oi_change_put_minus_call"] = (
|
| 647 |
+
base_daily["chg_in_oi_pe"] - base_daily["chg_in_oi_ce"]
|
| 648 |
+
)
|
| 649 |
+
|
| 650 |
+
weighted = (
|
| 651 |
+
df.assign(weighted_strike=df["STRIKE_PR"] * df["OPEN_INT"])
|
| 652 |
+
.groupby(["trade_date", "OPTION_TYP"], observed=True)
|
| 653 |
+
.agg(weighted_strike=("weighted_strike", "sum"), open_int=("OPEN_INT", "sum"))
|
| 654 |
+
.reset_index()
|
| 655 |
+
)
|
| 656 |
+
weighted["oi_weighted_strike"] = weighted["weighted_strike"] / weighted["open_int"].replace(0, np.nan)
|
| 657 |
+
weighted = weighted.pivot(index="trade_date", columns="OPTION_TYP", values="oi_weighted_strike").reset_index()
|
| 658 |
+
weighted.columns = [
|
| 659 |
+
"trade_date" if col == "trade_date" else f"oi_weighted_strike_{str(col).lower()}"
|
| 660 |
+
for col in weighted.columns
|
| 661 |
+
]
|
| 662 |
+
|
| 663 |
+
spot_map = daily_spot[meta["spot_asset"]].rename(columns={"close": "spot_close"})
|
| 664 |
+
with_spot = df.merge(spot_map, left_on="trade_date", right_on="date", how="left").drop(columns=["date"])
|
| 665 |
+
with_spot = with_spot[with_spot["dte"] >= 0].copy()
|
| 666 |
+
with_spot["abs_moneyness"] = (with_spot["STRIKE_PR"] - with_spot["spot_close"]).abs()
|
| 667 |
+
near_dte = with_spot.groupby("trade_date")["dte"].transform("min")
|
| 668 |
+
near = with_spot[with_spot["dte"] == near_dte].copy()
|
| 669 |
+
min_abs_mny = near.groupby("trade_date")["abs_moneyness"].transform("min")
|
| 670 |
+
atm = near[near["abs_moneyness"] == min_abs_mny].copy()
|
| 671 |
+
atm = (
|
| 672 |
+
atm.groupby(["trade_date", "OPTION_TYP"], observed=True)
|
| 673 |
+
.agg(
|
| 674 |
+
atm_close=("CLOSE", "mean"),
|
| 675 |
+
atm_open_int=("OPEN_INT", "sum"),
|
| 676 |
+
atm_contracts=("CONTRACTS", "sum"),
|
| 677 |
+
atm_strike=("STRIKE_PR", "mean"),
|
| 678 |
+
near_dte=("dte", "min"),
|
| 679 |
+
)
|
| 680 |
+
.reset_index()
|
| 681 |
+
)
|
| 682 |
+
atm = atm.pivot(index="trade_date", columns="OPTION_TYP")
|
| 683 |
+
atm.columns = [f"{metric}_{str(opt).lower()}" for metric, opt in atm.columns.to_flat_index()]
|
| 684 |
+
atm = atm.reset_index()
|
| 685 |
+
if {"atm_close_ce", "atm_close_pe"}.issubset(atm.columns):
|
| 686 |
+
atm["atm_straddle_close"] = atm["atm_close_ce"] + atm["atm_close_pe"]
|
| 687 |
+
if {"atm_open_int_ce", "atm_open_int_pe"}.issubset(atm.columns):
|
| 688 |
+
atm["atm_pcr_open_int"] = atm["atm_open_int_pe"] / atm["atm_open_int_ce"].replace(0, np.nan)
|
| 689 |
+
|
| 690 |
+
option_daily = (
|
| 691 |
+
base_daily.merge(weighted, on="trade_date", how="left")
|
| 692 |
+
.merge(atm, on="trade_date", how="left")
|
| 693 |
+
.merge(spot_map, left_on="trade_date", right_on="date", how="left")
|
| 694 |
+
.drop(columns=["date"])
|
| 695 |
+
.sort_values("trade_date")
|
| 696 |
+
.reset_index(drop=True)
|
| 697 |
+
.rename(columns={"trade_date": "date"})
|
| 698 |
+
)
|
| 699 |
+
option_daily["date"] = pd.to_datetime(option_daily["date"])
|
| 700 |
+
|
| 701 |
+
asset = meta["asset"]
|
| 702 |
+
option_path = paths["alt_options_processed_dir"] / f"{asset}_options_daily_features.csv"
|
| 703 |
+
write_csv(option_daily, option_path)
|
| 704 |
+
manifest.append(summarize_frame("options_features", asset, "1d", option_path, option_daily))
|
| 705 |
+
|
| 706 |
+
return manifest
|
| 707 |
+
|
| 708 |
+
|
| 709 |
+
def build_yahoo_chart_url(symbol: str, start_dt: datetime, end_dt_exclusive: datetime, interval: str = "1d") -> str:
|
| 710 |
+
period1 = int(start_dt.timestamp())
|
| 711 |
+
period2 = int(end_dt_exclusive.timestamp())
|
| 712 |
+
return (
|
| 713 |
+
f"https://query1.finance.yahoo.com/v8/finance/chart/{requests.utils.quote(symbol, safe='')}"
|
| 714 |
+
f"?period1={period1}&period2={period2}&interval={interval}&includePrePost=false&events=div%2Csplits"
|
| 715 |
+
)
|
| 716 |
+
|
| 717 |
+
|
| 718 |
+
def fetch_yahoo_chart_series(symbol: str, start_date: str, end_date: str) -> pd.DataFrame:
|
| 719 |
+
start_dt = datetime.strptime(start_date, "%Y-%m-%d")
|
| 720 |
+
end_dt_exclusive = datetime.strptime(end_date, "%Y-%m-%d") + timedelta(days=1)
|
| 721 |
+
url = build_yahoo_chart_url(symbol, start_dt, end_dt_exclusive, interval="1d")
|
| 722 |
+
response = requests.get(url, headers=YAHOO_HEADERS, timeout=30)
|
| 723 |
+
response.raise_for_status()
|
| 724 |
+
|
| 725 |
+
payload = response.json()
|
| 726 |
+
result = payload.get("chart", {}).get("result") or []
|
| 727 |
+
if not result:
|
| 728 |
+
return pd.DataFrame(columns=["date", "open", "high", "low", "close", "adj_close", "volume"])
|
| 729 |
+
|
| 730 |
+
result0 = result[0]
|
| 731 |
+
timestamps = result0.get("timestamp") or []
|
| 732 |
+
quote_sets = result0.get("indicators", {}).get("quote") or []
|
| 733 |
+
if not timestamps or not quote_sets:
|
| 734 |
+
return pd.DataFrame(columns=["date", "open", "high", "low", "close", "adj_close", "volume"])
|
| 735 |
+
|
| 736 |
+
quote = quote_sets[0]
|
| 737 |
+
adjclose_sets = result0.get("indicators", {}).get("adjclose") or [{}]
|
| 738 |
+
adjclose = adjclose_sets[0].get("adjclose", []) if adjclose_sets else []
|
| 739 |
+
|
| 740 |
+
frame = pd.DataFrame(
|
| 741 |
+
{
|
| 742 |
+
"date": pd.to_datetime(timestamps, unit="s", utc=True).tz_convert("UTC").tz_localize(None).normalize(),
|
| 743 |
+
"open": quote.get("open", []),
|
| 744 |
+
"high": quote.get("high", []),
|
| 745 |
+
"low": quote.get("low", []),
|
| 746 |
+
"close": quote.get("close", []),
|
| 747 |
+
"adj_close": adjclose,
|
| 748 |
+
"volume": quote.get("volume", []),
|
| 749 |
+
}
|
| 750 |
+
)
|
| 751 |
+
for column in ["open", "high", "low", "close", "adj_close", "volume"]:
|
| 752 |
+
if column in frame.columns:
|
| 753 |
+
frame[column] = pd.to_numeric(frame[column], errors="coerce")
|
| 754 |
+
|
| 755 |
+
frame = frame.dropna(subset=["date", "close"]).drop_duplicates(subset=["date"]).sort_values("date").reset_index(drop=True)
|
| 756 |
+
if "volume" in frame.columns:
|
| 757 |
+
frame["volume"] = frame["volume"].fillna(0.0)
|
| 758 |
+
return frame
|
| 759 |
+
|
| 760 |
+
|
| 761 |
+
def fetch_yahoo_series(start_date: str, end_date: str) -> tuple[dict[str, pd.DataFrame], list[ManifestEntry]]:
|
| 762 |
+
raw_frames: dict[str, pd.DataFrame] = {}
|
| 763 |
+
manifest: list[ManifestEntry] = []
|
| 764 |
+
|
| 765 |
+
for asset_name, ticker in YAHOO_SERIES.items():
|
| 766 |
+
try:
|
| 767 |
+
frame = fetch_yahoo_chart_series(ticker, start_date, end_date)
|
| 768 |
+
except Exception:
|
| 769 |
+
continue
|
| 770 |
+
if frame.empty:
|
| 771 |
+
continue
|
| 772 |
+
|
| 773 |
+
raw_frames[asset_name] = frame
|
| 774 |
+
raw_path = ALT_ROOT / "external" / "raw" / "yahoo" / f"{asset_name}.csv"
|
| 775 |
+
write_csv(frame, raw_path)
|
| 776 |
+
manifest.append(summarize_frame("external_raw", asset_name, "1d", raw_path, frame))
|
| 777 |
+
|
| 778 |
+
return raw_frames, manifest
|
| 779 |
+
|
| 780 |
+
|
| 781 |
+
def fetch_fred_series(start_date: str) -> tuple[dict[str, pd.DataFrame], list[ManifestEntry]]:
|
| 782 |
+
raw_frames: dict[str, pd.DataFrame] = {}
|
| 783 |
+
manifest: list[ManifestEntry] = []
|
| 784 |
+
|
| 785 |
+
for asset_name, series_id in FRED_SERIES.items():
|
| 786 |
+
url = f"https://fred.stlouisfed.org/graph/fredgraph.csv?id={series_id}"
|
| 787 |
+
try:
|
| 788 |
+
frame = pd.read_csv(url)
|
| 789 |
+
except Exception:
|
| 790 |
+
continue
|
| 791 |
+
frame = frame.rename(columns={"observation_date": "date", series_id: "value"})
|
| 792 |
+
frame["date"] = pd.to_datetime(frame["date"])
|
| 793 |
+
frame["value"] = pd.to_numeric(frame["value"], errors="coerce")
|
| 794 |
+
frame = frame[frame["date"] >= pd.Timestamp(start_date)].reset_index(drop=True)
|
| 795 |
+
raw_frames[asset_name] = frame
|
| 796 |
+
raw_path = ALT_ROOT / "external" / "raw" / "fred" / f"{asset_name}.csv"
|
| 797 |
+
write_csv(frame, raw_path)
|
| 798 |
+
manifest.append(summarize_frame("external_raw", asset_name, "1d", raw_path, frame))
|
| 799 |
+
|
| 800 |
+
return raw_frames, manifest
|
| 801 |
+
|
| 802 |
+
|
| 803 |
+
def build_external_panels(
|
| 804 |
+
yahoo_frames: dict[str, pd.DataFrame],
|
| 805 |
+
fred_frames: dict[str, pd.DataFrame],
|
| 806 |
+
paths: dict[str, Path],
|
| 807 |
+
) -> list[ManifestEntry]:
|
| 808 |
+
manifest: list[ManifestEntry] = []
|
| 809 |
+
panel_slices: list[pd.DataFrame] = []
|
| 810 |
+
|
| 811 |
+
for asset_name, frame in yahoo_frames.items():
|
| 812 |
+
temp = frame[["date", "close"]].copy()
|
| 813 |
+
temp["date"] = pd.to_datetime(temp["date"]).dt.normalize()
|
| 814 |
+
temp = temp.rename(columns={"close": f"{asset_name}_close"})
|
| 815 |
+
temp[f"{asset_name}_value"] = temp[f"{asset_name}_close"]
|
| 816 |
+
temp[f"{asset_name}_change_1"] = temp[f"{asset_name}_value"].diff()
|
| 817 |
+
temp[f"{asset_name}_return_1"] = temp[f"{asset_name}_close"].pct_change(fill_method=None)
|
| 818 |
+
panel_slices.append(temp.set_index("date"))
|
| 819 |
+
|
| 820 |
+
for asset_name, frame in fred_frames.items():
|
| 821 |
+
if asset_name in yahoo_frames:
|
| 822 |
+
continue
|
| 823 |
+
temp = frame.copy()
|
| 824 |
+
temp["date"] = pd.to_datetime(temp["date"]).dt.normalize()
|
| 825 |
+
temp = temp.rename(columns={"value": f"{asset_name}_value"})
|
| 826 |
+
temp[f"{asset_name}_close"] = temp[f"{asset_name}_value"]
|
| 827 |
+
temp[f"{asset_name}_change_1"] = temp[f"{asset_name}_value"].diff()
|
| 828 |
+
if asset_name in FRED_RETURN_SERIES:
|
| 829 |
+
temp[f"{asset_name}_return_1"] = temp[f"{asset_name}_value"].pct_change(fill_method=None)
|
| 830 |
+
panel_slices.append(temp.set_index("date"))
|
| 831 |
+
|
| 832 |
+
if not panel_slices:
|
| 833 |
+
return manifest
|
| 834 |
+
|
| 835 |
+
panel = pd.concat(panel_slices, axis=1, join="outer").reset_index().sort_values("date").reset_index(drop=True)
|
| 836 |
+
panel_path = paths["alt_external_processed_dir"] / "external_daily_panel.csv"
|
| 837 |
+
write_csv(panel, panel_path)
|
| 838 |
+
manifest.append(summarize_frame("external_panel", "external", "1d", panel_path, panel))
|
| 839 |
+
return manifest
|
| 840 |
+
|
| 841 |
+
|
| 842 |
+
def build_daily_master_panel(
|
| 843 |
+
paths: dict[str, Path],
|
| 844 |
+
asset_frames: dict[str, dict[str, pd.DataFrame]],
|
| 845 |
+
) -> list[ManifestEntry]:
|
| 846 |
+
manifest: list[ManifestEntry] = []
|
| 847 |
+
panel_slices: list[pd.DataFrame] = []
|
| 848 |
+
|
| 849 |
+
for asset, frame_map in asset_frames.items():
|
| 850 |
+
frame = frame_map["1d"]
|
| 851 |
+
cols = ["date", "open", "high", "low", "close", "return_1", "realized_vol_20", "rsi_14", "target_return_1"]
|
| 852 |
+
asset_frame = frame[cols].rename(
|
| 853 |
+
columns={col: f"{asset}_{col}" for col in cols if col != "date"}
|
| 854 |
+
)
|
| 855 |
+
panel_slices.append(asset_frame.set_index("date"))
|
| 856 |
+
|
| 857 |
+
panel = pd.concat(panel_slices, axis=1, join="outer").reset_index() if panel_slices else None
|
| 858 |
+
|
| 859 |
+
for meta in OPTION_FILES.values():
|
| 860 |
+
asset = meta["asset"]
|
| 861 |
+
option_path = paths["alt_options_processed_dir"] / f"{asset}_options_daily_features.csv"
|
| 862 |
+
if option_path.exists():
|
| 863 |
+
option_frame = pd.read_csv(option_path, parse_dates=["date"])
|
| 864 |
+
option_frame = option_frame.rename(columns={col: f"{asset}_opt_{col}" for col in option_frame.columns if col != "date"})
|
| 865 |
+
panel = panel.merge(option_frame, on="date", how="left") if panel is not None else option_frame
|
| 866 |
+
|
| 867 |
+
external_path = paths["alt_external_processed_dir"] / "external_daily_panel.csv"
|
| 868 |
+
if external_path.exists():
|
| 869 |
+
external_frame = pd.read_csv(external_path, parse_dates=["date"])
|
| 870 |
+
panel = panel.merge(external_frame, on="date", how="left") if panel is not None else external_frame
|
| 871 |
+
|
| 872 |
+
institutional_path = paths["alt_institutional_processed_dir"] / "institutional_daily_panel.csv"
|
| 873 |
+
if institutional_path.exists():
|
| 874 |
+
institutional_frame = pd.read_csv(institutional_path, parse_dates=["date"])
|
| 875 |
+
institutional_frame = institutional_frame.rename(
|
| 876 |
+
columns={col: f"institutional_{col}" for col in institutional_frame.columns if col != "date"}
|
| 877 |
+
)
|
| 878 |
+
panel = panel.merge(institutional_frame, on="date", how="left") if panel is not None else institutional_frame
|
| 879 |
+
|
| 880 |
+
if panel is None:
|
| 881 |
+
return manifest
|
| 882 |
+
|
| 883 |
+
panel = panel.sort_values("date").reset_index(drop=True)
|
| 884 |
+
panel_path = paths["panels_dir"] / "daily_master_panel.csv"
|
| 885 |
+
write_csv(panel, panel_path)
|
| 886 |
+
manifest.append(summarize_frame("panel", "master", "1d", panel_path, panel))
|
| 887 |
+
return manifest
|
| 888 |
+
|
| 889 |
+
|
| 890 |
+
def write_metadata(paths: dict[str, Path], manifest: list[ManifestEntry]) -> None:
|
| 891 |
+
ensure_dir(paths["metadata_dir"])
|
| 892 |
+
ensure_dir(paths["alt_metadata_dir"])
|
| 893 |
+
|
| 894 |
+
manifest_df = pd.DataFrame([entry.__dict__ for entry in manifest]).sort_values(["category", "asset", "timeframe", "path"])
|
| 895 |
+
manifest_path = paths["metadata_dir"] / "manifest.csv"
|
| 896 |
+
write_csv(manifest_df, manifest_path)
|
| 897 |
+
|
| 898 |
+
feature_dictionary = pd.DataFrame(
|
| 899 |
+
[{"feature": key, "description": value} for key, value in FEATURE_DESCRIPTIONS.items()]
|
| 900 |
+
)
|
| 901 |
+
feature_dict_path = paths["metadata_dir"] / "feature_dictionary.csv"
|
| 902 |
+
write_csv(feature_dictionary, feature_dict_path)
|
| 903 |
+
|
| 904 |
+
raw_minute_assets = discover_raw_minute_files(paths["raw_minute_dir"])
|
| 905 |
+
layout = {
|
| 906 |
+
"project_root": str(PROJECT_ROOT),
|
| 907 |
+
"data_root": str(DATA_ROOT),
|
| 908 |
+
"alt_root": str(ALT_ROOT),
|
| 909 |
+
"code_root": str(CODE_ROOT),
|
| 910 |
+
"timeframes": ["1m", "5m", "1h", "4h", "1d"],
|
| 911 |
+
"notes": [
|
| 912 |
+
"Raw minute files were moved into Data/raw/minute.",
|
| 913 |
+
"Raw options files were moved into Alt Data/options/raw.",
|
| 914 |
+
f"Market minute assets currently include: {', '.join(raw_minute_assets.values())}.",
|
| 915 |
+
"Indices in the provided minute files have zero volume throughout, so engineered volume features are intentionally suppressed and replaced with volume_all_zero.",
|
| 916 |
+
"Intraday bars are grouped within each observed session to avoid overnight bars leaking across sessions.",
|
| 917 |
+
],
|
| 918 |
+
}
|
| 919 |
+
layout_path = paths["metadata_dir"] / "layout.json"
|
| 920 |
+
layout_path.write_text(json.dumps(layout, indent=2), encoding="utf-8")
|
| 921 |
+
|
| 922 |
+
|
| 923 |
+
def main() -> None:
|
| 924 |
+
start_date = "2015-01-01"
|
| 925 |
+
end_date = (date.today() + timedelta(days=1)).isoformat()
|
| 926 |
+
|
| 927 |
+
paths = organize_existing_files()
|
| 928 |
+
for key in [
|
| 929 |
+
"bars_dir",
|
| 930 |
+
"features_dir",
|
| 931 |
+
"panels_dir",
|
| 932 |
+
"metadata_dir",
|
| 933 |
+
"alt_options_processed_dir",
|
| 934 |
+
"alt_external_raw_dir",
|
| 935 |
+
"alt_external_processed_dir",
|
| 936 |
+
"alt_institutional_processed_dir",
|
| 937 |
+
"alt_metadata_dir",
|
| 938 |
+
]:
|
| 939 |
+
ensure_dir(paths[key])
|
| 940 |
+
|
| 941 |
+
asset_frames, manifest = build_market_datasets(paths)
|
| 942 |
+
manifest.extend(build_multiactive_panels(paths, asset_frames))
|
| 943 |
+
manifest.extend(build_option_features(paths, asset_frames))
|
| 944 |
+
|
| 945 |
+
yahoo_frames, yahoo_manifest = fetch_yahoo_series(start_date, end_date)
|
| 946 |
+
manifest.extend(yahoo_manifest)
|
| 947 |
+
fred_frames, fred_manifest = fetch_fred_series(start_date)
|
| 948 |
+
manifest.extend(fred_manifest)
|
| 949 |
+
manifest.extend(build_external_panels(yahoo_frames, fred_frames, paths))
|
| 950 |
+
manifest.extend(build_daily_master_panel(paths, asset_frames))
|
| 951 |
+
|
| 952 |
+
write_metadata(paths, manifest)
|
| 953 |
+
print("Research data build complete.")
|
| 954 |
+
|
| 955 |
+
|
| 956 |
+
if __name__ == "__main__":
|
| 957 |
+
main()
|
backend/research_runtime/Code/scripts/data_preparation/process_raw_minutes.py
ADDED
|
@@ -0,0 +1,52 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from __future__ import annotations
|
| 2 |
+
|
| 3 |
+
import argparse
|
| 4 |
+
import os
|
| 5 |
+
import time
|
| 6 |
+
|
| 7 |
+
import pandas as pd
|
| 8 |
+
|
| 9 |
+
import build_research_data as research_data
|
| 10 |
+
|
| 11 |
+
|
| 12 |
+
def parse_args() -> argparse.Namespace:
|
| 13 |
+
parser = argparse.ArgumentParser(description="Process raw minute OHLCV files into bars, features, and market panels.")
|
| 14 |
+
parser.add_argument("--workers", type=int, default=None, help="Parallel asset workers. Defaults to the pipeline setting.")
|
| 15 |
+
return parser.parse_args()
|
| 16 |
+
|
| 17 |
+
|
| 18 |
+
def main() -> None:
|
| 19 |
+
args = parse_args()
|
| 20 |
+
if args.workers is not None:
|
| 21 |
+
os.environ["MARKET_BUILD_WORKERS"] = str(args.workers)
|
| 22 |
+
|
| 23 |
+
start = time.perf_counter()
|
| 24 |
+
paths = research_data.organize_existing_files()
|
| 25 |
+
for key in ["bars_dir", "features_dir", "panels_dir", "metadata_dir"]:
|
| 26 |
+
research_data.ensure_dir(paths[key])
|
| 27 |
+
|
| 28 |
+
asset_frames, manifest = research_data.build_market_datasets(paths)
|
| 29 |
+
manifest.extend(research_data.build_multiactive_panels(paths, asset_frames))
|
| 30 |
+
|
| 31 |
+
existing_manifest = []
|
| 32 |
+
manifest_path = paths["metadata_dir"] / "manifest.csv"
|
| 33 |
+
if manifest_path.exists():
|
| 34 |
+
old = pd.read_csv(manifest_path)
|
| 35 |
+
if not old.empty:
|
| 36 |
+
replace_market = old["category"].isin(["bars", "features"]) | (
|
| 37 |
+
(old["category"] == "panel") & (old["asset"] == "market")
|
| 38 |
+
)
|
| 39 |
+
old = old[~replace_market]
|
| 40 |
+
for row in old.to_dict("records"):
|
| 41 |
+
existing_manifest.append(research_data.ManifestEntry(**row))
|
| 42 |
+
|
| 43 |
+
research_data.write_metadata(paths, existing_manifest + manifest)
|
| 44 |
+
elapsed = time.perf_counter() - start
|
| 45 |
+
print(
|
| 46 |
+
f"Processed raw minute folder: {len(asset_frames)} assets, "
|
| 47 |
+
f"{len(manifest)} market entries, {elapsed:.2f}s"
|
| 48 |
+
)
|
| 49 |
+
|
| 50 |
+
|
| 51 |
+
if __name__ == "__main__":
|
| 52 |
+
main()
|
backend/runtime_config.example.env
ADDED
|
@@ -0,0 +1,14 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# Hugging Face Space backend settings
|
| 2 |
+
FORECASTING_PROJECT_ROOT=/app/research_runtime
|
| 3 |
+
FRONTEND_ORIGINS=https://your-netlify-site.netlify.app
|
| 4 |
+
CRON_SECRET=replace-with-a-long-shared-secret
|
| 5 |
+
HF_DATASET_REPO_ID=your-hf-username/your-forecasting-dataset
|
| 6 |
+
HF_DATASET_REVISION=main
|
| 7 |
+
|
| 8 |
+
# Automatic update settings
|
| 9 |
+
AUTO_UPDATE_ENABLED=true
|
| 10 |
+
AUTO_RETRAIN_ENABLED=true
|
| 11 |
+
AUTO_UPDATE_ON_START=false
|
| 12 |
+
DAILY_UPDATE_TIME=17:30
|
| 13 |
+
UPDATE_TIMEZONE=Asia/Kolkata
|
| 14 |
+
MARKET_BUILD_WORKERS=2
|