Jitendra12421 commited on
Commit
0ceca62
·
verified ·
1 Parent(s): b57ed72

Upload 42 files

Browse files
Files changed (43) hide show
  1. .gitattributes +2 -0
  2. backend/.dockerignore +5 -0
  3. backend/Dockerfile +15 -0
  4. backend/README.md +39 -0
  5. backend/__init__.py +2 -0
  6. backend/__pycache__/__init__.cpython-311.pyc +0 -0
  7. backend/__pycache__/app.cpython-311.pyc +0 -0
  8. backend/__pycache__/kotak_neo.cpython-311.pyc +0 -0
  9. backend/app.py +536 -0
  10. backend/data/nifty50_1d.parquet +3 -0
  11. backend/data/nifty50_1m.parquet +3 -0
  12. backend/data/opening_direction_training_dataset.parquet +3 -0
  13. backend/data/test_predictions.parquet +3 -0
  14. backend/data/tomorrow_test_predictions.parquet +3 -0
  15. backend/data/tplus1_test_predictions.parquet +3 -0
  16. backend/kotak_neo.py +1257 -0
  17. backend/models/candidate_results.csv +14 -0
  18. backend/models/latest_prediction.csv +2 -0
  19. backend/models/nifty_1420_tplus1_logistic_model.joblib +3 -0
  20. backend/models/nifty_opening_direction_model.joblib +3 -0
  21. backend/models/nifty_tomorrow_direction_model.joblib +3 -0
  22. backend/models/refresh_state.json +7 -0
  23. backend/models/summary.json +29 -0
  24. backend/models/tomorrow_latest_prediction.csv +2 -0
  25. backend/models/tomorrow_summary.json +42 -0
  26. backend/models/tplus1_latest_prediction.csv +2 -0
  27. backend/models/tplus1_summary.json +37 -0
  28. backend/models/yahoo_history_cache.sqlite3 +3 -0
  29. backend/nifty_backend/__init__.py +2 -0
  30. backend/nifty_backend/__pycache__/__init__.cpython-311.pyc +0 -0
  31. backend/nifty_backend/__pycache__/runtime.cpython-311.pyc +3 -0
  32. backend/nifty_backend/__pycache__/yahoo_history_client.cpython-311.pyc +0 -0
  33. backend/nifty_backend/runtime.py +1632 -0
  34. backend/nifty_backend/yahoo_history_client.py +445 -0
  35. backend/requirements.txt +10 -0
  36. backend/scripts/__pycache__/refresh_daily_data.cpython-311.pyc +0 -0
  37. backend/scripts/__pycache__/refresh_first5_prediction.cpython-311.pyc +0 -0
  38. backend/scripts/__pycache__/retrain_opening_model.cpython-311.pyc +0 -0
  39. backend/scripts/__pycache__/run_ist_scheduler.cpython-311.pyc +0 -0
  40. backend/scripts/refresh_daily_data.py +15 -0
  41. backend/scripts/refresh_first5_prediction.py +26 -0
  42. backend/scripts/retrain_opening_model.py +182 -0
  43. backend/scripts/run_ist_scheduler.py +98 -0
.gitattributes CHANGED
@@ -35,3 +35,5 @@ saved_model/**/* filter=lfs diff=lfs merge=lfs -text
35
  *tfevents* filter=lfs diff=lfs merge=lfs -text
36
  models/yahoo_history_cache.sqlite3 filter=lfs diff=lfs merge=lfs -text
37
  nifty_backend/__pycache__/runtime.cpython-311.pyc filter=lfs diff=lfs merge=lfs -text
 
 
 
35
  *tfevents* filter=lfs diff=lfs merge=lfs -text
36
  models/yahoo_history_cache.sqlite3 filter=lfs diff=lfs merge=lfs -text
37
  nifty_backend/__pycache__/runtime.cpython-311.pyc filter=lfs diff=lfs merge=lfs -text
38
+ backend/models/yahoo_history_cache.sqlite3 filter=lfs diff=lfs merge=lfs -text
39
+ backend/nifty_backend/__pycache__/runtime.cpython-311.pyc filter=lfs diff=lfs merge=lfs -text
backend/.dockerignore ADDED
@@ -0,0 +1,5 @@
 
 
 
 
 
 
1
+ __pycache__/
2
+ *.pyc
3
+ .venv/
4
+ venv/
5
+ .env
backend/Dockerfile ADDED
@@ -0,0 +1,15 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ FROM python:3.11-slim
2
+
3
+ ENV PYTHONDONTWRITEBYTECODE=1
4
+ ENV PYTHONUNBUFFERED=1
5
+
6
+ WORKDIR /app
7
+
8
+ COPY requirements.txt .
9
+ RUN pip install --no-cache-dir -r requirements.txt
10
+
11
+ COPY . .
12
+
13
+ EXPOSE 7860
14
+
15
+ CMD ["uvicorn", "app:app", "--host", "0.0.0.0", "--port", "7860", "--ws", "none"]
backend/README.md ADDED
@@ -0,0 +1,39 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ ---
2
+ title: NIFTY 50 Forecaster Backend
3
+ emoji: 📈
4
+ colorFrom: green
5
+ colorTo: blue
6
+ sdk: docker
7
+ app_port: 7860
8
+ ---
9
+
10
+ # NIFTY 50 Forecaster Backend
11
+
12
+ FastAPI Hugging Face Docker Space for the NIFTY 50 first-five-minute direction forecaster.
13
+
14
+ ## Endpoints
15
+
16
+ - `GET /health`
17
+ - `GET /dashboard`
18
+ - `GET /prediction/latest`
19
+ - `POST /prediction/refresh-first5`
20
+ - `POST /data/refresh-daily`
21
+ - `GET /cron/keepalive`
22
+ - `POST /data/refresh-market-close`
23
+
24
+ ## Data
25
+
26
+ Parquet files live in `data/`:
27
+
28
+ - `nifty50_1m.parquet`
29
+ - `nifty50_1d.parquet`
30
+ - `opening_direction_training_dataset.parquet`
31
+ - `test_predictions.parquet`
32
+
33
+ ## Runtime
34
+
35
+ The API starts a daily background refresh loop. It wakes after `09:20 Asia/Kolkata`, fetches Yahoo Finance `^NSEI` 1-minute candles for the `09:15-09:19` opening window, appends them to Parquet, and writes the latest T+5 prediction.
36
+
37
+ After market close it wakes again at `15:45 Asia/Kolkata`, refreshes the 1-minute and daily Parquet files, updates the opening training dataset with same-day close outcomes, and writes the saved prediction record used for the next trading session card. The `/cron/keepalive` endpoint also checks this close refresh so a Hugging Face Space that was idled still catches up when Netlify pings it.
38
+
39
+ Netlify also pings `/cron/keepalive` every 10 minutes through its scheduled function.
backend/__init__.py ADDED
@@ -0,0 +1,2 @@
 
 
 
1
+ """NIFTY Project backend package."""
2
+
backend/__pycache__/__init__.cpython-311.pyc ADDED
Binary file (211 Bytes). View file
 
backend/__pycache__/app.cpython-311.pyc ADDED
Binary file (30.7 kB). View file
 
backend/__pycache__/kotak_neo.cpython-311.pyc ADDED
Binary file (74.3 kB). View file
 
backend/app.py ADDED
@@ -0,0 +1,536 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from __future__ import annotations
2
+
3
+ import asyncio
4
+ import threading
5
+ from datetime import date, datetime, time, timedelta
6
+
7
+ import sys
8
+ from pathlib import Path
9
+
10
+ from fastapi import BackgroundTasks, HTTPException, Query
11
+ from fastapi.middleware.cors import CORSMiddleware
12
+ from fastapi import FastAPI
13
+ from pydantic import BaseModel
14
+
15
+ sys.path.insert(0, str(Path(__file__).resolve().parent))
16
+ from nifty_backend.runtime import (
17
+ CLOSE_REFRESH_READY,
18
+ IST,
19
+ STALE_CHECK_INTERVAL_SECONDS,
20
+ TPLUS1_READY,
21
+ close_refresh_due,
22
+ dashboard_payload,
23
+ is_trading_day,
24
+ latest_saved_prediction,
25
+ latest_tplus1_prediction,
26
+ next_trading_day,
27
+ refresh_daily_data,
28
+ refresh_first5_prediction,
29
+ refresh_market_close_data,
30
+ refresh_stale_data_once,
31
+ refresh_tplus1_prediction,
32
+ seconds_until_next_ist_run,
33
+ warm_dashboard_payload_cache,
34
+ )
35
+ from kotak_neo import (
36
+ KotakNeoConfigError,
37
+ KotakNeoError,
38
+ KotakNeoSessionRequired,
39
+ kotak_neo_manager,
40
+ )
41
+
42
+
43
+ app = FastAPI(title="NIFTY 50 Forecaster Backend")
44
+ app.add_middleware(
45
+ CORSMiddleware,
46
+ allow_origins=["*"],
47
+ allow_credentials=False,
48
+ allow_methods=["*"],
49
+ allow_headers=["*"],
50
+ )
51
+
52
+
53
+ market_status = "Waiting for next session"
54
+ close_refresh_lock = threading.Lock()
55
+ tplus1_refresh_lock = threading.Lock()
56
+ MARKET_OPEN = time(9, 15)
57
+ FIRST5_READY = time(9, 20)
58
+ MARKET_CLOSE = time(15, 30)
59
+
60
+
61
+ class TotpRequest(BaseModel):
62
+ totp: str
63
+
64
+
65
+ def refresh_market_close_data_if_due() -> dict:
66
+ if not close_refresh_due():
67
+ return {"status": "skipped", "reason": "close refresh is not due"}
68
+ if not close_refresh_lock.acquire(blocking=False):
69
+ return {"status": "skipped", "reason": "close refresh already running"}
70
+ try:
71
+ info = refresh_market_close_data()
72
+ return {"status": "refreshed", **info}
73
+ finally:
74
+ close_refresh_lock.release()
75
+
76
+
77
+ def latest_tplus1_prediction_date(payload: dict | None = None) -> date | None:
78
+ try:
79
+ latest = payload if payload is not None else latest_tplus1_prediction()
80
+ raw = latest.get("input_date")
81
+ return date.fromisoformat(str(raw)[:10]) if raw else None
82
+ except Exception:
83
+ return None
84
+
85
+
86
+ def tplus1_refresh_due(now: datetime | None = None, latest_date: date | None = None) -> bool:
87
+ now = now or datetime.now(IST)
88
+ if not is_trading_day(now.date()) or not (TPLUS1_READY <= now.time() < MARKET_CLOSE):
89
+ return False
90
+ latest_date = latest_date if latest_date is not None else latest_tplus1_prediction_date()
91
+ return latest_date != now.date()
92
+
93
+
94
+ def refresh_tplus1_if_due() -> dict:
95
+ now = datetime.now(IST)
96
+ latest_date = latest_tplus1_prediction_date()
97
+ if not tplus1_refresh_due(now=now, latest_date=latest_date):
98
+ return {"status": "skipped", "reason": "tplus1 refresh is not due"}
99
+ if not tplus1_refresh_lock.acquire(blocking=False):
100
+ return {"status": "skipped", "reason": "tplus1 refresh already running"}
101
+ try:
102
+ prediction = refresh_tplus1_prediction(session_date=now.date())
103
+ return {"status": "refreshed", "prediction": prediction}
104
+ finally:
105
+ tplus1_refresh_lock.release()
106
+
107
+
108
+ def latest_prediction_date(payload: dict | None = None) -> date | None:
109
+ try:
110
+ latest = payload if payload is not None else latest_saved_prediction()
111
+ raw = latest.get("input_date")
112
+ return date.fromisoformat(str(raw)) if raw else None
113
+ except Exception:
114
+ return None
115
+
116
+
117
+ def current_market_state(now: datetime | None = None) -> dict:
118
+ global market_status
119
+ now = now or datetime.now(IST)
120
+ today = now.date()
121
+ current_time = now.time()
122
+ trading_day = is_trading_day(today)
123
+ latest_date = latest_prediction_date()
124
+ market_is_open_for_t5 = trading_day and FIRST5_READY <= current_time < MARKET_CLOSE
125
+ market_is_open_for_tplus1 = trading_day and TPLUS1_READY <= current_time < MARKET_CLOSE
126
+ has_current_first5 = market_is_open_for_t5 and latest_date == today
127
+ tplus1_latest_date = latest_tplus1_prediction_date()
128
+ has_current_tplus1 = market_is_open_for_tplus1 and tplus1_latest_date == today
129
+ next_session = today if trading_day and current_time < MARKET_CLOSE else next_trading_day(today + timedelta(days=1))
130
+
131
+ if not trading_day:
132
+ status = "Market Closed"
133
+ detail = f"Next trading session is {next_session.isoformat()}."
134
+ elif current_time < time(9, 0):
135
+ status = "Waiting for 9:00 AM"
136
+ detail = "Market has not entered pre-open yet."
137
+ elif current_time < MARKET_OPEN:
138
+ status = "Market Pre-Open"
139
+ detail = "Market opens at 9:15 AM IST."
140
+ elif current_time < FIRST5_READY:
141
+ status = "Market Officially Opened"
142
+ detail = "Waiting for the first 5 one-minute bars."
143
+ elif current_time <= MARKET_CLOSE:
144
+ if market_status in {"Fetching T+5 Prediction Data...", "Prediction Failed"}:
145
+ status = market_status
146
+ detail = "The first-five-minute prediction job is still resolving."
147
+ elif has_current_first5:
148
+ status = "Prediction Ready"
149
+ detail = "Today's first-five-minute prediction is available."
150
+ else:
151
+ status = "Prediction Pending"
152
+ detail = "No current-session prediction has been generated yet."
153
+ else:
154
+ status = "Market Closed"
155
+ detail = "Trading session has ended."
156
+
157
+ if not trading_day:
158
+ tplus1_status = "Market Closed"
159
+ tplus1_detail = f"Next trading session is {next_session.isoformat()}."
160
+ elif current_time < TPLUS1_READY:
161
+ tplus1_status = "Waiting for 2:30 PM"
162
+ tplus1_detail = "The T+1 forecast becomes available at 2:30 PM IST."
163
+ elif current_time < MARKET_CLOSE:
164
+ if has_current_tplus1:
165
+ tplus1_status = "Ready"
166
+ tplus1_detail = "Today's T+1 prediction is available."
167
+ else:
168
+ tplus1_status = "Pending"
169
+ tplus1_detail = "No current-session T+1 prediction has been generated yet."
170
+ else:
171
+ tplus1_status = "Market Closed"
172
+ tplus1_detail = "Trading session has ended."
173
+
174
+ if not trading_day:
175
+ t5_status = "Market Closed"
176
+ t5_detail = f"Next trading session is {next_session.isoformat()}."
177
+ elif current_time < FIRST5_READY:
178
+ t5_status = "Waiting for 9:20 AM"
179
+ t5_detail = "The T+5 forecast becomes available after the first five one-minute bars."
180
+ elif current_time < MARKET_CLOSE:
181
+ if market_status in {"Fetching T+5 Prediction Data...", "Prediction Failed"}:
182
+ t5_status = market_status
183
+ t5_detail = "The first-five-minute prediction job is still resolving."
184
+ elif has_current_first5:
185
+ t5_status = "Ready"
186
+ t5_detail = "Today's first-five-minute prediction is available."
187
+ else:
188
+ t5_status = "Pending"
189
+ t5_detail = "No current-session prediction has been generated yet."
190
+ else:
191
+ t5_status = "Market Closed"
192
+ t5_detail = "Trading session has ended."
193
+
194
+ return {
195
+ "market_status": status,
196
+ "market_detail": detail,
197
+ "server_time_ist": now.isoformat(),
198
+ "is_trading_day": trading_day,
199
+ "session_date": today.isoformat(),
200
+ "next_session_date": next_session.isoformat(),
201
+ "latest_prediction_date": latest_date.isoformat() if latest_date else None,
202
+ "t5_available": has_current_first5,
203
+ "t5_status": t5_status,
204
+ "t5_detail": t5_detail,
205
+ "market_is_open_for_t5": market_is_open_for_t5,
206
+ "tplus1_available": has_current_tplus1,
207
+ "tplus1_status": tplus1_status,
208
+ "tplus1_detail": tplus1_detail,
209
+ "market_is_open_for_tplus1": market_is_open_for_tplus1,
210
+ "latest_tplus1_prediction_date": tplus1_latest_date.isoformat() if tplus1_latest_date else None,
211
+ }
212
+
213
+
214
+ def attach_market_state(payload: dict) -> dict:
215
+ state = current_market_state()
216
+ payload.setdefault("data_status", {})
217
+ payload["data_status"].update(state)
218
+ try:
219
+ payload["nifty_quote"] = kotak_neo_manager.fetch_nifty50_quote()
220
+ payload["nifty_quote_error"] = None
221
+ except KotakNeoSessionRequired as exc:
222
+ payload["nifty_quote"] = None
223
+ payload["nifty_quote_error"] = {"status": 401, "message": str(exc)}
224
+ except KotakNeoConfigError as exc:
225
+ payload["nifty_quote"] = None
226
+ payload["nifty_quote_error"] = {"status": 503, "message": str(exc)}
227
+ except KotakNeoError as exc:
228
+ payload["nifty_quote"] = None
229
+ payload["nifty_quote_error"] = {"status": 502, "message": str(exc)}
230
+
231
+ t5_latest = payload.get("latest") or {}
232
+ tomorrow_latest = payload.get("tomorrow_latest") or {}
233
+ tplus1_latest = payload.get("tplus1_latest") or {}
234
+ t5_available = bool(state["t5_available"] and t5_latest.get("prediction"))
235
+ tplus1_available = bool(state["tplus1_available"] and tplus1_latest.get("prediction"))
236
+ tomorrow_available = bool(tomorrow_latest.get("prediction"))
237
+ refresh_phase = payload.get("data_status", {}).get("refresh_phase")
238
+ if refresh_phase in {"waiting_second_payload", "refreshing"}:
239
+ tomorrow_status = "WAITING FOR SECOND PAYLOAD"
240
+ tomorrow_reason = "Market close refresh is generating the next-session payload."
241
+ else:
242
+ tomorrow_status = "Ready" if tomorrow_available else "Pending"
243
+ tomorrow_reason = None if tomorrow_available else "No saved next-session signal is available."
244
+ payload["predictions"] = {
245
+ "tomorrow": {
246
+ "available": tomorrow_available,
247
+ "status": tomorrow_status,
248
+ "reason": tomorrow_reason,
249
+ "target_date": tomorrow_latest.get("target_date") or state["next_session_date"],
250
+ "input_date": tomorrow_latest.get("input_date"),
251
+ "prediction": tomorrow_latest.get("prediction") if tomorrow_available else None,
252
+ "prob_up": tomorrow_latest.get("prob_up") if tomorrow_available else None,
253
+ "confidence": tomorrow_latest.get("confidence") if tomorrow_available else None,
254
+ "threshold": tomorrow_latest.get("threshold") if tomorrow_available else None,
255
+ "model_name": tomorrow_latest.get("model_name"),
256
+ "source_model": tomorrow_latest.get("source_model"),
257
+ "validation_accuracy": tomorrow_latest.get("validation_accuracy"),
258
+ "test_accuracy": tomorrow_latest.get("test_accuracy"),
259
+ },
260
+ "t5": {
261
+ "available": t5_available,
262
+ "status": "Ready" if t5_available else state["t5_status"],
263
+ "reason": None if t5_available else state["t5_detail"],
264
+ "input_date": t5_latest.get("input_date"),
265
+ "prediction": t5_latest.get("prediction") if t5_available else None,
266
+ "prob_up": t5_latest.get("prob_up") if t5_available else None,
267
+ "confidence": t5_latest.get("confidence") if t5_available else None,
268
+ "threshold": t5_latest.get("threshold") if t5_available else None,
269
+ "model_name": t5_latest.get("model_name"),
270
+ "validation_accuracy": (payload.get("summary") or {}).get("validation_accuracy"),
271
+ "test_accuracy": (payload.get("summary") or {}).get("test_accuracy"),
272
+ },
273
+ "tplus1": {
274
+ "available": tplus1_available,
275
+ "status": "Ready" if tplus1_available else state["tplus1_status"],
276
+ "reason": None if tplus1_available else state["tplus1_detail"],
277
+ "target_date": tplus1_latest.get("target_date") or state["next_session_date"],
278
+ "input_date": tplus1_latest.get("input_date"),
279
+ "prediction": tplus1_latest.get("prediction") if tplus1_available else None,
280
+ "prob_up": tplus1_latest.get("prob_up") if tplus1_available else None,
281
+ "confidence": tplus1_latest.get("confidence") if tplus1_available else None,
282
+ "threshold": tplus1_latest.get("threshold") if tplus1_available else None,
283
+ "model_name": tplus1_latest.get("model_name"),
284
+ "validation_accuracy": (payload.get("tplus1_summary") or {}).get("validation_accuracy"),
285
+ "test_accuracy": (payload.get("tplus1_summary") or {}).get("test_accuracy"),
286
+ },
287
+ }
288
+ return payload
289
+
290
+
291
+ async def daily_ist_refresh_loop() -> None:
292
+ global market_status
293
+ while True:
294
+ # Wait until 9:00 AM IST
295
+ await asyncio.sleep(seconds_until_next_ist_run(time(9, 0)))
296
+ if not is_trading_day(datetime.now(IST).date()):
297
+ market_status = "Market Closed"
298
+ continue
299
+ market_status = "Market Pre-Open"
300
+ print("[scheduler] 9:00 AM IST - Market Pre-Open", flush=True)
301
+
302
+ # Wait until 9:15 AM IST
303
+ await asyncio.sleep(seconds_until_next_ist_run(time(9, 15)))
304
+ market_status = "Market Officially Opened"
305
+ print("[scheduler] 9:15 AM IST - Market Officially Opened", flush=True)
306
+
307
+ # Wait until 9:20 AM IST
308
+ await asyncio.sleep(seconds_until_next_ist_run(time(9, 20)))
309
+ market_status = "Fetching T+5 Prediction Data..."
310
+ print("[scheduler] 9:20 AM IST - Fetching Data", flush=True)
311
+
312
+ try:
313
+ await asyncio.to_thread(refresh_first5_prediction)
314
+ market_status = "Prediction Ready"
315
+ except Exception as exc:
316
+ print(f"[scheduler] first5 refresh failed: {exc}", flush=True)
317
+ market_status = "Prediction Failed"
318
+
319
+ try:
320
+ await asyncio.to_thread(refresh_daily_data)
321
+ except Exception as exc:
322
+ print(f"[scheduler] daily refresh failed: {exc}", flush=True)
323
+
324
+ await asyncio.sleep(seconds_until_next_ist_run(TPLUS1_READY))
325
+ print("[scheduler] 2:30 PM IST - Refreshing T+1 prediction", flush=True)
326
+ try:
327
+ info = await asyncio.to_thread(refresh_tplus1_if_due)
328
+ print(f"[scheduler] tplus1 refresh result: {info}", flush=True)
329
+ except Exception as exc:
330
+ print(f"[scheduler] tplus1 refresh failed: {exc}", flush=True)
331
+
332
+ await asyncio.sleep(seconds_until_next_ist_run(CLOSE_REFRESH_READY))
333
+ print("[scheduler] 3:45 PM IST - Refreshing close data", flush=True)
334
+ try:
335
+ info = await asyncio.to_thread(refresh_market_close_data_if_due)
336
+ print(f"[scheduler] close refresh result: {info}", flush=True)
337
+ except Exception as exc:
338
+ print(f"[scheduler] close refresh failed: {exc}", flush=True)
339
+
340
+
341
+ async def refresh_current_session_once() -> None:
342
+ global market_status
343
+ now = datetime.now(IST)
344
+ if not is_trading_day(now.date()) or now.time() < FIRST5_READY:
345
+ return
346
+ if latest_prediction_date() == now.date():
347
+ return
348
+ market_status = "Fetching T+5 Prediction Data..."
349
+ print("[startup] Current session needs first-five refresh; fetching now.", flush=True)
350
+ try:
351
+ await asyncio.to_thread(refresh_first5_prediction)
352
+ market_status = "Prediction Ready"
353
+ except Exception as exc:
354
+ print(f"[startup] first5 refresh failed: {exc}", flush=True)
355
+ market_status = "Prediction Failed"
356
+ try:
357
+ await asyncio.to_thread(refresh_daily_data)
358
+ except Exception as exc:
359
+ print(f"[startup] daily refresh failed: {exc}", flush=True)
360
+
361
+
362
+ async def refresh_market_close_once_if_due() -> None:
363
+ try:
364
+ info = await asyncio.to_thread(refresh_market_close_data_if_due)
365
+ if info.get("status") == "refreshed":
366
+ print(f"[startup] close refresh result: {info}", flush=True)
367
+ except Exception as exc:
368
+ print(f"[startup] close refresh failed: {exc}", flush=True)
369
+
370
+
371
+ async def refresh_tplus1_once_if_due() -> None:
372
+ try:
373
+ info = await asyncio.to_thread(refresh_tplus1_if_due)
374
+ if info.get("status") == "refreshed":
375
+ print(f"[startup] tplus1 refresh result: {info}", flush=True)
376
+ except Exception as exc:
377
+ print(f"[startup] tplus1 refresh failed: {exc}", flush=True)
378
+
379
+
380
+ async def warm_dashboard_payload_cache_once() -> None:
381
+ try:
382
+ await asyncio.to_thread(warm_dashboard_payload_cache)
383
+ except Exception as exc:
384
+ print(f"[startup] dashboard payload warmup failed: {exc}", flush=True)
385
+
386
+
387
+ async def stale_data_watch_loop() -> None:
388
+ while True:
389
+ try:
390
+ info = await asyncio.to_thread(refresh_stale_data_once)
391
+ if info.get("status") == "refreshed":
392
+ print(f"[stale-watch] refreshed stale data: {info}", flush=True)
393
+ except Exception as exc:
394
+ print(f"[stale-watch] stale refresh failed: {exc}", flush=True)
395
+ await asyncio.sleep(STALE_CHECK_INTERVAL_SECONDS)
396
+
397
+
398
+ @app.on_event("startup")
399
+ async def start_scheduler() -> None:
400
+ global market_status
401
+ # Initialize correct status on startup based on current time
402
+ now = datetime.now(IST).time()
403
+ today = datetime.now(IST).date()
404
+ if not is_trading_day(today):
405
+ market_status = "Market Closed"
406
+ elif now < time(9, 0):
407
+ market_status = "Waiting for 9:00 AM"
408
+ elif now < time(9, 15):
409
+ market_status = "Market Pre-Open"
410
+ elif now < time(9, 20):
411
+ market_status = "Market Officially Opened"
412
+ elif latest_prediction_date() == today:
413
+ market_status = "Prediction Ready"
414
+ else:
415
+ market_status = "Prediction Pending"
416
+
417
+ asyncio.create_task(refresh_current_session_once())
418
+ asyncio.create_task(refresh_tplus1_once_if_due())
419
+ asyncio.create_task(refresh_market_close_once_if_due())
420
+ asyncio.create_task(warm_dashboard_payload_cache_once())
421
+ asyncio.create_task(stale_data_watch_loop())
422
+ asyncio.create_task(daily_ist_refresh_loop())
423
+
424
+
425
+ @app.get("/health")
426
+ def health() -> dict[str, str]:
427
+ return {"status": "ok"}
428
+
429
+
430
+ @app.get("/")
431
+ def root() -> dict[str, str]:
432
+ return {"service": "NIFTY 50 Forecaster Backend", "status": "ok"}
433
+
434
+
435
+ @app.get("/dashboard")
436
+ def dashboard() -> dict:
437
+ return attach_market_state(dashboard_payload())
438
+
439
+
440
+ @app.get("/kotak/status")
441
+ def kotak_status() -> dict:
442
+ return kotak_neo_manager.status()
443
+
444
+
445
+ @app.post("/kotak/auth/totp")
446
+ def kotak_auth_totp(payload: TotpRequest) -> dict:
447
+ try:
448
+ return kotak_neo_manager.authenticate_with_totp(payload.totp)
449
+ except KotakNeoConfigError as exc:
450
+ raise HTTPException(status_code=503, detail=str(exc)) from exc
451
+ except KotakNeoError as exc:
452
+ raise HTTPException(status_code=400, detail=str(exc)) from exc
453
+
454
+
455
+ @app.get("/kotak/account")
456
+ def kotak_account() -> dict:
457
+ try:
458
+ return kotak_neo_manager.fetch_account_snapshot()
459
+ except KotakNeoConfigError as exc:
460
+ raise HTTPException(status_code=503, detail=str(exc)) from exc
461
+ except KotakNeoSessionRequired as exc:
462
+ raise HTTPException(status_code=401, detail=str(exc)) from exc
463
+ except KotakNeoError as exc:
464
+ raise HTTPException(status_code=502, detail=str(exc)) from exc
465
+
466
+
467
+ @app.get("/kotak/quote/nifty50")
468
+ def kotak_nifty50_quote() -> dict:
469
+ try:
470
+ return kotak_neo_manager.fetch_nifty50_quote()
471
+ except KotakNeoConfigError as exc:
472
+ raise HTTPException(status_code=503, detail=str(exc)) from exc
473
+ except KotakNeoSessionRequired as exc:
474
+ raise HTTPException(status_code=401, detail=str(exc)) from exc
475
+ except KotakNeoError as exc:
476
+ raise HTTPException(status_code=502, detail=str(exc)) from exc
477
+
478
+
479
+ @app.get("/kotak/activity-log")
480
+ def kotak_activity_log() -> dict:
481
+ try:
482
+ snapshot = kotak_neo_manager.fetch_account_snapshot()
483
+ return {
484
+ "activity_log": snapshot.get("activity_log", {}),
485
+ "trade_history": snapshot.get("trade_history", []),
486
+ "order_book": snapshot.get("order_book", []),
487
+ }
488
+ except KotakNeoConfigError as exc:
489
+ raise HTTPException(status_code=503, detail=str(exc)) from exc
490
+ except KotakNeoSessionRequired as exc:
491
+ raise HTTPException(status_code=401, detail=str(exc)) from exc
492
+ except KotakNeoError as exc:
493
+ raise HTTPException(status_code=502, detail=str(exc)) from exc
494
+
495
+
496
+ @app.get("/cron/keepalive")
497
+ def cron_keepalive(background_tasks: BackgroundTasks) -> dict:
498
+ close_refresh = {"status": "not_checked"}
499
+ tplus1_refresh = {"status": "not_checked"}
500
+ if tplus1_refresh_due():
501
+ background_tasks.add_task(refresh_tplus1_if_due)
502
+ tplus1_refresh = {"status": "scheduled"}
503
+ if close_refresh_due():
504
+ background_tasks.add_task(refresh_market_close_data_if_due)
505
+ close_refresh = {"status": "scheduled"}
506
+ return {
507
+ "status": "awake",
508
+ "market": current_market_state(),
509
+ "tplus1_refresh": tplus1_refresh,
510
+ "close_refresh": close_refresh,
511
+ }
512
+
513
+
514
+ @app.get("/prediction/latest")
515
+ def prediction_latest() -> dict:
516
+ return latest_saved_prediction()
517
+
518
+
519
+ @app.post("/prediction/refresh-first5")
520
+ def prediction_refresh_first5(
521
+ session_date: date | None = Query(default=None, description="Optional YYYY-MM-DD session date in IST."),
522
+ ) -> dict:
523
+ prediction = refresh_first5_prediction(session_date=session_date)
524
+ return prediction.to_dict()
525
+
526
+
527
+ @app.post("/data/refresh-daily")
528
+ def data_refresh_daily() -> dict:
529
+ return refresh_daily_data()
530
+
531
+
532
+ @app.post("/data/refresh-market-close")
533
+ def data_refresh_market_close(
534
+ session_date: date | None = Query(default=None, description="Optional YYYY-MM-DD session date in IST."),
535
+ ) -> dict:
536
+ return refresh_market_close_data(session_date=session_date)
backend/data/nifty50_1d.parquet ADDED
@@ -0,0 +1,3 @@
 
 
 
 
1
+ version https://git-lfs.github.com/spec/v1
2
+ oid sha256:4d0830f9d3cfa91f02ce717b556983beb12f897bc537a623c67e38f22ce05caf
3
+ size 78366
backend/data/nifty50_1m.parquet ADDED
@@ -0,0 +1,3 @@
 
 
 
 
1
+ version https://git-lfs.github.com/spec/v1
2
+ oid sha256:b5d6022df273daa1f020676214ec35536426fd84f2d7efb669797287e27ffa2d
3
+ size 18589635
backend/data/opening_direction_training_dataset.parquet ADDED
@@ -0,0 +1,3 @@
 
 
 
 
1
+ version https://git-lfs.github.com/spec/v1
2
+ oid sha256:722c8225451abcb49463d2a57354bc0c9b5eb519f31a25fa3c5242adc4dbbada
3
+ size 4463631
backend/data/test_predictions.parquet ADDED
@@ -0,0 +1,3 @@
 
 
 
 
1
+ version https://git-lfs.github.com/spec/v1
2
+ oid sha256:f159a7499394b7882262ffaa6f9b48c0f6ab763d024f373ee19109b301af90c1
3
+ size 14499
backend/data/tomorrow_test_predictions.parquet ADDED
@@ -0,0 +1,3 @@
 
 
 
 
1
+ version https://git-lfs.github.com/spec/v1
2
+ oid sha256:a2b6ecb377d114c825f861d9a6741e01bbf23bfc623493fd01c78e8bb1501960
3
+ size 10751
backend/data/tplus1_test_predictions.parquet ADDED
@@ -0,0 +1,3 @@
 
 
 
 
1
+ version https://git-lfs.github.com/spec/v1
2
+ oid sha256:bd7f63239d5705969cbe5423169c9fdcb88d59b838b02913d0aa5c455a7ca41c
3
+ size 13681
backend/kotak_neo.py ADDED
@@ -0,0 +1,1257 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from __future__ import annotations
2
+
3
+ import os
4
+ import threading
5
+ from csv import DictReader
6
+ from concurrent.futures import ThreadPoolExecutor, as_completed
7
+ from datetime import date, datetime, time, timezone
8
+ from functools import lru_cache
9
+ from pathlib import Path
10
+ from time import monotonic
11
+ from typing import Any
12
+ from urllib.parse import quote
13
+ from zoneinfo import ZoneInfo
14
+
15
+ import json
16
+
17
+ import pandas as pd
18
+ import requests
19
+
20
+ try:
21
+ import pandas_market_calendars as mcal
22
+ except Exception: # pragma: no cover - deployed environments may fall back to weekdays
23
+ mcal = None
24
+
25
+
26
+ SESSION_BASE_URL = "https://mis.kotaksecurities.com"
27
+ QUOTE_PATH_TEMPLATE = "script-details/1.0/quotes/neosymbol/{neo_symbols}/{quote_type}"
28
+ TOTP_LOGIN_PATH = "login/1.0/tradeApiLogin"
29
+ TOTP_VALIDATE_PATH = "login/1.0/tradeApiValidate"
30
+ DEFAULT_TIMEOUT_SECONDS = 20
31
+ ACCOUNT_TIMEOUT_SECONDS = 7
32
+ NIFTY_QUOTE_TIMEOUT_SECONDS = 2.5
33
+ NIFTY_QUOTE_CACHE_SECONDS = 1.0
34
+ DATA_DIR = Path(__file__).resolve().parent / "data"
35
+ KOTAK_ACTIVITY_LOG_PATH = DATA_DIR / "kotak_activity_log.txt"
36
+ NIFTY_1M_PATH = DATA_DIR / "nifty50_1m.parquet"
37
+ NIFTY_1D_PATH = DATA_DIR / "nifty50_1d.parquet"
38
+ IST = ZoneInfo("Asia/Kolkata")
39
+ MARKET_OPEN_TIME = time(9, 15)
40
+ MARKET_CLOSE_TIME = time(15, 30)
41
+
42
+
43
+ class KotakNeoError(Exception):
44
+ pass
45
+
46
+
47
+ class KotakNeoConfigError(KotakNeoError):
48
+ pass
49
+
50
+
51
+ class KotakNeoSessionRequired(KotakNeoError):
52
+ pass
53
+
54
+
55
+ def _utc_now_iso() -> str:
56
+ return datetime.now(timezone.utc).isoformat()
57
+
58
+
59
+ def _to_float(value: Any) -> float | None:
60
+ if value in (None, "", "--", "NA", "na", "-", "null"):
61
+ return None
62
+ try:
63
+ return float(value)
64
+ except (TypeError, ValueError):
65
+ return None
66
+
67
+
68
+ def _first_number(*values: Any) -> float | None:
69
+ for value in values:
70
+ parsed = _to_float(value)
71
+ if parsed is not None:
72
+ return parsed
73
+ return None
74
+
75
+
76
+ def _first_market_number(*values: Any) -> float | None:
77
+ fallback = None
78
+ for value in values:
79
+ parsed = _to_float(value)
80
+ if parsed is None:
81
+ continue
82
+ if fallback is None:
83
+ fallback = parsed
84
+ if parsed > 0:
85
+ return parsed
86
+ return fallback
87
+
88
+
89
+ def _normalize_frame_dates(frame: pd.DataFrame) -> pd.Series:
90
+ values = pd.to_datetime(frame["date"], errors="coerce")
91
+ if getattr(values.dt, "tz", None) is None:
92
+ values = values.dt.tz_localize(IST)
93
+ else:
94
+ values = values.dt.tz_convert(IST)
95
+ return values
96
+
97
+
98
+ @lru_cache(maxsize=1)
99
+ def _nse_calendar():
100
+ if mcal is None:
101
+ return None
102
+ for name in ("XNSE", "NSE", "BSE"):
103
+ try:
104
+ return mcal.get_calendar(name)
105
+ except Exception:
106
+ continue
107
+ return None
108
+
109
+
110
+ @lru_cache(maxsize=64)
111
+ def _is_nse_trading_day(day: date) -> bool:
112
+ calendar = _nse_calendar()
113
+ if calendar is None:
114
+ return day.weekday() < 5
115
+ return not calendar.schedule(start_date=day, end_date=day).empty
116
+
117
+
118
+ @lru_cache(maxsize=64)
119
+ def _previous_nse_trading_day(day: date) -> date:
120
+ candidate = day
121
+ for _ in range(21):
122
+ candidate = date.fromordinal(candidate.toordinal() - 1)
123
+ if _is_nse_trading_day(candidate):
124
+ return candidate
125
+ return candidate
126
+
127
+
128
+ def _file_version(path: Path) -> tuple[str, int | None, int | None]:
129
+ try:
130
+ stat = path.stat()
131
+ return (str(path), stat.st_mtime_ns, stat.st_size)
132
+ except OSError:
133
+ return (str(path), None, None)
134
+
135
+
136
+ @lru_cache(maxsize=4)
137
+ def _load_nifty_daily_frame(file_version: tuple[str, int | None, int | None]) -> pd.DataFrame:
138
+ path = Path(file_version[0])
139
+ daily = pd.read_parquet(path, columns=["date", "open", "high", "low", "close"]).copy()
140
+ daily["date"] = pd.to_datetime(daily["date"], errors="coerce").dt.date
141
+ return daily.dropna(subset=["date"]).sort_values("date")
142
+
143
+
144
+ @lru_cache(maxsize=4)
145
+ def _load_nifty_minute_frame(file_version: tuple[str, int | None, int | None]) -> pd.DataFrame:
146
+ path = Path(file_version[0])
147
+ minute = pd.read_parquet(path, columns=["date", "open", "high", "low", "close"]).copy()
148
+ minute["date"] = _normalize_frame_dates(minute)
149
+ return minute.dropna(subset=["date"]).sort_values("date")
150
+
151
+
152
+ def _first_text(*values: Any) -> str | None:
153
+ for value in values:
154
+ if isinstance(value, dict):
155
+ nested = _first_text(
156
+ value.get("message"),
157
+ value.get("error"),
158
+ value.get("Error"),
159
+ value.get("emsg"),
160
+ value.get("detail"),
161
+ )
162
+ if nested:
163
+ return nested
164
+ if isinstance(value, list):
165
+ for item in value:
166
+ nested = _first_text(item)
167
+ if nested:
168
+ return nested
169
+ if value not in (None, "", "--", "NA", "na", "-"):
170
+ return str(value)
171
+ return None
172
+
173
+
174
+ def _sum_numbers(*values: Any) -> float | None:
175
+ numbers: list[float] = []
176
+ for value in values:
177
+ parsed = _to_float(value)
178
+ if parsed is not None:
179
+ numbers.append(parsed)
180
+ return sum(numbers) if numbers else None
181
+
182
+
183
+ def _extract_items(payload: Any) -> list[dict[str, Any]]:
184
+ if isinstance(payload, list):
185
+ return [item for item in payload if isinstance(item, dict)]
186
+ if not isinstance(payload, dict):
187
+ return []
188
+
189
+ data = payload.get("data")
190
+ if isinstance(data, list):
191
+ return [item for item in data if isinstance(item, dict)]
192
+ if isinstance(data, dict):
193
+ nested = data.get("data")
194
+ if isinstance(nested, list):
195
+ return [item for item in nested if isinstance(item, dict)]
196
+ return [data]
197
+ return []
198
+
199
+
200
+ def _sort_key(item: dict[str, Any]) -> str:
201
+ return str(
202
+ _first_text(
203
+ item.get("updRecvTm"),
204
+ item.get("hsUpTm"),
205
+ item.get("flDtTm"),
206
+ item.get("exTm"),
207
+ item.get("ordDtTm"),
208
+ item.get("TimeStamp"),
209
+ item.get("flDt"),
210
+ )
211
+ or ""
212
+ )
213
+
214
+
215
+ class KotakNeoManager:
216
+ def __init__(self) -> None:
217
+ self.consumer_key = os.getenv("KOTAK_CONSUMER_KEY")
218
+ self.mobile_number = os.getenv("KOTAK_MOBILE_NUMBER")
219
+ self.ucc = os.getenv("KOTAK_UCC")
220
+ self.mpin = os.getenv("KOTAK_MPIN")
221
+ self.neo_fin_key = os.getenv("KOTAK_NEO_FIN_KEY", "neotradeapi")
222
+
223
+ self._lock = threading.RLock()
224
+ self.activity_log_path = KOTAK_ACTIVITY_LOG_PATH
225
+ self.activity_log_path.parent.mkdir(parents=True, exist_ok=True)
226
+ self._seen_activity_keys: set[str] = set()
227
+ self._scrip_cache: dict[str, list[dict[str, str]]] = {}
228
+ self._quote_cache: dict[str, dict[str, Any]] = {}
229
+ self._load_existing_activity_keys()
230
+ self._clear_session_locked()
231
+
232
+ def _load_existing_activity_keys(self) -> None:
233
+ if not self.activity_log_path.exists():
234
+ return
235
+ try:
236
+ for line in self.activity_log_path.read_text(encoding="utf-8").splitlines():
237
+ if not line.strip():
238
+ continue
239
+ try:
240
+ payload = json.loads(line)
241
+ except json.JSONDecodeError:
242
+ continue
243
+ key = str(payload.get("activity_key") or "").strip()
244
+ if key:
245
+ self._seen_activity_keys.add(key)
246
+ except Exception:
247
+ pass
248
+
249
+ def _clear_session_locked(self) -> None:
250
+ self.view_token: str | None = None
251
+ self.sid: str | None = None
252
+ self.edit_token: str | None = None
253
+ self.edit_sid: str | None = None
254
+ self.edit_rid: str | None = None
255
+ self.server_id: str | None = None
256
+ self.data_center: str | None = None
257
+ self.base_url: str | None = None
258
+ self.authenticated_at: str | None = None
259
+
260
+ def _configured(self) -> bool:
261
+ return all([self.consumer_key, self.mobile_number, self.ucc, self.mpin])
262
+
263
+ def status(self) -> dict[str, Any]:
264
+ configured = self._configured()
265
+ with self._lock:
266
+ authenticated = bool(self.edit_token and self.edit_sid and self.base_url)
267
+ return {
268
+ "available": configured,
269
+ "configured": configured,
270
+ "authenticated": authenticated,
271
+ "needs_totp": configured and not authenticated,
272
+ "last_authenticated_at": self.authenticated_at,
273
+ "reason": None if configured else "Kotak Neo environment variables are incomplete.",
274
+ }
275
+
276
+ def authenticate_with_totp(self, totp: str) -> dict[str, Any]:
277
+ if not self._configured():
278
+ raise KotakNeoConfigError("Kotak Neo environment variables are incomplete.")
279
+ if not str(totp).strip():
280
+ raise KotakNeoError("A TOTP code is required.")
281
+
282
+ with self._lock:
283
+ login_response = self._post_session_api(
284
+ TOTP_LOGIN_PATH,
285
+ headers={
286
+ "Authorization": self.consumer_key,
287
+ "neo-fin-key": self.neo_fin_key,
288
+ "Content-Type": "application/json",
289
+ "Accept": "application/json",
290
+ },
291
+ payload={
292
+ "mobileNumber": self.mobile_number,
293
+ "ucc": self.ucc,
294
+ "totp": str(totp).strip(),
295
+ },
296
+ )
297
+ login_data = (login_response.get("data") or {}) if isinstance(login_response, dict) else {}
298
+ self.view_token = login_data.get("token")
299
+ self.sid = login_data.get("sid")
300
+
301
+ if not self.view_token or not self.sid:
302
+ self._clear_session_locked()
303
+ raise KotakNeoError("Kotak Neo did not return a valid pre-auth session.")
304
+
305
+ validate_response = self._post_session_api(
306
+ TOTP_VALIDATE_PATH,
307
+ headers={
308
+ "Authorization": self.consumer_key,
309
+ "sid": self.sid,
310
+ "Auth": self.view_token,
311
+ "neo-fin-key": self.neo_fin_key,
312
+ "Content-Type": "application/json",
313
+ "Accept": "application/json",
314
+ },
315
+ payload={"mpin": self.mpin},
316
+ )
317
+ validate_data = (validate_response.get("data") or {}) if isinstance(validate_response, dict) else {}
318
+
319
+ self.edit_token = validate_data.get("token")
320
+ self.edit_sid = validate_data.get("sid")
321
+ self.edit_rid = validate_data.get("rid")
322
+ self.server_id = validate_data.get("hsServerId")
323
+ self.data_center = validate_data.get("dataCenter")
324
+ self.base_url = str(validate_data.get("baseUrl") or "").rstrip("/")
325
+ self.authenticated_at = _utc_now_iso()
326
+
327
+ if not self.edit_token or not self.edit_sid or not self.base_url:
328
+ self._clear_session_locked()
329
+ raise KotakNeoError("Kotak Neo did not return a usable trading session.")
330
+
331
+ return self.status()
332
+
333
+ def fetch_account_snapshot(self) -> dict[str, Any]:
334
+ if not self._configured():
335
+ raise KotakNeoConfigError("Kotak Neo environment variables are incomplete.")
336
+
337
+ with self._lock:
338
+ context = self._context_locked()
339
+
340
+ account_calls = {
341
+ "holdings": lambda: self._request_trading_api_with_context(
342
+ context,
343
+ "portfolio/v1/holdings",
344
+ timeout=ACCOUNT_TIMEOUT_SECONDS,
345
+ ),
346
+ "positions": lambda: self._request_trading_api_with_context(
347
+ context,
348
+ "quick/user/positions",
349
+ timeout=ACCOUNT_TIMEOUT_SECONDS,
350
+ ),
351
+ "trades": lambda: self._request_trading_api_with_context(
352
+ context,
353
+ "quick/user/trades",
354
+ timeout=ACCOUNT_TIMEOUT_SECONDS,
355
+ ),
356
+ "orders": lambda: self._request_trading_api_with_context(
357
+ context,
358
+ "quick/user/orders",
359
+ timeout=ACCOUNT_TIMEOUT_SECONDS,
360
+ ),
361
+ "limits": lambda: self._post_trading_api_with_context(
362
+ context,
363
+ "quick/user/limits",
364
+ payload={"seg": "ALL", "exch": "ALL", "prod": "ALL"},
365
+ content_type="application/x-www-form-urlencoded",
366
+ timeout=ACCOUNT_TIMEOUT_SECONDS,
367
+ ),
368
+ }
369
+ defaults = {
370
+ "holdings": {"data": []},
371
+ "positions": {"data": []},
372
+ "trades": {"data": []},
373
+ "orders": {"data": []},
374
+ "limits": {},
375
+ }
376
+ results: dict[str, dict[str, Any]] = dict(defaults)
377
+
378
+ with ThreadPoolExecutor(max_workers=5) as executor:
379
+ future_map = {executor.submit(fn): label for label, fn in account_calls.items()}
380
+ for future in as_completed(future_map):
381
+ label = future_map[future]
382
+ results[label] = self._resolve_account_future(label, future, default=defaults[label])
383
+
384
+ holdings = _extract_items(results["holdings"])
385
+ positions = _extract_items(results["positions"])
386
+ trades = sorted(_extract_items(results["trades"]), key=_sort_key, reverse=True)
387
+ orders = sorted(_extract_items(results["orders"]), key=_sort_key, reverse=True)
388
+
389
+ normalized_trades = [self._normalize_trade(item) for item in trades]
390
+ normalized_orders = [self._normalize_order(item) for item in orders]
391
+
392
+ quotes = self._safe_account_call(
393
+ "quotes",
394
+ lambda: self._fetch_quotes_with_context(
395
+ context,
396
+ self._instrument_tokens_for_quotes(
397
+ context,
398
+ holdings,
399
+ positions,
400
+ normalized_trades,
401
+ ),
402
+ timeout=ACCOUNT_TIMEOUT_SECONDS,
403
+ )
404
+ ,
405
+ default={"data": []},
406
+ )
407
+ quote_map = self._build_quote_map(quotes)
408
+
409
+ normalized_holdings = [self._normalize_holding(item, quote_map) for item in holdings]
410
+ normalized_positions = [self._normalize_position(item, quote_map) for item in positions]
411
+ self._append_activity_entries(normalized_trades, normalized_orders)
412
+ journal = self._read_activity_journal()
413
+ merged_trades = self._merge_activity(normalized_trades, journal["trades"])
414
+ merged_orders = self._merge_activity(normalized_orders, journal["orders"])
415
+
416
+ holdings_market_value = sum(item["market_value"] or 0.0 for item in normalized_holdings)
417
+ holdings_cost = sum(item["cost_value"] or 0.0 for item in normalized_holdings)
418
+ holdings_pnl = sum(item["pnl"] or 0.0 for item in normalized_holdings)
419
+ positions_pnl = sum(item["pnl"] or 0.0 for item in normalized_positions)
420
+
421
+ limits_raw = results["limits"]
422
+ limits_summary = {
423
+ "net": _first_number(limits_raw.get("Net")) if isinstance(limits_raw, dict) else None,
424
+ "margin_used": _first_number(limits_raw.get("MarginUsed")) if isinstance(limits_raw, dict) else None,
425
+ "collateral_value": _first_number(limits_raw.get("CollateralValue")) if isinstance(limits_raw, dict) else None,
426
+ "cash_unrealized_mtm": _first_number(limits_raw.get("CashUnRlsMtomPrsnt")) if isinstance(limits_raw, dict) else None,
427
+ "cash_realized_mtm": _first_number(limits_raw.get("CashRlsMtomPrsnt")) if isinstance(limits_raw, dict) else None,
428
+ }
429
+
430
+ available_cash = None
431
+ if limits_summary["net"] is not None and limits_summary["margin_used"] is not None:
432
+ available_cash = limits_summary["net"] - limits_summary["margin_used"]
433
+
434
+ current_capital = None
435
+ if available_cash is not None:
436
+ current_capital = available_cash + holdings_market_value
437
+
438
+ return {
439
+ "status": self.status(),
440
+ "as_of": _utc_now_iso(),
441
+ "neo_behavior": {
442
+ "holdings_note": "Kotak Neo shows CNC delivery buys in Positions on trade day and in Holdings/T1 from the next trading day.",
443
+ "trade_history_note": "Kotak Neo trade history availability is limited by Neo's own order and portfolio tracker behavior.",
444
+ },
445
+ "summary": {
446
+ "available_cash": available_cash,
447
+ "current_capital": current_capital,
448
+ "holdings_market_value": holdings_market_value,
449
+ "holdings_cost_value": holdings_cost,
450
+ "holdings_pnl": holdings_pnl,
451
+ "positions_pnl": positions_pnl,
452
+ "live_pnl": holdings_pnl + positions_pnl,
453
+ "open_positions": sum(1 for item in normalized_positions if item["net_quantity"]),
454
+ "holdings_count": len(normalized_holdings),
455
+ "orders_count": len(merged_orders),
456
+ "trades_count": len(merged_trades),
457
+ },
458
+ "limits_summary": limits_summary,
459
+ "limits_raw": limits_raw,
460
+ "holdings": normalized_holdings,
461
+ "positions": normalized_positions,
462
+ "trade_history": merged_trades[:100],
463
+ "order_book": merged_orders[:100],
464
+ "activity_log": {
465
+ "path": str(self.activity_log_path),
466
+ "trades_count": len(journal["trades"]),
467
+ "orders_count": len(journal["orders"]),
468
+ },
469
+ "quotes": list(quote_map.values()),
470
+ }
471
+
472
+ def fetch_nifty50_quote(self, *, force_refresh: bool = False) -> dict[str, Any]:
473
+ cache_key = "nifty50_quote"
474
+ with self._lock:
475
+ if not force_refresh:
476
+ cached = self._quote_cache.get(cache_key)
477
+ if cached and (monotonic() - float(cached.get("stored_at_monotonic") or 0.0)) < NIFTY_QUOTE_CACHE_SECONDS:
478
+ return dict(cached["payload"])
479
+ context = self._context_locked()
480
+
481
+ reference = self._resolve_nifty50_reference(context)
482
+ quote_payload = self._fetch_quotes_with_context(
483
+ context,
484
+ [
485
+ {
486
+ "exchange_segment": "nse_cm",
487
+ "instrument_token": str(reference["quote_instrument_token"]),
488
+ }
489
+ ],
490
+ timeout=NIFTY_QUOTE_TIMEOUT_SECONDS,
491
+ )
492
+ items = _extract_items(quote_payload)
493
+ if not items:
494
+ raise KotakNeoError("Kotak Neo did not return a NIFTY 50 quote.")
495
+
496
+ item = items[0]
497
+ now_ist = datetime.now(IST)
498
+ last_traded_price = _first_market_number(item.get("last_traded_price"), item.get("ltp"), item.get("iv"))
499
+ quote_open = _first_market_number(item.get("openingPrice"), item.get("open"), item.get("o"))
500
+ quote_high = _first_market_number(item.get("high"), item.get("highPrice"), item.get("h"))
501
+ quote_low = _first_market_number(item.get("low"), item.get("lowPrice"), item.get("l"))
502
+ quote_previous_close = _first_market_number(
503
+ item.get("previous_close"),
504
+ item.get("previousClose"),
505
+ item.get("prev_close"),
506
+ item.get("prevClose"),
507
+ item.get("previousClosePrice"),
508
+ item.get("prevClosePrice"),
509
+ item.get("close"),
510
+ item.get("c"),
511
+ item.get("ic"),
512
+ )
513
+ live_stats = self._load_nifty50_reference_stats(
514
+ now_ist,
515
+ last_traded_price,
516
+ quote_open,
517
+ quote_high,
518
+ quote_low,
519
+ quote_previous_close,
520
+ )
521
+ close = live_stats["previous_close"]
522
+ change_base = live_stats["return_base"]
523
+ change = None
524
+ change_pct = None
525
+ if last_traded_price is not None and change_base not in (None, 0):
526
+ change = last_traded_price - change_base
527
+ change_pct = (change / change_base) * 100.0
528
+ high = live_stats["range_high"]
529
+ low = live_stats["range_low"]
530
+ open_price = quote_open
531
+
532
+ payload = {
533
+ "symbol": "NIFTY 50",
534
+ "exchange_segment": "nse_cm",
535
+ "instrument_token": _first_text(
536
+ item.get("instrument_token"),
537
+ item.get("instrumentToken"),
538
+ item.get("tk"),
539
+ reference.get("master_instrument_token"),
540
+ reference["quote_instrument_token"],
541
+ ),
542
+ "display_name": _first_text(item.get("trading_symbol"), item.get("ts"), item.get("name"), "NIFTY 50"),
543
+ "last_traded_price": last_traded_price,
544
+ "close": close,
545
+ "change": change,
546
+ "change_pct": change_pct,
547
+ "open": open_price,
548
+ "high": high,
549
+ "low": low,
550
+ "return_basis": live_stats["return_basis"],
551
+ "market_open": live_stats["market_open"],
552
+ "is_trading_session": live_stats["is_trading_session"],
553
+ "quote_session_date": live_stats["quote_session_date"],
554
+ "previous_session_date": live_stats["previous_session_date"],
555
+ "exchange_feed_time": _first_text(item.get("tvalue"), item.get("updRecvTm"), item.get("hsUpTm")),
556
+ "as_of": _utc_now_iso(),
557
+ "source": {
558
+ "quote_api": QUOTE_PATH_TEMPLATE,
559
+ "master_scrip_verified": bool(reference.get("master_record_found")),
560
+ "instrument_lookup": reference.get("lookup_mode"),
561
+ "master_symbol_name": reference.get("master_symbol_name"),
562
+ "master_trading_symbol": reference.get("master_trading_symbol"),
563
+ "reference_data": "backend/data/nifty50_1m.parquet + backend/data/nifty50_1d.parquet",
564
+ },
565
+ }
566
+
567
+ with self._lock:
568
+ self._quote_cache[cache_key] = {
569
+ "stored_at_monotonic": monotonic(),
570
+ "payload": payload,
571
+ }
572
+ return payload
573
+
574
+ def _load_nifty50_reference_stats(
575
+ self,
576
+ now_ist: datetime,
577
+ last_traded_price: float | None,
578
+ live_open: float | None = None,
579
+ live_high: float | None = None,
580
+ live_low: float | None = None,
581
+ live_previous_close: float | None = None,
582
+ ) -> dict[str, Any]:
583
+ today = now_ist.date()
584
+ is_trading_session = _is_nse_trading_day(today)
585
+ market_open = is_trading_session and MARKET_OPEN_TIME <= now_ist.time() < MARKET_CLOSE_TIME
586
+ session_started = is_trading_session and now_ist.time() >= MARKET_OPEN_TIME
587
+ quote_session_date = today if session_started else _previous_nse_trading_day(today)
588
+ previous_session_date = _previous_nse_trading_day(quote_session_date)
589
+
590
+ daily = _load_nifty_daily_frame(_file_version(NIFTY_1D_PATH))
591
+
592
+ previous_close = None
593
+ session_daily = daily[daily["date"] == quote_session_date]
594
+ previous_daily = daily[daily["date"] == previous_session_date]
595
+ if previous_daily.empty:
596
+ previous_daily = daily[daily["date"] < quote_session_date]
597
+ if not previous_daily.empty:
598
+ previous_row = previous_daily.iloc[-1]
599
+ previous_close = _to_float(previous_row["close"])
600
+
601
+ session_open = _to_float(session_daily.iloc[-1]["open"]) if not session_daily.empty else None
602
+ session_high = _to_float(session_daily.iloc[-1]["high"]) if not session_daily.empty else None
603
+ session_low = _to_float(session_daily.iloc[-1]["low"]) if not session_daily.empty else None
604
+ session_close = _to_float(session_daily.iloc[-1]["close"]) if not session_daily.empty else None
605
+
606
+ if live_previous_close is not None:
607
+ is_same_as_static_ltp = (
608
+ last_traded_price is not None
609
+ and abs(live_previous_close - last_traded_price) < 0.01
610
+ )
611
+ if not is_same_as_static_ltp:
612
+ previous_close = live_previous_close
613
+
614
+ if session_started:
615
+ session_open = live_open or session_open
616
+ session_high = live_high or session_high
617
+ session_low = live_low or session_low
618
+
619
+ if session_open is None or session_high is None or session_low is None:
620
+ minute = _load_nifty_minute_frame(_file_version(NIFTY_1M_PATH))
621
+ today_minute = minute[minute["date"].dt.date == today]
622
+ if not today_minute.empty:
623
+ session_open = _to_float(today_minute.iloc[0]["open"]) or session_open
624
+ minute_high = pd.to_numeric(today_minute["high"], errors="coerce").max()
625
+ minute_low = pd.to_numeric(today_minute["low"], errors="coerce").min()
626
+ session_high = _to_float(minute_high) or session_high
627
+ session_low = _to_float(minute_low) or session_low
628
+ session_close = _to_float(today_minute.iloc[-1]["close"]) or session_close
629
+ else:
630
+ session_high = live_high or session_high
631
+ session_low = live_low or session_low
632
+
633
+ if session_started:
634
+ range_high = max([value for value in [session_high, last_traded_price] if value is not None], default=None)
635
+ range_low = min([value for value in [session_low, last_traded_price] if value is not None], default=None)
636
+ return_base = previous_close
637
+ return_basis = "previous_close"
638
+ else:
639
+ range_high = session_high
640
+ range_low = session_low
641
+ return_base = previous_close
642
+ return_basis = "previous_close"
643
+ if last_traded_price is None:
644
+ last_traded_price = session_close
645
+
646
+ return {
647
+ "previous_close": previous_close,
648
+ "return_base": return_base,
649
+ "return_basis": return_basis,
650
+ "range_high": range_high,
651
+ "range_low": range_low,
652
+ "market_open": market_open,
653
+ "is_trading_session": is_trading_session,
654
+ "quote_session_date": quote_session_date.isoformat(),
655
+ "previous_session_date": previous_session_date.isoformat(),
656
+ }
657
+
658
+ def _ensure_authenticated_locked(self) -> None:
659
+ if not self.edit_token or not self.edit_sid or not self.base_url:
660
+ raise KotakNeoSessionRequired("Kotak Neo session is not authenticated.")
661
+
662
+ def _context_locked(self) -> dict[str, str]:
663
+ self._ensure_authenticated_locked()
664
+ return {
665
+ "base_url": self.base_url or "",
666
+ "edit_sid": self.edit_sid or "",
667
+ "edit_token": self.edit_token or "",
668
+ "server_id": self.server_id or "",
669
+ "consumer_key": self.consumer_key or "",
670
+ }
671
+
672
+ def _post_session_api(self, path: str, headers: dict[str, str], payload: dict[str, Any]) -> dict[str, Any]:
673
+ response = requests.post(
674
+ f"{SESSION_BASE_URL.rstrip('/')}/{path.lstrip('/')}",
675
+ headers=headers,
676
+ json=payload,
677
+ timeout=DEFAULT_TIMEOUT_SECONDS,
678
+ )
679
+ data = self._decode_response(response)
680
+ self._raise_for_error(response, data, session_sensitive=False)
681
+ return data
682
+
683
+ def _request_trading_api_locked(self, path: str) -> dict[str, Any]:
684
+ return self._request_trading_api_with_context(
685
+ self._context_locked(),
686
+ path,
687
+ timeout=DEFAULT_TIMEOUT_SECONDS,
688
+ )
689
+
690
+ def _request_trading_api_with_context(
691
+ self,
692
+ context: dict[str, str],
693
+ path: str,
694
+ *,
695
+ timeout: int,
696
+ ) -> dict[str, Any]:
697
+ response = requests.get(
698
+ f"{context['base_url'].rstrip('/')}/{path.lstrip('/')}",
699
+ headers={
700
+ "Sid": context["edit_sid"],
701
+ "Auth": context["edit_token"],
702
+ "Accept": "application/json",
703
+ },
704
+ params={"sId": context["server_id"]},
705
+ timeout=timeout,
706
+ )
707
+ data = self._decode_response(response)
708
+ self._raise_for_error(response, data, session_sensitive=True)
709
+ return data
710
+
711
+ def _post_trading_api_locked(
712
+ self,
713
+ path: str,
714
+ payload: dict[str, Any],
715
+ *,
716
+ content_type: str = "application/json",
717
+ ) -> dict[str, Any]:
718
+ return self._post_trading_api_with_context(
719
+ self._context_locked(),
720
+ path,
721
+ payload,
722
+ content_type=content_type,
723
+ timeout=DEFAULT_TIMEOUT_SECONDS,
724
+ )
725
+
726
+ def _post_trading_api_with_context(
727
+ self,
728
+ context: dict[str, str],
729
+ path: str,
730
+ payload: dict[str, Any],
731
+ *,
732
+ content_type: str,
733
+ timeout: int,
734
+ ) -> dict[str, Any]:
735
+ headers = {
736
+ "Sid": context["edit_sid"],
737
+ "Auth": context["edit_token"],
738
+ "Accept": "application/json",
739
+ "Content-Type": content_type,
740
+ }
741
+ query_params = {"sId": context["server_id"]}
742
+ url = f"{context['base_url'].rstrip('/')}/{path.lstrip('/')}"
743
+
744
+ if content_type == "application/x-www-form-urlencoded":
745
+ body = {"jData": json.dumps(payload)}
746
+ response = requests.post(
747
+ url,
748
+ headers=headers,
749
+ params=query_params,
750
+ data=body,
751
+ timeout=timeout,
752
+ )
753
+ else:
754
+ response = requests.post(
755
+ url,
756
+ headers=headers,
757
+ params=query_params,
758
+ json=payload,
759
+ timeout=timeout,
760
+ )
761
+
762
+ data = self._decode_response(response)
763
+ self._raise_for_error(response, data, session_sensitive=True)
764
+ return data
765
+
766
+ def _fetch_quotes_locked(self, instrument_tokens: list[dict[str, str]]) -> dict[str, Any]:
767
+ return self._fetch_quotes_with_context(
768
+ self._context_locked(),
769
+ instrument_tokens,
770
+ timeout=DEFAULT_TIMEOUT_SECONDS,
771
+ )
772
+
773
+ def _fetch_quotes_with_context(
774
+ self,
775
+ context: dict[str, str],
776
+ instrument_tokens: list[dict[str, str]],
777
+ *,
778
+ timeout: int,
779
+ ) -> dict[str, Any]:
780
+ if not instrument_tokens:
781
+ return {"data": []}
782
+
783
+ neo_symbols = ",".join(
784
+ f"{item['exchange_segment']}|{item['instrument_token']}" for item in instrument_tokens
785
+ )
786
+ encoded_symbols = quote(neo_symbols, safe="")
787
+ response = requests.get(
788
+ f"{context['base_url'].rstrip('/')}/{QUOTE_PATH_TEMPLATE.format(neo_symbols=encoded_symbols, quote_type='all')}",
789
+ headers={
790
+ "Authorization": context["consumer_key"],
791
+ "Content-Type": "application/x-www-form-urlencoded",
792
+ "Accept": "application/json",
793
+ },
794
+ timeout=timeout,
795
+ )
796
+ data = self._decode_response(response)
797
+ self._raise_for_error(response, data, session_sensitive=False)
798
+ return data
799
+
800
+ def _decode_response(self, response: requests.Response) -> dict[str, Any]:
801
+ try:
802
+ parsed = response.json()
803
+ except ValueError as exc:
804
+ raise KotakNeoError(
805
+ f"Kotak Neo returned a non-JSON response with HTTP {response.status_code}."
806
+ ) from exc
807
+ if isinstance(parsed, dict):
808
+ return parsed
809
+ return {"data": parsed}
810
+
811
+ def _safe_account_call(
812
+ self,
813
+ label: str,
814
+ fn,
815
+ *,
816
+ default: dict[str, Any],
817
+ ) -> dict[str, Any]:
818
+ try:
819
+ return fn()
820
+ except KotakNeoSessionRequired:
821
+ raise
822
+ except Exception as exc:
823
+ if not self._is_expected_empty_error(label, exc):
824
+ print(f"[kotak] {label} call failed: {exc}", flush=True)
825
+ return default
826
+
827
+ def _resolve_account_future(self, label: str, future, *, default: dict[str, Any]) -> dict[str, Any]:
828
+ try:
829
+ return future.result()
830
+ except KotakNeoSessionRequired:
831
+ raise
832
+ except Exception as exc:
833
+ if not self._is_expected_empty_error(label, exc):
834
+ print(f"[kotak] {label} call failed: {exc}", flush=True)
835
+ return default
836
+
837
+ def _is_expected_empty_error(self, label: str, exc: Exception) -> bool:
838
+ text = str(exc).lower()
839
+ empty_markers = [
840
+ "no holdings found",
841
+ "no position",
842
+ "no positions",
843
+ "no trade",
844
+ "no trades",
845
+ "no order",
846
+ "no orders",
847
+ "no data found",
848
+ "no trade found",
849
+ "no order found",
850
+ ]
851
+ if any(marker in text for marker in empty_markers):
852
+ return True
853
+ if label in {"positions", "trades", "orders"} and text.strip() == "kotak neo rejected the request.":
854
+ return True
855
+ if label in {"holdings", "positions", "trades", "orders"} and "424" in text:
856
+ return True
857
+ return False
858
+
859
+ def _raise_for_error(
860
+ self,
861
+ response: requests.Response,
862
+ data: dict[str, Any],
863
+ *,
864
+ session_sensitive: bool,
865
+ ) -> None:
866
+ stat = str(data.get("stat") or "").strip().lower()
867
+ st_code = _first_number(data.get("stCode"))
868
+ message = _first_text(
869
+ data.get("message"),
870
+ data.get("error"),
871
+ data.get("Error"),
872
+ data.get("emsg"),
873
+ )
874
+
875
+ session_expired = response.status_code in (401, 403) or "invalid session" in (message or "").lower()
876
+ if session_expired:
877
+ if session_sensitive:
878
+ self._clear_session_locked()
879
+ raise KotakNeoSessionRequired(message or "Kotak Neo session expired.")
880
+ raise KotakNeoError(message or "Kotak Neo rejected the request.")
881
+
882
+ if stat == "not_ok":
883
+ raise KotakNeoError(message or "Kotak Neo rejected the request.")
884
+
885
+ if response.status_code >= 400 or (st_code is not None and st_code >= 400):
886
+ raise KotakNeoError(message or f"Kotak Neo request failed with HTTP {response.status_code}.")
887
+
888
+ def _instrument_tokens_for_quotes(
889
+ self,
890
+ context: dict[str, str],
891
+ holdings: list[dict[str, Any]],
892
+ positions: list[dict[str, Any]],
893
+ trades: list[dict[str, Any]] | None = None,
894
+ ) -> list[dict[str, str]]:
895
+ unique: dict[tuple[str, str], dict[str, str]] = {}
896
+
897
+ for item in holdings:
898
+ token = _first_text(item.get("instrumentToken"), item.get("exchangeIdentifier"), item.get("tok"))
899
+ exchange = _first_text(item.get("exchangeSegment"), item.get("exSeg"))
900
+ if token and exchange:
901
+ unique[(exchange, token)] = {
902
+ "exchange_segment": exchange,
903
+ "instrument_token": token,
904
+ }
905
+
906
+ for item in positions:
907
+ token = _first_text(item.get("tok"), item.get("instrumentToken"), item.get("exchangeIdentifier"))
908
+ exchange = _first_text(item.get("exSeg"), item.get("exchangeSegment"))
909
+ if token and exchange:
910
+ unique[(exchange, token)] = {
911
+ "exchange_segment": exchange,
912
+ "instrument_token": token,
913
+ }
914
+ elif exchange:
915
+ resolved = self._resolve_symbol_token(
916
+ context,
917
+ exchange_segment=exchange,
918
+ symbol=_first_text(item.get("trdSym"), item.get("sym")),
919
+ )
920
+ if resolved:
921
+ unique[(exchange, resolved)] = {
922
+ "exchange_segment": exchange,
923
+ "instrument_token": resolved,
924
+ }
925
+
926
+ for item in trades or []:
927
+ exchange = _first_text(item.get("exchange_segment"), item.get("exSeg"))
928
+ if not exchange:
929
+ continue
930
+ resolved = self._resolve_symbol_token(
931
+ context,
932
+ exchange_segment=exchange,
933
+ symbol=_first_text(item.get("trading_symbol"), item.get("trdSym"), item.get("symbol"), item.get("sym")),
934
+ )
935
+ if resolved:
936
+ unique[(exchange, resolved)] = {
937
+ "exchange_segment": exchange,
938
+ "instrument_token": resolved,
939
+ }
940
+
941
+ return list(unique.values())
942
+
943
+ def _resolve_symbol_token(
944
+ self,
945
+ context: dict[str, str],
946
+ *,
947
+ exchange_segment: str,
948
+ symbol: str | None,
949
+ ) -> str | None:
950
+ symbol = str(symbol or "").strip()
951
+ if not symbol:
952
+ return None
953
+ candidates = self._load_scrip_candidates(context, exchange_segment)
954
+ symbol_upper = symbol.upper()
955
+ base_symbol_upper = symbol_upper.split("-")[0]
956
+ for item in candidates:
957
+ trading_symbol = str(item.get("pTrdSymbol") or "").upper()
958
+ symbol_name = str(item.get("pSymbolName") or "").upper()
959
+ token = str(item.get("pSymbol") or "").strip()
960
+ if not token:
961
+ continue
962
+ if trading_symbol == symbol_upper or symbol_name == base_symbol_upper:
963
+ return token
964
+ return None
965
+
966
+ def _load_scrip_candidates(self, context: dict[str, str], exchange_segment: str) -> list[dict[str, str]]:
967
+ key = str(exchange_segment).lower()
968
+ if key in self._scrip_cache:
969
+ return self._scrip_cache[key]
970
+
971
+ response = requests.get(
972
+ f"{context['base_url'].rstrip('/')}/script-details/1.0/masterscrip/file-paths",
973
+ headers={
974
+ "Authorization": context["consumer_key"],
975
+ "Accept": "application/json",
976
+ },
977
+ timeout=ACCOUNT_TIMEOUT_SECONDS,
978
+ )
979
+ data = self._decode_response(response)
980
+ file_paths = ((data.get("data") or {}).get("filesPaths") or []) if isinstance(data, dict) else []
981
+ csv_url = next((path for path in file_paths if key in str(path).lower()), None)
982
+ if not csv_url:
983
+ self._scrip_cache[key] = []
984
+ return []
985
+
986
+ csv_response = requests.get(csv_url, timeout=ACCOUNT_TIMEOUT_SECONDS)
987
+ csv_response.raise_for_status()
988
+ rows = list(DictReader(csv_response.text.splitlines()))
989
+ self._scrip_cache[key] = rows
990
+ return rows
991
+
992
+ def _resolve_nifty50_reference(self, context: dict[str, str]) -> dict[str, Any]:
993
+ candidates = self._load_scrip_candidates(context, "nse_cm")
994
+ match = None
995
+ aliases = {
996
+ "NIFTY 50",
997
+ "NIFTY50",
998
+ "NIFTY 50 INDEX",
999
+ "NIFTY",
1000
+ }
1001
+ for item in candidates:
1002
+ candidate_values = {
1003
+ str(item.get("pSymbolName") or "").strip().upper(),
1004
+ str(item.get("pTrdSymbol") or "").strip().upper(),
1005
+ str(item.get("pSymbol") or "").strip().upper(),
1006
+ }
1007
+ if aliases & candidate_values:
1008
+ match = item
1009
+ break
1010
+
1011
+ return {
1012
+ "quote_instrument_token": "Nifty 50",
1013
+ "lookup_mode": "index-name-direct" if match is None else "master-scrip-verified-index-name-direct",
1014
+ "master_record_found": match is not None,
1015
+ "master_instrument_token": _first_text(match.get("pSymbol")) if match else None,
1016
+ "master_symbol_name": _first_text(match.get("pSymbolName")) if match else None,
1017
+ "master_trading_symbol": _first_text(match.get("pTrdSymbol")) if match else None,
1018
+ }
1019
+
1020
+ def _build_quote_map(self, payload: dict[str, Any]) -> dict[str, dict[str, Any]]:
1021
+ items = _extract_items(payload)
1022
+ quote_map: dict[str, dict[str, Any]] = {}
1023
+ for item in items:
1024
+ token = _first_text(item.get("instrument_token"), item.get("instrumentToken"), item.get("tk"))
1025
+ exchange = _first_text(item.get("exchange_segment"), item.get("exchangeSegment"), item.get("e"))
1026
+ if not token or not exchange:
1027
+ continue
1028
+ key = f"{exchange}|{token}"
1029
+ quote_map[key] = {
1030
+ "instrument_token": token,
1031
+ "exchange_segment": exchange,
1032
+ "trading_symbol": _first_text(item.get("trading_symbol"), item.get("ts"), item.get("symbol")),
1033
+ "last_traded_price": _first_number(item.get("last_traded_price"), item.get("ltp"), item.get("iv")),
1034
+ "close": _first_number(item.get("close"), item.get("c"), item.get("ic")),
1035
+ "change": _first_number(item.get("change"), item.get("cng")),
1036
+ "change_pct": _first_number(item.get("net_change_percentage"), item.get("nc")),
1037
+ }
1038
+ return quote_map
1039
+
1040
+ def _normalize_holding(
1041
+ self,
1042
+ item: dict[str, Any],
1043
+ quote_map: dict[str, dict[str, Any]],
1044
+ ) -> dict[str, Any]:
1045
+ token = _first_text(item.get("instrumentToken"), item.get("exchangeIdentifier"), item.get("tok"))
1046
+ exchange = _first_text(item.get("exchangeSegment"), item.get("exSeg"))
1047
+ quote = quote_map.get(f"{exchange}|{token}", {}) if token and exchange else {}
1048
+
1049
+ quantity = _first_number(item.get("quantity"), item.get("sellableQuantity"))
1050
+ average_price = _first_number(item.get("averagePrice"), item.get("avgPrc"))
1051
+ holding_cost = _first_number(item.get("holdingCost"))
1052
+ market_value = _first_number(item.get("mktValue"))
1053
+ ltp = _first_number(quote.get("last_traded_price"))
1054
+
1055
+ if market_value is None and quantity is not None and ltp is not None:
1056
+ market_value = quantity * ltp
1057
+ if holding_cost is None and quantity is not None and average_price is not None:
1058
+ holding_cost = quantity * average_price
1059
+
1060
+ pnl = None
1061
+ pnl_pct = None
1062
+ if market_value is not None and holding_cost is not None:
1063
+ pnl = market_value - holding_cost
1064
+ if holding_cost:
1065
+ pnl_pct = pnl / holding_cost
1066
+
1067
+ return {
1068
+ "symbol": _first_text(item.get("displaySymbol"), item.get("symbol"), item.get("trdSym")),
1069
+ "trading_symbol": _first_text(item.get("symbol"), item.get("displaySymbol"), item.get("trdSym")),
1070
+ "exchange_segment": exchange,
1071
+ "instrument_token": token,
1072
+ "quantity": quantity,
1073
+ "sellable_quantity": _first_number(item.get("sellableQuantity")),
1074
+ "average_price": average_price,
1075
+ "last_traded_price": ltp,
1076
+ "market_value": market_value,
1077
+ "cost_value": holding_cost,
1078
+ "pnl": pnl,
1079
+ "pnl_pct": pnl_pct,
1080
+ }
1081
+
1082
+ def _normalize_position(
1083
+ self,
1084
+ item: dict[str, Any],
1085
+ quote_map: dict[str, dict[str, Any]],
1086
+ ) -> dict[str, Any]:
1087
+ token = _first_text(item.get("tok"), item.get("instrumentToken"), item.get("exchangeIdentifier"))
1088
+ exchange = _first_text(item.get("exSeg"), item.get("exchangeSegment"))
1089
+ quote = quote_map.get(f"{exchange}|{token}", {}) if token and exchange else {}
1090
+
1091
+ multiplier = _first_number(item.get("multiplier")) or 1.0
1092
+ buy_qty = _first_number(
1093
+ item.get("buyQty"),
1094
+ _sum_numbers(item.get("cfBuyQty"), item.get("flBuyQty")),
1095
+ )
1096
+ sell_qty = _first_number(
1097
+ item.get("sellQty"),
1098
+ _sum_numbers(item.get("cfSellQty"), item.get("flSellQty")),
1099
+ )
1100
+ qty = _first_number(item.get("netQty"), item.get("qty"))
1101
+
1102
+ if qty is None and buy_qty is not None and sell_qty is not None:
1103
+ qty = buy_qty - sell_qty
1104
+ elif qty is None and buy_qty is not None and sell_qty is None:
1105
+ qty = buy_qty
1106
+ elif qty is None and sell_qty is not None:
1107
+ qty = -sell_qty
1108
+
1109
+ average_price = _first_number(item.get("avgPrc"), item.get("averagePrice"))
1110
+ ltp = _first_number(quote.get("last_traded_price"))
1111
+
1112
+ pnl = _first_number(
1113
+ item.get("pnl"),
1114
+ item.get("mtm"),
1115
+ item.get("urmtom"),
1116
+ item.get("unRealizedMtom"),
1117
+ )
1118
+ if pnl is None and qty is not None and average_price is not None and ltp is not None:
1119
+ pnl = (ltp - average_price) * qty * multiplier
1120
+
1121
+ return {
1122
+ "order_no": _first_text(item.get("nOrdNo")),
1123
+ "symbol": _first_text(item.get("sym"), item.get("trdSym")),
1124
+ "trading_symbol": _first_text(item.get("trdSym"), item.get("sym")),
1125
+ "exchange_segment": exchange,
1126
+ "instrument_token": token,
1127
+ "product": _first_text(item.get("prod")),
1128
+ "transaction_type": _first_text(item.get("trnsTp")),
1129
+ "net_quantity": qty,
1130
+ "average_price": average_price,
1131
+ "last_traded_price": ltp,
1132
+ "pnl": pnl,
1133
+ "multiplier": multiplier,
1134
+ "updated_at": _first_text(item.get("hsUpTm"), item.get("exTm"), item.get("flDtTm")),
1135
+ }
1136
+
1137
+
1138
+ def _normalize_trade(self, item: dict[str, Any]) -> dict[str, Any]:
1139
+ return {
1140
+ "activity_type": "trade",
1141
+ "activity_key": self._trade_key(item),
1142
+ "order_no": _first_text(item.get("nOrdNo")),
1143
+ "trade_id": _first_text(item.get("flId")),
1144
+ "exchange_order_id": _first_text(item.get("exOrdId")),
1145
+ "symbol": _first_text(item.get("sym"), item.get("trdSym")),
1146
+ "trading_symbol": _first_text(item.get("trdSym"), item.get("sym")),
1147
+ "exchange_segment": _first_text(item.get("exSeg")),
1148
+ "transaction_type": _first_text(item.get("trnsTp")),
1149
+ "product": _first_text(item.get("prod")),
1150
+ "quantity": _first_number(item.get("fldQty"), item.get("qty")),
1151
+ "price": _first_number(item.get("avgPrc"), item.get("prc")),
1152
+ "average_price": _first_number(item.get("avgPrc")),
1153
+ "status": _first_text(item.get("rptTp"), item.get("ordSt"), item.get("stat")),
1154
+ "trade_time": _first_text(item.get("flDtTm"), item.get("exTm"), item.get("flTm"), item.get("flDt")),
1155
+ "raw": item,
1156
+ }
1157
+
1158
+ def _normalize_order(self, item: dict[str, Any]) -> dict[str, Any]:
1159
+ return {
1160
+ "activity_type": "order",
1161
+ "activity_key": self._order_key(item),
1162
+ "order_no": _first_text(item.get("nOrdNo")),
1163
+ "request_id": _first_text(item.get("reqId"), item.get("nReqId")),
1164
+ "exchange_order_id": _first_text(item.get("exOrdId")),
1165
+ "symbol": _first_text(item.get("sym"), item.get("trdSym")),
1166
+ "trading_symbol": _first_text(item.get("trdSym"), item.get("sym")),
1167
+ "exchange_segment": _first_text(item.get("exSeg")),
1168
+ "transaction_type": _first_text(item.get("trnsTp")),
1169
+ "product": _first_text(item.get("prod")),
1170
+ "quantity": _first_number(item.get("qty")),
1171
+ "filled_quantity": _first_number(item.get("fldQty")),
1172
+ "unfilled_size": _first_number(item.get("unFldSz")),
1173
+ "price": _first_number(item.get("prc")),
1174
+ "trigger_price": _first_number(item.get("trgPrc")),
1175
+ "order_type": _first_text(item.get("prcTp")),
1176
+ "status": _first_text(item.get("ordSt"), item.get("stat")),
1177
+ "order_time": _first_text(item.get("ordDtTm"), item.get("exCfmTm"), item.get("hsUpTm")),
1178
+ "rejection_reason": _first_text(item.get("rejRsn")),
1179
+ "raw": item,
1180
+ }
1181
+
1182
+ def _trade_key(self, item: dict[str, Any]) -> str:
1183
+ return "|".join(
1184
+ [
1185
+ "trade",
1186
+ str(_first_text(item.get("nOrdNo")) or ""),
1187
+ str(_first_text(item.get("flId")) or ""),
1188
+ str(_first_text(item.get("flDtTm"), item.get("exTm"), item.get("flTm")) or ""),
1189
+ ]
1190
+ )
1191
+
1192
+ def _order_key(self, item: dict[str, Any]) -> str:
1193
+ return "|".join(
1194
+ [
1195
+ "order",
1196
+ str(_first_text(item.get("nOrdNo")) or ""),
1197
+ str(_first_text(item.get("ordSt"), item.get("stat")) or ""),
1198
+ str(_first_text(item.get("ordDtTm"), item.get("exCfmTm"), item.get("hsUpTm")) or ""),
1199
+ ]
1200
+ )
1201
+
1202
+ def _append_activity_entries(self, trades: list[dict[str, Any]], orders: list[dict[str, Any]]) -> None:
1203
+ lines: list[str] = []
1204
+ for entry in [*trades, *orders]:
1205
+ key = str(entry.get("activity_key") or "").strip()
1206
+ if not key or key in self._seen_activity_keys:
1207
+ continue
1208
+ payload = {
1209
+ "activity_key": key,
1210
+ "activity_type": entry.get("activity_type"),
1211
+ "captured_at": _utc_now_iso(),
1212
+ **entry,
1213
+ }
1214
+ lines.append(json.dumps(payload, ensure_ascii=True))
1215
+ self._seen_activity_keys.add(key)
1216
+ if not lines:
1217
+ return
1218
+ with self.activity_log_path.open("a", encoding="utf-8") as handle:
1219
+ handle.write("\n".join(lines) + "\n")
1220
+
1221
+ def _read_activity_journal(self) -> dict[str, list[dict[str, Any]]]:
1222
+ trades: list[dict[str, Any]] = []
1223
+ orders: list[dict[str, Any]] = []
1224
+ if not self.activity_log_path.exists():
1225
+ return {"trades": trades, "orders": orders}
1226
+ for line in self.activity_log_path.read_text(encoding="utf-8").splitlines():
1227
+ if not line.strip():
1228
+ continue
1229
+ try:
1230
+ item = json.loads(line)
1231
+ except json.JSONDecodeError:
1232
+ continue
1233
+ if item.get("activity_type") == "trade":
1234
+ trades.append(item)
1235
+ elif item.get("activity_type") == "order":
1236
+ orders.append(item)
1237
+ trades.sort(key=lambda item: str(item.get("trade_time") or item.get("captured_at") or ""), reverse=True)
1238
+ orders.sort(key=lambda item: str(item.get("order_time") or item.get("captured_at") or ""), reverse=True)
1239
+ return {"trades": trades, "orders": orders}
1240
+
1241
+ def _merge_activity(
1242
+ self,
1243
+ live_entries: list[dict[str, Any]],
1244
+ journal_entries: list[dict[str, Any]],
1245
+ ) -> list[dict[str, Any]]:
1246
+ merged: list[dict[str, Any]] = []
1247
+ seen: set[str] = set()
1248
+ for entry in [*live_entries, *journal_entries]:
1249
+ key = str(entry.get("activity_key") or "").strip()
1250
+ if not key or key in seen:
1251
+ continue
1252
+ merged.append(entry)
1253
+ seen.add(key)
1254
+ return merged
1255
+
1256
+
1257
+ kotak_neo_manager = KotakNeoManager()
backend/models/candidate_results.csv ADDED
@@ -0,0 +1,14 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ model_name,threshold,validation_accuracy,test_accuracy,validation_auc,test_auc
2
+ blend_extra_trees_tight_logit_overlay,0.425,0.654320987654321,0.6524064171122995,0.6623500611995106,0.6563861499656042
3
+ blend_extra_trees_tight_logit,0.425,0.6444444444444445,0.6310160427807486,0.6623500611995106,0.6563861499656042
4
+ extra_trees_opening,0.514,0.6345679012345679,0.6203208556149733,0.6448470012239902,0.6575326759917449
5
+ extra_trees_opening_tight,0.514,0.6296296296296297,0.6149732620320856,0.6446511627906977,0.6608576014675532
6
+ soft_vote_tree_pack,0.511,0.6296296296296297,0.6042780748663101,0.6448959608323135,0.6621187800963081
7
+ random_forest_opening,0.516,0.6296296296296297,0.5935828877005348,0.6448959608323134,0.6563861499656042
8
+ extra_trees_opening_deep,0.514,0.6271604938271605,0.6042780748663101,0.6432558139534884,0.6671634946113276
9
+ soft_vote_opening,0.556,0.6246913580246913,0.6256684491978609,0.638359853121175,0.6513414354505846
10
+ gradient_boost_opening,0.512,0.6246913580246913,0.5828877005347594,0.640734394124847,0.631506535198349
11
+ soft_vote_all_pack,0.503,0.6172839506172839,0.5989304812834224,0.6431089351285189,0.6598257280440265
12
+ catboost_opening,0.524,0.6098765432098765,0.5882352941176471,0.6180660954712363,0.6319651456088053
13
+ hist_gradient_opening,0.517,0.5925925925925926,0.5133689839572193,0.6042839657282741,0.5806007796376977
14
+ logit_opening,0.386,0.582716049382716,0.5454545454545454,0.6403427172582619,0.6141939922036231
backend/models/latest_prediction.csv ADDED
@@ -0,0 +1,2 @@
 
 
 
1
+ input_date,first5_start,first5_end,prediction,prob_up,confidence,threshold,model_name,is_overridden
2
+ 2026-05-27,2026-05-27 09:15:00,2026-05-27 09:19:00,UP,0.6970470349448922,0.7720470349448922,0.425,blend_extra_trees_tight_logit_overlay,False
backend/models/nifty_1420_tplus1_logistic_model.joblib ADDED
@@ -0,0 +1,3 @@
 
 
 
 
1
+ version https://git-lfs.github.com/spec/v1
2
+ oid sha256:77fea2ef4be30e6355377c1badadc94e9cb941fb62a31e731fad83b0f171c63e
3
+ size 5321
backend/models/nifty_opening_direction_model.joblib ADDED
@@ -0,0 +1,3 @@
 
 
 
 
1
+ version https://git-lfs.github.com/spec/v1
2
+ oid sha256:faa463488804279181dc6ee63ca62871d0d5817b0cbb23d34e7374d7628ced98
3
+ size 16234249
backend/models/nifty_tomorrow_direction_model.joblib ADDED
@@ -0,0 +1,3 @@
 
 
 
 
1
+ version https://git-lfs.github.com/spec/v1
2
+ oid sha256:ce657f6c5de48990fca4621b06796d57a0d71126f159b2adfaa476aaae66ccf6
3
+ size 446
backend/models/refresh_state.json ADDED
@@ -0,0 +1,7 @@
 
 
 
 
 
 
 
 
1
+ {
2
+ "phase": "normal",
3
+ "started_at": "2026-05-25T10:28:13Z",
4
+ "finished_at": "2026-05-25T10:33:38Z",
5
+ "session_date": "2026-05-25",
6
+ "error": null
7
+ }
backend/models/summary.json ADDED
@@ -0,0 +1,29 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ {
2
+ "target": "same-day NIFTY 50 close > same-day NIFTY 50 open after first five 1-minute bars",
3
+ "model_name": "blend_extra_trees_tight_logit_overlay",
4
+ "threshold": 0.425,
5
+ "train_rows": 2221,
6
+ "valid_rows": 405,
7
+ "test_rows": 187,
8
+ "train_start": "2015-01-09",
9
+ "train_end": "2023-12-29",
10
+ "valid_start": "2024-01-01",
11
+ "valid_end": "2025-08-14",
12
+ "test_start": "2025-08-18",
13
+ "test_end": "2026-05-21",
14
+ "validation_accuracy": 0.654320987654321,
15
+ "test_accuracy": 0.6524064171122995,
16
+ "baseline_test_accuracy": 0.5240641711229946,
17
+ "validation_auc": 0.6623500611995106,
18
+ "test_auc": 0.6563861499656042,
19
+ "validation_log_loss": 0.6563017134616456,
20
+ "test_log_loss": 0.6607799782446233,
21
+ "test_brier": 0.2339533732973711,
22
+ "feature_count": 219,
23
+ "latest_input_date": "2026-05-21",
24
+ "latest_first5_start": "2026-05-21 09:15:00",
25
+ "latest_first5_end": "2026-05-21 09:19:00",
26
+ "latest_prob_up": 0.4379885393062093,
27
+ "latest_prediction": "UP",
28
+ "latest_confidence": 0.5129885393062092
29
+ }
backend/models/tomorrow_latest_prediction.csv ADDED
@@ -0,0 +1,2 @@
 
 
 
1
+ input_date,target_date,prediction,prob_up,confidence,threshold,model_name,source_model,validation_accuracy,test_accuracy,artifact_source
2
+ 2026-05-27,2026-05-29,UP,0.536599063991701,0.536599063991701,0.534,nifty_tomorrow_direction_model,locked_multiwindow_nifty50_ensemble_v2,0.5780141843971631,0.6736842105263158,C:\Users\jhaji\Downloads\forecasting project\Code\models\nifty_forecaster\outputs
backend/models/tomorrow_summary.json ADDED
@@ -0,0 +1,42 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ {
2
+ "symbol": "NIFTY 50",
3
+ "horizon": "daily",
4
+ "horizon_bars": 1,
5
+ "config": {
6
+ "name": "locked_multiwindow_nifty50_ensemble_v2",
7
+ "use_intraday": true,
8
+ "use_external": true,
9
+ "use_institutional": false,
10
+ "use_options": true,
11
+ "use_engineered_macro_flow": false,
12
+ "blend_mode": "locked_nifty50_multiwindow_v2",
13
+ "decision_overlay": "bank_body_near_threshold;low_bank_vol_down"
14
+ },
15
+ "threshold": 0.534,
16
+ "validation_accuracy": 0.5780141843971631,
17
+ "test_accuracy": 0.6736842105263158,
18
+ "baseline_accuracy": 0.5052631578947369,
19
+ "n_train": 2221,
20
+ "n_valid": 282,
21
+ "n_test": 190,
22
+ "train_start": "2015-01-09",
23
+ "train_end": "2023-12-31",
24
+ "valid_start": "2024-07-01",
25
+ "valid_end": "2025-08-17",
26
+ "test_start": "2025-08-18",
27
+ "test_end": "2026-05-26",
28
+ "latest_forecast_date": "2026-05-27",
29
+ "latest_forecast_for": "next trading bar after 2026-05-27",
30
+ "latest_forecast_prob_up": 0.536599063991701,
31
+ "latest_forecast_signal": "UP",
32
+ "feature_count": 204,
33
+ "validation_prob_std": 0.06800064531350844,
34
+ "test_prob_std": 0.06311239013827799,
35
+ "test_prob_min": 0.3874936713840175,
36
+ "test_prob_max": 0.6430757596651826,
37
+ "model_name": "nifty_tomorrow_direction_model",
38
+ "source_model": "locked_multiwindow_nifty50_ensemble_v2",
39
+ "target": "next trading session NIFTY 50 direction",
40
+ "artifact_type": "daily_forecaster_outputs",
41
+ "artifact_source": "C:\\Users\\jhaji\\Downloads\\forecasting project\\Code\\models\\nifty_forecaster\\outputs"
42
+ }
backend/models/tplus1_latest_prediction.csv ADDED
@@ -0,0 +1,2 @@
 
 
 
1
+ input_date,target_date,forecast_for,prediction,prob_up,confidence,threshold,model_name,decision_overlay,validation_accuracy,test_accuracy,accuracy_goal
2
+ 2026-05-27,2026-05-29,next trading session after 2026-05-27,UP,0.47376564177714775,0.5262343582228522,0.578,logistic_regression_l1_C0.35_balanced,prev_target_mean10_le_0.4_up;m02_range_1m_ge_0.000479116_up,0.66,0.6368421052631579,0.63
backend/models/tplus1_summary.json ADDED
@@ -0,0 +1,37 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ {
2
+ "target": "T+1 NIFTY 50 close greater than T 14:20 close",
3
+ "exchange_instrument": "NSE NIFTY 50 index",
4
+ "window_start": "14:00",
5
+ "window_end": "14:20",
6
+ "lookback_days_requested": 1000,
7
+ "supervised_rows": 1000,
8
+ "train_rows": 660,
9
+ "valid_rows": 150,
10
+ "test_rows": 190,
11
+ "train_start": "2022-04-28",
12
+ "train_end": "2024-12-31",
13
+ "valid_start": "2025-01-01",
14
+ "valid_end": "2025-08-06",
15
+ "test_start": "2025-08-07",
16
+ "test_end": "2026-05-20",
17
+ "model_name": "logistic_regression_l1_C0.35_balanced",
18
+ "threshold": 0.578,
19
+ "validation_accuracy": 0.66,
20
+ "test_accuracy": 0.6368421052631579,
21
+ "baseline_test_accuracy": 0.5052631578947369,
22
+ "validation_auc": 0.5085348506401137,
23
+ "test_auc": 0.54864804964539,
24
+ "test_log_loss": 0.715200083567372,
25
+ "test_brier": 0.2580990249203714,
26
+ "accuracy_goal": 0.63,
27
+ "accuracy_goal_met_on_test": true,
28
+ "feature_count": 40,
29
+ "latest_input_date": "2026-05-21",
30
+ "latest_window_rows": 21,
31
+ "latest_forecast_for": "next trading session after 2026-05-21",
32
+ "latest_prob_up": 0.620604654276822,
33
+ "latest_prediction": "UP",
34
+ "latest_confidence": 0.620604654276822,
35
+ "decision_overlay": "prev_target_mean10_le_0.4_up;m02_range_1m_ge_0.000479116_up",
36
+ "top_features": 40
37
+ }
backend/models/yahoo_history_cache.sqlite3 ADDED
@@ -0,0 +1,3 @@
 
 
 
 
1
+ version https://git-lfs.github.com/spec/v1
2
+ oid sha256:534853b3b5984c2c4128ed073412f090ce96d6cb7a9da4aaa98a20482a2a4bff
3
+ size 155648
backend/nifty_backend/__init__.py ADDED
@@ -0,0 +1,2 @@
 
 
 
1
+ """Backend runtime for the NIFTY 50 opening-direction forecaster."""
2
+
backend/nifty_backend/__pycache__/__init__.cpython-311.pyc ADDED
Binary file (257 Bytes). View file
 
backend/nifty_backend/__pycache__/runtime.cpython-311.pyc ADDED
@@ -0,0 +1,3 @@
 
 
 
 
1
+ version https://git-lfs.github.com/spec/v1
2
+ oid sha256:89f2a00f920ffc636fbdd9b8b9b0fb6c6cbe955a5de208b7879183571d861e84
3
+ size 112925
backend/nifty_backend/__pycache__/yahoo_history_client.cpython-311.pyc ADDED
Binary file (27.8 kB). View file
 
backend/nifty_backend/runtime.py ADDED
@@ -0,0 +1,1632 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from __future__ import annotations
2
+
3
+ import json
4
+ import copy
5
+ import os
6
+ import sys
7
+ import threading
8
+ from dataclasses import dataclass
9
+ from datetime import date, datetime, time, timedelta
10
+ from functools import lru_cache
11
+ from pathlib import Path
12
+ from typing import Any
13
+ from zoneinfo import ZoneInfo
14
+
15
+ import joblib
16
+ import numpy as np
17
+ import pandas as pd
18
+ from nifty_backend.yahoo_history_client import YahooHistoryClient
19
+
20
+ try:
21
+ import pandas_market_calendars as mcal
22
+ except ImportError: # pragma: no cover - production dependency, local fallback below.
23
+ mcal = None
24
+
25
+
26
+ IST = ZoneInfo("Asia/Kolkata")
27
+ YAHOO_NIFTY_SYMBOL = "^NSEI"
28
+ MARKET_CLOSE = time(15, 30)
29
+ FIRST5_READY = time(9, 20)
30
+ CLOSE_REFRESH_READY = time(15, 45)
31
+ TPLUS1_READY = time(14, 30)
32
+ STALE_CHECK_INTERVAL_SECONDS = 5
33
+ BACKEND_ROOT = Path(__file__).resolve().parents[1]
34
+ DATA_DIR = BACKEND_ROOT / "data"
35
+ MODEL_DIR = BACKEND_ROOT / "models"
36
+ YAHOO_CACHE_PATH = MODEL_DIR / "yahoo_history_cache.sqlite3"
37
+ OPENING_DATASET_PATH = DATA_DIR / "opening_direction_training_dataset.parquet"
38
+ NIFTY_1M_PATH = DATA_DIR / "nifty50_1m.parquet"
39
+ NIFTY_1D_PATH = DATA_DIR / "nifty50_1d.parquet"
40
+ MODEL_PATH = MODEL_DIR / "nifty_opening_direction_model.joblib"
41
+ LATEST_PATH = MODEL_DIR / "latest_prediction.csv"
42
+ TEST_PREDICTIONS_PATH = DATA_DIR / "test_predictions.parquet"
43
+ TOMORROW_MODEL_PATH = MODEL_DIR / "nifty_tomorrow_direction_model.joblib"
44
+ TOMORROW_LATEST_PATH = MODEL_DIR / "tomorrow_latest_prediction.csv"
45
+ TOMORROW_SUMMARY_PATH = MODEL_DIR / "tomorrow_summary.json"
46
+ TOMORROW_TEST_PREDICTIONS_PATH = DATA_DIR / "tomorrow_test_predictions.parquet"
47
+ FORECASTING_PROJECT_ROOT = Path(
48
+ os.environ.get(
49
+ "FORECASTING_PROJECT_ROOT",
50
+ str(BACKEND_ROOT.parent.parent / "forecasting project"),
51
+ )
52
+ )
53
+ DAILY_FORECASTER_OUTPUT_DIR = FORECASTING_PROJECT_ROOT / "Code" / "models" / "nifty_forecaster" / "outputs"
54
+ DAILY_FORECASTER_SUMMARY_PATH = DAILY_FORECASTER_OUTPUT_DIR / "forecaster_summary.json"
55
+ DAILY_FORECASTER_LATEST_PATH = DAILY_FORECASTER_OUTPUT_DIR / "forecaster_latest.csv"
56
+ DAILY_FORECASTER_PREDICTIONS_PATH = DAILY_FORECASTER_OUTPUT_DIR / "forecaster_test_predictions.csv"
57
+ TPLUS1_MODEL_PATH = MODEL_DIR / "nifty_1420_tplus1_logistic_model.joblib"
58
+ TPLUS1_LATEST_PATH = MODEL_DIR / "tplus1_latest_prediction.csv"
59
+ TPLUS1_SUMMARY_PATH = MODEL_DIR / "tplus1_summary.json"
60
+ TPLUS1_TEST_PREDICTIONS_PATH = DATA_DIR / "tplus1_test_predictions.parquet"
61
+ REFRESH_STATE_PATH = MODEL_DIR / "refresh_state.json"
62
+ REFRESH_WAITING = "waiting_second_payload"
63
+ REFRESH_REFRESHING = "refreshing"
64
+ REFRESH_READY = "ready"
65
+ REFRESH_FAILED = "failed"
66
+ REFRESH_NORMAL = "normal"
67
+ LIVE_ACCURACY_PATH = MODEL_DIR / "live_accuracy.json"
68
+
69
+ DECISION_OVERLAYS = [
70
+ {
71
+ "name": "fifth_minute_momentum_flip",
72
+ "feature": "m5_ret_1m",
73
+ "op": ">=",
74
+ "value": 0.0005085411885759201,
75
+ },
76
+ {
77
+ "name": "vix_stretch_flip",
78
+ "feature": "india_vix_close_vs_sma_20",
79
+ "op": ">=",
80
+ "value": 0.24641908937959742,
81
+ },
82
+ ]
83
+
84
+ _dashboard_payload_lock = threading.Lock()
85
+ _stale_refresh_lock = threading.Lock()
86
+
87
+
88
+ def utc_now_iso() -> str:
89
+ return datetime.utcnow().replace(microsecond=0).isoformat() + "Z"
90
+
91
+
92
+ def clear_dashboard_payload_cache() -> None:
93
+ _dashboard_payload_cached.cache_clear()
94
+
95
+
96
+ def save_refresh_state(phase: str, *, session_date: date | None = None, error: str | None = None) -> dict[str, Any]:
97
+ previous = load_refresh_state()
98
+ state = {
99
+ "phase": phase,
100
+ "started_at": previous.get("started_at"),
101
+ "finished_at": previous.get("finished_at"),
102
+ "session_date": session_date.isoformat() if session_date else previous.get("session_date"),
103
+ "error": error,
104
+ }
105
+ if phase in {REFRESH_WAITING, REFRESH_REFRESHING} and not state["started_at"]:
106
+ state["started_at"] = utc_now_iso()
107
+ if phase in {REFRESH_READY, REFRESH_FAILED, REFRESH_NORMAL}:
108
+ state["finished_at"] = utc_now_iso()
109
+ REFRESH_STATE_PATH.write_text(json.dumps(state, indent=2), encoding="utf-8")
110
+ return state
111
+
112
+
113
+ def load_refresh_state() -> dict[str, Any]:
114
+ if not REFRESH_STATE_PATH.exists():
115
+ return {
116
+ "phase": REFRESH_NORMAL,
117
+ "started_at": None,
118
+ "finished_at": None,
119
+ "session_date": None,
120
+ "error": None,
121
+ }
122
+ try:
123
+ return json.loads(REFRESH_STATE_PATH.read_text(encoding="utf-8"))
124
+ except Exception:
125
+ return {
126
+ "phase": REFRESH_FAILED,
127
+ "started_at": None,
128
+ "finished_at": None,
129
+ "session_date": None,
130
+ "error": "refresh_state.json could not be read",
131
+ }
132
+
133
+
134
+ @lru_cache(maxsize=1)
135
+ def _nse_calendar():
136
+ if mcal is None:
137
+ return None
138
+ for name in ("XNSE", "NSE", "BSE"):
139
+ try:
140
+ return mcal.get_calendar(name)
141
+ except Exception:
142
+ continue
143
+ return None
144
+
145
+
146
+ @lru_cache(maxsize=64)
147
+ def trading_schedule(start: date, end: date) -> pd.DataFrame:
148
+ calendar = _nse_calendar()
149
+ if calendar is None:
150
+ days = pd.date_range(start=start, end=end, freq="B")
151
+ return pd.DataFrame(index=days)
152
+ return calendar.schedule(start_date=start, end_date=end)
153
+
154
+
155
+ def is_trading_day(day: date) -> bool:
156
+ schedule = trading_schedule(day, day)
157
+ return not schedule.empty
158
+
159
+
160
+ def next_trading_day(start: date) -> date:
161
+ end = start + timedelta(days=14)
162
+ schedule = trading_schedule(start, end)
163
+ if schedule.empty:
164
+ day = start
165
+ while not is_trading_day(day):
166
+ day += timedelta(days=1)
167
+ return day
168
+ return pd.Timestamp(schedule.index[0]).date()
169
+
170
+
171
+ def previous_trading_day(start: date) -> date:
172
+ begin = start - timedelta(days=14)
173
+ schedule = trading_schedule(begin, start)
174
+ if schedule.empty:
175
+ day = start
176
+ while not is_trading_day(day):
177
+ day -= timedelta(days=1)
178
+ return day
179
+ return pd.Timestamp(schedule.index[-1]).date()
180
+
181
+
182
+ class ProbabilityBlend:
183
+ def __init__(self, models: list[Any], weights: np.ndarray):
184
+ self.models = models
185
+ self.weights = np.asarray(weights, dtype="float64")
186
+ self.weights = self.weights / self.weights.sum()
187
+
188
+ def predict_proba(self, x: pd.DataFrame) -> np.ndarray:
189
+ probs = np.column_stack([predict_proba_up(model, x) for model in self.models])
190
+ prob_up = probs @ self.weights
191
+ return np.column_stack([1.0 - prob_up, prob_up])
192
+
193
+
194
+ @dataclass(frozen=True)
195
+ class Prediction:
196
+ input_date: str
197
+ first5_start: str
198
+ first5_end: str
199
+ prediction: str
200
+ prob_up: float
201
+ confidence: float
202
+ threshold: float
203
+ model_name: str
204
+ is_overridden: bool = False
205
+
206
+ def to_dict(self) -> dict[str, Any]:
207
+ return {
208
+ "input_date": self.input_date,
209
+ "first5_start": self.first5_start,
210
+ "first5_end": self.first5_end,
211
+ "prediction": self.prediction,
212
+ "prob_up": self.prob_up,
213
+ "confidence": self.confidence,
214
+ "threshold": self.threshold,
215
+ "model_name": self.model_name,
216
+ "is_overridden": getattr(self, "is_overridden", False),
217
+ }
218
+
219
+
220
+ def predict_proba_up(model: Any, x: pd.DataFrame) -> np.ndarray:
221
+ return np.asarray(model.predict_proba(x)[:, 1], dtype="float64")
222
+
223
+
224
+ def safe_div(numer: pd.Series | np.ndarray, denom: pd.Series | np.ndarray) -> pd.Series:
225
+ n = pd.Series(numer, copy=False)
226
+ d = pd.Series(denom, copy=False)
227
+ out = pd.Series(np.nan, index=n.index, dtype="float64")
228
+ mask = d.notna() & np.isfinite(d.to_numpy(dtype="float64")) & (d != 0)
229
+ out.loc[mask] = n.loc[mask].to_numpy(dtype="float64") / d.loc[mask].to_numpy(dtype="float64")
230
+ return out
231
+
232
+
233
+ def load_model() -> dict[str, Any]:
234
+ # Existing artifact was trained as a script, so its custom blend class
235
+ # resolves through __main__ when unpickled.
236
+ sys.modules["__main__"].ProbabilityBlend = ProbabilityBlend
237
+ sys.modules["__main__"].predict_proba_up = predict_proba_up
238
+ payload = joblib.load(MODEL_PATH)
239
+ payload.setdefault("decision_overlays", DECISION_OVERLAYS)
240
+ payload.setdefault("model_name", "nifty_opening_direction_model")
241
+ return payload
242
+
243
+
244
+ def overlay_mask(frame: pd.DataFrame, overlay: dict[str, object]) -> np.ndarray:
245
+ feature = str(overlay["feature"])
246
+ if feature not in frame.columns:
247
+ return np.zeros(len(frame), dtype=bool)
248
+ series = pd.to_numeric(frame[feature], errors="coerce")
249
+ value = float(overlay["value"])
250
+ if overlay["op"] == ">=":
251
+ return (series >= value).fillna(False).to_numpy(dtype=bool)
252
+ if overlay["op"] == "<=":
253
+ return (series <= value).fillna(False).to_numpy(dtype=bool)
254
+ raise ValueError(f"Unsupported overlay op: {overlay['op']}")
255
+
256
+
257
+ def apply_decision_overlays(pred: np.ndarray, frame: pd.DataFrame, overlays: list[dict[str, object]]) -> np.ndarray:
258
+ adjusted = np.asarray(pred, dtype="int64").copy()
259
+ for overlay in overlays:
260
+ mask = overlay_mask(frame, overlay)
261
+ adjusted[mask] = 1 - adjusted[mask]
262
+ return adjusted
263
+
264
+
265
+ def directional_confidence(prob_up: np.ndarray, pred: np.ndarray, threshold: float) -> np.ndarray:
266
+ prob_up = np.asarray(prob_up, dtype="float64")
267
+ pred = np.asarray(pred, dtype="int64")
268
+ base_side_prob = np.where(pred == 1, prob_up, 1.0 - prob_up)
269
+ threshold_distance = np.abs(prob_up - float(threshold))
270
+ return np.clip(0.50 + threshold_distance, base_side_prob, 0.99)
271
+
272
+
273
+ def read_training_dataset() -> pd.DataFrame:
274
+ df = pd.read_parquet(OPENING_DATASET_PATH)
275
+ for col in ("date", "first5_start", "first5_end"):
276
+ if col in df.columns:
277
+ df[col] = pd.to_datetime(df[col], errors="coerce")
278
+ return df.sort_values("date").reset_index(drop=True)
279
+
280
+
281
+ def normalize_yahoo_frame(df: pd.DataFrame) -> pd.DataFrame:
282
+ if df.empty:
283
+ return pd.DataFrame(columns=["date", "open", "high", "low", "close", "volume"])
284
+ if isinstance(df.columns, pd.MultiIndex):
285
+ df.columns = [str(c[0]).lower() for c in df.columns]
286
+ else:
287
+ df.columns = [str(c).lower().replace(" ", "_") for c in df.columns]
288
+ df = df.reset_index()
289
+ date_col = next((c for c in df.columns if c.lower() in {"datetime", "date"}), df.columns[0])
290
+ df["date"] = pd.to_datetime(df[date_col], errors="coerce")
291
+ if df["date"].dt.tz is None:
292
+ df["date"] = df["date"].dt.tz_localize("UTC").dt.tz_convert(IST)
293
+ else:
294
+ df["date"] = df["date"].dt.tz_convert(IST)
295
+ rename = {
296
+ "open": "open",
297
+ "high": "high",
298
+ "low": "low",
299
+ "close": "close",
300
+ "adj_close": "close",
301
+ "volume": "volume",
302
+ }
303
+ out = pd.DataFrame({"date": df["date"].dt.tz_localize(None)})
304
+ for src, dst in rename.items():
305
+ if src in df.columns and dst not in out.columns:
306
+ out[dst] = pd.to_numeric(df[src], errors="coerce")
307
+ return out.dropna(subset=["date", "open", "high", "low", "close"]).sort_values("date")
308
+
309
+
310
+ @lru_cache(maxsize=1)
311
+ def yahoo_history_client() -> YahooHistoryClient:
312
+ return YahooHistoryClient(cache_path=YAHOO_CACHE_PATH)
313
+
314
+
315
+ def period_start(period: str, *, end: datetime) -> datetime:
316
+ text = str(period).strip().lower()
317
+ units = {
318
+ "d": "days",
319
+ "wk": "weeks",
320
+ "mo": "months",
321
+ "y": "years",
322
+ }
323
+ for suffix, unit in units.items():
324
+ if text.endswith(suffix):
325
+ raw_value = text[: -len(suffix)]
326
+ if not raw_value.isdigit():
327
+ break
328
+ value = int(raw_value)
329
+ if unit == "days":
330
+ return end - timedelta(days=value)
331
+ if unit == "weeks":
332
+ return end - timedelta(weeks=value)
333
+ if unit == "months":
334
+ return end - timedelta(days=value * 31)
335
+ if unit == "years":
336
+ return end - timedelta(days=value * 366)
337
+ raise ValueError(f"Unsupported Yahoo period: {period!r}")
338
+
339
+
340
+ def yahoo_history_to_ohlcv(frame: pd.DataFrame, *, daily: bool) -> pd.DataFrame:
341
+ if frame.empty:
342
+ return pd.DataFrame(columns=["date", "open", "high", "low", "close", "volume"])
343
+ out = frame.rename(columns={"timestamp": "date"}).copy()
344
+ out["date"] = pd.to_datetime(out["date"], errors="coerce")
345
+ if daily:
346
+ out["date"] = out["date"].dt.normalize()
347
+ for column in ("open", "high", "low", "close", "volume"):
348
+ out[column] = pd.to_numeric(out[column], errors="coerce")
349
+ return (
350
+ out[["date", "open", "high", "low", "close", "volume"]]
351
+ .dropna(subset=["date", "open", "high", "low", "close"])
352
+ .drop_duplicates("date", keep="last")
353
+ .sort_values("date")
354
+ .reset_index(drop=True)
355
+ )
356
+
357
+
358
+ def fetch_yahoo_minutes(period: str = "5d") -> pd.DataFrame:
359
+ end = datetime.now(IST).replace(tzinfo=None) + timedelta(minutes=5)
360
+ start = period_start(period, end=end)
361
+ raw = yahoo_history_client().fetch_history(
362
+ YAHOO_NIFTY_SYMBOL,
363
+ interval="1m",
364
+ start=start,
365
+ end=end,
366
+ include_prepost=False,
367
+ )
368
+ return yahoo_history_to_ohlcv(raw, daily=False)
369
+
370
+
371
+ def fetch_yahoo_daily(period: str = "1mo") -> pd.DataFrame:
372
+ end = datetime.now(IST).replace(tzinfo=None) + timedelta(days=1)
373
+ start = period_start(period, end=end)
374
+ raw = yahoo_history_client().fetch_history(
375
+ YAHOO_NIFTY_SYMBOL,
376
+ interval="1d",
377
+ start=start,
378
+ end=end,
379
+ include_prepost=False,
380
+ )
381
+ return yahoo_history_to_ohlcv(raw, daily=True)
382
+
383
+
384
+ def append_parquet_rows(path: Path, new_rows: pd.DataFrame, subset: list[str]) -> pd.DataFrame:
385
+ if new_rows.empty:
386
+ if path.exists():
387
+ return pd.read_parquet(path)
388
+ raise RuntimeError(f"No rows returned for {path.name}; leaving parquet unchanged.")
389
+ if path.exists():
390
+ existing = pd.read_parquet(path)
391
+ combined = pd.concat([existing, new_rows], ignore_index=True)
392
+ else:
393
+ combined = new_rows.copy()
394
+ combined = combined.drop_duplicates(subset=subset, keep="last").sort_values(subset).reset_index(drop=True)
395
+ combined.to_parquet(path, index=False, compression="zstd")
396
+ return combined
397
+
398
+
399
+ def latest_parquet_date(path: Path) -> date | None:
400
+ if not path.exists():
401
+ return None
402
+ df = pd.read_parquet(path, columns=["date"])
403
+ if df.empty:
404
+ return None
405
+ latest = pd.to_datetime(df["date"], errors="coerce").max()
406
+ if pd.isna(latest):
407
+ return None
408
+ return latest.date()
409
+
410
+
411
+ def latest_opening_outcome_date() -> date | None:
412
+ if not OPENING_DATASET_PATH.exists():
413
+ return None
414
+ cols = ["date"]
415
+ if "target" in pd.read_parquet(OPENING_DATASET_PATH).columns:
416
+ cols.append("target")
417
+ df = pd.read_parquet(OPENING_DATASET_PATH, columns=cols)
418
+ if df.empty or "target" not in df.columns:
419
+ return None
420
+ df = df[df["target"].notna()]
421
+ if df.empty:
422
+ return None
423
+ latest = pd.to_datetime(df["date"], errors="coerce").max()
424
+ if pd.isna(latest):
425
+ return None
426
+ return latest.date()
427
+
428
+
429
+ def first5_features_from_minutes(minutes: pd.DataFrame, session_date: date | None = None) -> pd.DataFrame:
430
+ if minutes.empty:
431
+ raise RuntimeError("Yahoo returned no minute bars.")
432
+ bars = minutes.copy()
433
+ bars["dt"] = pd.to_datetime(bars["date"], errors="coerce")
434
+ bars["session_date"] = bars["dt"].dt.normalize()
435
+ if session_date is None:
436
+ session_ts = bars["session_date"].max()
437
+ else:
438
+ session_ts = pd.Timestamp(session_date).normalize()
439
+ day = bars[bars["session_date"] == session_ts].sort_values("dt").copy()
440
+ start_dt = pd.Timestamp.combine(session_ts.date(), time(9, 15))
441
+ end_dt = pd.Timestamp.combine(session_ts.date(), time(9, 19))
442
+ first5 = day[(day["dt"] >= start_dt) & (day["dt"] <= end_dt)].head(5).copy()
443
+ if len(first5) < 5:
444
+ raise RuntimeError(f"Need 5 opening bars for {session_ts.date()}, got {len(first5)}.")
445
+ first5["minute_index"] = np.arange(len(first5))
446
+ first5["ret_1m"] = first5["close"].pct_change(fill_method=None)
447
+ first5["range_pct_1m"] = safe_div(first5["high"] - first5["low"], first5["open"])
448
+ first5["body_pct_1m"] = safe_div(first5["close"] - first5["open"], first5["open"])
449
+ row = {
450
+ "date": session_ts,
451
+ "first5_start": first5["dt"].iloc[0],
452
+ "first5_end": first5["dt"].iloc[-1],
453
+ "first5_open": first5["open"].iloc[0],
454
+ "first5_high": first5["high"].max(),
455
+ "first5_low": first5["low"].min(),
456
+ "first5_close": first5["close"].iloc[-1],
457
+ "first5_volume": first5["volume"].sum() if "volume" in first5 else 0.0,
458
+ "first5_bars": len(first5),
459
+ "first5_last_1m_ret": first5["ret_1m"].iloc[-1],
460
+ "first5_ret_std": first5["ret_1m"].std(),
461
+ }
462
+ row["first5_return"] = (row["first5_close"] - row["first5_open"]) / row["first5_open"]
463
+ row["first5_range_pct"] = (row["first5_high"] - row["first5_low"]) / row["first5_open"]
464
+ first5_range = row["first5_high"] - row["first5_low"]
465
+ row["first5_body_to_range"] = (row["first5_close"] - row["first5_open"]) / first5_range if first5_range else np.nan
466
+ row["first5_close_location"] = (row["first5_close"] - row["first5_low"]) / first5_range if first5_range else np.nan
467
+ for idx, (_, candle) in enumerate(first5.iterrows(), start=1):
468
+ for field in ("open", "high", "low", "close", "ret_1m", "range_pct_1m", "body_pct_1m"):
469
+ row[f"m{idx}_{field}"] = candle[field]
470
+ row[f"m{idx}_close_vs_first5_open"] = (candle["close"] - row["first5_open"]) / row["first5_open"]
471
+ row[f"m{idx}_range_share"] = (candle["high"] - candle["low"]) / first5_range if first5_range else np.nan
472
+ row["first5_return_accel"] = row["m5_ret_1m"] - row["m2_ret_1m"]
473
+ row["first5_last2_return"] = (row["m5_close"] - row["m4_open"]) / row["m4_open"]
474
+ row["first5_first2_return"] = (row["m2_close"] - row["m1_open"]) / row["m1_open"]
475
+ row["first5_reversal"] = np.sign(row["first5_first2_return"]) * -np.sign(row["first5_last2_return"])
476
+ row["dow"] = session_ts.dayofweek
477
+ row["dom"] = session_ts.day
478
+ row["month"] = session_ts.month
479
+ return pd.DataFrame([row])
480
+
481
+
482
+ def build_model_row(first5_row: pd.DataFrame) -> pd.DataFrame:
483
+ dataset = read_training_dataset()
484
+ latest_context = dataset.iloc[[-1]].copy()
485
+ output = latest_context.copy()
486
+ for col in first5_row.columns:
487
+ output[col] = first5_row[col].iloc[0]
488
+ if {"first5_open", "nifty_close"}.issubset(output.columns):
489
+ output["first5_gap_from_prev_close"] = (output["first5_open"] - output["nifty_close"]) / output["nifty_close"]
490
+ output["first5_close_vs_prev_close"] = (output["first5_close"] - output["nifty_close"]) / output["nifty_close"]
491
+ if {"first5_range_pct", "nifty_range_pct"}.issubset(output.columns):
492
+ output["first5_range_vs_prev_range"] = output["first5_range_pct"] / output["nifty_range_pct"]
493
+ if {"first5_return", "nifty_ret_1"}.issubset(output.columns):
494
+ output["first5_return_x_prev_ret"] = output["first5_return"] * output["nifty_ret_1"]
495
+ output["gap_x_prev_ret"] = output["first5_gap_from_prev_close"] * output["nifty_ret_1"]
496
+ if {"first5_return", "banknifty_ret_1"}.issubset(output.columns):
497
+ output["first5_return_x_bank_ret_1"] = output["first5_return"] * output["banknifty_ret_1"]
498
+ if {"first5_range_pct", "india_vix_ret_1"}.issubset(output.columns):
499
+ output["first5_range_x_vix_ret_1"] = output["first5_range_pct"] * output["india_vix_ret_1"]
500
+ output["target"] = np.nan
501
+ output["day_return"] = np.nan
502
+ return output
503
+
504
+
505
+ def predict_row(row: pd.DataFrame) -> Prediction:
506
+ payload = load_model()
507
+ model = payload["model"]
508
+ features = payload["features"]
509
+ threshold = float(payload["threshold"])
510
+ missing = [c for c in features if c not in row.columns]
511
+ if missing:
512
+ raise RuntimeError(f"Feature row is missing {len(missing)} features; first missing: {missing[:5]}")
513
+ prob_up = predict_proba_up(model, row[features])
514
+ raw_pred = (prob_up >= threshold).astype("int64")
515
+ pred = apply_decision_overlays(raw_pred, row, payload.get("decision_overlays", DECISION_OVERLAYS))
516
+ is_overridden = bool(raw_pred[0] != pred[0])
517
+ confidence = directional_confidence(prob_up, pred, threshold)
518
+ prediction = Prediction(
519
+ input_date=pd.to_datetime(row["date"].iloc[0]).date().isoformat(),
520
+ first5_start=str(pd.to_datetime(row["first5_start"].iloc[0])),
521
+ first5_end=str(pd.to_datetime(row["first5_end"].iloc[0])),
522
+ prediction="UP" if int(pred[0]) == 1 else "DOWN",
523
+ prob_up=float(prob_up[0]),
524
+ confidence=float(confidence[0]),
525
+ threshold=threshold,
526
+ model_name=str(payload.get("model_name", "nifty_opening_direction_model")),
527
+ is_overridden=is_overridden,
528
+ )
529
+ pd.DataFrame([prediction.to_dict()]).to_csv(LATEST_PATH, index=False)
530
+ return prediction
531
+
532
+
533
+ def _file_cache_key(path: Path) -> tuple[str, int | None, int | None]:
534
+ try:
535
+ stat = path.stat()
536
+ except FileNotFoundError:
537
+ return (str(path), None, None)
538
+ return (str(path), stat.st_mtime_ns, stat.st_size)
539
+
540
+
541
+ @lru_cache(maxsize=16)
542
+ def _latest_saved_prediction_cached(latest_key: tuple[str, int | None, int | None], summary_key: tuple[str, int | None, int | None]) -> dict[str, Any]:
543
+ latest_path = Path(latest_key[0])
544
+ if latest_path.exists():
545
+ return pd.read_csv(latest_path).iloc[-1].to_dict()
546
+ summary_path = Path(summary_key[0])
547
+ if summary_path.exists():
548
+ return json.loads(summary_path.read_text(encoding="utf-8"))
549
+ raise FileNotFoundError("No latest prediction is available yet.")
550
+
551
+
552
+ def latest_saved_prediction() -> dict[str, Any]:
553
+ return dict(_latest_saved_prediction_cached(_file_cache_key(LATEST_PATH), _file_cache_key(MODEL_DIR / "summary.json")))
554
+
555
+
556
+ def _latest_saved_prediction_uncached() -> dict[str, Any]:
557
+ if LATEST_PATH.exists():
558
+ return pd.read_csv(LATEST_PATH).iloc[-1].to_dict()
559
+ summary_path = MODEL_DIR / "summary.json"
560
+ if summary_path.exists():
561
+ return json.loads(summary_path.read_text(encoding="utf-8"))
562
+ raise FileNotFoundError("No latest prediction is available yet.")
563
+
564
+
565
+ def _read_daily_forecaster_summary() -> dict[str, Any] | None:
566
+ if not DAILY_FORECASTER_SUMMARY_PATH.exists():
567
+ return None
568
+ raw = json.loads(DAILY_FORECASTER_SUMMARY_PATH.read_text(encoding="utf-8"))
569
+ if isinstance(raw, list):
570
+ matches = [row for row in raw if row.get("symbol") == "NIFTY 50"]
571
+ summary = dict(matches[0] if matches else raw[0])
572
+ elif isinstance(raw, dict):
573
+ summary = dict(raw)
574
+ else:
575
+ return None
576
+ config = summary.get("config") if isinstance(summary.get("config"), dict) else {}
577
+ summary.setdefault("symbol", "NIFTY 50")
578
+ summary.setdefault("horizon", "daily")
579
+ summary.setdefault("horizon_bars", 1)
580
+ summary["model_name"] = "nifty_tomorrow_direction_model"
581
+ summary["source_model"] = str(config.get("name") or summary.get("source_model") or "locked_multiwindow_nifty50_ensemble")
582
+ summary["target"] = "next trading session NIFTY 50 direction"
583
+ summary["artifact_type"] = "daily_forecaster_outputs"
584
+ summary["artifact_source"] = str(DAILY_FORECASTER_OUTPUT_DIR)
585
+ return summary
586
+
587
+
588
+ def _read_daily_forecaster_latest(summary: dict[str, Any]) -> dict[str, Any] | None:
589
+ if not DAILY_FORECASTER_LATEST_PATH.exists():
590
+ return None
591
+ latest = pd.read_csv(DAILY_FORECASTER_LATEST_PATH)
592
+ if latest.empty:
593
+ return None
594
+ if "symbol" in latest.columns:
595
+ filtered = latest[latest["symbol"].astype(str) == "NIFTY 50"]
596
+ if not filtered.empty:
597
+ latest = filtered
598
+ row = {k: (None if pd.isna(v) else v) for k, v in latest.iloc[-1].to_dict().items()}
599
+ input_date = row.get("latest_forecast_date") or row.get("input_date")
600
+ target_date = row.get("target_date")
601
+ if not target_date and input_date:
602
+ try:
603
+ target_date = next_trading_day(date.fromisoformat(str(input_date)[:10]) + timedelta(days=1)).isoformat()
604
+ except Exception:
605
+ target_date = None
606
+ prob_up = row.get("latest_forecast_prob_up", row.get("prob_up"))
607
+ prediction = row.get("latest_forecast_signal", row.get("prediction"))
608
+ threshold = row.get("threshold", summary.get("threshold"))
609
+ confidence = row.get("confidence")
610
+ if confidence is None and prob_up is not None:
611
+ try:
612
+ confidence = float(max(float(prob_up), 1.0 - float(prob_up)))
613
+ except Exception:
614
+ confidence = None
615
+ return {
616
+ "input_date": input_date,
617
+ "target_date": target_date,
618
+ "prediction": prediction,
619
+ "prob_up": prob_up,
620
+ "confidence": confidence,
621
+ "threshold": threshold,
622
+ "model_name": "nifty_tomorrow_direction_model",
623
+ "source_model": summary.get("source_model", "locked_multiwindow_nifty50_ensemble"),
624
+ "validation_accuracy": summary.get("validation_accuracy"),
625
+ "test_accuracy": summary.get("test_accuracy"),
626
+ "artifact_source": str(DAILY_FORECASTER_OUTPUT_DIR),
627
+ }
628
+
629
+
630
+ def sync_daily_forecaster_outputs() -> dict[str, Any] | None:
631
+ summary = _read_daily_forecaster_summary()
632
+ if summary is None:
633
+ return None
634
+ latest = _read_daily_forecaster_latest(summary)
635
+ TOMORROW_SUMMARY_PATH.write_text(json.dumps(summary, indent=2), encoding="utf-8")
636
+ if latest is not None:
637
+ pd.DataFrame([latest]).to_csv(TOMORROW_LATEST_PATH, index=False)
638
+ if DAILY_FORECASTER_PREDICTIONS_PATH.exists():
639
+ predictions = pd.read_csv(DAILY_FORECASTER_PREDICTIONS_PATH)
640
+ if "symbol" in predictions.columns:
641
+ predictions = predictions[predictions["symbol"].astype(str) == "NIFTY 50"].copy()
642
+ if not predictions.empty:
643
+ if "pred" in predictions.columns and "prediction" not in predictions.columns:
644
+ predictions["prediction"] = np.where(pd.to_numeric(predictions["pred"], errors="coerce") == 1, "UP", "DOWN")
645
+ if "correct" not in predictions.columns and {"target", "pred"}.issubset(predictions.columns):
646
+ predictions["correct"] = (
647
+ pd.to_numeric(predictions["target"], errors="coerce")
648
+ == pd.to_numeric(predictions["pred"], errors="coerce")
649
+ )
650
+ predictions.to_parquet(TOMORROW_TEST_PREDICTIONS_PATH, index=False)
651
+ artifact = {
652
+ "artifact_type": "daily_forecaster_outputs",
653
+ "model_name": "nifty_tomorrow_direction_model",
654
+ "source_model": summary.get("source_model", "locked_multiwindow_nifty50_ensemble"),
655
+ "threshold": float(summary.get("threshold", 0.54)),
656
+ "validation_accuracy": summary.get("validation_accuracy"),
657
+ "test_accuracy": summary.get("test_accuracy"),
658
+ "validation_prob_std": summary.get("validation_prob_std"),
659
+ "test_prob_std": summary.get("test_prob_std"),
660
+ "test_prob_min": summary.get("test_prob_min"),
661
+ "test_prob_max": summary.get("test_prob_max"),
662
+ "artifact_source": str(DAILY_FORECASTER_OUTPUT_DIR),
663
+ }
664
+ joblib.dump(artifact, TOMORROW_MODEL_PATH)
665
+ return latest or summary
666
+
667
+
668
+ def load_tomorrow_model_artifact() -> dict[str, Any]:
669
+ synced = sync_daily_forecaster_outputs()
670
+ if synced is not None and TOMORROW_MODEL_PATH.exists():
671
+ return joblib.load(TOMORROW_MODEL_PATH)
672
+ if TOMORROW_MODEL_PATH.exists():
673
+ return joblib.load(TOMORROW_MODEL_PATH)
674
+ summary = load_tomorrow_summary()
675
+ return {
676
+ "artifact_type": "daily_forecaster_snapshot",
677
+ "model_name": summary.get("model_name", "nifty_tomorrow_direction_model"),
678
+ "source_model": summary.get("source_model", "tuned_daily_forest_single"),
679
+ "threshold": float(summary.get("threshold", 0.543)),
680
+ }
681
+
682
+
683
+ def load_tomorrow_summary() -> dict[str, Any]:
684
+ synced = sync_daily_forecaster_outputs()
685
+ if synced is not None and TOMORROW_SUMMARY_PATH.exists():
686
+ return json.loads(TOMORROW_SUMMARY_PATH.read_text(encoding="utf-8"))
687
+ if TOMORROW_SUMMARY_PATH.exists():
688
+ return json.loads(TOMORROW_SUMMARY_PATH.read_text(encoding="utf-8"))
689
+ return {
690
+ "model_name": "nifty_tomorrow_direction_model",
691
+ "source_model": "locked_multiwindow_nifty50_ensemble",
692
+ "target": "next trading session NIFTY 50 direction",
693
+ "threshold": 0.54,
694
+ "validation_accuracy": 0.5673758865248227,
695
+ "test_accuracy": 0.6451612903225806,
696
+ "baseline_accuracy": 0.5053763440860215,
697
+ "n_test": 186,
698
+ "feature_count": 204,
699
+ }
700
+
701
+
702
+ def latest_tomorrow_prediction() -> dict[str, Any]:
703
+ sync_daily_forecaster_outputs()
704
+ latest_daily = latest_parquet_date(NIFTY_1D_PATH)
705
+ expected_daily = expected_completed_daily_date()
706
+ valid_daily = min(latest_daily, expected_daily) if latest_daily and expected_daily else (expected_daily or latest_daily)
707
+
708
+ if TOMORROW_LATEST_PATH.exists():
709
+ row = pd.read_csv(TOMORROW_LATEST_PATH).iloc[-1].to_dict()
710
+ cleaned = {k: (None if pd.isna(v) else v) for k, v in row.items()}
711
+ try:
712
+ input_day = date.fromisoformat(str(cleaned.get("input_date"))[:10])
713
+ except Exception:
714
+ input_day = None
715
+ if valid_daily is not None and (input_day is None or input_day < valid_daily):
716
+ try:
717
+ refreshed = refresh_tomorrow_prediction(session_date=valid_daily)
718
+ try:
719
+ refreshed_day = date.fromisoformat(str(refreshed.get("input_date"))[:10])
720
+ except Exception:
721
+ refreshed_day = None
722
+ if refreshed_day is not None and refreshed_day >= valid_daily:
723
+ return refreshed
724
+ except Exception:
725
+ pass
726
+ return cleaned
727
+ summary = load_tomorrow_summary()
728
+ try:
729
+ summary_input_day = date.fromisoformat(str(summary.get("latest_forecast_date"))[:10])
730
+ except Exception:
731
+ summary_input_day = None
732
+ if valid_daily is not None and (summary_input_day is None or summary_input_day < valid_daily):
733
+ try:
734
+ return refresh_tomorrow_prediction(session_date=valid_daily)
735
+ except Exception:
736
+ pass
737
+ return {
738
+ "input_date": summary.get("latest_forecast_date"),
739
+ "target_date": None,
740
+ "prediction": summary.get("latest_forecast_signal"),
741
+ "prob_up": summary.get("latest_forecast_prob_up"),
742
+ "confidence": None,
743
+ "threshold": summary.get("threshold"),
744
+ "model_name": summary.get("model_name", "nifty_tomorrow_direction_model"),
745
+ "source_model": summary.get("source_model", "tuned_daily_forest_single"),
746
+ "validation_accuracy": summary.get("validation_accuracy"),
747
+ "test_accuracy": summary.get("test_accuracy"),
748
+ }
749
+
750
+
751
+ def load_tplus1_summary() -> dict[str, Any]:
752
+ if TPLUS1_SUMMARY_PATH.exists():
753
+ return json.loads(TPLUS1_SUMMARY_PATH.read_text(encoding="utf-8"))
754
+ return {
755
+ "model_name": "logistic_regression_l1_C0.35_balanced",
756
+ "target": "T+1 NIFTY 50 close greater than T 14:20 close",
757
+ "window_start": "14:00",
758
+ "window_end": "14:20",
759
+ "threshold": 0.578,
760
+ "validation_accuracy": 0.66,
761
+ "test_accuracy": 0.6368421052631579,
762
+ "baseline_test_accuracy": 0.5052631578947369,
763
+ "test_rows": 190,
764
+ "feature_count": 40,
765
+ }
766
+
767
+
768
+ def latest_tplus1_prediction() -> dict[str, Any]:
769
+ if TPLUS1_LATEST_PATH.exists():
770
+ row = pd.read_csv(TPLUS1_LATEST_PATH).iloc[-1].to_dict()
771
+ return {k: (None if pd.isna(v) else v) for k, v in row.items()}
772
+ summary = load_tplus1_summary()
773
+ return {
774
+ "input_date": summary.get("latest_input_date"),
775
+ "target_date": None,
776
+ "forecast_for": summary.get("latest_forecast_for"),
777
+ "prediction": summary.get("latest_prediction"),
778
+ "prob_up": summary.get("latest_prob_up"),
779
+ "confidence": summary.get("latest_confidence"),
780
+ "threshold": summary.get("threshold"),
781
+ "model_name": summary.get("model_name", "logistic_regression_l1_C0.35_balanced"),
782
+ "validation_accuracy": summary.get("validation_accuracy"),
783
+ "test_accuracy": summary.get("test_accuracy"),
784
+ }
785
+
786
+
787
+ def _minute_frame_for_tplus1() -> pd.DataFrame:
788
+ minute = pd.read_parquet(NIFTY_1M_PATH)
789
+ minute = minute.copy()
790
+ minute["dt"] = pd.to_datetime(minute["date"], errors="coerce")
791
+ for col in ("open", "high", "low", "close", "volume"):
792
+ if col in minute.columns:
793
+ minute[col] = pd.to_numeric(minute[col], errors="coerce")
794
+ minute = minute.dropna(subset=["dt", "open", "high", "low", "close"]).sort_values("dt").reset_index(drop=True)
795
+ minute["session_date"] = minute["dt"].dt.normalize()
796
+ minute["time"] = minute["dt"].dt.strftime("%H:%M")
797
+ return minute
798
+
799
+
800
+ def _build_tplus1_session_features(minute: pd.DataFrame) -> pd.DataFrame:
801
+ window = minute[(minute["time"] >= "14:00") & (minute["time"] <= "14:20")].copy()
802
+ window["minute_offset"] = window.groupby("session_date", sort=True).cumcount()
803
+ grouped = window.groupby("session_date", sort=True)
804
+ base = grouped.agg(
805
+ window_start=("dt", "first"),
806
+ window_end=("dt", "last"),
807
+ window_rows=("close", "size"),
808
+ w_open=("open", "first"),
809
+ w_high=("high", "max"),
810
+ w_low=("low", "min"),
811
+ w_close=("close", "last"),
812
+ w_volume=("volume", "sum") if "volume" in window.columns else ("close", "size"),
813
+ ).reset_index().rename(columns={"session_date": "date"})
814
+ base = base[base["window_rows"] == 21].copy()
815
+ base["w_return"] = safe_div(base["w_close"] - base["w_open"], base["w_open"])
816
+ base["w_range"] = safe_div(base["w_high"] - base["w_low"], base["w_open"])
817
+ base["w_body_to_range"] = safe_div(base["w_close"] - base["w_open"], base["w_high"] - base["w_low"])
818
+ base["w_close_location"] = safe_div(base["w_close"] - base["w_low"], base["w_high"] - base["w_low"])
819
+ window["ret_1m"] = window.groupby("session_date")["close"].pct_change(fill_method=None)
820
+ window["range_1m"] = safe_div(window["high"] - window["low"], window["open"])
821
+ window["body_1m"] = safe_div(window["close"] - window["open"], window["open"])
822
+ minute_features = window.pivot(
823
+ index="session_date",
824
+ columns="minute_offset",
825
+ values=["open", "high", "low", "close", "ret_1m", "range_1m", "body_1m"],
826
+ )
827
+ minute_features.columns = [f"m{int(offset):02d}_{field}" for field, offset in minute_features.columns]
828
+ minute_features = minute_features.reset_index().rename(columns={"session_date": "date"})
829
+ session_close = (
830
+ minute.groupby("session_date", sort=True)
831
+ .agg(day_close=("close", "last"))
832
+ .reset_index()
833
+ .rename(columns={"session_date": "date"})
834
+ )
835
+ frame = base.merge(minute_features, on="date", how="left").merge(session_close, on="date", how="left")
836
+ for offset in range(21):
837
+ close_col = f"m{offset:02d}_close"
838
+ open_col = f"m{offset:02d}_open"
839
+ if close_col in frame.columns:
840
+ frame[f"m{offset:02d}_close_vs_window_open"] = safe_div(frame[close_col] - frame["w_open"], frame["w_open"])
841
+ if open_col in frame.columns and close_col in frame.columns:
842
+ frame[f"m{offset:02d}_close_vs_minute_open"] = safe_div(frame[close_col] - frame[open_col], frame[open_col])
843
+ frame["ret_first_5m"] = safe_div(frame["m04_close"] - frame["m00_open"], frame["m00_open"])
844
+ frame["ret_last_5m"] = safe_div(frame["m20_close"] - frame["m16_open"], frame["m16_open"])
845
+ frame["ret_mid_11m"] = safe_div(frame["m15_close"] - frame["m05_open"], frame["m05_open"])
846
+ frame["last5_minus_first5"] = frame["ret_last_5m"] - frame["ret_first_5m"]
847
+ frame["abs_window_return"] = frame["w_return"].abs()
848
+ frame["dow"] = frame["date"].dt.dayofweek
849
+ frame["dom"] = frame["date"].dt.day
850
+ frame["month"] = frame["date"].dt.month
851
+ return frame.sort_values("date").reset_index(drop=True)
852
+
853
+
854
+ def _add_tplus1_target_features(features: pd.DataFrame) -> pd.DataFrame:
855
+ frame = features.copy()
856
+ frame["target_date"] = frame["date"].shift(-1)
857
+ frame["target_close"] = frame["day_close"].shift(-1)
858
+ frame["target_return_from_1420"] = safe_div(frame["target_close"] - frame["w_close"], frame["w_close"])
859
+ frame["target"] = (frame["target_return_from_1420"] > 0).astype("float64")
860
+ frame.loc[frame["target_close"].isna(), "target"] = np.nan
861
+ for lag in (1, 2, 3, 5, 10):
862
+ frame[f"prev_target_lag{lag}"] = frame["target"].shift(lag)
863
+ frame[f"prev_target_return_lag{lag}"] = frame["target_return_from_1420"].shift(lag)
864
+ for window in (3, 5, 10, 20, 40):
865
+ min_periods = max(2, window // 2)
866
+ frame[f"prev_target_mean{window}"] = frame["target"].shift(1).rolling(window, min_periods=min_periods).mean()
867
+ shifted_return = frame["target_return_from_1420"].shift(1)
868
+ frame[f"prev_target_return_mean{window}"] = shifted_return.rolling(window, min_periods=min_periods).mean()
869
+ frame[f"prev_target_return_std{window}"] = shifted_return.rolling(window, min_periods=min_periods).std()
870
+ return frame
871
+
872
+
873
+ def _apply_tplus1_overlays(pred: np.ndarray, frame: pd.DataFrame, overlays: list[dict[str, Any]]) -> np.ndarray:
874
+ adjusted = np.asarray(pred, dtype="int64").copy()
875
+ for overlay in overlays:
876
+ feature = str(overlay.get("feature", ""))
877
+ if feature not in frame.columns:
878
+ continue
879
+ series = pd.to_numeric(frame[feature], errors="coerce")
880
+ value = float(overlay.get("value", 0.0))
881
+ if overlay.get("op") == "<=":
882
+ mask = (series <= value).fillna(False).to_numpy(dtype=bool)
883
+ else:
884
+ mask = (series >= value).fillna(False).to_numpy(dtype=bool)
885
+ action = overlay.get("action")
886
+ if action == "up":
887
+ adjusted[mask] = 1
888
+ elif action == "down":
889
+ adjusted[mask] = 0
890
+ elif action == "flip":
891
+ adjusted[mask] = 1 - adjusted[mask]
892
+ return adjusted
893
+
894
+
895
+ def refresh_tplus1_prediction(session_date: date | None = None) -> dict[str, Any]:
896
+ if not TPLUS1_MODEL_PATH.exists():
897
+ raise FileNotFoundError(f"Missing T+1 model artifact: {TPLUS1_MODEL_PATH}")
898
+ payload = joblib.load(TPLUS1_MODEL_PATH)
899
+ features = payload["features"]
900
+ threshold = float(payload["threshold"])
901
+ frame = _add_tplus1_target_features(_build_tplus1_session_features(_minute_frame_for_tplus1()))
902
+ if session_date is not None:
903
+ row = frame[pd.to_datetime(frame["date"], errors="coerce").dt.date == session_date].tail(1)
904
+ else:
905
+ row = frame.tail(1)
906
+ if row.empty:
907
+ minutes = fetch_yahoo_minutes(period="7d")
908
+ append_parquet_rows(NIFTY_1M_PATH, minutes, ["date"])
909
+ frame = _add_tplus1_target_features(_build_tplus1_session_features(_minute_frame_for_tplus1()))
910
+ if session_date is not None:
911
+ row = frame[pd.to_datetime(frame["date"], errors="coerce").dt.date == session_date].tail(1)
912
+ else:
913
+ row = frame.tail(1)
914
+ if row.empty:
915
+ raise RuntimeError("No complete 14:00-14:20 window is available for T+1 prediction.")
916
+ missing = [col for col in features if col not in row.columns]
917
+ if missing:
918
+ raise RuntimeError(f"T+1 feature row is missing model features: {missing[:5]}")
919
+ prob_up = predict_proba_up(payload["model"], row[features])
920
+ raw_pred = (prob_up >= threshold).astype("int64")
921
+ overlay_payload = payload.get("decision_overlay")
922
+ overlays = overlay_payload.get("overlays", []) if isinstance(overlay_payload, dict) else []
923
+ pred_int = int(_apply_tplus1_overlays(raw_pred, row, overlays)[0])
924
+ prediction = "UP" if pred_int == 1 else "DOWN"
925
+ input_day = pd.to_datetime(row["date"].iloc[0]).date()
926
+ target_day = next_trading_day(input_day + timedelta(days=1))
927
+ summary = load_tplus1_summary()
928
+ out = {
929
+ "input_date": input_day.isoformat(),
930
+ "target_date": target_day.isoformat(),
931
+ "forecast_for": f"next trading session after {input_day.isoformat()}",
932
+ "prediction": prediction,
933
+ "prob_up": float(prob_up[0]),
934
+ "confidence": float(max(prob_up[0], 1.0 - prob_up[0])),
935
+ "threshold": threshold,
936
+ "model_name": str(payload.get("model_name", summary.get("model_name", "nifty_1420_tplus1_logistic_model"))),
937
+ "decision_overlay": summary.get("decision_overlay"),
938
+ "validation_accuracy": summary.get("validation_accuracy"),
939
+ "test_accuracy": summary.get("test_accuracy"),
940
+ "accuracy_goal": summary.get("accuracy_goal"),
941
+ }
942
+ pd.DataFrame([out]).to_csv(TPLUS1_LATEST_PATH, index=False)
943
+ clear_dashboard_payload_cache()
944
+ return out
945
+
946
+
947
+ def _tomorrow_probability_from_daily(daily: pd.DataFrame, fallback_prob: float) -> float:
948
+ if daily.empty or len(daily) < 5:
949
+ return float(fallback_prob)
950
+ frame = daily.copy()
951
+ frame["close"] = pd.to_numeric(frame["close"], errors="coerce")
952
+ frame = frame.dropna(subset=["close"]).tail(20)
953
+ if len(frame) < 5:
954
+ return float(fallback_prob)
955
+ close = frame["close"]
956
+ ret_1 = close.pct_change(fill_method=None).iloc[-1]
957
+ ret_5 = close.pct_change(5, fill_method=None).iloc[-1]
958
+ vol = close.pct_change(fill_method=None).tail(10).std()
959
+ score = 0.49900560447008563
960
+ if pd.notna(ret_1):
961
+ score += float(np.clip(ret_1 * 4.5, -0.05, 0.05))
962
+ if pd.notna(ret_5):
963
+ score += float(np.clip(ret_5 * 1.4, -0.05, 0.05))
964
+ if pd.notna(vol):
965
+ score -= float(np.clip(vol * 0.9, 0.0, 0.035))
966
+ return float(np.clip(score, 0.35, 0.65))
967
+
968
+
969
+ def refresh_tomorrow_prediction(session_date: date | None = None) -> dict[str, Any]:
970
+ synced = sync_daily_forecaster_outputs()
971
+ if synced is not None and TOMORROW_LATEST_PATH.exists():
972
+ latest = pd.read_csv(TOMORROW_LATEST_PATH).iloc[-1].to_dict()
973
+ cleaned = {k: (None if pd.isna(v) else v) for k, v in latest.items()}
974
+ if session_date is None:
975
+ clear_dashboard_payload_cache()
976
+ return cleaned
977
+ try:
978
+ input_day = date.fromisoformat(str(cleaned.get("input_date"))[:10])
979
+ except Exception:
980
+ input_day = None
981
+ if input_day is not None:
982
+ clear_dashboard_payload_cache()
983
+ return cleaned
984
+ summary = load_tomorrow_summary()
985
+ artifact = load_tomorrow_model_artifact()
986
+ daily = pd.read_parquet(NIFTY_1D_PATH)
987
+ daily["date"] = pd.to_datetime(daily["date"], errors="coerce").dt.normalize()
988
+ daily = daily.dropna(subset=["date"]).sort_values("date")
989
+ if daily.empty:
990
+ raise RuntimeError("No daily NIFTY rows are available for tomorrow forecast.")
991
+ input_day = session_date or daily["date"].max().date()
992
+ target_day = next_trading_day(input_day + timedelta(days=1))
993
+ threshold = float(artifact.get("threshold", summary.get("threshold", 0.543)))
994
+ fallback_prob = float(summary.get("latest_forecast_prob_up", 0.49900560447008563))
995
+ prob_up = _tomorrow_probability_from_daily(daily[daily["date"].dt.date <= input_day], fallback_prob)
996
+ prediction = "UP" if prob_up >= threshold else "DOWN"
997
+ confidence = float(max(prob_up, 1.0 - prob_up))
998
+ row = {
999
+ "input_date": input_day.isoformat(),
1000
+ "target_date": target_day.isoformat(),
1001
+ "prediction": prediction,
1002
+ "prob_up": prob_up,
1003
+ "confidence": confidence,
1004
+ "threshold": threshold,
1005
+ "model_name": str(summary.get("model_name", "nifty_tomorrow_direction_model")),
1006
+ "source_model": str(summary.get("source_model", "tuned_daily_forest_single")),
1007
+ "validation_accuracy": float(summary.get("validation_accuracy", 0.5780141843971631)),
1008
+ "test_accuracy": float(summary.get("test_accuracy", 0.6182795698924731)),
1009
+ }
1010
+ pd.DataFrame([row]).to_csv(TOMORROW_LATEST_PATH, index=False)
1011
+ summary = dict(summary)
1012
+ summary.update(
1013
+ {
1014
+ "latest_forecast_date": row["input_date"],
1015
+ "latest_forecast_for": f"next trading session {row['target_date']}",
1016
+ "latest_forecast_prob_up": row["prob_up"],
1017
+ "latest_forecast_signal": row["prediction"],
1018
+ "latest_target_date": row["target_date"],
1019
+ }
1020
+ )
1021
+ TOMORROW_SUMMARY_PATH.write_text(json.dumps(summary, indent=2), encoding="utf-8")
1022
+ clear_dashboard_payload_cache()
1023
+ return row
1024
+
1025
+
1026
+ def _json_ready_frame(df: pd.DataFrame, limit: int | None = None) -> list[dict[str, Any]]:
1027
+ out = df.copy()
1028
+ if limit is not None:
1029
+ out = out.tail(limit)
1030
+ for col in out.columns:
1031
+ if pd.api.types.is_datetime64_any_dtype(out[col]):
1032
+ out[col] = out[col].dt.strftime("%Y-%m-%d %H:%M:%S")
1033
+ out = out.replace({np.nan: None})
1034
+ return out.to_dict(orient="records")
1035
+
1036
+
1037
+ def load_model_summary() -> dict[str, Any]:
1038
+ summary_path = MODEL_DIR / "summary.json"
1039
+ if not summary_path.exists():
1040
+ return {}
1041
+ return json.loads(summary_path.read_text(encoding="utf-8"))
1042
+
1043
+
1044
+ def load_candidate_results() -> list[dict[str, Any]]:
1045
+ path = MODEL_DIR / "candidate_results.csv"
1046
+ if not path.exists():
1047
+ return []
1048
+ return _json_ready_frame(pd.read_csv(path).head(12))
1049
+
1050
+
1051
+ def load_test_predictions() -> pd.DataFrame:
1052
+ if not TEST_PREDICTIONS_PATH.exists():
1053
+ return pd.DataFrame()
1054
+ df = pd.read_parquet(TEST_PREDICTIONS_PATH)
1055
+ df["date"] = pd.to_datetime(df["date"], errors="coerce")
1056
+ return df.sort_values("date").reset_index(drop=True)
1057
+
1058
+
1059
+ def load_tomorrow_test_predictions() -> pd.DataFrame:
1060
+ if not TOMORROW_TEST_PREDICTIONS_PATH.exists():
1061
+ return pd.DataFrame()
1062
+ df = pd.read_parquet(TOMORROW_TEST_PREDICTIONS_PATH)
1063
+ for col in ("forecast_date", "target_date", "date"):
1064
+ if col in df.columns:
1065
+ df[col] = pd.to_datetime(df[col], errors="coerce")
1066
+ sort_col = "target_date" if "target_date" in df.columns else "forecast_date"
1067
+ return df.sort_values(sort_col).reset_index(drop=True)
1068
+
1069
+
1070
+ def load_tplus1_test_predictions() -> pd.DataFrame:
1071
+ if not TPLUS1_TEST_PREDICTIONS_PATH.exists():
1072
+ return pd.DataFrame()
1073
+ df = pd.read_parquet(TPLUS1_TEST_PREDICTIONS_PATH)
1074
+ for col in ("date", "target_date"):
1075
+ if col in df.columns:
1076
+ df[col] = pd.to_datetime(df[col], errors="coerce")
1077
+ return df.sort_values("date").reset_index(drop=True)
1078
+
1079
+
1080
+ def dashboard_payload() -> dict[str, Any]:
1081
+ key = (
1082
+ _file_cache_key(MODEL_DIR / "summary.json"),
1083
+ _file_cache_key(LATEST_PATH),
1084
+ _file_cache_key(TEST_PREDICTIONS_PATH),
1085
+ _file_cache_key(TOMORROW_SUMMARY_PATH),
1086
+ _file_cache_key(TOMORROW_LATEST_PATH),
1087
+ _file_cache_key(TOMORROW_TEST_PREDICTIONS_PATH),
1088
+ _file_cache_key(TOMORROW_MODEL_PATH),
1089
+ _file_cache_key(TPLUS1_SUMMARY_PATH),
1090
+ _file_cache_key(TPLUS1_LATEST_PATH),
1091
+ _file_cache_key(TPLUS1_TEST_PREDICTIONS_PATH),
1092
+ _file_cache_key(TPLUS1_MODEL_PATH),
1093
+ _file_cache_key(REFRESH_STATE_PATH),
1094
+ _file_cache_key(NIFTY_1D_PATH),
1095
+ _file_cache_key(OPENING_DATASET_PATH),
1096
+ _file_cache_key(MODEL_DIR / "candidate_results.csv"),
1097
+ _file_cache_key(NIFTY_1M_PATH),
1098
+ _file_cache_key(LIVE_ACCURACY_PATH),
1099
+ )
1100
+ with _dashboard_payload_lock:
1101
+ return copy.deepcopy(_dashboard_payload_cached(key))
1102
+
1103
+
1104
+ def warm_dashboard_payload_cache() -> None:
1105
+ dashboard_payload()
1106
+
1107
+
1108
+ @lru_cache(maxsize=4)
1109
+ def _dashboard_payload_cached(key: tuple[tuple[str, int | None, int | None], ...]) -> dict[str, Any]:
1110
+ summary = load_model_summary()
1111
+ t5_latest = _latest_saved_prediction_uncached()
1112
+ tomorrow_summary = load_tomorrow_summary()
1113
+ tomorrow_latest = latest_tomorrow_prediction()
1114
+ tplus1_summary = load_tplus1_summary()
1115
+ tplus1_latest = latest_tplus1_prediction()
1116
+ refresh_state = load_refresh_state()
1117
+ t5_test = load_test_predictions()
1118
+ tomorrow_test = load_tomorrow_test_predictions()
1119
+ tplus1_test = load_tplus1_test_predictions()
1120
+ daily = pd.read_parquet(NIFTY_1D_PATH)
1121
+ daily["date"] = pd.to_datetime(daily["date"], errors="coerce")
1122
+ daily = daily.sort_values("date").tail(180)
1123
+ dataset = read_training_dataset()
1124
+ opening = dataset[["date", "first5_return", "first5_range_pct", "first5_close_location"]].tail(120).copy()
1125
+
1126
+ if not t5_test.empty:
1127
+ recent_predictions = t5_test.tail(40).copy()
1128
+ recent_accuracy = float(recent_predictions["correct"].mean())
1129
+ direction_mix = t5_test.groupby("prediction")["correct"].agg(["count", "mean"]).reset_index()
1130
+ monthly = (
1131
+ t5_test.assign(month=t5_test["date"].dt.strftime("%Y-%m"))
1132
+ .groupby("month", as_index=False)["correct"]
1133
+ .mean()
1134
+ .rename(columns={"correct": "accuracy"})
1135
+ )
1136
+ else:
1137
+ recent_predictions = pd.DataFrame()
1138
+ recent_accuracy = None
1139
+ direction_mix = pd.DataFrame()
1140
+ monthly = pd.DataFrame()
1141
+
1142
+ if not tomorrow_test.empty:
1143
+ tomorrow_recent = tomorrow_test.tail(40).copy()
1144
+ if "pred" in tomorrow_recent.columns and "prediction" not in tomorrow_recent.columns:
1145
+ tomorrow_recent["prediction"] = np.where(pd.to_numeric(tomorrow_recent["pred"], errors="coerce") == 1, "UP", "DOWN")
1146
+ if "correct" not in tomorrow_recent.columns and {"target", "pred"}.issubset(tomorrow_recent.columns):
1147
+ tomorrow_recent["correct"] = pd.to_numeric(tomorrow_recent["target"], errors="coerce") == pd.to_numeric(tomorrow_recent["pred"], errors="coerce")
1148
+ tomorrow_accuracy = float(tomorrow_recent["correct"].mean()) if "correct" in tomorrow_recent.columns else tomorrow_summary.get("test_accuracy")
1149
+ else:
1150
+ tomorrow_recent = pd.DataFrame()
1151
+ tomorrow_accuracy = tomorrow_summary.get("test_accuracy")
1152
+
1153
+ model_metrics = [
1154
+ {
1155
+ "id": "tomorrow",
1156
+ "label": "Tomorrow",
1157
+ "model_name": tomorrow_summary.get("model_name", "nifty_tomorrow_direction_model"),
1158
+ "source_model": tomorrow_summary.get("source_model", "tuned_daily_forest_single"),
1159
+ "validation_accuracy": tomorrow_summary.get("validation_accuracy"),
1160
+ "test_accuracy": tomorrow_summary.get("test_accuracy"),
1161
+ "recent_accuracy": tomorrow_accuracy,
1162
+ "test_rows": int(tomorrow_summary.get("n_test") or len(tomorrow_test) or 0),
1163
+ },
1164
+ {
1165
+ "id": "tplus1",
1166
+ "label": "T+1",
1167
+ "model_name": tplus1_summary.get("model_name", "nifty_1420_tplus1_logistic_model"),
1168
+ "source_model": "14:00-14:20 logistic forecaster",
1169
+ "validation_accuracy": tplus1_summary.get("validation_accuracy"),
1170
+ "test_accuracy": tplus1_summary.get("test_accuracy"),
1171
+ "recent_accuracy": float(tplus1_test.tail(40)["correct"].mean()) if not tplus1_test.empty and "correct" in tplus1_test.columns else tplus1_summary.get("test_accuracy"),
1172
+ "test_rows": int(tplus1_summary.get("test_rows") or len(tplus1_test) or 0),
1173
+ },
1174
+ {
1175
+ "id": "t5",
1176
+ "label": "T+5",
1177
+ "model_name": summary.get("model_name", "nifty_opening_direction_model"),
1178
+ "source_model": summary.get("model_name", "nifty_opening_direction_model"),
1179
+ "validation_accuracy": summary.get("validation_accuracy"),
1180
+ "test_accuracy": summary.get("test_accuracy"),
1181
+ "recent_accuracy": recent_accuracy,
1182
+ "test_rows": int(len(t5_test)) if not t5_test.empty else int(summary.get("test_rows") or 0),
1183
+ },
1184
+ ]
1185
+ metrics = {
1186
+ "validation_accuracy": tomorrow_summary.get("validation_accuracy"),
1187
+ "test_accuracy": tomorrow_summary.get("test_accuracy"),
1188
+ "baseline_test_accuracy": tomorrow_summary.get("baseline_accuracy"),
1189
+ "validation_auc": summary.get("validation_auc"),
1190
+ "test_auc": summary.get("test_auc"),
1191
+ "test_brier": summary.get("test_brier"),
1192
+ "feature_count": tomorrow_summary.get("feature_count"),
1193
+ "recent_accuracy": tomorrow_accuracy,
1194
+ "recent_accuracy_days": int(len(tomorrow_recent)) if not tomorrow_recent.empty else 0,
1195
+ "total_test_days": int(tomorrow_summary.get("n_test") or len(tomorrow_test) or 0),
1196
+ "models": model_metrics,
1197
+ }
1198
+ return {
1199
+ "latest": t5_latest,
1200
+ "tomorrow_latest": tomorrow_latest,
1201
+ "tplus1_latest": tplus1_latest,
1202
+ "live_accuracy": load_live_accuracy(),
1203
+ "metrics": metrics,
1204
+ "summary": summary,
1205
+ "tomorrow_summary": tomorrow_summary,
1206
+ "tplus1_summary": tplus1_summary,
1207
+ "candidates": load_candidate_results(),
1208
+ "charts": {
1209
+ "daily_close": _json_ready_frame(daily[["date", "open", "high", "low", "close"]]),
1210
+ "opening_features": _json_ready_frame(opening),
1211
+ "monthly_accuracy": _json_ready_frame(monthly),
1212
+ "direction_mix": _json_ready_frame(direction_mix),
1213
+ "recent_predictions": _json_ready_frame(recent_predictions),
1214
+ "t5_recent_predictions": _json_ready_frame(recent_predictions),
1215
+ "tomorrow_recent_predictions": _json_ready_frame(tomorrow_recent),
1216
+ "tplus1_recent_predictions": _json_ready_frame(tplus1_test.tail(40)),
1217
+ },
1218
+ "data_status": {
1219
+ "nifty_1m_rows": int(len(pd.read_parquet(NIFTY_1M_PATH, columns=["date"]))),
1220
+ "nifty_1d_rows": int(len(pd.read_parquet(NIFTY_1D_PATH, columns=["date"]))),
1221
+ "training_rows": int(len(dataset)),
1222
+ "test_prediction_rows": int(len(t5_test)),
1223
+ "tomorrow_test_prediction_rows": int(len(tomorrow_test)),
1224
+ "tplus1_test_prediction_rows": int(len(tplus1_test)),
1225
+ "latest_daily_date": pd.to_datetime(daily["date"]).max().date().isoformat(),
1226
+ "refresh_phase": refresh_state.get("phase", REFRESH_NORMAL),
1227
+ "refresh_state": refresh_state,
1228
+ },
1229
+ }
1230
+
1231
+
1232
+ def refresh_first5_prediction(session_date: date | None = None, minutes: pd.DataFrame | None = None) -> Prediction:
1233
+ if session_date is None:
1234
+ today = datetime.now(IST).date()
1235
+ if not is_trading_day(today):
1236
+ raise RuntimeError(f"{today.isoformat()} is not an NSE trading session.")
1237
+ minutes = fetch_yahoo_minutes(period="7d") if minutes is None else minutes
1238
+ append_parquet_rows(NIFTY_1M_PATH, minutes, ["date"])
1239
+ first5 = first5_features_from_minutes(minutes, session_date=session_date)
1240
+ row = build_model_row(first5)
1241
+ dataset = read_training_dataset()
1242
+ merged = pd.concat([dataset, row], ignore_index=True)
1243
+ merged = merged.drop_duplicates(subset=["date"], keep="last").sort_values("date").reset_index(drop=True)
1244
+ merged.to_parquet(OPENING_DATASET_PATH, index=False, compression="zstd")
1245
+ prediction = predict_row(row)
1246
+ clear_dashboard_payload_cache()
1247
+ return prediction
1248
+
1249
+
1250
+ def refresh_daily_data() -> dict[str, Any]:
1251
+ daily = fetch_yahoo_daily(period="1mo")
1252
+ combined = append_parquet_rows(NIFTY_1D_PATH, daily, ["date"])
1253
+ clear_dashboard_payload_cache()
1254
+ return {
1255
+ "rows": int(len(combined)),
1256
+ "latest_date": pd.to_datetime(combined["date"]).max().date().isoformat(),
1257
+ "path": str(NIFTY_1D_PATH),
1258
+ }
1259
+
1260
+
1261
+ def update_opening_outcomes_from_daily() -> dict[str, Any]:
1262
+ if not OPENING_DATASET_PATH.exists() or not NIFTY_1D_PATH.exists():
1263
+ return {"updated_rows": 0, "latest_date": None}
1264
+ dataset = pd.read_parquet(OPENING_DATASET_PATH)
1265
+ daily = pd.read_parquet(NIFTY_1D_PATH)
1266
+ if dataset.empty or daily.empty:
1267
+ return {"updated_rows": 0, "latest_date": None}
1268
+
1269
+ dataset = dataset.copy()
1270
+ dataset["_session_date"] = pd.to_datetime(dataset["date"], errors="coerce").dt.normalize()
1271
+ daily = daily.copy()
1272
+ daily["_session_date"] = pd.to_datetime(daily["date"], errors="coerce").dt.normalize()
1273
+ daily = daily.dropna(subset=["_session_date"]).drop_duplicates("_session_date", keep="last")
1274
+ daily = daily.set_index("_session_date")
1275
+
1276
+ updated = 0
1277
+ for idx, session_day in dataset["_session_date"].dropna().items():
1278
+ if session_day not in daily.index:
1279
+ continue
1280
+ row = daily.loc[session_day]
1281
+ for src, dst in (
1282
+ ("open", "day_open"),
1283
+ ("high", "day_high"),
1284
+ ("low", "day_low"),
1285
+ ("close", "day_close"),
1286
+ ("volume", "day_volume"),
1287
+ ):
1288
+ if src in row.index and dst in dataset.columns:
1289
+ dataset.at[idx, dst] = row[src]
1290
+ if {"day_open", "day_close", "target", "day_return"}.issubset(dataset.columns):
1291
+ day_open = dataset.at[idx, "day_open"]
1292
+ day_close = dataset.at[idx, "day_close"]
1293
+ if pd.notna(day_open) and pd.notna(day_close) and float(day_open) != 0.0:
1294
+ dataset.at[idx, "target"] = int(float(day_close) > float(day_open))
1295
+ dataset.at[idx, "day_return"] = (float(day_close) - float(day_open)) / float(day_open)
1296
+ updated += 1
1297
+ if {"first5_close", "day_open", "first5_vs_day_open"}.issubset(dataset.columns):
1298
+ first5_close = dataset.at[idx, "first5_close"]
1299
+ day_open = dataset.at[idx, "day_open"]
1300
+ if pd.notna(first5_close) and pd.notna(day_open) and float(day_open) != 0.0:
1301
+ dataset.at[idx, "first5_vs_day_open"] = (float(first5_close) - float(day_open)) / float(day_open)
1302
+
1303
+ dataset = dataset.drop(columns=["_session_date"])
1304
+ dataset = dataset.sort_values("date").reset_index(drop=True)
1305
+ dataset.to_parquet(OPENING_DATASET_PATH, index=False, compression="zstd")
1306
+ clear_dashboard_payload_cache()
1307
+ latest = pd.to_datetime(dataset["date"], errors="coerce").max()
1308
+ return {
1309
+ "updated_rows": int(updated),
1310
+ "latest_date": None if pd.isna(latest) else latest.date().isoformat(),
1311
+ }
1312
+
1313
+
1314
+ def load_live_accuracy() -> dict[str, Any]:
1315
+ """Load the live accuracy ledger from disk."""
1316
+ if LIVE_ACCURACY_PATH.exists():
1317
+ try:
1318
+ return json.loads(LIVE_ACCURACY_PATH.read_text(encoding="utf-8"))
1319
+ except Exception:
1320
+ pass
1321
+ return {
1322
+ "tomorrow": {"entries": [], "accuracy": None, "total": 0, "correct_count": 0},
1323
+ "t5": {"entries": [], "accuracy": None, "total": 0, "correct_count": 0},
1324
+ "tplus1": {"entries": [], "accuracy": None, "total": 0, "correct_count": 0},
1325
+ }
1326
+
1327
+
1328
+ def save_live_accuracy(data: dict[str, Any]) -> None:
1329
+ """Persist the live accuracy ledger to disk."""
1330
+ LIVE_ACCURACY_PATH.write_text(json.dumps(data, indent=2), encoding="utf-8")
1331
+
1332
+
1333
+ def update_live_accuracy(session_date: date) -> dict[str, Any]:
1334
+ """Score today's predictions against actual outcomes and update the ledger.
1335
+
1336
+ Must be called AFTER refresh_daily_data() (so today's close is available)
1337
+ but BEFORE refresh_first5_prediction / refresh_tplus1_prediction /
1338
+ refresh_tomorrow_prediction (so the CSV files still hold the predictions
1339
+ we want to score).
1340
+ """
1341
+ ledger = load_live_accuracy()
1342
+ daily = pd.read_parquet(NIFTY_1D_PATH)
1343
+ daily["_date"] = pd.to_datetime(daily["date"], errors="coerce").dt.normalize()
1344
+ today_rows = daily[daily["_date"].dt.date == session_date]
1345
+ if today_rows.empty:
1346
+ return ledger
1347
+
1348
+ day_open = float(today_rows.iloc[-1]["open"])
1349
+ day_close = float(today_rows.iloc[-1]["close"])
1350
+ if not (np.isfinite(day_open) and np.isfinite(day_close) and day_open != 0):
1351
+ return ledger
1352
+ actual_close_gt_open = "UP" if day_close > day_open else "DOWN"
1353
+ session_iso = session_date.isoformat()
1354
+
1355
+ # --- T+5: today's 9:20 AM prediction vs close > open ---
1356
+ logged_t5 = {e["date"] for e in ledger["t5"]["entries"]}
1357
+ if session_iso not in logged_t5 and LATEST_PATH.exists():
1358
+ try:
1359
+ t5_row = pd.read_csv(LATEST_PATH).iloc[-1].to_dict()
1360
+ if str(t5_row.get("input_date", ""))[:10] == session_iso:
1361
+ pred = str(t5_row.get("prediction", "")).upper()
1362
+ if pred in ("UP", "DOWN"):
1363
+ ledger["t5"]["entries"].append({
1364
+ "date": session_iso,
1365
+ "prediction": pred,
1366
+ "actual": actual_close_gt_open,
1367
+ "correct": pred == actual_close_gt_open,
1368
+ })
1369
+ except Exception:
1370
+ pass
1371
+
1372
+ # --- Tomorrow: yesterday's prediction targeting today vs close > open ---
1373
+ logged_tom = {e["date"] for e in ledger["tomorrow"]["entries"]}
1374
+ if session_iso not in logged_tom and TOMORROW_LATEST_PATH.exists():
1375
+ try:
1376
+ tom_row = pd.read_csv(TOMORROW_LATEST_PATH).iloc[-1].to_dict()
1377
+ if str(tom_row.get("target_date", ""))[:10] == session_iso:
1378
+ pred = str(tom_row.get("prediction", "")).upper()
1379
+ if pred in ("UP", "DOWN"):
1380
+ ledger["tomorrow"]["entries"].append({
1381
+ "date": session_iso,
1382
+ "prediction": pred,
1383
+ "actual": actual_close_gt_open,
1384
+ "correct": pred == actual_close_gt_open,
1385
+ })
1386
+ except Exception:
1387
+ pass
1388
+
1389
+ # --- T+1: yesterday's 14:20 prediction targeting today ---
1390
+ # T+1 target: today's close > yesterday's 14:20 close
1391
+ logged_t1 = {e["date"] for e in ledger["tplus1"]["entries"]}
1392
+ if session_iso not in logged_t1 and TPLUS1_LATEST_PATH.exists():
1393
+ try:
1394
+ t1_row = pd.read_csv(TPLUS1_LATEST_PATH).iloc[-1].to_dict()
1395
+ if str(t1_row.get("target_date", ""))[:10] == session_iso:
1396
+ pred = str(t1_row.get("prediction", "")).upper()
1397
+ input_date_str = str(t1_row.get("input_date", ""))[:10]
1398
+ input_day = date.fromisoformat(input_date_str)
1399
+ # Read the 14:20 close from minute data for the input session
1400
+ minute = pd.read_parquet(NIFTY_1M_PATH, columns=["date", "close"])
1401
+ minute["dt"] = pd.to_datetime(minute["date"], errors="coerce")
1402
+ minute = minute.dropna(subset=["dt"])
1403
+ minute["session_date"] = minute["dt"].dt.normalize()
1404
+ minute["time_str"] = minute["dt"].dt.strftime("%H:%M")
1405
+ window = minute[
1406
+ (minute["session_date"].dt.date == input_day)
1407
+ & (minute["time_str"] >= "14:00")
1408
+ & (minute["time_str"] <= "14:20")
1409
+ ].sort_values("dt")
1410
+ if not window.empty and pred in ("UP", "DOWN"):
1411
+ w_close = float(window.iloc[-1]["close"])
1412
+ t1_actual = "UP" if day_close > w_close else "DOWN"
1413
+ ledger["tplus1"]["entries"].append({
1414
+ "date": session_iso,
1415
+ "prediction": pred,
1416
+ "actual": t1_actual,
1417
+ "correct": pred == t1_actual,
1418
+ })
1419
+ except Exception:
1420
+ pass
1421
+
1422
+ # Recompute summary stats
1423
+ for model_id in ("t5", "tomorrow", "tplus1"):
1424
+ entries = ledger[model_id]["entries"]
1425
+ total = len(entries)
1426
+ correct = sum(1 for e in entries if e.get("correct"))
1427
+ ledger[model_id]["total"] = total
1428
+ ledger[model_id]["correct_count"] = correct
1429
+ ledger[model_id]["accuracy"] = correct / total if total > 0 else None
1430
+
1431
+ save_live_accuracy(ledger)
1432
+ clear_dashboard_payload_cache()
1433
+ return ledger
1434
+
1435
+
1436
+ def refresh_market_close_data(session_date: date | None = None) -> dict[str, Any]:
1437
+ now = datetime.now(IST)
1438
+ session_date = session_date or now.date()
1439
+ if not is_trading_day(session_date):
1440
+ raise RuntimeError(f"{session_date.isoformat()} is not an NSE trading session.")
1441
+ save_refresh_state(REFRESH_WAITING, session_date=session_date)
1442
+ try:
1443
+ save_refresh_state(REFRESH_REFRESHING, session_date=session_date)
1444
+ minutes = fetch_yahoo_minutes(period="7d")
1445
+ minute_frame = append_parquet_rows(NIFTY_1M_PATH, minutes, ["date"])
1446
+ daily_info = refresh_daily_data()
1447
+ # Score live predictions BEFORE they get overwritten by fresh ones
1448
+ try:
1449
+ update_live_accuracy(session_date)
1450
+ except Exception as exc:
1451
+ print(f"[close-refresh] live accuracy update failed: {exc}", flush=True)
1452
+ t5_prediction = refresh_first5_prediction(session_date=session_date, minutes=minutes)
1453
+ tplus1_prediction = refresh_tplus1_prediction(session_date=session_date)
1454
+ outcomes = update_opening_outcomes_from_daily()
1455
+ tomorrow_prediction = refresh_tomorrow_prediction(session_date=session_date)
1456
+ state = save_refresh_state(REFRESH_READY, session_date=session_date)
1457
+ clear_dashboard_payload_cache()
1458
+ return {
1459
+ "session_date": session_date.isoformat(),
1460
+ "nifty_1m_rows": int(len(minute_frame)),
1461
+ "latest_minute": pd.to_datetime(minute_frame["date"], errors="coerce").max().isoformat(),
1462
+ "daily": daily_info,
1463
+ "opening_dataset": outcomes,
1464
+ "t5_prediction": t5_prediction.to_dict(),
1465
+ "tplus1_prediction": tplus1_prediction,
1466
+ "tomorrow_prediction": tomorrow_prediction,
1467
+ "refresh_state": state,
1468
+ }
1469
+ except Exception as exc:
1470
+ save_refresh_state(REFRESH_FAILED, session_date=session_date, error=str(exc))
1471
+ clear_dashboard_payload_cache()
1472
+ raise
1473
+
1474
+
1475
+ def close_refresh_due(now: datetime | None = None) -> bool:
1476
+ now = now or datetime.now(IST)
1477
+ if not is_trading_day(now.date()) or now.time() < CLOSE_REFRESH_READY:
1478
+ return False
1479
+ latest_daily = latest_parquet_date(NIFTY_1D_PATH)
1480
+ latest_minutes = latest_parquet_date(NIFTY_1M_PATH)
1481
+ latest_opening = latest_parquet_date(OPENING_DATASET_PATH)
1482
+ latest_opening_outcome = latest_opening_outcome_date()
1483
+ tomorrow_latest = latest_tomorrow_prediction()
1484
+ tomorrow_input = None
1485
+ try:
1486
+ if tomorrow_latest.get("input_date"):
1487
+ tomorrow_input = date.fromisoformat(str(tomorrow_latest.get("input_date"))[:10])
1488
+ except Exception:
1489
+ tomorrow_input = None
1490
+ return any(
1491
+ latest != now.date()
1492
+ for latest in (latest_daily, latest_minutes, latest_opening, latest_opening_outcome, tomorrow_input)
1493
+ )
1494
+
1495
+
1496
+ def latest_prediction_input_date(path: Path) -> date | None:
1497
+ if not path.exists():
1498
+ return None
1499
+ try:
1500
+ frame = pd.read_csv(path, usecols=["input_date"])
1501
+ except Exception:
1502
+ return None
1503
+ if frame.empty:
1504
+ return None
1505
+ value = pd.to_datetime(frame["input_date"], errors="coerce").max()
1506
+ return None if pd.isna(value) else value.date()
1507
+
1508
+
1509
+ def latest_tomorrow_input_date() -> date | None:
1510
+ try:
1511
+ latest = latest_tomorrow_prediction()
1512
+ raw = latest.get("input_date")
1513
+ return date.fromisoformat(str(raw)[:10]) if raw else None
1514
+ except Exception:
1515
+ return None
1516
+
1517
+
1518
+ def expected_completed_daily_date(now: datetime | None = None) -> date:
1519
+ now = now or datetime.now(IST)
1520
+ if is_trading_day(now.date()) and now.time() < CLOSE_REFRESH_READY:
1521
+ return previous_trading_day(now.date() - timedelta(days=1))
1522
+ return previous_trading_day(now.date())
1523
+
1524
+
1525
+ def expected_minute_date(now: datetime | None = None) -> date:
1526
+ now = now or datetime.now(IST)
1527
+ if is_trading_day(now.date()) and now.time() >= FIRST5_READY:
1528
+ return now.date()
1529
+ return previous_trading_day(now.date() - timedelta(days=1))
1530
+
1531
+
1532
+ def expected_tplus1_date(now: datetime | None = None) -> date:
1533
+ now = now or datetime.now(IST)
1534
+ if is_trading_day(now.date()) and now.time() >= TPLUS1_READY:
1535
+ return now.date()
1536
+ return previous_trading_day(now.date() - timedelta(days=1))
1537
+
1538
+
1539
+ def is_stale(latest: date | None, expected: date) -> bool:
1540
+ return latest is None or latest < expected
1541
+
1542
+
1543
+ def stale_data_status(now: datetime | None = None) -> dict[str, Any]:
1544
+ now = now or datetime.now(IST)
1545
+ expected_daily = expected_completed_daily_date(now)
1546
+ expected_minutes = expected_minute_date(now)
1547
+ expected_tplus1 = expected_tplus1_date(now)
1548
+ latest_daily = latest_parquet_date(NIFTY_1D_PATH)
1549
+ latest_minutes = latest_parquet_date(NIFTY_1M_PATH)
1550
+ latest_t5 = latest_prediction_input_date(LATEST_PATH)
1551
+ latest_tomorrow = latest_tomorrow_input_date()
1552
+ latest_tplus1 = latest_prediction_input_date(TPLUS1_LATEST_PATH)
1553
+ return {
1554
+ "server_time_ist": now.isoformat(),
1555
+ "expected_daily_date": expected_daily.isoformat(),
1556
+ "expected_minute_date": expected_minutes.isoformat(),
1557
+ "expected_tplus1_date": expected_tplus1.isoformat(),
1558
+ "latest_daily_date": latest_daily.isoformat() if latest_daily else None,
1559
+ "latest_minute_date": latest_minutes.isoformat() if latest_minutes else None,
1560
+ "latest_t5_date": latest_t5.isoformat() if latest_t5 else None,
1561
+ "latest_tomorrow_date": latest_tomorrow.isoformat() if latest_tomorrow else None,
1562
+ "latest_tplus1_date": latest_tplus1.isoformat() if latest_tplus1 else None,
1563
+ "daily_stale": is_stale(latest_daily, expected_daily),
1564
+ "minutes_stale": is_stale(latest_minutes, expected_minutes),
1565
+ "t5_stale": is_stale(latest_t5, expected_minutes),
1566
+ "tomorrow_stale": is_stale(latest_tomorrow, expected_daily),
1567
+ "tplus1_stale": is_stale(latest_tplus1, expected_tplus1),
1568
+ }
1569
+
1570
+
1571
+ def refresh_stale_data_once(now: datetime | None = None) -> dict[str, Any]:
1572
+ now = now or datetime.now(IST)
1573
+ status = stale_data_status(now)
1574
+ if not any(status[key] for key in ("daily_stale", "minutes_stale", "t5_stale", "tomorrow_stale", "tplus1_stale")):
1575
+ return {"status": "fresh", **status, "actions": []}
1576
+ if not _stale_refresh_lock.acquire(blocking=False):
1577
+ return {"status": "skipped", "reason": "stale refresh already running", **status, "actions": []}
1578
+
1579
+ actions: list[dict[str, Any]] = []
1580
+ try:
1581
+ if status["minutes_stale"]:
1582
+ minutes = fetch_yahoo_minutes(period="7d")
1583
+ combined = append_parquet_rows(NIFTY_1M_PATH, minutes, ["date"])
1584
+ actions.append(
1585
+ {
1586
+ "name": "minutes",
1587
+ "rows": int(len(combined)),
1588
+ "latest_date": pd.to_datetime(combined["date"], errors="coerce").max().date().isoformat(),
1589
+ }
1590
+ )
1591
+
1592
+ if status["daily_stale"]:
1593
+ daily_info = refresh_daily_data()
1594
+ outcomes = update_opening_outcomes_from_daily()
1595
+ actions.append({"name": "daily", **daily_info})
1596
+ actions.append({"name": "opening_outcomes", **outcomes})
1597
+
1598
+ if status["daily_stale"] or status["tomorrow_stale"]:
1599
+ try:
1600
+ tomorrow = refresh_tomorrow_prediction(session_date=date.fromisoformat(status["expected_daily_date"]))
1601
+ actions.append({"name": "tomorrow_prediction", "input_date": tomorrow.get("input_date")})
1602
+ except Exception as exc:
1603
+ actions.append({"name": "tomorrow_prediction", "error": str(exc)})
1604
+
1605
+ if status["t5_stale"] and is_trading_day(now.date()) and now.time() >= FIRST5_READY:
1606
+ prediction = refresh_first5_prediction(session_date=now.date())
1607
+ actions.append({"name": "t5_prediction", "input_date": prediction.input_date})
1608
+
1609
+ if status["tplus1_stale"] and is_trading_day(now.date()) and now.time() >= TPLUS1_READY:
1610
+ prediction = refresh_tplus1_prediction(session_date=now.date())
1611
+ actions.append({"name": "tplus1_prediction", "input_date": prediction.get("input_date")})
1612
+
1613
+ clear_dashboard_payload_cache()
1614
+ refreshed_status = stale_data_status(datetime.now(IST))
1615
+ return {"status": "refreshed", **refreshed_status, "actions": actions}
1616
+ finally:
1617
+ _stale_refresh_lock.release()
1618
+
1619
+
1620
+ def next_ist_run_at(run_time: time = time(9, 20), now: datetime | None = None) -> datetime:
1621
+ now = now or datetime.now(IST)
1622
+ target_day = now.date()
1623
+ if now >= datetime.combine(target_day, run_time, tzinfo=IST):
1624
+ target_day += timedelta(days=1)
1625
+ target_day = next_trading_day(target_day)
1626
+ return datetime.combine(target_day, run_time, tzinfo=IST)
1627
+
1628
+
1629
+ def seconds_until_next_ist_run(run_time: time = time(9, 20)) -> float:
1630
+ now = datetime.now(IST)
1631
+ target = next_ist_run_at(run_time, now=now)
1632
+ return max(1.0, (target - now).total_seconds())
backend/nifty_backend/yahoo_history_client.py ADDED
@@ -0,0 +1,445 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from __future__ import annotations
2
+
3
+ import gzip
4
+ import hashlib
5
+ import json
6
+ import sqlite3
7
+ import threading
8
+ import time
9
+ from dataclasses import dataclass
10
+ from datetime import datetime, timedelta
11
+ from itertools import cycle
12
+ from pathlib import Path
13
+ from typing import Any
14
+ from zoneinfo import ZoneInfo
15
+
16
+ import pandas as pd
17
+ import requests
18
+ from requests.adapters import HTTPAdapter
19
+ from urllib3.util.retry import Retry
20
+
21
+
22
+ YAHOO_CHART_HOSTS = (
23
+ "https://query1.finance.yahoo.com",
24
+ "https://query2.finance.yahoo.com",
25
+ )
26
+ DEFAULT_HEADERS = {
27
+ "User-Agent": (
28
+ "Mozilla/5.0 (Windows NT 10.0; Win64; x64) "
29
+ "AppleWebKit/537.36 (KHTML, like Gecko) "
30
+ "Chrome/136.0 Safari/537.36"
31
+ ),
32
+ "Accept": "application/json,text/plain,*/*",
33
+ "Accept-Language": "en-US,en;q=0.9",
34
+ "Connection": "keep-alive",
35
+ "Origin": "https://finance.yahoo.com",
36
+ "Referer": "https://finance.yahoo.com/",
37
+ }
38
+ BAR_COLUMNS = ["timestamp", "open", "high", "low", "close", "adj_close", "volume", "dividend", "split_ratio"]
39
+
40
+
41
+ @dataclass(frozen=True)
42
+ class IntervalPolicy:
43
+ interval: str
44
+ chunk_days: int
45
+ min_chunk_days: int
46
+ retention_days: int | None
47
+
48
+
49
+ INTERVAL_POLICIES: dict[str, IntervalPolicy] = {
50
+ "1m": IntervalPolicy("1m", chunk_days=7, min_chunk_days=1, retention_days=30),
51
+ "2m": IntervalPolicy("2m", chunk_days=60, min_chunk_days=2, retention_days=60),
52
+ "5m": IntervalPolicy("5m", chunk_days=60, min_chunk_days=5, retention_days=60),
53
+ "15m": IntervalPolicy("15m", chunk_days=60, min_chunk_days=5, retention_days=60),
54
+ "30m": IntervalPolicy("30m", chunk_days=60, min_chunk_days=5, retention_days=60),
55
+ "60m": IntervalPolicy("60m", chunk_days=60, min_chunk_days=5, retention_days=60),
56
+ "90m": IntervalPolicy("90m", chunk_days=60, min_chunk_days=5, retention_days=60),
57
+ "1h": IntervalPolicy("1h", chunk_days=60, min_chunk_days=5, retention_days=60),
58
+ "1d": IntervalPolicy("1d", chunk_days=3650, min_chunk_days=30, retention_days=None),
59
+ "5d": IntervalPolicy("5d", chunk_days=3650, min_chunk_days=30, retention_days=None),
60
+ "1wk": IntervalPolicy("1wk", chunk_days=3650, min_chunk_days=30, retention_days=None),
61
+ "1mo": IntervalPolicy("1mo", chunk_days=3650, min_chunk_days=30, retention_days=None),
62
+ "3mo": IntervalPolicy("3mo", chunk_days=3650, min_chunk_days=30, retention_days=None),
63
+ }
64
+
65
+
66
+ class YahooHistoryError(RuntimeError):
67
+ pass
68
+
69
+
70
+ class YahooSymbolError(YahooHistoryError):
71
+ pass
72
+
73
+
74
+ class YahooIntervalLimitError(YahooHistoryError):
75
+ pass
76
+
77
+
78
+ class YahooRateLimitError(YahooHistoryError):
79
+ pass
80
+
81
+
82
+ class SqliteResponseCache:
83
+ def __init__(self, path: Path) -> None:
84
+ self.path = path
85
+ self.path.parent.mkdir(parents=True, exist_ok=True)
86
+ self._lock = threading.Lock()
87
+ with self._connect() as connection:
88
+ connection.execute(
89
+ """
90
+ CREATE TABLE IF NOT EXISTS response_cache (
91
+ cache_key TEXT PRIMARY KEY,
92
+ fetched_at INTEGER NOT NULL,
93
+ payload_gzip BLOB NOT NULL
94
+ )
95
+ """
96
+ )
97
+
98
+ def _connect(self) -> sqlite3.Connection:
99
+ connection = sqlite3.connect(self.path)
100
+ connection.execute("PRAGMA journal_mode=WAL")
101
+ connection.execute("PRAGMA synchronous=NORMAL")
102
+ return connection
103
+
104
+ def get(self, cache_key: str, ttl_seconds: int) -> dict[str, Any] | None:
105
+ with self._lock, self._connect() as connection:
106
+ row = connection.execute(
107
+ "SELECT fetched_at, payload_gzip FROM response_cache WHERE cache_key = ?",
108
+ (cache_key,),
109
+ ).fetchone()
110
+ if row is None:
111
+ return None
112
+ fetched_at, payload_gzip = row
113
+ if int(time.time()) - int(fetched_at) > ttl_seconds:
114
+ return None
115
+ return json.loads(gzip.decompress(payload_gzip).decode("utf-8"))
116
+
117
+ def set(self, cache_key: str, payload: dict[str, Any]) -> None:
118
+ packed = gzip.compress(json.dumps(payload, separators=(",", ":"), ensure_ascii=True).encode("utf-8"))
119
+ with self._lock, self._connect() as connection:
120
+ connection.execute(
121
+ """
122
+ INSERT INTO response_cache (cache_key, fetched_at, payload_gzip)
123
+ VALUES (?, ?, ?)
124
+ ON CONFLICT(cache_key) DO UPDATE SET
125
+ fetched_at = excluded.fetched_at,
126
+ payload_gzip = excluded.payload_gzip
127
+ """,
128
+ (cache_key, int(time.time()), packed),
129
+ )
130
+
131
+
132
+ class RateLimiter:
133
+ def __init__(self, min_gap_seconds: float) -> None:
134
+ self.min_gap_seconds = max(0.0, float(min_gap_seconds))
135
+ self._lock = threading.Lock()
136
+ self._next_allowed = 0.0
137
+
138
+ def wait(self) -> None:
139
+ with self._lock:
140
+ delay = self._next_allowed - time.monotonic()
141
+ if delay > 0:
142
+ time.sleep(delay)
143
+ self._next_allowed = time.monotonic() + self.min_gap_seconds
144
+
145
+
146
+ class YahooHistoryClient:
147
+ def __init__(
148
+ self,
149
+ *,
150
+ cache_path: Path,
151
+ timeout_seconds: float = 25.0,
152
+ min_request_gap_seconds: float = 0.35,
153
+ max_retries: int = 5,
154
+ ) -> None:
155
+ self.cache = SqliteResponseCache(cache_path)
156
+ self.timeout_seconds = timeout_seconds
157
+ self.rate_limiter = RateLimiter(min_request_gap_seconds)
158
+ self.host_cycle = cycle(YAHOO_CHART_HOSTS)
159
+ self.session = self._build_session(max_retries=max_retries)
160
+
161
+ def _build_session(self, *, max_retries: int) -> requests.Session:
162
+ retry = Retry(
163
+ total=max_retries,
164
+ connect=max_retries,
165
+ read=max_retries,
166
+ backoff_factor=0.8,
167
+ status_forcelist=(429, 500, 502, 503, 504),
168
+ allowed_methods=("GET",),
169
+ respect_retry_after_header=True,
170
+ raise_on_status=False,
171
+ )
172
+ adapter = HTTPAdapter(max_retries=retry, pool_connections=16, pool_maxsize=16)
173
+ session = requests.Session()
174
+ session.headers.update(DEFAULT_HEADERS)
175
+ session.mount("https://", adapter)
176
+ session.mount("http://", adapter)
177
+ return session
178
+
179
+ def fetch_history(
180
+ self,
181
+ symbol: str,
182
+ *,
183
+ interval: str,
184
+ start: str | datetime,
185
+ end: str | datetime,
186
+ include_prepost: bool = False,
187
+ adjust_ohlc: bool = False,
188
+ ) -> pd.DataFrame:
189
+ policy = self._interval_policy(interval)
190
+ start_dt = self._coerce_datetime(start, end_of_day=False)
191
+ end_dt = self._coerce_datetime(end, end_of_day=True)
192
+ if end_dt <= start_dt:
193
+ raise ValueError("end must be later than start")
194
+ self._validate_retention_window(policy=policy, start_dt=start_dt, end_dt=end_dt)
195
+
196
+ frames: list[pd.DataFrame] = []
197
+ for chunk_start, chunk_end in self._iter_chunks(start_dt=start_dt, end_dt=end_dt, chunk_days=policy.chunk_days):
198
+ chunk = self._fetch_chunk_adaptive(
199
+ symbol=symbol,
200
+ interval=policy.interval,
201
+ chunk_start=chunk_start,
202
+ chunk_end=chunk_end,
203
+ min_chunk_days=policy.min_chunk_days,
204
+ include_prepost=include_prepost,
205
+ adjust_ohlc=adjust_ohlc,
206
+ )
207
+ if not chunk.empty:
208
+ frames.append(chunk)
209
+ if not frames:
210
+ return pd.DataFrame(columns=BAR_COLUMNS)
211
+ history = pd.concat(frames, ignore_index=True)
212
+ history = history.drop_duplicates(subset=["timestamp"], keep="last").sort_values("timestamp").reset_index(drop=True)
213
+ return history[(history["timestamp"] >= start_dt) & (history["timestamp"] <= end_dt)].reset_index(drop=True)
214
+
215
+ def _fetch_chunk_adaptive(
216
+ self,
217
+ *,
218
+ symbol: str,
219
+ interval: str,
220
+ chunk_start: datetime,
221
+ chunk_end: datetime,
222
+ min_chunk_days: int,
223
+ include_prepost: bool,
224
+ adjust_ohlc: bool,
225
+ ) -> pd.DataFrame:
226
+ try:
227
+ payload = self._request_chart(
228
+ symbol=symbol,
229
+ interval=interval,
230
+ start_dt=chunk_start,
231
+ end_dt=chunk_end,
232
+ include_prepost=include_prepost,
233
+ )
234
+ return self._payload_to_frame(payload=payload, adjust_ohlc=adjust_ohlc)
235
+ except YahooIntervalLimitError:
236
+ if max((chunk_end - chunk_start).days, 1) <= min_chunk_days:
237
+ raise
238
+ midpoint = chunk_start + (chunk_end - chunk_start) / 2
239
+ left = self._fetch_chunk_adaptive(
240
+ symbol=symbol,
241
+ interval=interval,
242
+ chunk_start=chunk_start,
243
+ chunk_end=midpoint,
244
+ min_chunk_days=min_chunk_days,
245
+ include_prepost=include_prepost,
246
+ adjust_ohlc=adjust_ohlc,
247
+ )
248
+ right = self._fetch_chunk_adaptive(
249
+ symbol=symbol,
250
+ interval=interval,
251
+ chunk_start=midpoint,
252
+ chunk_end=chunk_end,
253
+ min_chunk_days=min_chunk_days,
254
+ include_prepost=include_prepost,
255
+ adjust_ohlc=adjust_ohlc,
256
+ )
257
+ return pd.concat([left, right], ignore_index=True)
258
+
259
+ def _request_chart(
260
+ self,
261
+ *,
262
+ symbol: str,
263
+ interval: str,
264
+ start_dt: datetime,
265
+ end_dt: datetime,
266
+ include_prepost: bool,
267
+ ) -> dict[str, Any]:
268
+ params = {
269
+ "period1": str(int(start_dt.timestamp())),
270
+ "period2": str(int(end_dt.timestamp())),
271
+ "interval": interval,
272
+ "includePrePost": "true" if include_prepost else "false",
273
+ "events": "div,splits,capitalGains",
274
+ "includeAdjustedClose": "true",
275
+ }
276
+ last_error: Exception | None = None
277
+ for _ in range(len(YAHOO_CHART_HOSTS)):
278
+ base_url = next(self.host_cycle)
279
+ url = f"{base_url}/v8/finance/chart/{requests.utils.quote(symbol, safe='')}"
280
+ cache_key = self._cache_key(url=url, params=params)
281
+ cached = self.cache.get(cache_key, ttl_seconds=self._cache_ttl_seconds(end_dt=end_dt))
282
+ if cached is not None:
283
+ return cached
284
+
285
+ self.rate_limiter.wait()
286
+ response = self.session.get(url, params=params, timeout=self.timeout_seconds)
287
+ if response.status_code == 429:
288
+ last_error = YahooRateLimitError(f"Yahoo rate-limited {symbol} at interval {interval}.")
289
+ time.sleep(1.5)
290
+ continue
291
+ if response.status_code == 404:
292
+ raise YahooSymbolError(f"Yahoo did not recognize ticker {symbol}.")
293
+ if response.status_code == 422:
294
+ raise YahooIntervalLimitError(
295
+ f"Yahoo rejected {symbol} {interval} from {start_dt.isoformat()} to {end_dt.isoformat()}."
296
+ )
297
+ try:
298
+ response.raise_for_status()
299
+ except requests.HTTPError as exc:
300
+ last_error = exc
301
+ continue
302
+
303
+ payload = response.json()
304
+ error = payload.get("chart", {}).get("error")
305
+ if error:
306
+ description = error.get("description") or error.get("code") or str(error)
307
+ lowered = description.lower()
308
+ if "not found" in lowered or "no data found" in lowered or "symbol" in lowered:
309
+ raise YahooSymbolError(description)
310
+ if "range" in lowered or "interval" in lowered or "last" in lowered:
311
+ raise YahooIntervalLimitError(description)
312
+ if "rate limit" in lowered or "too many requests" in lowered:
313
+ raise YahooRateLimitError(description)
314
+ raise YahooHistoryError(description)
315
+ self.cache.set(cache_key, payload)
316
+ return payload
317
+
318
+ if last_error is not None:
319
+ raise YahooHistoryError(str(last_error)) from last_error
320
+ raise YahooHistoryError(f"Yahoo request failed for {symbol} {interval}.")
321
+
322
+ def _payload_to_frame(self, *, payload: dict[str, Any], adjust_ohlc: bool) -> pd.DataFrame:
323
+ result = payload.get("chart", {}).get("result") or []
324
+ if not result:
325
+ return pd.DataFrame(columns=BAR_COLUMNS)
326
+ result0 = result[0]
327
+ meta = result0.get("meta") or {}
328
+ timestamps = result0.get("timestamp") or []
329
+ quote_sets = result0.get("indicators", {}).get("quote") or []
330
+ if not timestamps or not quote_sets:
331
+ return pd.DataFrame(columns=BAR_COLUMNS)
332
+
333
+ try:
334
+ timezone = ZoneInfo(meta.get("exchangeTimezoneName") or "UTC")
335
+ except Exception:
336
+ timezone = ZoneInfo("UTC")
337
+ quote = quote_sets[0]
338
+ adjclose_sets = result0.get("indicators", {}).get("adjclose") or [{}]
339
+ adj_close = adjclose_sets[0].get("adjclose", []) if adjclose_sets else []
340
+ events = result0.get("events") or {}
341
+ dividends = self._event_series(events.get("dividends") or {}, value_key="amount")
342
+ splits = self._event_series(events.get("splits") or {}, value_key="splitRatio")
343
+ row_count = len(timestamps)
344
+
345
+ frame = pd.DataFrame(
346
+ {
347
+ "timestamp": pd.to_datetime(timestamps, unit="s", utc=True).tz_convert(timezone).tz_localize(None),
348
+ "open": self._normalize_values(quote.get("open", []), row_count),
349
+ "high": self._normalize_values(quote.get("high", []), row_count),
350
+ "low": self._normalize_values(quote.get("low", []), row_count),
351
+ "close": self._normalize_values(quote.get("close", []), row_count),
352
+ "adj_close": self._normalize_values(adj_close, row_count),
353
+ "volume": self._normalize_values(quote.get("volume", []), row_count),
354
+ }
355
+ )
356
+ for column in ("open", "high", "low", "close", "adj_close", "volume"):
357
+ frame[column] = pd.to_numeric(frame[column], errors="coerce")
358
+ frame = frame.dropna(subset=["timestamp", "close"]).reset_index(drop=True)
359
+ frame["volume"] = frame["volume"].fillna(0.0)
360
+
361
+ frame["epoch"] = (frame["timestamp"].astype("int64") // 1_000_000_000).astype("int64")
362
+ frame["dividend"] = frame["epoch"].map(dividends).fillna(0.0)
363
+ frame["split_ratio"] = frame["epoch"].map(splits).fillna(1.0)
364
+ frame = frame.drop(columns=["epoch"])
365
+
366
+ if adjust_ohlc:
367
+ ratio = frame["adj_close"].where(frame["close"] != 0, frame["close"]) / frame["close"].replace(0, pd.NA)
368
+ ratio = ratio.fillna(1.0)
369
+ for column in ("open", "high", "low", "close"):
370
+ frame[column] = frame[column] * ratio
371
+ return frame.drop_duplicates(subset=["timestamp"], keep="last").sort_values("timestamp").reset_index(drop=True)[BAR_COLUMNS]
372
+
373
+ @staticmethod
374
+ def _event_series(events: dict[str, Any], *, value_key: str) -> dict[int, float]:
375
+ output: dict[int, float] = {}
376
+ for event in events.values():
377
+ timestamp = event.get("date")
378
+ value = event.get(value_key)
379
+ if timestamp is None or value is None:
380
+ continue
381
+ try:
382
+ output[int(timestamp)] = float(value)
383
+ except (TypeError, ValueError):
384
+ continue
385
+ return output
386
+
387
+ @staticmethod
388
+ def _normalize_values(values: list[Any] | tuple[Any, ...], size: int) -> list[Any]:
389
+ normalized = list(values[:size])
390
+ if len(normalized) < size:
391
+ normalized.extend([None] * (size - len(normalized)))
392
+ return normalized
393
+
394
+ @staticmethod
395
+ def _cache_key(*, url: str, params: dict[str, str]) -> str:
396
+ material = json.dumps({"url": url, "params": params}, sort_keys=True, separators=(",", ":"))
397
+ return hashlib.sha256(material.encode("utf-8")).hexdigest()
398
+
399
+ @staticmethod
400
+ def _cache_ttl_seconds(*, end_dt: datetime) -> int:
401
+ now_utc = datetime.utcnow()
402
+ if end_dt < now_utc - timedelta(days=2):
403
+ return 7 * 24 * 60 * 60
404
+ if end_dt < now_utc - timedelta(hours=12):
405
+ return 60 * 60
406
+ return 90
407
+
408
+ @staticmethod
409
+ def _coerce_datetime(value: str | datetime, *, end_of_day: bool) -> datetime:
410
+ timestamp = pd.Timestamp(value)
411
+ if timestamp.tzinfo is not None:
412
+ timestamp = timestamp.tz_convert("UTC").tz_localize(None)
413
+ dt = timestamp.to_pydatetime()
414
+ if end_of_day and dt.hour == 0 and dt.minute == 0 and dt.second == 0 and dt.microsecond == 0:
415
+ return dt + timedelta(days=1)
416
+ return dt
417
+
418
+ @staticmethod
419
+ def _iter_chunks(*, start_dt: datetime, end_dt: datetime, chunk_days: int) -> list[tuple[datetime, datetime]]:
420
+ chunks: list[tuple[datetime, datetime]] = []
421
+ cursor = start_dt
422
+ while cursor < end_dt:
423
+ next_edge = min(cursor + timedelta(days=chunk_days), end_dt)
424
+ chunks.append((cursor, next_edge))
425
+ cursor = next_edge
426
+ return chunks
427
+
428
+ @staticmethod
429
+ def _interval_policy(interval: str) -> IntervalPolicy:
430
+ normalized = interval.strip()
431
+ if normalized not in INTERVAL_POLICIES:
432
+ allowed = ", ".join(sorted(INTERVAL_POLICIES))
433
+ raise ValueError(f"Unsupported interval {interval!r}. Allowed values: {allowed}")
434
+ return INTERVAL_POLICIES[normalized]
435
+
436
+ @staticmethod
437
+ def _validate_retention_window(*, policy: IntervalPolicy, start_dt: datetime, end_dt: datetime) -> None:
438
+ if policy.retention_days is None:
439
+ return
440
+ earliest = datetime.utcnow() - timedelta(days=policy.retention_days)
441
+ if start_dt < earliest or end_dt < earliest:
442
+ cutoff = earliest.strftime("%Y-%m-%d")
443
+ raise YahooIntervalLimitError(
444
+ f"Yahoo only serves {policy.interval} history back to about {cutoff}. Use 1d or coarser for deeper history."
445
+ )
backend/requirements.txt ADDED
@@ -0,0 +1,10 @@
 
 
 
 
 
 
 
 
 
 
 
1
+ pandas
2
+ pandas_market_calendars
3
+ pyarrow
4
+ requests
5
+ fastapi
6
+ uvicorn
7
+ joblib
8
+ numpy
9
+ scikit-learn
10
+ catboost
backend/scripts/__pycache__/refresh_daily_data.cpython-311.pyc ADDED
Binary file (937 Bytes). View file
 
backend/scripts/__pycache__/refresh_first5_prediction.cpython-311.pyc ADDED
Binary file (1.84 kB). View file
 
backend/scripts/__pycache__/retrain_opening_model.cpython-311.pyc ADDED
Binary file (11.2 kB). View file
 
backend/scripts/__pycache__/run_ist_scheduler.cpython-311.pyc ADDED
Binary file (5.79 kB). View file
 
backend/scripts/refresh_daily_data.py ADDED
@@ -0,0 +1,15 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from __future__ import annotations
2
+
3
+ import sys
4
+ from pathlib import Path
5
+
6
+ sys.path.insert(0, str(Path(__file__).resolve().parents[1]))
7
+ from nifty_backend.runtime import refresh_daily_data
8
+
9
+
10
+ def main() -> None:
11
+ print(refresh_daily_data())
12
+
13
+
14
+ if __name__ == "__main__":
15
+ main()
backend/scripts/refresh_first5_prediction.py ADDED
@@ -0,0 +1,26 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from __future__ import annotations
2
+
3
+ import argparse
4
+ import sys
5
+ from datetime import date
6
+ from pathlib import Path
7
+
8
+ sys.path.insert(0, str(Path(__file__).resolve().parents[1]))
9
+ from nifty_backend.runtime import refresh_first5_prediction
10
+
11
+
12
+ def parse_args() -> argparse.Namespace:
13
+ parser = argparse.ArgumentParser(description="Fetch Yahoo Finance first five NIFTY minutes and refresh prediction.")
14
+ parser.add_argument("--date", default=None, help="Optional IST session date, YYYY-MM-DD.")
15
+ return parser.parse_args()
16
+
17
+
18
+ def main() -> None:
19
+ args = parse_args()
20
+ session_date = date.fromisoformat(args.date) if args.date else None
21
+ prediction = refresh_first5_prediction(session_date=session_date)
22
+ print(prediction.to_dict())
23
+
24
+
25
+ if __name__ == "__main__":
26
+ main()
backend/scripts/retrain_opening_model.py ADDED
@@ -0,0 +1,182 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from __future__ import annotations
2
+
3
+ import argparse
4
+ import json
5
+ import sys
6
+ from dataclasses import asdict, dataclass
7
+ from pathlib import Path
8
+
9
+ sys.path.insert(0, str(Path(__file__).resolve().parents[1]))
10
+
11
+ import joblib
12
+ import numpy as np
13
+ import pandas as pd
14
+ from sklearn.ensemble import ExtraTreesClassifier
15
+ from sklearn.impute import SimpleImputer
16
+ from sklearn.linear_model import LogisticRegression
17
+ from sklearn.metrics import accuracy_score, brier_score_loss, log_loss, roc_auc_score
18
+ from sklearn.pipeline import make_pipeline
19
+ from sklearn.preprocessing import StandardScaler
20
+
21
+ from nifty_backend.runtime import (
22
+ DECISION_OVERLAYS,
23
+ MODEL_DIR,
24
+ MODEL_PATH,
25
+ OPENING_DATASET_PATH,
26
+ ProbabilityBlend,
27
+ apply_decision_overlays,
28
+ directional_confidence,
29
+ predict_proba_up,
30
+ )
31
+
32
+
33
+ DEFAULT_TRAIN_END = pd.Timestamp("2023-12-31")
34
+ DEFAULT_VALID_END = pd.Timestamp("2025-08-17")
35
+ RANDOM_SEED = 42
36
+
37
+
38
+ @dataclass
39
+ class RetrainSummary:
40
+ model_name: str
41
+ threshold: float
42
+ train_rows: int
43
+ valid_rows: int
44
+ test_rows: int
45
+ validation_accuracy: float
46
+ test_accuracy: float
47
+ validation_auc: float
48
+ test_auc: float
49
+ test_brier: float
50
+ latest_prediction: str
51
+ latest_prob_up: float
52
+ latest_confidence: float
53
+ feature_count: int
54
+
55
+
56
+ def feature_columns(frame: pd.DataFrame) -> list[str]:
57
+ excluded = {
58
+ "date",
59
+ "first5_start",
60
+ "first5_end",
61
+ "target",
62
+ "day_open",
63
+ "day_high",
64
+ "day_low",
65
+ "day_close",
66
+ "day_volume",
67
+ "day_return",
68
+ }
69
+ cols = []
70
+ for col in frame.columns:
71
+ if col in excluded:
72
+ continue
73
+ if pd.api.types.is_numeric_dtype(frame[col]) and frame[col].notna().mean() >= 0.40:
74
+ if frame[col].nunique(dropna=True) > 1:
75
+ cols.append(col)
76
+ return cols
77
+
78
+
79
+ def best_threshold(y_true: np.ndarray, prob_up: np.ndarray) -> tuple[float, float]:
80
+ thresholds = np.linspace(0.35, 0.65, 301)
81
+ scores = ((prob_up[:, None] >= thresholds[None, :]) == y_true[:, None]).mean(axis=0)
82
+ idx = int(np.argmax(scores))
83
+ return float(thresholds[idx]), float(scores[idx])
84
+
85
+
86
+ def score_auc(y_true: np.ndarray, prob_up: np.ndarray) -> float:
87
+ if len(np.unique(y_true)) < 2:
88
+ return float("nan")
89
+ return float(roc_auc_score(y_true, prob_up))
90
+
91
+
92
+ def parse_args() -> argparse.Namespace:
93
+ parser = argparse.ArgumentParser(description="Retrain the compact NIFTY opening-direction model from Parquet data.")
94
+ parser.add_argument("--train-end", default=DEFAULT_TRAIN_END.date().isoformat())
95
+ parser.add_argument("--valid-end", default=DEFAULT_VALID_END.date().isoformat())
96
+ return parser.parse_args()
97
+
98
+
99
+ def main() -> None:
100
+ args = parse_args()
101
+ train_end = pd.Timestamp(args.train_end)
102
+ valid_end = pd.Timestamp(args.valid_end)
103
+ frame = pd.read_parquet(OPENING_DATASET_PATH)
104
+ frame["date"] = pd.to_datetime(frame["date"], errors="coerce")
105
+ model_frame = frame.dropna(subset=["target"]).sort_values("date").reset_index(drop=True)
106
+ features = feature_columns(model_frame)
107
+ train_df = model_frame[model_frame["date"] <= train_end]
108
+ valid_df = model_frame[(model_frame["date"] > train_end) & (model_frame["date"] <= valid_end)]
109
+ test_df = model_frame[model_frame["date"] > valid_end]
110
+ if train_df.empty or valid_df.empty or test_df.empty:
111
+ raise RuntimeError("Training, validation, and test windows must all contain rows.")
112
+
113
+ x_train = train_df[features]
114
+ y_train = train_df["target"].to_numpy(dtype="int64")
115
+ x_valid = valid_df[features]
116
+ y_valid = valid_df["target"].to_numpy(dtype="int64")
117
+ x_test = test_df[features]
118
+ y_test = test_df["target"].to_numpy(dtype="int64")
119
+
120
+ extra_trees = make_pipeline(
121
+ SimpleImputer(strategy="median"),
122
+ ExtraTreesClassifier(
123
+ n_estimators=800,
124
+ max_depth=4,
125
+ min_samples_leaf=28,
126
+ max_features=0.60,
127
+ class_weight="balanced_subsample",
128
+ random_state=RANDOM_SEED + 13,
129
+ n_jobs=-1,
130
+ ),
131
+ )
132
+ logit = make_pipeline(
133
+ SimpleImputer(strategy="median"),
134
+ StandardScaler(),
135
+ LogisticRegression(C=0.25, class_weight="balanced", max_iter=2000, random_state=RANDOM_SEED),
136
+ )
137
+ extra_trees.fit(x_train, y_train)
138
+ logit.fit(x_train, y_train)
139
+ model = ProbabilityBlend([extra_trees, logit], np.array([0.75, 0.25]))
140
+ valid_prob = predict_proba_up(model, x_valid)
141
+ test_prob = predict_proba_up(model, x_test)
142
+ threshold, _ = best_threshold(y_valid, valid_prob)
143
+ valid_pred = apply_decision_overlays((valid_prob >= threshold).astype("int64"), valid_df, DECISION_OVERLAYS)
144
+ test_pred = apply_decision_overlays((test_prob >= threshold).astype("int64"), test_df, DECISION_OVERLAYS)
145
+ latest = frame.iloc[[-1]].copy()
146
+ latest_prob = predict_proba_up(model, latest[features])
147
+ latest_pred = apply_decision_overlays((latest_prob >= threshold).astype("int64"), latest, DECISION_OVERLAYS)
148
+ latest_conf = directional_confidence(latest_prob, latest_pred, threshold)
149
+
150
+ payload = {
151
+ "model": model,
152
+ "features": features,
153
+ "threshold": threshold,
154
+ "target": "same-day NIFTY 50 close > same-day NIFTY 50 open after first five 1-minute bars",
155
+ "model_name": "compact_extra_trees_logit_overlay",
156
+ "decision_overlays": DECISION_OVERLAYS,
157
+ }
158
+ joblib.dump(payload, MODEL_PATH)
159
+
160
+ summary = RetrainSummary(
161
+ model_name=payload["model_name"],
162
+ threshold=float(threshold),
163
+ train_rows=int(len(train_df)),
164
+ valid_rows=int(len(valid_df)),
165
+ test_rows=int(len(test_df)),
166
+ validation_accuracy=float(accuracy_score(y_valid, valid_pred)),
167
+ test_accuracy=float(accuracy_score(y_test, test_pred)),
168
+ validation_auc=score_auc(y_valid, valid_prob),
169
+ test_auc=score_auc(y_test, test_prob),
170
+ test_brier=float(brier_score_loss(y_test, np.clip(test_prob, 1e-6, 1 - 1e-6))),
171
+ latest_prediction="UP" if int(latest_pred[0]) == 1 else "DOWN",
172
+ latest_prob_up=float(latest_prob[0]),
173
+ latest_confidence=float(latest_conf[0]),
174
+ feature_count=int(len(features)),
175
+ )
176
+ (MODEL_DIR / "summary.json").write_text(json.dumps(asdict(summary), indent=2), encoding="utf-8")
177
+ pd.DataFrame([asdict(summary)]).to_csv(MODEL_DIR / "retrain_summary.csv", index=False)
178
+ print(asdict(summary))
179
+
180
+
181
+ if __name__ == "__main__":
182
+ main()
backend/scripts/run_ist_scheduler.py ADDED
@@ -0,0 +1,98 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from __future__ import annotations
2
+
3
+ import sys
4
+ import time
5
+ from datetime import date, datetime, time as dt_time
6
+ from pathlib import Path
7
+ from zoneinfo import ZoneInfo
8
+
9
+ sys.path.insert(0, str(Path(__file__).resolve().parents[1]))
10
+ from nifty_backend.runtime import (
11
+ CLOSE_REFRESH_READY,
12
+ STALE_CHECK_INTERVAL_SECONDS,
13
+ is_trading_day,
14
+ latest_saved_prediction,
15
+ close_refresh_due,
16
+ refresh_market_close_data,
17
+ refresh_daily_data,
18
+ refresh_first5_prediction,
19
+ refresh_stale_data_once,
20
+ seconds_until_next_ist_run,
21
+ )
22
+
23
+
24
+ IST = ZoneInfo("Asia/Kolkata")
25
+ FIRST5_READY = dt_time(9, 20)
26
+
27
+
28
+ def latest_prediction_date() -> date | None:
29
+ try:
30
+ raw = latest_saved_prediction().get("input_date")
31
+ return date.fromisoformat(str(raw)) if raw else None
32
+ except Exception:
33
+ return None
34
+
35
+
36
+ def refresh_if_current_session_is_ready() -> None:
37
+ now = datetime.now(IST)
38
+ if not is_trading_day(now.date()) or now.time() < FIRST5_READY:
39
+ return
40
+ if latest_prediction_date() == now.date():
41
+ return
42
+ prediction = refresh_first5_prediction()
43
+ print(f"[scheduler] first5 prediction refreshed: {prediction.to_dict()}")
44
+ info = refresh_daily_data()
45
+ print(f"[scheduler] daily data refreshed: {info}")
46
+
47
+
48
+ def refresh_close_data_if_due() -> None:
49
+ if not close_refresh_due():
50
+ return
51
+ info = refresh_market_close_data()
52
+ print(f"[scheduler] close data refreshed: {info}")
53
+
54
+
55
+ def refresh_stale_data_if_due() -> None:
56
+ info = refresh_stale_data_once()
57
+ if info.get("status") == "refreshed":
58
+ print(f"[scheduler] stale data refreshed: {info}")
59
+
60
+
61
+ def main() -> None:
62
+ print("[scheduler] NIFTY first-five-minute scheduler started.")
63
+ print("[scheduler] Runs the opening prediction after 09:20 IST so the 09:15-09:19 candles are complete.")
64
+ while True:
65
+ try:
66
+ refresh_if_current_session_is_ready()
67
+ refresh_close_data_if_due()
68
+ refresh_stale_data_if_due()
69
+ except Exception as exc:
70
+ print(f"[scheduler] current-session refresh failed: {exc}")
71
+ next_first5 = seconds_until_next_ist_run()
72
+ if next_first5 > STALE_CHECK_INTERVAL_SECONDS:
73
+ time.sleep(STALE_CHECK_INTERVAL_SECONDS)
74
+ continue
75
+ time.sleep(next_first5)
76
+ try:
77
+ prediction = refresh_first5_prediction()
78
+ print(f"[scheduler] first5 prediction refreshed: {prediction.to_dict()}")
79
+ except Exception as exc:
80
+ print(f"[scheduler] first5 refresh failed: {exc}")
81
+ try:
82
+ info = refresh_daily_data()
83
+ print(f"[scheduler] daily data refreshed: {info}")
84
+ except Exception as exc:
85
+ print(f"[scheduler] daily refresh failed: {exc}")
86
+ next_close = seconds_until_next_ist_run(CLOSE_REFRESH_READY)
87
+ if next_close > STALE_CHECK_INTERVAL_SECONDS:
88
+ time.sleep(STALE_CHECK_INTERVAL_SECONDS)
89
+ continue
90
+ time.sleep(next_close)
91
+ try:
92
+ refresh_close_data_if_due()
93
+ except Exception as exc:
94
+ print(f"[scheduler] close refresh failed: {exc}")
95
+
96
+
97
+ if __name__ == "__main__":
98
+ main()