Jitendra12421 commited on
Commit
8103af4
·
verified ·
1 Parent(s): d1d02f6

Delete backend

Browse files
Files changed (27) hide show
  1. backend/.dockerignore +0 -18
  2. backend/.gitattributes +0 -5
  3. backend/Dockerfile +0 -23
  4. backend/README.md +0 -128
  5. backend/app.py +0 -525
  6. backend/requirements.txt +0 -11
  7. backend/research_runtime/Code/models/first_extrema_forecaster/__init__.py +0 -1
  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 +0 -174
  10. backend/research_runtime/Code/models/first_extrema_forecaster/train.py +0 -1466
  11. backend/research_runtime/Code/models/nifty_forecaster/__init__.py +0 -1
  12. backend/research_runtime/Code/models/nifty_forecaster/outputs/forecaster_latest_forecasts.csv +0 -2
  13. backend/research_runtime/Code/models/nifty_forecaster/outputs/forecaster_summary.json +0 -35
  14. backend/research_runtime/Code/models/nifty_forecaster/train.py +0 -1449
  15. backend/research_runtime/Code/models/stock_high_low_forecaster/__init__.py +0 -1
  16. backend/research_runtime/Code/models/stock_high_low_forecaster/outputs/latest_forecasts.csv +0 -9
  17. backend/research_runtime/Code/models/stock_high_low_forecaster/outputs/metrics_by_symbol.csv +0 -9
  18. backend/research_runtime/Code/models/stock_high_low_forecaster/outputs/summary.json +0 -439
  19. backend/research_runtime/Code/models/stock_high_low_forecaster/train.py +0 -1858
  20. backend/research_runtime/Code/scripts/data_ingestion/download_corporate_announcements.py +0 -102
  21. backend/research_runtime/Code/scripts/data_ingestion/download_index_options.py +0 -297
  22. backend/research_runtime/Code/scripts/data_ingestion/download_institutional_flows.py +0 -344
  23. backend/research_runtime/Code/scripts/data_ingestion/refresh_market_data.py +0 -147
  24. backend/research_runtime/Code/scripts/data_ingestion/update_index_minute_data.py +0 -228
  25. backend/research_runtime/Code/scripts/data_preparation/build_research_data.py +0 -957
  26. backend/research_runtime/Code/scripts/data_preparation/process_raw_minutes.py +0 -52
  27. backend/runtime_config.example.env +0 -14
backend/.dockerignore DELETED
@@ -1,18 +0,0 @@
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 DELETED
@@ -1,5 +0,0 @@
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 DELETED
@@ -1,23 +0,0 @@
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 DELETED
@@ -1,128 +0,0 @@
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 DELETED
@@ -1,525 +0,0 @@
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 DELETED
@@ -1,11 +0,0 @@
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 DELETED
@@ -1 +0,0 @@
1
- """First intraday extrema forecaster."""
 
 
backend/research_runtime/Code/models/first_extrema_forecaster/outputs/latest_forecasts.csv DELETED
The diff for this file is too large to render. See raw diff
 
backend/research_runtime/Code/models/first_extrema_forecaster/outputs/summary.json DELETED
@@ -1,174 +0,0 @@
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 DELETED
@@ -1,1466 +0,0 @@
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 DELETED
@@ -1 +0,0 @@
1
- """Daily forecaster package for NIFTY 50 and NIFTY BANK."""
 
 
backend/research_runtime/Code/models/nifty_forecaster/outputs/forecaster_latest_forecasts.csv DELETED
@@ -1,2 +0,0 @@
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 DELETED
@@ -1,35 +0,0 @@
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 DELETED
@@ -1,1449 +0,0 @@
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 DELETED
@@ -1 +0,0 @@
1
- """T+1 high/low forecaster for selected NSE stocks."""
 
 
backend/research_runtime/Code/models/stock_high_low_forecaster/outputs/latest_forecasts.csv DELETED
@@ -1,9 +0,0 @@
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 DELETED
@@ -1,9 +0,0 @@
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 DELETED
@@ -1,439 +0,0 @@
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 DELETED
@@ -1,1858 +0,0 @@
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 DELETED
@@ -1,102 +0,0 @@
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 DELETED
@@ -1,297 +0,0 @@
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 DELETED
@@ -1,344 +0,0 @@
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 DELETED
@@ -1,147 +0,0 @@
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 DELETED
@@ -1,228 +0,0 @@
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 DELETED
@@ -1,957 +0,0 @@
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 DELETED
@@ -1,52 +0,0 @@
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 DELETED
@@ -1,14 +0,0 @@
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