Spaces:
Running
Running
fix bugs
Browse files- README.md +16 -3
- api/dashboard.py +1 -1
- api/market.py +1 -0
- app/main.py +52 -9
- jobs/daily_pipeline.py +3 -10
- jobs/market_ingestion.py +12 -3
- jobs/rebuild_feature_stores.py +2 -2
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
|
| 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
|
| 2303 |
-
- All returned candles are upserted
|
| 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
|
| 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 |
-
#
|
| 32 |
-
#
|
| 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
|
| 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.
|
| 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 |
-
|
| 9 |
-
|
| 10 |
-
|
| 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,
|
|
|
|
|
|
|
| 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 (
|
| 67 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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[
|
| 92 |
)
|
| 93 |
|
| 94 |
# -------------------------------------------------
|
|
@@ -262,7 +262,7 @@ def rebuild_feature_stores() -> dict[str, int]:
|
|
| 262 |
""")
|
| 263 |
|
| 264 |
tables = [
|
| 265 |
-
row[
|
| 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 |
|