Jitendra12421 commited on
Commit
f68269f
·
verified ·
1 Parent(s): 50e757b

delete stale remote cache folders

Browse files
Files changed (42) hide show
  1. backend/.dockerignore +0 -5
  2. backend/Dockerfile +0 -15
  3. backend/README.md +0 -39
  4. backend/__init__.py +0 -2
  5. backend/__pycache__/__init__.cpython-311.pyc +0 -0
  6. backend/__pycache__/app.cpython-311.pyc +0 -0
  7. backend/__pycache__/kotak_neo.cpython-311.pyc +0 -0
  8. backend/app.py +0 -536
  9. backend/data/nifty50_1d.parquet +0 -3
  10. backend/data/nifty50_1m.parquet +0 -3
  11. backend/data/opening_direction_training_dataset.parquet +0 -3
  12. backend/data/test_predictions.parquet +0 -3
  13. backend/data/tomorrow_test_predictions.parquet +0 -3
  14. backend/data/tplus1_test_predictions.parquet +0 -3
  15. backend/kotak_neo.py +0 -1257
  16. backend/models/candidate_results.csv +0 -14
  17. backend/models/latest_prediction.csv +0 -2
  18. backend/models/nifty_1420_tplus1_logistic_model.joblib +0 -3
  19. backend/models/nifty_opening_direction_model.joblib +0 -3
  20. backend/models/nifty_tomorrow_direction_model.joblib +0 -3
  21. backend/models/refresh_state.json +0 -7
  22. backend/models/summary.json +0 -29
  23. backend/models/tomorrow_latest_prediction.csv +0 -2
  24. backend/models/tomorrow_summary.json +0 -42
  25. backend/models/tplus1_latest_prediction.csv +0 -2
  26. backend/models/tplus1_summary.json +0 -37
  27. backend/models/yahoo_history_cache.sqlite3 +0 -3
  28. backend/nifty_backend/__init__.py +0 -2
  29. backend/nifty_backend/__pycache__/__init__.cpython-311.pyc +0 -0
  30. backend/nifty_backend/__pycache__/runtime.cpython-311.pyc +0 -3
  31. backend/nifty_backend/__pycache__/yahoo_history_client.cpython-311.pyc +0 -0
  32. backend/nifty_backend/runtime.py +0 -1632
  33. backend/nifty_backend/yahoo_history_client.py +0 -445
  34. backend/requirements.txt +0 -10
  35. backend/scripts/__pycache__/refresh_daily_data.cpython-311.pyc +0 -0
  36. backend/scripts/__pycache__/refresh_first5_prediction.cpython-311.pyc +0 -0
  37. backend/scripts/__pycache__/retrain_opening_model.cpython-311.pyc +0 -0
  38. backend/scripts/__pycache__/run_ist_scheduler.cpython-311.pyc +0 -0
  39. backend/scripts/refresh_daily_data.py +0 -15
  40. backend/scripts/refresh_first5_prediction.py +0 -26
  41. backend/scripts/retrain_opening_model.py +0 -182
  42. backend/scripts/run_ist_scheduler.py +0 -98
backend/.dockerignore DELETED
@@ -1,5 +0,0 @@
1
- __pycache__/
2
- *.pyc
3
- .venv/
4
- venv/
5
- .env
 
 
 
 
 
 
backend/Dockerfile DELETED
@@ -1,15 +0,0 @@
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 DELETED
@@ -1,39 +0,0 @@
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 DELETED
@@ -1,2 +0,0 @@
1
- """NIFTY Project backend package."""
2
-
 
 
 
backend/__pycache__/__init__.cpython-311.pyc DELETED
Binary file (211 Bytes)
 
backend/__pycache__/app.cpython-311.pyc DELETED
Binary file (30.7 kB)
 
backend/__pycache__/kotak_neo.cpython-311.pyc DELETED
Binary file (74.3 kB)
 
backend/app.py DELETED
@@ -1,536 +0,0 @@
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 DELETED
@@ -1,3 +0,0 @@
1
- version https://git-lfs.github.com/spec/v1
2
- oid sha256:4d0830f9d3cfa91f02ce717b556983beb12f897bc537a623c67e38f22ce05caf
3
- size 78366
 
 
 
 
backend/data/nifty50_1m.parquet DELETED
@@ -1,3 +0,0 @@
1
- version https://git-lfs.github.com/spec/v1
2
- oid sha256:b5d6022df273daa1f020676214ec35536426fd84f2d7efb669797287e27ffa2d
3
- size 18589635
 
 
 
 
backend/data/opening_direction_training_dataset.parquet DELETED
@@ -1,3 +0,0 @@
1
- version https://git-lfs.github.com/spec/v1
2
- oid sha256:722c8225451abcb49463d2a57354bc0c9b5eb519f31a25fa3c5242adc4dbbada
3
- size 4463631
 
 
 
 
backend/data/test_predictions.parquet DELETED
@@ -1,3 +0,0 @@
1
- version https://git-lfs.github.com/spec/v1
2
- oid sha256:f159a7499394b7882262ffaa6f9b48c0f6ab763d024f373ee19109b301af90c1
3
- size 14499
 
 
 
 
backend/data/tomorrow_test_predictions.parquet DELETED
@@ -1,3 +0,0 @@
1
- version https://git-lfs.github.com/spec/v1
2
- oid sha256:a2b6ecb377d114c825f861d9a6741e01bbf23bfc623493fd01c78e8bb1501960
3
- size 10751
 
 
 
 
backend/data/tplus1_test_predictions.parquet DELETED
@@ -1,3 +0,0 @@
1
- version https://git-lfs.github.com/spec/v1
2
- oid sha256:bd7f63239d5705969cbe5423169c9fdcb88d59b838b02913d0aa5c455a7ca41c
3
- size 13681
 
 
 
 
backend/kotak_neo.py DELETED
@@ -1,1257 +0,0 @@
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 DELETED
@@ -1,14 +0,0 @@
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 DELETED
@@ -1,2 +0,0 @@
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 DELETED
@@ -1,3 +0,0 @@
1
- version https://git-lfs.github.com/spec/v1
2
- oid sha256:77fea2ef4be30e6355377c1badadc94e9cb941fb62a31e731fad83b0f171c63e
3
- size 5321
 
 
 
 
backend/models/nifty_opening_direction_model.joblib DELETED
@@ -1,3 +0,0 @@
1
- version https://git-lfs.github.com/spec/v1
2
- oid sha256:faa463488804279181dc6ee63ca62871d0d5817b0cbb23d34e7374d7628ced98
3
- size 16234249
 
 
 
 
backend/models/nifty_tomorrow_direction_model.joblib DELETED
@@ -1,3 +0,0 @@
1
- version https://git-lfs.github.com/spec/v1
2
- oid sha256:ce657f6c5de48990fca4621b06796d57a0d71126f159b2adfaa476aaae66ccf6
3
- size 446
 
 
 
 
backend/models/refresh_state.json DELETED
@@ -1,7 +0,0 @@
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 DELETED
@@ -1,29 +0,0 @@
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 DELETED
@@ -1,2 +0,0 @@
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 DELETED
@@ -1,42 +0,0 @@
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 DELETED
@@ -1,2 +0,0 @@
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 DELETED
@@ -1,37 +0,0 @@
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 DELETED
@@ -1,3 +0,0 @@
1
- version https://git-lfs.github.com/spec/v1
2
- oid sha256:534853b3b5984c2c4128ed073412f090ce96d6cb7a9da4aaa98a20482a2a4bff
3
- size 155648
 
 
 
 
backend/nifty_backend/__init__.py DELETED
@@ -1,2 +0,0 @@
1
- """Backend runtime for the NIFTY 50 opening-direction forecaster."""
2
-
 
 
 
backend/nifty_backend/__pycache__/__init__.cpython-311.pyc DELETED
Binary file (257 Bytes)
 
backend/nifty_backend/__pycache__/runtime.cpython-311.pyc DELETED
@@ -1,3 +0,0 @@
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 DELETED
Binary file (27.8 kB)
 
backend/nifty_backend/runtime.py DELETED
@@ -1,1632 +0,0 @@
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 DELETED
@@ -1,445 +0,0 @@
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 DELETED
@@ -1,10 +0,0 @@
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 DELETED
Binary file (937 Bytes)
 
backend/scripts/__pycache__/refresh_first5_prediction.cpython-311.pyc DELETED
Binary file (1.84 kB)
 
backend/scripts/__pycache__/retrain_opening_model.cpython-311.pyc DELETED
Binary file (11.2 kB)
 
backend/scripts/__pycache__/run_ist_scheduler.cpython-311.pyc DELETED
Binary file (5.79 kB)
 
backend/scripts/refresh_daily_data.py DELETED
@@ -1,15 +0,0 @@
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 DELETED
@@ -1,26 +0,0 @@
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 DELETED
@@ -1,182 +0,0 @@
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 DELETED
@@ -1,98 +0,0 @@
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()