Spaces:
Running
Running
Upload 20 files
Browse files- .dockerignore +5 -0
- Dockerfile +15 -0
- README.md +32 -7
- __init__.py +2 -0
- app.py +79 -0
- data/nifty50_1d.parquet +3 -0
- data/nifty50_1m.parquet +3 -0
- data/opening_direction_training_dataset.parquet +3 -0
- data/test_predictions.parquet +3 -0
- models/candidate_results.csv +14 -0
- models/latest_prediction.csv +2 -0
- models/nifty_opening_direction_model.joblib +3 -0
- models/summary.json +29 -0
- nifty_backend/__init__.py +2 -0
- nifty_backend/runtime.py +423 -0
- requirements.txt +9 -0
- scripts/refresh_daily_data.py +15 -0
- scripts/refresh_first5_prediction.py +26 -0
- scripts/retrain_opening_model.py +182 -0
- scripts/run_ist_scheduler.py +37 -0
.dockerignore
ADDED
|
@@ -0,0 +1,5 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
__pycache__/
|
| 2 |
+
*.pyc
|
| 3 |
+
.venv/
|
| 4 |
+
venv/
|
| 5 |
+
.env
|
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"]
|
README.md
CHANGED
|
@@ -1,11 +1,36 @@
|
|
| 1 |
---
|
| 2 |
-
title:
|
| 3 |
-
emoji:
|
| 4 |
-
colorFrom:
|
| 5 |
-
colorTo:
|
| 6 |
sdk: docker
|
| 7 |
-
|
| 8 |
-
short_description: PREDICTS STUFF, THATS IT
|
| 9 |
---
|
| 10 |
|
| 11 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 |
+
|
| 23 |
+
## Data
|
| 24 |
+
|
| 25 |
+
Parquet files live in `data/`:
|
| 26 |
+
|
| 27 |
+
- `nifty50_1m.parquet`
|
| 28 |
+
- `nifty50_1d.parquet`
|
| 29 |
+
- `opening_direction_training_dataset.parquet`
|
| 30 |
+
- `test_predictions.parquet`
|
| 31 |
+
|
| 32 |
+
## Runtime
|
| 33 |
+
|
| 34 |
+
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 prediction.
|
| 35 |
+
|
| 36 |
+
Netlify also pings `/cron/keepalive` every 10 minutes through its scheduled function.
|
__init__.py
ADDED
|
@@ -0,0 +1,2 @@
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""NIFTY Project backend package."""
|
| 2 |
+
|
app.py
ADDED
|
@@ -0,0 +1,79 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from __future__ import annotations
|
| 2 |
+
|
| 3 |
+
import asyncio
|
| 4 |
+
from datetime import date
|
| 5 |
+
|
| 6 |
+
import sys
|
| 7 |
+
from pathlib import Path
|
| 8 |
+
|
| 9 |
+
from fastapi import FastAPI, Query
|
| 10 |
+
from fastapi.middleware.cors import CORSMiddleware
|
| 11 |
+
|
| 12 |
+
sys.path.insert(0, str(Path(__file__).resolve().parent))
|
| 13 |
+
from nifty_backend.runtime import dashboard_payload, latest_saved_prediction, refresh_daily_data, refresh_first5_prediction, seconds_until_next_ist_run
|
| 14 |
+
|
| 15 |
+
|
| 16 |
+
app = FastAPI(title="NIFTY 50 Forecaster Backend")
|
| 17 |
+
app.add_middleware(
|
| 18 |
+
CORSMiddleware,
|
| 19 |
+
allow_origins=["*"],
|
| 20 |
+
allow_credentials=False,
|
| 21 |
+
allow_methods=["*"],
|
| 22 |
+
allow_headers=["*"],
|
| 23 |
+
)
|
| 24 |
+
|
| 25 |
+
|
| 26 |
+
async def daily_ist_refresh_loop() -> None:
|
| 27 |
+
while True:
|
| 28 |
+
await asyncio.sleep(seconds_until_next_ist_run())
|
| 29 |
+
try:
|
| 30 |
+
await asyncio.to_thread(refresh_first5_prediction)
|
| 31 |
+
except Exception as exc:
|
| 32 |
+
print(f"[scheduler] first5 refresh failed: {exc}", flush=True)
|
| 33 |
+
try:
|
| 34 |
+
await asyncio.to_thread(refresh_daily_data)
|
| 35 |
+
except Exception as exc:
|
| 36 |
+
print(f"[scheduler] daily refresh failed: {exc}", flush=True)
|
| 37 |
+
|
| 38 |
+
|
| 39 |
+
@app.on_event("startup")
|
| 40 |
+
async def start_scheduler() -> None:
|
| 41 |
+
asyncio.create_task(daily_ist_refresh_loop())
|
| 42 |
+
|
| 43 |
+
|
| 44 |
+
@app.get("/health")
|
| 45 |
+
def health() -> dict[str, str]:
|
| 46 |
+
return {"status": "ok"}
|
| 47 |
+
|
| 48 |
+
|
| 49 |
+
@app.get("/")
|
| 50 |
+
def root() -> dict[str, str]:
|
| 51 |
+
return {"service": "NIFTY 50 Forecaster Backend", "status": "ok"}
|
| 52 |
+
|
| 53 |
+
|
| 54 |
+
@app.get("/dashboard")
|
| 55 |
+
def dashboard() -> dict:
|
| 56 |
+
return dashboard_payload()
|
| 57 |
+
|
| 58 |
+
|
| 59 |
+
@app.get("/cron/keepalive")
|
| 60 |
+
def cron_keepalive() -> dict:
|
| 61 |
+
return {"status": "awake", "latest": latest_saved_prediction()}
|
| 62 |
+
|
| 63 |
+
|
| 64 |
+
@app.get("/prediction/latest")
|
| 65 |
+
def prediction_latest() -> dict:
|
| 66 |
+
return latest_saved_prediction()
|
| 67 |
+
|
| 68 |
+
|
| 69 |
+
@app.post("/prediction/refresh-first5")
|
| 70 |
+
def prediction_refresh_first5(
|
| 71 |
+
session_date: date | None = Query(default=None, description="Optional YYYY-MM-DD session date in IST."),
|
| 72 |
+
) -> dict:
|
| 73 |
+
prediction = refresh_first5_prediction(session_date=session_date)
|
| 74 |
+
return prediction.to_dict()
|
| 75 |
+
|
| 76 |
+
|
| 77 |
+
@app.post("/data/refresh-daily")
|
| 78 |
+
def data_refresh_daily() -> dict:
|
| 79 |
+
return refresh_daily_data()
|
data/nifty50_1d.parquet
ADDED
|
@@ -0,0 +1,3 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
version https://git-lfs.github.com/spec/v1
|
| 2 |
+
oid sha256:cae5a22e18a378933d815b7b22bb413eeb2e196cdb48504fe8136a4549f7b8a7
|
| 3 |
+
size 78101
|
data/nifty50_1m.parquet
ADDED
|
@@ -0,0 +1,3 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
version https://git-lfs.github.com/spec/v1
|
| 2 |
+
oid sha256:eff0ea13a459412466def2b530b482e84e06346ee4f7203519689baf0adce32c
|
| 3 |
+
size 18555782
|
data/opening_direction_training_dataset.parquet
ADDED
|
@@ -0,0 +1,3 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
version https://git-lfs.github.com/spec/v1
|
| 2 |
+
oid sha256:c21f3c5e7781b67e9849a6d07c9ec28d3f087b74be7e2472785124ceb35a34f4
|
| 3 |
+
size 4462258
|
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
|
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
|
models/latest_prediction.csv
ADDED
|
@@ -0,0 +1,2 @@
|
|
|
|
|
|
|
|
|
|
| 1 |
+
input_date,first5_start,first5_end,prediction,prob_up,confidence,threshold,model_name
|
| 2 |
+
2026-05-21,2026-05-21 09:15:00,2026-05-21 09:19:00,UP,0.4379885393062092,0.5129885393062092,0.425,blend_extra_trees_tight_logit_overlay
|
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
|
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 |
+
}
|
nifty_backend/__init__.py
ADDED
|
@@ -0,0 +1,2 @@
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Backend runtime for the NIFTY 50 opening-direction forecaster."""
|
| 2 |
+
|
nifty_backend/runtime.py
ADDED
|
@@ -0,0 +1,423 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from __future__ import annotations
|
| 2 |
+
|
| 3 |
+
import json
|
| 4 |
+
import sys
|
| 5 |
+
from dataclasses import dataclass
|
| 6 |
+
from datetime import date, datetime, time, timedelta
|
| 7 |
+
from pathlib import Path
|
| 8 |
+
from typing import Any
|
| 9 |
+
from zoneinfo import ZoneInfo
|
| 10 |
+
|
| 11 |
+
import joblib
|
| 12 |
+
import numpy as np
|
| 13 |
+
import pandas as pd
|
| 14 |
+
import yfinance as yf
|
| 15 |
+
|
| 16 |
+
|
| 17 |
+
IST = ZoneInfo("Asia/Kolkata")
|
| 18 |
+
YAHOO_NIFTY_SYMBOL = "^NSEI"
|
| 19 |
+
BACKEND_ROOT = Path(__file__).resolve().parents[1]
|
| 20 |
+
DATA_DIR = BACKEND_ROOT / "data"
|
| 21 |
+
MODEL_DIR = BACKEND_ROOT / "models"
|
| 22 |
+
OPENING_DATASET_PATH = DATA_DIR / "opening_direction_training_dataset.parquet"
|
| 23 |
+
NIFTY_1M_PATH = DATA_DIR / "nifty50_1m.parquet"
|
| 24 |
+
NIFTY_1D_PATH = DATA_DIR / "nifty50_1d.parquet"
|
| 25 |
+
MODEL_PATH = MODEL_DIR / "nifty_opening_direction_model.joblib"
|
| 26 |
+
LATEST_PATH = MODEL_DIR / "latest_prediction.csv"
|
| 27 |
+
TEST_PREDICTIONS_PATH = DATA_DIR / "test_predictions.parquet"
|
| 28 |
+
|
| 29 |
+
DECISION_OVERLAYS = [
|
| 30 |
+
{
|
| 31 |
+
"name": "fifth_minute_momentum_flip",
|
| 32 |
+
"feature": "m5_ret_1m",
|
| 33 |
+
"op": ">=",
|
| 34 |
+
"value": 0.0005085411885759201,
|
| 35 |
+
},
|
| 36 |
+
{
|
| 37 |
+
"name": "vix_stretch_flip",
|
| 38 |
+
"feature": "india_vix_close_vs_sma_20",
|
| 39 |
+
"op": ">=",
|
| 40 |
+
"value": 0.24641908937959742,
|
| 41 |
+
},
|
| 42 |
+
]
|
| 43 |
+
|
| 44 |
+
|
| 45 |
+
class ProbabilityBlend:
|
| 46 |
+
def __init__(self, models: list[Any], weights: np.ndarray):
|
| 47 |
+
self.models = models
|
| 48 |
+
self.weights = np.asarray(weights, dtype="float64")
|
| 49 |
+
self.weights = self.weights / self.weights.sum()
|
| 50 |
+
|
| 51 |
+
def predict_proba(self, x: pd.DataFrame) -> np.ndarray:
|
| 52 |
+
probs = np.column_stack([predict_proba_up(model, x) for model in self.models])
|
| 53 |
+
prob_up = probs @ self.weights
|
| 54 |
+
return np.column_stack([1.0 - prob_up, prob_up])
|
| 55 |
+
|
| 56 |
+
|
| 57 |
+
@dataclass(frozen=True)
|
| 58 |
+
class Prediction:
|
| 59 |
+
input_date: str
|
| 60 |
+
first5_start: str
|
| 61 |
+
first5_end: str
|
| 62 |
+
prediction: str
|
| 63 |
+
prob_up: float
|
| 64 |
+
confidence: float
|
| 65 |
+
threshold: float
|
| 66 |
+
model_name: str
|
| 67 |
+
|
| 68 |
+
def to_dict(self) -> dict[str, Any]:
|
| 69 |
+
return {
|
| 70 |
+
"input_date": self.input_date,
|
| 71 |
+
"first5_start": self.first5_start,
|
| 72 |
+
"first5_end": self.first5_end,
|
| 73 |
+
"prediction": self.prediction,
|
| 74 |
+
"prob_up": self.prob_up,
|
| 75 |
+
"confidence": self.confidence,
|
| 76 |
+
"threshold": self.threshold,
|
| 77 |
+
"model_name": self.model_name,
|
| 78 |
+
}
|
| 79 |
+
|
| 80 |
+
|
| 81 |
+
def predict_proba_up(model: Any, x: pd.DataFrame) -> np.ndarray:
|
| 82 |
+
return np.asarray(model.predict_proba(x)[:, 1], dtype="float64")
|
| 83 |
+
|
| 84 |
+
|
| 85 |
+
def safe_div(numer: pd.Series | np.ndarray, denom: pd.Series | np.ndarray) -> pd.Series:
|
| 86 |
+
n = pd.Series(numer, copy=False)
|
| 87 |
+
d = pd.Series(denom, copy=False)
|
| 88 |
+
out = pd.Series(np.nan, index=n.index, dtype="float64")
|
| 89 |
+
mask = d.notna() & np.isfinite(d.to_numpy(dtype="float64")) & (d != 0)
|
| 90 |
+
out.loc[mask] = n.loc[mask].to_numpy(dtype="float64") / d.loc[mask].to_numpy(dtype="float64")
|
| 91 |
+
return out
|
| 92 |
+
|
| 93 |
+
|
| 94 |
+
def load_model() -> dict[str, Any]:
|
| 95 |
+
# Existing artifact was trained as a script, so its custom blend class
|
| 96 |
+
# resolves through __main__ when unpickled.
|
| 97 |
+
sys.modules["__main__"].ProbabilityBlend = ProbabilityBlend
|
| 98 |
+
sys.modules["__main__"].predict_proba_up = predict_proba_up
|
| 99 |
+
payload = joblib.load(MODEL_PATH)
|
| 100 |
+
payload.setdefault("decision_overlays", DECISION_OVERLAYS)
|
| 101 |
+
payload.setdefault("model_name", "nifty_opening_direction_model")
|
| 102 |
+
return payload
|
| 103 |
+
|
| 104 |
+
|
| 105 |
+
def overlay_mask(frame: pd.DataFrame, overlay: dict[str, object]) -> np.ndarray:
|
| 106 |
+
feature = str(overlay["feature"])
|
| 107 |
+
if feature not in frame.columns:
|
| 108 |
+
return np.zeros(len(frame), dtype=bool)
|
| 109 |
+
series = pd.to_numeric(frame[feature], errors="coerce")
|
| 110 |
+
value = float(overlay["value"])
|
| 111 |
+
if overlay["op"] == ">=":
|
| 112 |
+
return (series >= value).fillna(False).to_numpy(dtype=bool)
|
| 113 |
+
if overlay["op"] == "<=":
|
| 114 |
+
return (series <= value).fillna(False).to_numpy(dtype=bool)
|
| 115 |
+
raise ValueError(f"Unsupported overlay op: {overlay['op']}")
|
| 116 |
+
|
| 117 |
+
|
| 118 |
+
def apply_decision_overlays(pred: np.ndarray, frame: pd.DataFrame, overlays: list[dict[str, object]]) -> np.ndarray:
|
| 119 |
+
adjusted = np.asarray(pred, dtype="int64").copy()
|
| 120 |
+
for overlay in overlays:
|
| 121 |
+
mask = overlay_mask(frame, overlay)
|
| 122 |
+
adjusted[mask] = 1 - adjusted[mask]
|
| 123 |
+
return adjusted
|
| 124 |
+
|
| 125 |
+
|
| 126 |
+
def directional_confidence(prob_up: np.ndarray, pred: np.ndarray, threshold: float) -> np.ndarray:
|
| 127 |
+
prob_up = np.asarray(prob_up, dtype="float64")
|
| 128 |
+
pred = np.asarray(pred, dtype="int64")
|
| 129 |
+
base_side_prob = np.where(pred == 1, prob_up, 1.0 - prob_up)
|
| 130 |
+
threshold_distance = np.abs(prob_up - float(threshold))
|
| 131 |
+
return np.clip(0.50 + threshold_distance, base_side_prob, 0.99)
|
| 132 |
+
|
| 133 |
+
|
| 134 |
+
def read_training_dataset() -> pd.DataFrame:
|
| 135 |
+
df = pd.read_parquet(OPENING_DATASET_PATH)
|
| 136 |
+
for col in ("date", "first5_start", "first5_end"):
|
| 137 |
+
if col in df.columns:
|
| 138 |
+
df[col] = pd.to_datetime(df[col], errors="coerce")
|
| 139 |
+
return df.sort_values("date").reset_index(drop=True)
|
| 140 |
+
|
| 141 |
+
|
| 142 |
+
def normalize_yahoo_frame(df: pd.DataFrame) -> pd.DataFrame:
|
| 143 |
+
if df.empty:
|
| 144 |
+
return pd.DataFrame(columns=["date", "open", "high", "low", "close", "volume"])
|
| 145 |
+
if isinstance(df.columns, pd.MultiIndex):
|
| 146 |
+
df.columns = [str(c[0]).lower() for c in df.columns]
|
| 147 |
+
else:
|
| 148 |
+
df.columns = [str(c).lower().replace(" ", "_") for c in df.columns]
|
| 149 |
+
df = df.reset_index()
|
| 150 |
+
date_col = next((c for c in df.columns if c.lower() in {"datetime", "date"}), df.columns[0])
|
| 151 |
+
df["date"] = pd.to_datetime(df[date_col], errors="coerce")
|
| 152 |
+
if df["date"].dt.tz is None:
|
| 153 |
+
df["date"] = df["date"].dt.tz_localize("UTC").dt.tz_convert(IST)
|
| 154 |
+
else:
|
| 155 |
+
df["date"] = df["date"].dt.tz_convert(IST)
|
| 156 |
+
rename = {
|
| 157 |
+
"open": "open",
|
| 158 |
+
"high": "high",
|
| 159 |
+
"low": "low",
|
| 160 |
+
"close": "close",
|
| 161 |
+
"adj_close": "close",
|
| 162 |
+
"volume": "volume",
|
| 163 |
+
}
|
| 164 |
+
out = pd.DataFrame({"date": df["date"].dt.tz_localize(None)})
|
| 165 |
+
for src, dst in rename.items():
|
| 166 |
+
if src in df.columns and dst not in out.columns:
|
| 167 |
+
out[dst] = pd.to_numeric(df[src], errors="coerce")
|
| 168 |
+
return out.dropna(subset=["date", "open", "high", "low", "close"]).sort_values("date")
|
| 169 |
+
|
| 170 |
+
|
| 171 |
+
def fetch_yahoo_minutes(period: str = "5d") -> pd.DataFrame:
|
| 172 |
+
raw = yf.download(YAHOO_NIFTY_SYMBOL, period=period, interval="1m", progress=False, prepost=False, auto_adjust=False)
|
| 173 |
+
return normalize_yahoo_frame(raw)
|
| 174 |
+
|
| 175 |
+
|
| 176 |
+
def fetch_yahoo_daily(period: str = "1mo") -> pd.DataFrame:
|
| 177 |
+
raw = yf.download(YAHOO_NIFTY_SYMBOL, period=period, interval="1d", progress=False, prepost=False, auto_adjust=False)
|
| 178 |
+
out = normalize_yahoo_frame(raw)
|
| 179 |
+
out["date"] = pd.to_datetime(out["date"], errors="coerce").dt.normalize()
|
| 180 |
+
return out.drop_duplicates("date", keep="last")
|
| 181 |
+
|
| 182 |
+
|
| 183 |
+
def append_parquet_rows(path: Path, new_rows: pd.DataFrame, subset: list[str]) -> pd.DataFrame:
|
| 184 |
+
if path.exists():
|
| 185 |
+
existing = pd.read_parquet(path)
|
| 186 |
+
combined = pd.concat([existing, new_rows], ignore_index=True)
|
| 187 |
+
else:
|
| 188 |
+
combined = new_rows.copy()
|
| 189 |
+
combined = combined.drop_duplicates(subset=subset, keep="last").sort_values(subset).reset_index(drop=True)
|
| 190 |
+
combined.to_parquet(path, index=False, compression="zstd")
|
| 191 |
+
return combined
|
| 192 |
+
|
| 193 |
+
|
| 194 |
+
def first5_features_from_minutes(minutes: pd.DataFrame, session_date: date | None = None) -> pd.DataFrame:
|
| 195 |
+
if minutes.empty:
|
| 196 |
+
raise RuntimeError("Yahoo returned no minute bars.")
|
| 197 |
+
bars = minutes.copy()
|
| 198 |
+
bars["dt"] = pd.to_datetime(bars["date"], errors="coerce")
|
| 199 |
+
bars["session_date"] = bars["dt"].dt.normalize()
|
| 200 |
+
if session_date is None:
|
| 201 |
+
session_ts = bars["session_date"].max()
|
| 202 |
+
else:
|
| 203 |
+
session_ts = pd.Timestamp(session_date).normalize()
|
| 204 |
+
day = bars[bars["session_date"] == session_ts].sort_values("dt").copy()
|
| 205 |
+
start_dt = pd.Timestamp.combine(session_ts.date(), time(9, 15))
|
| 206 |
+
end_dt = pd.Timestamp.combine(session_ts.date(), time(9, 19))
|
| 207 |
+
first5 = day[(day["dt"] >= start_dt) & (day["dt"] <= end_dt)].head(5).copy()
|
| 208 |
+
if len(first5) < 5:
|
| 209 |
+
raise RuntimeError(f"Need 5 opening bars for {session_ts.date()}, got {len(first5)}.")
|
| 210 |
+
first5["minute_index"] = np.arange(len(first5))
|
| 211 |
+
first5["ret_1m"] = first5["close"].pct_change(fill_method=None)
|
| 212 |
+
first5["range_pct_1m"] = safe_div(first5["high"] - first5["low"], first5["open"])
|
| 213 |
+
first5["body_pct_1m"] = safe_div(first5["close"] - first5["open"], first5["open"])
|
| 214 |
+
row = {
|
| 215 |
+
"date": session_ts,
|
| 216 |
+
"first5_start": first5["dt"].iloc[0],
|
| 217 |
+
"first5_end": first5["dt"].iloc[-1],
|
| 218 |
+
"first5_open": first5["open"].iloc[0],
|
| 219 |
+
"first5_high": first5["high"].max(),
|
| 220 |
+
"first5_low": first5["low"].min(),
|
| 221 |
+
"first5_close": first5["close"].iloc[-1],
|
| 222 |
+
"first5_volume": first5["volume"].sum() if "volume" in first5 else 0.0,
|
| 223 |
+
"first5_bars": len(first5),
|
| 224 |
+
"first5_last_1m_ret": first5["ret_1m"].iloc[-1],
|
| 225 |
+
"first5_ret_std": first5["ret_1m"].std(),
|
| 226 |
+
}
|
| 227 |
+
row["first5_return"] = (row["first5_close"] - row["first5_open"]) / row["first5_open"]
|
| 228 |
+
row["first5_range_pct"] = (row["first5_high"] - row["first5_low"]) / row["first5_open"]
|
| 229 |
+
first5_range = row["first5_high"] - row["first5_low"]
|
| 230 |
+
row["first5_body_to_range"] = (row["first5_close"] - row["first5_open"]) / first5_range if first5_range else np.nan
|
| 231 |
+
row["first5_close_location"] = (row["first5_close"] - row["first5_low"]) / first5_range if first5_range else np.nan
|
| 232 |
+
for idx, (_, candle) in enumerate(first5.iterrows(), start=1):
|
| 233 |
+
for field in ("open", "high", "low", "close", "ret_1m", "range_pct_1m", "body_pct_1m"):
|
| 234 |
+
row[f"m{idx}_{field}"] = candle[field]
|
| 235 |
+
row[f"m{idx}_close_vs_first5_open"] = (candle["close"] - row["first5_open"]) / row["first5_open"]
|
| 236 |
+
row[f"m{idx}_range_share"] = (candle["high"] - candle["low"]) / first5_range if first5_range else np.nan
|
| 237 |
+
row["first5_return_accel"] = row["m5_ret_1m"] - row["m2_ret_1m"]
|
| 238 |
+
row["first5_last2_return"] = (row["m5_close"] - row["m4_open"]) / row["m4_open"]
|
| 239 |
+
row["first5_first2_return"] = (row["m2_close"] - row["m1_open"]) / row["m1_open"]
|
| 240 |
+
row["first5_reversal"] = np.sign(row["first5_first2_return"]) * -np.sign(row["first5_last2_return"])
|
| 241 |
+
row["dow"] = session_ts.dayofweek
|
| 242 |
+
row["dom"] = session_ts.day
|
| 243 |
+
row["month"] = session_ts.month
|
| 244 |
+
return pd.DataFrame([row])
|
| 245 |
+
|
| 246 |
+
|
| 247 |
+
def build_model_row(first5_row: pd.DataFrame) -> pd.DataFrame:
|
| 248 |
+
dataset = read_training_dataset()
|
| 249 |
+
latest_context = dataset.iloc[[-1]].copy()
|
| 250 |
+
output = latest_context.copy()
|
| 251 |
+
for col in first5_row.columns:
|
| 252 |
+
output[col] = first5_row[col].iloc[0]
|
| 253 |
+
if {"first5_open", "nifty_close"}.issubset(output.columns):
|
| 254 |
+
output["first5_gap_from_prev_close"] = (output["first5_open"] - output["nifty_close"]) / output["nifty_close"]
|
| 255 |
+
output["first5_close_vs_prev_close"] = (output["first5_close"] - output["nifty_close"]) / output["nifty_close"]
|
| 256 |
+
if {"first5_range_pct", "nifty_range_pct"}.issubset(output.columns):
|
| 257 |
+
output["first5_range_vs_prev_range"] = output["first5_range_pct"] / output["nifty_range_pct"]
|
| 258 |
+
if {"first5_return", "nifty_ret_1"}.issubset(output.columns):
|
| 259 |
+
output["first5_return_x_prev_ret"] = output["first5_return"] * output["nifty_ret_1"]
|
| 260 |
+
output["gap_x_prev_ret"] = output["first5_gap_from_prev_close"] * output["nifty_ret_1"]
|
| 261 |
+
if {"first5_return", "banknifty_ret_1"}.issubset(output.columns):
|
| 262 |
+
output["first5_return_x_bank_ret_1"] = output["first5_return"] * output["banknifty_ret_1"]
|
| 263 |
+
if {"first5_range_pct", "india_vix_ret_1"}.issubset(output.columns):
|
| 264 |
+
output["first5_range_x_vix_ret_1"] = output["first5_range_pct"] * output["india_vix_ret_1"]
|
| 265 |
+
output["target"] = np.nan
|
| 266 |
+
output["day_return"] = np.nan
|
| 267 |
+
return output
|
| 268 |
+
|
| 269 |
+
|
| 270 |
+
def predict_row(row: pd.DataFrame) -> Prediction:
|
| 271 |
+
payload = load_model()
|
| 272 |
+
model = payload["model"]
|
| 273 |
+
features = payload["features"]
|
| 274 |
+
threshold = float(payload["threshold"])
|
| 275 |
+
missing = [c for c in features if c not in row.columns]
|
| 276 |
+
if missing:
|
| 277 |
+
raise RuntimeError(f"Feature row is missing {len(missing)} features; first missing: {missing[:5]}")
|
| 278 |
+
prob_up = predict_proba_up(model, row[features])
|
| 279 |
+
raw_pred = (prob_up >= threshold).astype("int64")
|
| 280 |
+
pred = apply_decision_overlays(raw_pred, row, payload.get("decision_overlays", DECISION_OVERLAYS))
|
| 281 |
+
confidence = directional_confidence(prob_up, pred, threshold)
|
| 282 |
+
prediction = Prediction(
|
| 283 |
+
input_date=pd.to_datetime(row["date"].iloc[0]).date().isoformat(),
|
| 284 |
+
first5_start=str(pd.to_datetime(row["first5_start"].iloc[0])),
|
| 285 |
+
first5_end=str(pd.to_datetime(row["first5_end"].iloc[0])),
|
| 286 |
+
prediction="UP" if int(pred[0]) == 1 else "DOWN",
|
| 287 |
+
prob_up=float(prob_up[0]),
|
| 288 |
+
confidence=float(confidence[0]),
|
| 289 |
+
threshold=threshold,
|
| 290 |
+
model_name=str(payload.get("model_name", "nifty_opening_direction_model")),
|
| 291 |
+
)
|
| 292 |
+
pd.DataFrame([prediction.to_dict()]).to_csv(LATEST_PATH, index=False)
|
| 293 |
+
return prediction
|
| 294 |
+
|
| 295 |
+
|
| 296 |
+
def latest_saved_prediction() -> dict[str, Any]:
|
| 297 |
+
if LATEST_PATH.exists():
|
| 298 |
+
return pd.read_csv(LATEST_PATH).iloc[-1].to_dict()
|
| 299 |
+
summary_path = MODEL_DIR / "summary.json"
|
| 300 |
+
if summary_path.exists():
|
| 301 |
+
return json.loads(summary_path.read_text(encoding="utf-8"))
|
| 302 |
+
raise FileNotFoundError("No latest prediction is available yet.")
|
| 303 |
+
|
| 304 |
+
|
| 305 |
+
def _json_ready_frame(df: pd.DataFrame, limit: int | None = None) -> list[dict[str, Any]]:
|
| 306 |
+
out = df.copy()
|
| 307 |
+
if limit is not None:
|
| 308 |
+
out = out.tail(limit)
|
| 309 |
+
for col in out.columns:
|
| 310 |
+
if pd.api.types.is_datetime64_any_dtype(out[col]):
|
| 311 |
+
out[col] = out[col].dt.strftime("%Y-%m-%d %H:%M:%S")
|
| 312 |
+
out = out.replace({np.nan: None})
|
| 313 |
+
return out.to_dict(orient="records")
|
| 314 |
+
|
| 315 |
+
|
| 316 |
+
def load_model_summary() -> dict[str, Any]:
|
| 317 |
+
summary_path = MODEL_DIR / "summary.json"
|
| 318 |
+
if not summary_path.exists():
|
| 319 |
+
return {}
|
| 320 |
+
return json.loads(summary_path.read_text(encoding="utf-8"))
|
| 321 |
+
|
| 322 |
+
|
| 323 |
+
def load_candidate_results() -> list[dict[str, Any]]:
|
| 324 |
+
path = MODEL_DIR / "candidate_results.csv"
|
| 325 |
+
if not path.exists():
|
| 326 |
+
return []
|
| 327 |
+
return _json_ready_frame(pd.read_csv(path).head(12))
|
| 328 |
+
|
| 329 |
+
|
| 330 |
+
def load_test_predictions() -> pd.DataFrame:
|
| 331 |
+
if not TEST_PREDICTIONS_PATH.exists():
|
| 332 |
+
return pd.DataFrame()
|
| 333 |
+
df = pd.read_parquet(TEST_PREDICTIONS_PATH)
|
| 334 |
+
df["date"] = pd.to_datetime(df["date"], errors="coerce")
|
| 335 |
+
return df.sort_values("date").reset_index(drop=True)
|
| 336 |
+
|
| 337 |
+
|
| 338 |
+
def dashboard_payload() -> dict[str, Any]:
|
| 339 |
+
summary = load_model_summary()
|
| 340 |
+
latest = latest_saved_prediction()
|
| 341 |
+
test = load_test_predictions()
|
| 342 |
+
daily = pd.read_parquet(NIFTY_1D_PATH)
|
| 343 |
+
daily["date"] = pd.to_datetime(daily["date"], errors="coerce")
|
| 344 |
+
daily = daily.sort_values("date").tail(180)
|
| 345 |
+
dataset = read_training_dataset()
|
| 346 |
+
opening = dataset[["date", "first5_return", "first5_range_pct", "first5_close_location"]].tail(120).copy()
|
| 347 |
+
|
| 348 |
+
if not test.empty:
|
| 349 |
+
recent_predictions = test.tail(40).copy()
|
| 350 |
+
recent_accuracy = float(recent_predictions["correct"].mean())
|
| 351 |
+
direction_mix = test.groupby("prediction")["correct"].agg(["count", "mean"]).reset_index()
|
| 352 |
+
monthly = (
|
| 353 |
+
test.assign(month=test["date"].dt.strftime("%Y-%m"))
|
| 354 |
+
.groupby("month", as_index=False)["correct"]
|
| 355 |
+
.mean()
|
| 356 |
+
.rename(columns={"correct": "accuracy"})
|
| 357 |
+
)
|
| 358 |
+
else:
|
| 359 |
+
recent_predictions = pd.DataFrame()
|
| 360 |
+
recent_accuracy = None
|
| 361 |
+
direction_mix = pd.DataFrame()
|
| 362 |
+
monthly = pd.DataFrame()
|
| 363 |
+
|
| 364 |
+
metrics = {
|
| 365 |
+
"validation_accuracy": summary.get("validation_accuracy"),
|
| 366 |
+
"test_accuracy": summary.get("test_accuracy"),
|
| 367 |
+
"baseline_test_accuracy": summary.get("baseline_test_accuracy"),
|
| 368 |
+
"validation_auc": summary.get("validation_auc"),
|
| 369 |
+
"test_auc": summary.get("test_auc"),
|
| 370 |
+
"test_brier": summary.get("test_brier"),
|
| 371 |
+
"feature_count": summary.get("feature_count"),
|
| 372 |
+
"recent_accuracy": recent_accuracy,
|
| 373 |
+
}
|
| 374 |
+
return {
|
| 375 |
+
"latest": latest,
|
| 376 |
+
"metrics": metrics,
|
| 377 |
+
"summary": summary,
|
| 378 |
+
"candidates": load_candidate_results(),
|
| 379 |
+
"charts": {
|
| 380 |
+
"daily_close": _json_ready_frame(daily[["date", "open", "high", "low", "close"]]),
|
| 381 |
+
"opening_features": _json_ready_frame(opening),
|
| 382 |
+
"monthly_accuracy": _json_ready_frame(monthly),
|
| 383 |
+
"direction_mix": _json_ready_frame(direction_mix),
|
| 384 |
+
"recent_predictions": _json_ready_frame(recent_predictions),
|
| 385 |
+
},
|
| 386 |
+
"data_status": {
|
| 387 |
+
"nifty_1m_rows": int(len(pd.read_parquet(NIFTY_1M_PATH, columns=["date"]))),
|
| 388 |
+
"nifty_1d_rows": int(len(pd.read_parquet(NIFTY_1D_PATH, columns=["date"]))),
|
| 389 |
+
"training_rows": int(len(dataset)),
|
| 390 |
+
"test_prediction_rows": int(len(test)),
|
| 391 |
+
"latest_daily_date": pd.to_datetime(daily["date"]).max().date().isoformat(),
|
| 392 |
+
},
|
| 393 |
+
}
|
| 394 |
+
|
| 395 |
+
|
| 396 |
+
def refresh_first5_prediction(session_date: date | None = None) -> Prediction:
|
| 397 |
+
minutes = fetch_yahoo_minutes(period="5d")
|
| 398 |
+
append_parquet_rows(NIFTY_1M_PATH, minutes, ["date"])
|
| 399 |
+
first5 = first5_features_from_minutes(minutes, session_date=session_date)
|
| 400 |
+
row = build_model_row(first5)
|
| 401 |
+
dataset = read_training_dataset()
|
| 402 |
+
merged = pd.concat([dataset, row], ignore_index=True)
|
| 403 |
+
merged = merged.drop_duplicates(subset=["date"], keep="last").sort_values("date").reset_index(drop=True)
|
| 404 |
+
merged.to_parquet(OPENING_DATASET_PATH, index=False, compression="zstd")
|
| 405 |
+
return predict_row(row)
|
| 406 |
+
|
| 407 |
+
|
| 408 |
+
def refresh_daily_data() -> dict[str, Any]:
|
| 409 |
+
daily = fetch_yahoo_daily(period="1mo")
|
| 410 |
+
combined = append_parquet_rows(NIFTY_1D_PATH, daily, ["date"])
|
| 411 |
+
return {
|
| 412 |
+
"rows": int(len(combined)),
|
| 413 |
+
"latest_date": pd.to_datetime(combined["date"]).max().date().isoformat(),
|
| 414 |
+
"path": str(NIFTY_1D_PATH),
|
| 415 |
+
}
|
| 416 |
+
|
| 417 |
+
|
| 418 |
+
def seconds_until_next_ist_run(run_time: time = time(9, 20)) -> float:
|
| 419 |
+
now = datetime.now(IST)
|
| 420 |
+
target = datetime.combine(now.date(), run_time, tzinfo=IST)
|
| 421 |
+
if now >= target:
|
| 422 |
+
target += timedelta(days=1)
|
| 423 |
+
return max(1.0, (target - now).total_seconds())
|
requirements.txt
ADDED
|
@@ -0,0 +1,9 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
pandas
|
| 2 |
+
pyarrow
|
| 3 |
+
yfinance
|
| 4 |
+
fastapi
|
| 5 |
+
uvicorn
|
| 6 |
+
joblib
|
| 7 |
+
numpy
|
| 8 |
+
scikit-learn
|
| 9 |
+
catboost
|
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()
|
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()
|
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()
|
scripts/run_ist_scheduler.py
ADDED
|
@@ -0,0 +1,37 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from __future__ import annotations
|
| 2 |
+
|
| 3 |
+
import time
|
| 4 |
+
import sys
|
| 5 |
+
from datetime import datetime
|
| 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 refresh_daily_data, refresh_first5_prediction, seconds_until_next_ist_run
|
| 11 |
+
|
| 12 |
+
|
| 13 |
+
IST = ZoneInfo("Asia/Kolkata")
|
| 14 |
+
|
| 15 |
+
|
| 16 |
+
def main() -> None:
|
| 17 |
+
print("[scheduler] NIFTY first-five-minute scheduler started.")
|
| 18 |
+
print("[scheduler] Runs the opening prediction after 09:20 IST so the 09:15-09:19 candles are complete.")
|
| 19 |
+
while True:
|
| 20 |
+
sleep_for = seconds_until_next_ist_run()
|
| 21 |
+
target = datetime.now(IST).timestamp() + sleep_for
|
| 22 |
+
print(f"[scheduler] sleeping {sleep_for / 60:.1f} minutes; next wake timestamp={target:.0f}")
|
| 23 |
+
time.sleep(sleep_for)
|
| 24 |
+
try:
|
| 25 |
+
prediction = refresh_first5_prediction()
|
| 26 |
+
print(f"[scheduler] first5 prediction refreshed: {prediction.to_dict()}")
|
| 27 |
+
except Exception as exc:
|
| 28 |
+
print(f"[scheduler] first5 refresh failed: {exc}")
|
| 29 |
+
try:
|
| 30 |
+
info = refresh_daily_data()
|
| 31 |
+
print(f"[scheduler] daily data refreshed: {info}")
|
| 32 |
+
except Exception as exc:
|
| 33 |
+
print(f"[scheduler] daily refresh failed: {exc}")
|
| 34 |
+
|
| 35 |
+
|
| 36 |
+
if __name__ == "__main__":
|
| 37 |
+
main()
|