Jitendra12421 commited on
Commit
d1d02f6
·
verified ·
1 Parent(s): 1730cb5

Upload 27 files

Browse files
Files changed (27) hide show
  1. backend/.dockerignore +18 -0
  2. backend/.gitattributes +5 -0
  3. backend/Dockerfile +23 -0
  4. backend/README.md +128 -0
  5. backend/app.py +525 -0
  6. backend/requirements.txt +11 -0
  7. backend/research_runtime/Code/models/first_extrema_forecaster/__init__.py +1 -0
  8. backend/research_runtime/Code/models/first_extrema_forecaster/outputs/latest_forecasts.csv +0 -0
  9. backend/research_runtime/Code/models/first_extrema_forecaster/outputs/summary.json +174 -0
  10. backend/research_runtime/Code/models/first_extrema_forecaster/train.py +1466 -0
  11. backend/research_runtime/Code/models/nifty_forecaster/__init__.py +1 -0
  12. backend/research_runtime/Code/models/nifty_forecaster/outputs/forecaster_latest_forecasts.csv +2 -0
  13. backend/research_runtime/Code/models/nifty_forecaster/outputs/forecaster_summary.json +35 -0
  14. backend/research_runtime/Code/models/nifty_forecaster/train.py +1449 -0
  15. backend/research_runtime/Code/models/stock_high_low_forecaster/__init__.py +1 -0
  16. backend/research_runtime/Code/models/stock_high_low_forecaster/outputs/latest_forecasts.csv +9 -0
  17. backend/research_runtime/Code/models/stock_high_low_forecaster/outputs/metrics_by_symbol.csv +9 -0
  18. backend/research_runtime/Code/models/stock_high_low_forecaster/outputs/summary.json +439 -0
  19. backend/research_runtime/Code/models/stock_high_low_forecaster/train.py +1858 -0
  20. backend/research_runtime/Code/scripts/data_ingestion/download_corporate_announcements.py +102 -0
  21. backend/research_runtime/Code/scripts/data_ingestion/download_index_options.py +297 -0
  22. backend/research_runtime/Code/scripts/data_ingestion/download_institutional_flows.py +344 -0
  23. backend/research_runtime/Code/scripts/data_ingestion/refresh_market_data.py +147 -0
  24. backend/research_runtime/Code/scripts/data_ingestion/update_index_minute_data.py +228 -0
  25. backend/research_runtime/Code/scripts/data_preparation/build_research_data.py +957 -0
  26. backend/research_runtime/Code/scripts/data_preparation/process_raw_minutes.py +52 -0
  27. 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