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