sbasu2512 commited on
Commit
1f61baa
·
1 Parent(s): d60ae27
README.md CHANGED
@@ -53,6 +53,19 @@ Once it's up:
53
  - API health check: <http://localhost:8000/health>
54
  - Metrics: <http://localhost:8000/metrics/twenty-day>
55
 
 
 
 
 
 
 
 
 
 
 
 
 
 
56
  On a first-ever run the ticker registry and `predictions` table are empty, so the dashboard will show "No predictions yet." until either the 16:00 IST scheduler fires or you trigger the pipeline manually:
57
 
58
  ```bash
@@ -2286,7 +2299,7 @@ Ticker source files are `static_data/ind_nifty_list.csv` and `static_data/us_sto
2286
 
2287
  All state-changing API routes require the `X-Pipeline-Guid` request header. Its value must equal `PIPELINE_ADMIN_GUID` in `.env`. Do not expose that value in browser code or source control.
2288
 
2289
- At 16:00 IST on weekdays, the scheduler runs the full lifecycle. It initializes the ticker registry from the CSV files only when empty, then incrementally upserts yfinance OHLCV into SQLite tables `market_tickers` and `market_ohlcv`. `GET /dashboard` presents the prediction and five-session outcome table.
2290
 
2291
  ### Feature-contract note
2292
 
@@ -2299,8 +2312,8 @@ The production scheduler invokes the full lifecycle at **16:00 IST, Monday throu
2299
  ### Incremental yfinance policy
2300
 
2301
  - A ticker with no stored OHLCV history is backfilled with up to 800 calendar days, providing sufficient warm-up history for the 200-day indicators.
2302
- - A ticker already in `market_ohlcv` is refreshed from its latest stored trading date minus seven days. This overlap makes daily runs idempotent and allows yfinance corrections to be upserted without re-downloading the full history.
2303
- - All returned candles are upserted into `market_ohlcv` by `(ticker_id, trading_date)`.
2304
  - The market-data registry is imported from the CSV files only when it is empty, or when an administrator calls `POST /market/tickers/sync`. Daily runs preserve ticker additions, edits, deactivations, and deletions made through the protected API.
2305
 
2306
  ### Tables
 
53
  - API health check: <http://localhost:8000/health>
54
  - Metrics: <http://localhost:8000/metrics/twenty-day>
55
 
56
+ ### Prediction API and authorization
57
+
58
+ `GET /predict?prediction_date=YYYY-MM-DD` is public and returns the stored
59
+ predictions for that NSE trading day. Saturdays, Sundays, exchange holidays,
60
+ and dates with no NSE candle return `{"status":"not_a_trading_day"}`. A
61
+ prediction is available after the weekday scheduler has completed its run.
62
+
63
+ `POST /predict` is the protected manual catch-up route; it accepts the same
64
+ date parameter, incrementally ingests missing candles, rebuilds features, and
65
+ writes predictions. All POST, PATCH, and DELETE routes require
66
+ `X-Pipeline-Guid`; GET routes, including `/dashboard` and `GET /predict`, do
67
+ not require it.
68
+
69
  On a first-ever run the ticker registry and `predictions` table are empty, so the dashboard will show "No predictions yet." until either the 16:00 IST scheduler fires or you trigger the pipeline manually:
70
 
71
  ```bash
 
2299
 
2300
  All state-changing API routes require the `X-Pipeline-Guid` request header. Its value must equal `PIPELINE_ADMIN_GUID` in `.env`. Do not expose that value in browser code or source control.
2301
 
2302
+ At 16:00 IST on weekdays, the scheduler runs market-data ingestion, feature rebuild, prediction, resolution, metrics, and retrain checks in that order. It incrementally upserts yfinance candles into the SQLite dataset tables, then `GET /dashboard` presents the resulting prediction and five-session outcome table.
2303
 
2304
  ### Feature-contract note
2305
 
 
2312
  ### Incremental yfinance policy
2313
 
2314
  - A ticker with no stored OHLCV history is backfilled with up to 800 calendar days, providing sufficient warm-up history for the 200-day indicators.
2315
+ - A populated ticker is fetched from its first missing calendar date only; daily runs do not re-download the 800-day warm-up history.
2316
+ - All returned candles are upserted by `(ticker_id, trading_date)` into their dataset-specific SQLite tables.
2317
  - The market-data registry is imported from the CSV files only when it is empty, or when an administrator calls `POST /market/tickers/sync`. Daily runs preserve ticker additions, edits, deactivations, and deletions made through the protected API.
2318
 
2319
  ### Tables
api/dashboard.py CHANGED
@@ -44,7 +44,7 @@ def _fetch_default_prediction(conn):
44
  with conn.cursor() as cur:
45
  cur.execute(f"""
46
  SELECT {PREDICTION_COLUMNS} FROM predictions
47
- ORDER BY resolved_at IS NULL, resolved_at DESC, prediction_date DESC, rank ASC
48
  LIMIT 1
49
  """)
50
  return cur.fetchone()
 
44
  with conn.cursor() as cur:
45
  cur.execute(f"""
46
  SELECT {PREDICTION_COLUMNS} FROM predictions
47
+ ORDER BY prediction_date DESC, rank ASC
48
  LIMIT 1
49
  """)
50
  return cur.fetchone()
api/market.py CHANGED
@@ -92,3 +92,4 @@ def ingest():
92
  with connect(settings.DATABASE_URL) as conn:
93
  rows = ingest_market_data(conn)
94
  return {"status": "ingested", "candles_upserted": rows}
 
 
92
  with connect(settings.DATABASE_URL) as conn:
93
  rows = ingest_market_data(conn)
94
  return {"status": "ingested", "candles_upserted": rows}
95
+
app/main.py CHANGED
@@ -28,18 +28,14 @@ from jobs.daily_prediction import (
28
 
29
  @asynccontextmanager
30
  async def lifespan(app: FastAPI):
31
- # Keep the market universe and its SQLite cache ready before requests.
32
- # Ingestion starts at each ticker's first missing date, so restarts do not
33
- # repeatedly download the initial 800-day history.
34
  from app.config import settings
35
  from database.connection import connect
36
- from jobs.market_ingestion import ingest_market_data, sync_static_tickers
37
- from jobs.rebuild_feature_stores import rebuild_feature_stores
38
 
39
  with connect(settings.DATABASE_URL) as conn:
40
  sync_static_tickers(conn, settings.STATIC_DATA_DIR)
41
- ingest_market_data(conn)
42
- rebuild_feature_stores()
43
  async with scrapper_lifespan(app):
44
  yield
45
 
@@ -61,6 +57,36 @@ def health():
61
  }
62
 
63
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
64
  @app.post("/predict", dependencies=[Depends(require_pipeline_guid)])
65
  def predict(prediction_date: date | None = None):
66
 
@@ -69,9 +95,26 @@ def predict(prediction_date: date | None = None):
69
  target_date = prediction_date or pd.Timestamp.now(tz="Asia/Kolkata").date()
70
  from app.config import settings
71
  from database.connection import connect
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
72
 
73
- # NSE candles are the trading-calendar authority. This also handles
74
- # exchange holidays without maintaining a second hard-coded calendar.
75
  with connect(settings.DATABASE_URL) as conn:
76
  with conn.cursor() as cur:
77
  cur.execute("SELECT 1 FROM ohlcv WHERE trading_date = ? LIMIT 1", (target_date,))
 
28
 
29
  @asynccontextmanager
30
  async def lifespan(app: FastAPI):
31
+ # Startup seeds only the local ticker registry. Market-network fetching is
32
+ # deliberately performed by the guarded prediction request, never here.
 
33
  from app.config import settings
34
  from database.connection import connect
35
+ from jobs.market_ingestion import sync_static_tickers
 
36
 
37
  with connect(settings.DATABASE_URL) as conn:
38
  sync_static_tickers(conn, settings.STATIC_DATA_DIR)
 
 
39
  async with scrapper_lifespan(app):
40
  yield
41
 
 
57
  }
58
 
59
 
60
+ @app.get("/predict")
61
+ def get_prediction(prediction_date: date | None = None):
62
+ """Return scheduler-produced predictions for a requested NSE trading day."""
63
+ target_date = prediction_date or pd.Timestamp.now(tz="Asia/Kolkata").date()
64
+ if target_date.weekday() >= 5:
65
+ return {"status": "not_a_trading_day", "date": target_date.isoformat()}
66
+
67
+ from app.config import settings
68
+ from database.connection import connect
69
+ with connect(settings.DATABASE_URL) as conn:
70
+ with conn.cursor() as cur:
71
+ cur.execute("SELECT 1 FROM ohlcv WHERE trading_date = ? LIMIT 1", (target_date,))
72
+ if cur.fetchone() is None:
73
+ return {"status": "not_a_trading_day", "date": target_date.isoformat()}
74
+ cur.execute("""
75
+ SELECT symbol, predicted_probability, "rank", prediction_close
76
+ FROM predictions WHERE prediction_date = ? ORDER BY "rank"
77
+ """, (target_date,))
78
+ rows = cur.fetchall()
79
+
80
+ if not rows:
81
+ raise HTTPException(status_code=404, detail="Prediction has not been generated for this trading day yet")
82
+ return {
83
+ "status": "success",
84
+ "date": target_date.isoformat(),
85
+ "rows": len(rows),
86
+ "predictions": rows,
87
+ }
88
+
89
+
90
  @app.post("/predict", dependencies=[Depends(require_pipeline_guid)])
91
  def predict(prediction_date: date | None = None):
92
 
 
95
  target_date = prediction_date or pd.Timestamp.now(tz="Asia/Kolkata").date()
96
  from app.config import settings
97
  from database.connection import connect
98
+ from jobs.market_ingestion import ingest_market_data, sync_static_tickers
99
+ from jobs.rebuild_feature_stores import rebuild_feature_stores
100
+
101
+ # Never call yfinance for weekends. This is intentionally before any
102
+ # ticker/database/network work.
103
+ if target_date.weekday() >= 5:
104
+ return {"status": "not_a_trading_day", "date": target_date.isoformat()}
105
+
106
+ today_ist = pd.Timestamp.now(tz="Asia/Kolkata").date()
107
+ if target_date > today_ist:
108
+ return {"status": "not_a_trading_day", "date": target_date.isoformat()}
109
+
110
+ # Protected manual catch-up: fetch only missing candles through the
111
+ # requested date, then rebuild the local SQLite feature/label tables.
112
+ with connect(settings.DATABASE_URL) as conn:
113
+ sync_static_tickers(conn, settings.STATIC_DATA_DIR)
114
+ ingest_market_data(conn, end_date=target_date)
115
+ rebuild_feature_stores()
116
 
117
+ # NSE candles are the holiday-aware trading-calendar authority.
 
118
  with connect(settings.DATABASE_URL) as conn:
119
  with conn.cursor() as cur:
120
  cur.execute("SELECT 1 FROM ohlcv WHERE trading_date = ? LIMIT 1", (target_date,))
jobs/daily_pipeline.py CHANGED
@@ -5,9 +5,9 @@ Runs at 16:00 IST in this exact order:
5
 
6
  1. market-data and feature rebuild
7
  2. prediction
8
- 2. resolution
9
- 3. metrics
10
- 4. retrain trigger
11
 
12
  Each step must succeed before the next step starts.
13
 
@@ -257,10 +257,3 @@ def run_pipeline() -> None:
257
  "============================================================"
258
  )
259
 
260
-
261
- # ============================================================
262
- # DIRECT EXECUTION
263
- # ============================================================
264
-
265
- if __name__ == "__main__":
266
- run_pipeline()
 
5
 
6
  1. market-data and feature rebuild
7
  2. prediction
8
+ 3. resolution
9
+ 4. metrics
10
+ 5. retrain trigger
11
 
12
  Each step must succeed before the next step starts.
13
 
 
257
  "============================================================"
258
  )
259
 
 
 
 
 
 
 
 
jobs/market_ingestion.py CHANGED
@@ -55,7 +55,9 @@ def sync_static_tickers(conn, static_dir: Path) -> int:
55
  return len(tickers)
56
 
57
 
58
- def ingest_market_data(conn, lookback_days: int = 800, overlap_days: int = 7) -> int:
 
 
59
  rows = [ticker for ticker in list_tickers(conn) if ticker["active"]]
60
  latest_dates = latest_ohlcv_dates(conn)
61
  inserted = 0
@@ -63,8 +65,15 @@ def ingest_market_data(conn, lookback_days: int = 800, overlap_days: int = 7) ->
63
  latest_date = latest_dates.get(ticker["id"])
64
  # A populated ticker is fetched only from the first missing session;
65
  # the initial history window is used once, not every day.
66
- start = (latest_date + timedelta(days=1)) if latest_date else (date.today() - timedelta(days=lookback_days))
67
- history = yf.Ticker(ticker["yfinance_symbol"]).history(start=start.isoformat(), auto_adjust=False)
 
 
 
 
 
 
 
68
  if history.empty:
69
  continue
70
  for timestamp, candle in history.iterrows():
 
55
  return len(tickers)
56
 
57
 
58
+ def ingest_market_data(conn, *, end_date: date | None = None, lookback_days: int = 800) -> int:
59
+ """Fetch each ticker only through the requested final calendar date."""
60
+ end_date = min(end_date or date.today(), date.today())
61
  rows = [ticker for ticker in list_tickers(conn) if ticker["active"]]
62
  latest_dates = latest_ohlcv_dates(conn)
63
  inserted = 0
 
65
  latest_date = latest_dates.get(ticker["id"])
66
  # A populated ticker is fetched only from the first missing session;
67
  # the initial history window is used once, not every day.
68
+ start = (latest_date + timedelta(days=1)) if latest_date else (end_date - timedelta(days=lookback_days))
69
+ # A restored database can contain candles dated after today's market
70
+ # session. yfinance rejects a start date after its end date.
71
+ if start > end_date:
72
+ continue
73
+ # yfinance's end date is exclusive, so include the requested day.
74
+ history = yf.Ticker(ticker["yfinance_symbol"]).history(
75
+ start=start.isoformat(), end=(end_date + timedelta(days=1)).isoformat(), auto_adjust=False,
76
+ )
77
  if history.empty:
78
  continue
79
  for timestamp, candle in history.iterrows():
jobs/rebuild_feature_stores.py CHANGED
@@ -88,7 +88,7 @@ def rebuild_feature_stores() -> dict[str, int]:
88
 
89
  logger.info(
90
  f"Tables BEFORE rebuild: "
91
- f"{[row[0] for row in cur.fetchall()]}"
92
  )
93
 
94
  # -------------------------------------------------
@@ -262,7 +262,7 @@ def rebuild_feature_stores() -> dict[str, int]:
262
  """)
263
 
264
  tables = [
265
- row[0]
266
  for row in cur.fetchall()
267
  ]
268
 
 
88
 
89
  logger.info(
90
  f"Tables BEFORE rebuild: "
91
+ f"{[row['name'] for row in cur.fetchall()]}"
92
  )
93
 
94
  # -------------------------------------------------
 
262
  """)
263
 
264
  tables = [
265
+ row["name"]
266
  for row in cur.fetchall()
267
  ]
268