Spaces:
Running
Running
remove unwanted files and create proper sql tables
Browse files- Dockerfile +2 -4
- README.md +14 -1
- app/config.py +2 -2
- app/main.py +44 -4
- database/market_repository.py +26 -6
- database/resolution_repository.py +1 -1
- database/schema.sql +90 -0
- docker/entrypoint.sh +0 -8
- evaluation/README.md +0 -40
- evaluation/resolver.py +0 -9
- feature_engine/engine.py +49 -66
- feature_engine/macro.py +0 -0
- feature_engine/nifty.py +0 -27
- feature_engine/stock.py +0 -29
- feature_engine/us_market.py +14 -73
- feature_engine/vix.py +0 -0
- jobs/healthcheck.py +0 -34
- jobs/market_ingestion.py +4 -4
- jobs/rebuild_feature_stores.py +29 -1
- jobs/resolve_predictions.py +1 -4
- jobs/run_once.py +0 -13
- pyproject.toml +0 -3
- requirements.txt +1 -2
- tests/manual_test.py +0 -29
- tests/test_metrics.py +0 -40
- tests/test_resolver.py +0 -76
- tests/test_retrain_trigger.py +0 -27
Dockerfile
CHANGED
|
@@ -2,7 +2,7 @@ FROM python:3.12-slim
|
|
| 2 |
|
| 3 |
WORKDIR /app
|
| 4 |
|
| 5 |
-
# build-essential: needed for xgboost
|
| 6 |
# The rest: Playwright Chromium runtime libs (from the scrapper service).
|
| 7 |
RUN apt-get update \
|
| 8 |
&& apt-get install -y --no-install-recommends \
|
|
@@ -42,16 +42,14 @@ RUN python -m playwright install chromium
|
|
| 42 |
COPY api ./api
|
| 43 |
COPY app ./app
|
| 44 |
COPY database ./database
|
| 45 |
-
COPY docker ./docker
|
| 46 |
COPY evaluation ./evaluation
|
| 47 |
COPY feature_engine ./feature_engine
|
| 48 |
COPY inference ./inference
|
| 49 |
COPY jobs ./jobs
|
| 50 |
COPY static_data ./static_data
|
| 51 |
-
COPY tests ./tests
|
| 52 |
COPY scrapper_service ./scrapper_service
|
| 53 |
|
| 54 |
-
RUN mkdir -p /
|
| 55 |
|
| 56 |
EXPOSE 8000
|
| 57 |
|
|
|
|
| 2 |
|
| 3 |
WORKDIR /app
|
| 4 |
|
| 5 |
+
# build-essential: needed for xgboost wheels.
|
| 6 |
# The rest: Playwright Chromium runtime libs (from the scrapper service).
|
| 7 |
RUN apt-get update \
|
| 8 |
&& apt-get install -y --no-install-recommends \
|
|
|
|
| 42 |
COPY api ./api
|
| 43 |
COPY app ./app
|
| 44 |
COPY database ./database
|
|
|
|
| 45 |
COPY evaluation ./evaluation
|
| 46 |
COPY feature_engine ./feature_engine
|
| 47 |
COPY inference ./inference
|
| 48 |
COPY jobs ./jobs
|
| 49 |
COPY static_data ./static_data
|
|
|
|
| 50 |
COPY scrapper_service ./scrapper_service
|
| 51 |
|
| 52 |
+
RUN mkdir -p /data/db
|
| 53 |
|
| 54 |
EXPOSE 8000
|
| 55 |
|
README.md
CHANGED
|
@@ -499,6 +499,19 @@ assert result.actual_return == pytest.approx(0.03)
|
|
| 499 |
|
| 500 |
# 9. Architecture: SQLite vs Market Data
|
| 501 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 502 |
A critical architectural detail:
|
| 503 |
|
| 504 |
## The database does NOT contain the engineered feature matrix
|
|
@@ -1831,7 +1844,7 @@ ORDER BY created_at DESC;
|
|
| 1831 |
Run:
|
| 1832 |
|
| 1833 |
```bash
|
| 1834 |
-
|
| 1835 |
```
|
| 1836 |
|
| 1837 |
Expected:
|
|
|
|
| 499 |
|
| 500 |
# 9. Architecture: SQLite vs Market Data
|
| 501 |
|
| 502 |
+
## Current SQLite feature pipeline
|
| 503 |
+
|
| 504 |
+
Raw data is separated into `ohlcv` (India stocks), `nifty_ohlcv`,
|
| 505 |
+
`india_vix_ohlcv`, `us_stocks_ohlcv`, and `macro_ohlcv`. Macro rows use the
|
| 506 |
+
`macro_type` values `gold`, `brent_crude_oil`, and `usd_inr`; the
|
| 507 |
+
`all_market_ohlcv` view is the unified read path.
|
| 508 |
+
|
| 509 |
+
`jobs.rebuild_feature_stores` persists the five source feature tables, the
|
| 510 |
+
model-ready `merged_features` table, and the D+1-open / next-five-closes
|
| 511 |
+
`training_labels` table in SQLite. No DuckDB dependency is used. The guarded
|
| 512 |
+
`POST /predict?prediction_date=YYYY-MM-DD` endpoint returns
|
| 513 |
+
`not_a_trading_day` when no NSE candle exists for the requested date.
|
| 514 |
+
|
| 515 |
A critical architectural detail:
|
| 516 |
|
| 517 |
## The database does NOT contain the engineered feature matrix
|
|
|
|
| 1844 |
Run:
|
| 1845 |
|
| 1846 |
```bash
|
| 1847 |
+
python -m jobs.resolve_predictions
|
| 1848 |
```
|
| 1849 |
|
| 1850 |
Expected:
|
app/config.py
CHANGED
|
@@ -39,7 +39,7 @@ class Settings:
|
|
| 39 |
STATIC_DATA_DIR = Path(
|
| 40 |
os.getenv(
|
| 41 |
"STATIC_DATA_DIR",
|
| 42 |
-
|
| 43 |
)
|
| 44 |
)
|
| 45 |
|
|
@@ -115,4 +115,4 @@ class Settings:
|
|
| 115 |
CORR_LOOKBACK_DAYS = 70
|
| 116 |
|
| 117 |
|
| 118 |
-
settings = Settings()
|
|
|
|
| 39 |
STATIC_DATA_DIR = Path(
|
| 40 |
os.getenv(
|
| 41 |
"STATIC_DATA_DIR",
|
| 42 |
+
str(Path(__file__).resolve().parents[1] / "static_data"),
|
| 43 |
)
|
| 44 |
)
|
| 45 |
|
|
|
|
| 115 |
CORR_LOOKBACK_DAYS = 70
|
| 116 |
|
| 117 |
|
| 118 |
+
settings = Settings()
|
app/main.py
CHANGED
|
@@ -1,3 +1,5 @@
|
|
|
|
|
|
|
|
| 1 |
from fastapi import Depends, FastAPI, HTTPException
|
| 2 |
from fastapi.middleware.gzip import GZipMiddleware
|
| 3 |
from dotenv import load_dotenv
|
|
@@ -24,10 +26,28 @@ from jobs.daily_prediction import (
|
|
| 24 |
)
|
| 25 |
|
| 26 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 27 |
app = FastAPI(
|
| 28 |
title="Stock Prediction Engine",
|
| 29 |
version="1.0.0",
|
| 30 |
-
lifespan=
|
| 31 |
)
|
| 32 |
|
| 33 |
app.add_middleware(GZipMiddleware, minimum_size=1000)
|
|
@@ -42,18 +62,38 @@ def health():
|
|
| 42 |
|
| 43 |
|
| 44 |
@app.post("/predict", dependencies=[Depends(require_pipeline_guid)])
|
| 45 |
-
def predict():
|
| 46 |
|
| 47 |
try:
|
| 48 |
|
| 49 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 50 |
|
| 51 |
return {
|
| 52 |
"status": "success",
|
| 53 |
"rows": len(result),
|
| 54 |
"date": str(
|
| 55 |
-
|
| 56 |
),
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 57 |
}
|
| 58 |
|
| 59 |
except Exception as exc:
|
|
|
|
| 1 |
+
from contextlib import asynccontextmanager
|
| 2 |
+
from datetime import date
|
| 3 |
from fastapi import Depends, FastAPI, HTTPException
|
| 4 |
from fastapi.middleware.gzip import GZipMiddleware
|
| 5 |
from dotenv import load_dotenv
|
|
|
|
| 26 |
)
|
| 27 |
|
| 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 |
+
|
| 46 |
+
|
| 47 |
app = FastAPI(
|
| 48 |
title="Stock Prediction Engine",
|
| 49 |
version="1.0.0",
|
| 50 |
+
lifespan=lifespan,
|
| 51 |
)
|
| 52 |
|
| 53 |
app.add_middleware(GZipMiddleware, minimum_size=1000)
|
|
|
|
| 62 |
|
| 63 |
|
| 64 |
@app.post("/predict", dependencies=[Depends(require_pipeline_guid)])
|
| 65 |
+
def predict(prediction_date: date | None = None):
|
| 66 |
|
| 67 |
try:
|
| 68 |
|
| 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,))
|
| 78 |
+
if cur.fetchone() is None:
|
| 79 |
+
return {"status": "not_a_trading_day", "date": target_date.isoformat()}
|
| 80 |
+
|
| 81 |
+
result = run_daily_prediction(pd.Timestamp(target_date))
|
| 82 |
|
| 83 |
return {
|
| 84 |
"status": "success",
|
| 85 |
"rows": len(result),
|
| 86 |
"date": str(
|
| 87 |
+
target_date
|
| 88 |
),
|
| 89 |
+
"predictions": [
|
| 90 |
+
{
|
| 91 |
+
"symbol": row.symbol,
|
| 92 |
+
"predicted_probability": float(row.predicted_probability),
|
| 93 |
+
"rank": int(row.rank),
|
| 94 |
+
}
|
| 95 |
+
for row in result.itertuples(index=False)
|
| 96 |
+
],
|
| 97 |
}
|
| 98 |
|
| 99 |
except Exception as exc:
|
database/market_repository.py
CHANGED
|
@@ -40,15 +40,35 @@ def delete_ticker(conn, ticker_id: int) -> bool:
|
|
| 40 |
return cur.rowcount == 1
|
| 41 |
|
| 42 |
|
| 43 |
-
def
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 44 |
with conn.cursor() as cur:
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 45 |
cur.execute("""
|
| 46 |
-
INSERT INTO
|
| 47 |
-
VALUES (:ticker_id, :trading_date, :open, :high, :low, :close, :volume)
|
| 48 |
ON CONFLICT (ticker_id, trading_date) DO UPDATE SET
|
| 49 |
open = excluded.open, high = excluded.high, low = excluded.low,
|
| 50 |
close = excluded.close, volume = excluded.volume, fetched_at = STRFTIME('%Y-%m-%dT%H:%M:%fZ', 'now')
|
| 51 |
-
""",
|
| 52 |
|
| 53 |
|
| 54 |
def list_ohlcv(
|
|
@@ -62,7 +82,7 @@ def list_ohlcv(
|
|
| 62 |
with conn.cursor() as cur:
|
| 63 |
cur.execute("""
|
| 64 |
SELECT t.symbol, t.market, o.trading_date, o.open, o.high, o.low, o.close, o.volume
|
| 65 |
-
FROM
|
| 66 |
WHERE (:symbol IS NULL OR t.symbol = :symbol)
|
| 67 |
AND (:market IS NULL OR t.market = :market)
|
| 68 |
AND (:start IS NULL OR o.trading_date >= :start)
|
|
@@ -78,5 +98,5 @@ def latest_ohlcv_dates(conn) -> dict[int, object]:
|
|
| 78 |
# column type for it (unlike a bare column reference) and the PARSE_DECLTYPES
|
| 79 |
# date converter never fires here — parse the returned ISO text explicitly.
|
| 80 |
with conn.cursor() as cur:
|
| 81 |
-
cur.execute("SELECT ticker_id, MAX(trading_date) AS trading_date FROM
|
| 82 |
return {row["ticker_id"]: date.fromisoformat(row["trading_date"]) for row in cur.fetchall()}
|
|
|
|
| 40 |
return cur.rowcount == 1
|
| 41 |
|
| 42 |
|
| 43 |
+
def _ohlcv_table(ticker: dict[str, Any]) -> tuple[str, str | None]:
|
| 44 |
+
if ticker["market"] == "NSE":
|
| 45 |
+
return "ohlcv", None
|
| 46 |
+
if ticker["market"] == "US":
|
| 47 |
+
return "us_stocks_ohlcv", None
|
| 48 |
+
if ticker["symbol"] == "NIFTY":
|
| 49 |
+
return "nifty_ohlcv", None
|
| 50 |
+
if ticker["symbol"] == "INDIA_VIX":
|
| 51 |
+
return "india_vix_ohlcv", None
|
| 52 |
+
macro_types = {"GOLD": "gold", "BRENT": "brent_crude_oil", "USDINR": "usd_inr"}
|
| 53 |
+
return "macro_ohlcv", macro_types[ticker["symbol"]]
|
| 54 |
+
|
| 55 |
+
|
| 56 |
+
def upsert_ohlcv(conn, ticker: dict[str, Any], candle: dict[str, Any]) -> None:
|
| 57 |
+
table, macro_type = _ohlcv_table(ticker)
|
| 58 |
with conn.cursor() as cur:
|
| 59 |
+
columns = "ticker_id, trading_date, open, high, low, close, volume"
|
| 60 |
+
values = ":ticker_id, :trading_date, :open, :high, :low, :close, :volume"
|
| 61 |
+
params = {"ticker_id": ticker["id"], **candle}
|
| 62 |
+
if macro_type:
|
| 63 |
+
columns = "ticker_id, macro_type, trading_date, open, high, low, close, volume"
|
| 64 |
+
values = ":ticker_id, :macro_type, :trading_date, :open, :high, :low, :close, :volume"
|
| 65 |
+
params["macro_type"] = macro_type
|
| 66 |
cur.execute("""
|
| 67 |
+
INSERT INTO {table} ({columns}) VALUES ({values})
|
|
|
|
| 68 |
ON CONFLICT (ticker_id, trading_date) DO UPDATE SET
|
| 69 |
open = excluded.open, high = excluded.high, low = excluded.low,
|
| 70 |
close = excluded.close, volume = excluded.volume, fetched_at = STRFTIME('%Y-%m-%dT%H:%M:%fZ', 'now')
|
| 71 |
+
""".format(table=table, columns=columns, values=values), params)
|
| 72 |
|
| 73 |
|
| 74 |
def list_ohlcv(
|
|
|
|
| 82 |
with conn.cursor() as cur:
|
| 83 |
cur.execute("""
|
| 84 |
SELECT t.symbol, t.market, o.trading_date, o.open, o.high, o.low, o.close, o.volume
|
| 85 |
+
FROM all_market_ohlcv o JOIN market_tickers t ON t.id = o.ticker_id
|
| 86 |
WHERE (:symbol IS NULL OR t.symbol = :symbol)
|
| 87 |
AND (:market IS NULL OR t.market = :market)
|
| 88 |
AND (:start IS NULL OR o.trading_date >= :start)
|
|
|
|
| 98 |
# column type for it (unlike a bare column reference) and the PARSE_DECLTYPES
|
| 99 |
# date converter never fires here — parse the returned ISO text explicitly.
|
| 100 |
with conn.cursor() as cur:
|
| 101 |
+
cur.execute("SELECT ticker_id, MAX(trading_date) AS trading_date FROM all_market_ohlcv GROUP BY ticker_id")
|
| 102 |
return {row["ticker_id"]: date.fromisoformat(row["trading_date"]) for row in cur.fetchall()}
|
database/resolution_repository.py
CHANGED
|
@@ -21,7 +21,7 @@ def fetch_future_ohlcv(conn, symbol: str, prediction_date: date, forward_days: i
|
|
| 21 |
with conn.cursor() as cur:
|
| 22 |
cur.execute("""
|
| 23 |
SELECT o.trading_date AS timestamp, o.open, o.close
|
| 24 |
-
FROM
|
| 25 |
WHERE t.symbol = ? AND t.market = 'NSE' AND o.trading_date > ?
|
| 26 |
ORDER BY o.trading_date ASC LIMIT ?
|
| 27 |
""", (symbol, prediction_date, forward_days))
|
|
|
|
| 21 |
with conn.cursor() as cur:
|
| 22 |
cur.execute("""
|
| 23 |
SELECT o.trading_date AS timestamp, o.open, o.close
|
| 24 |
+
FROM ohlcv o JOIN market_tickers t ON t.id = o.ticker_id
|
| 25 |
WHERE t.symbol = ? AND t.market = 'NSE' AND o.trading_date > ?
|
| 26 |
ORDER BY o.trading_date ASC LIMIT ?
|
| 27 |
""", (symbol, prediction_date, forward_days))
|
database/schema.sql
CHANGED
|
@@ -250,3 +250,93 @@ CREATE TABLE IF NOT EXISTS market_ohlcv (
|
|
| 250 |
|
| 251 |
CREATE INDEX IF NOT EXISTS idx_market_ohlcv_ticker_date
|
| 252 |
ON market_ohlcv (ticker_id, trading_date DESC);
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 250 |
|
| 251 |
CREATE INDEX IF NOT EXISTS idx_market_ohlcv_ticker_date
|
| 252 |
ON market_ohlcv (ticker_id, trading_date DESC);
|
| 253 |
+
|
| 254 |
+
-- ============================================================
|
| 255 |
+
-- 6. MARKET DATA BY DATASET
|
| 256 |
+
--
|
| 257 |
+
-- The legacy market_ohlcv table remains only as a migration source for an
|
| 258 |
+
-- existing installation. New reads and writes use these dataset tables.
|
| 259 |
+
-- ============================================================
|
| 260 |
+
|
| 261 |
+
CREATE TABLE IF NOT EXISTS ohlcv (
|
| 262 |
+
ticker_id INTEGER NOT NULL REFERENCES market_tickers(id) ON DELETE CASCADE,
|
| 263 |
+
trading_date DATE NOT NULL,
|
| 264 |
+
open REAL NOT NULL, high REAL NOT NULL, low REAL NOT NULL, close REAL NOT NULL,
|
| 265 |
+
volume INTEGER, source TEXT NOT NULL DEFAULT 'yfinance',
|
| 266 |
+
fetched_at TIMESTAMP NOT NULL DEFAULT (STRFTIME('%Y-%m-%dT%H:%M:%fZ', 'now')),
|
| 267 |
+
PRIMARY KEY (ticker_id, trading_date)
|
| 268 |
+
);
|
| 269 |
+
|
| 270 |
+
CREATE TABLE IF NOT EXISTS nifty_ohlcv (
|
| 271 |
+
ticker_id INTEGER NOT NULL REFERENCES market_tickers(id) ON DELETE CASCADE,
|
| 272 |
+
trading_date DATE NOT NULL,
|
| 273 |
+
open REAL NOT NULL, high REAL NOT NULL, low REAL NOT NULL, close REAL NOT NULL,
|
| 274 |
+
volume INTEGER, source TEXT NOT NULL DEFAULT 'yfinance',
|
| 275 |
+
fetched_at TIMESTAMP NOT NULL DEFAULT (STRFTIME('%Y-%m-%dT%H:%M:%fZ', 'now')),
|
| 276 |
+
PRIMARY KEY (ticker_id, trading_date)
|
| 277 |
+
);
|
| 278 |
+
|
| 279 |
+
CREATE TABLE IF NOT EXISTS india_vix_ohlcv (
|
| 280 |
+
ticker_id INTEGER NOT NULL REFERENCES market_tickers(id) ON DELETE CASCADE,
|
| 281 |
+
trading_date DATE NOT NULL,
|
| 282 |
+
open REAL NOT NULL, high REAL NOT NULL, low REAL NOT NULL, close REAL NOT NULL,
|
| 283 |
+
volume INTEGER, source TEXT NOT NULL DEFAULT 'yfinance',
|
| 284 |
+
fetched_at TIMESTAMP NOT NULL DEFAULT (STRFTIME('%Y-%m-%dT%H:%M:%fZ', 'now')),
|
| 285 |
+
PRIMARY KEY (ticker_id, trading_date)
|
| 286 |
+
);
|
| 287 |
+
|
| 288 |
+
CREATE TABLE IF NOT EXISTS us_stocks_ohlcv (
|
| 289 |
+
ticker_id INTEGER NOT NULL REFERENCES market_tickers(id) ON DELETE CASCADE,
|
| 290 |
+
trading_date DATE NOT NULL,
|
| 291 |
+
open REAL NOT NULL, high REAL NOT NULL, low REAL NOT NULL, close REAL NOT NULL,
|
| 292 |
+
volume INTEGER, source TEXT NOT NULL DEFAULT 'yfinance',
|
| 293 |
+
fetched_at TIMESTAMP NOT NULL DEFAULT (STRFTIME('%Y-%m-%dT%H:%M:%fZ', 'now')),
|
| 294 |
+
PRIMARY KEY (ticker_id, trading_date)
|
| 295 |
+
);
|
| 296 |
+
|
| 297 |
+
CREATE TABLE IF NOT EXISTS macro_ohlcv (
|
| 298 |
+
ticker_id INTEGER NOT NULL REFERENCES market_tickers(id) ON DELETE CASCADE,
|
| 299 |
+
macro_type TEXT NOT NULL CHECK (macro_type IN ('gold', 'brent_crude_oil', 'usd_inr')),
|
| 300 |
+
trading_date DATE NOT NULL,
|
| 301 |
+
open REAL NOT NULL, high REAL NOT NULL, low REAL NOT NULL, close REAL NOT NULL,
|
| 302 |
+
volume INTEGER, source TEXT NOT NULL DEFAULT 'yfinance',
|
| 303 |
+
fetched_at TIMESTAMP NOT NULL DEFAULT (STRFTIME('%Y-%m-%dT%H:%M:%fZ', 'now')),
|
| 304 |
+
PRIMARY KEY (ticker_id, trading_date)
|
| 305 |
+
);
|
| 306 |
+
|
| 307 |
+
CREATE VIEW IF NOT EXISTS all_market_ohlcv AS
|
| 308 |
+
SELECT ticker_id, trading_date, open, high, low, close, volume, source, fetched_at FROM ohlcv
|
| 309 |
+
UNION ALL SELECT ticker_id, trading_date, open, high, low, close, volume, source, fetched_at FROM nifty_ohlcv
|
| 310 |
+
UNION ALL SELECT ticker_id, trading_date, open, high, low, close, volume, source, fetched_at FROM india_vix_ohlcv
|
| 311 |
+
UNION ALL SELECT ticker_id, trading_date, open, high, low, close, volume, source, fetched_at FROM us_stocks_ohlcv
|
| 312 |
+
UNION ALL SELECT ticker_id, trading_date, open, high, low, close, volume, source, fetched_at FROM macro_ohlcv;
|
| 313 |
+
|
| 314 |
+
-- Copy legacy data exactly once when upgrading an existing database.
|
| 315 |
+
INSERT OR IGNORE INTO ohlcv SELECT o.* FROM market_ohlcv o JOIN market_tickers t ON t.id = o.ticker_id WHERE t.market = 'NSE';
|
| 316 |
+
INSERT OR IGNORE INTO nifty_ohlcv SELECT o.* FROM market_ohlcv o JOIN market_tickers t ON t.id = o.ticker_id WHERE t.symbol = 'NIFTY';
|
| 317 |
+
INSERT OR IGNORE INTO india_vix_ohlcv SELECT o.* FROM market_ohlcv o JOIN market_tickers t ON t.id = o.ticker_id WHERE t.symbol = 'INDIA_VIX';
|
| 318 |
+
INSERT OR IGNORE INTO us_stocks_ohlcv SELECT o.* FROM market_ohlcv o JOIN market_tickers t ON t.id = o.ticker_id WHERE t.market = 'US';
|
| 319 |
+
INSERT OR IGNORE INTO macro_ohlcv (ticker_id, macro_type, trading_date, open, high, low, close, volume, source, fetched_at)
|
| 320 |
+
SELECT o.ticker_id, CASE t.symbol WHEN 'GOLD' THEN 'gold' WHEN 'BRENT' THEN 'brent_crude_oil' WHEN 'USDINR' THEN 'usd_inr' END,
|
| 321 |
+
o.trading_date, o.open, o.high, o.low, o.close, o.volume, o.source, o.fetched_at
|
| 322 |
+
FROM market_ohlcv o JOIN market_tickers t ON t.id = o.ticker_id WHERE t.symbol IN ('GOLD', 'BRENT', 'USDINR');
|
| 323 |
+
|
| 324 |
+
-- Feature tables are populated by jobs/rebuild_feature_stores.py in SQLite.
|
| 325 |
+
-- Their dynamic model columns replace these minimal bootstrap definitions.
|
| 326 |
+
CREATE TABLE IF NOT EXISTS ohlcv_features (timestamp DATE NOT NULL, symbol TEXT NOT NULL);
|
| 327 |
+
CREATE TABLE IF NOT EXISTS nifty_features (timestamp DATE NOT NULL);
|
| 328 |
+
CREATE TABLE IF NOT EXISTS vix_features (timestamp DATE NOT NULL);
|
| 329 |
+
CREATE TABLE IF NOT EXISTS macro_features (timestamp DATE NOT NULL);
|
| 330 |
+
CREATE TABLE IF NOT EXISTS us_features (timestamp DATE NOT NULL, symbol TEXT NOT NULL);
|
| 331 |
+
CREATE TABLE IF NOT EXISTS merged_features (timestamp DATE NOT NULL, symbol TEXT NOT NULL);
|
| 332 |
+
|
| 333 |
+
CREATE TABLE IF NOT EXISTS training_labels (
|
| 334 |
+
timestamp DATE NOT NULL,
|
| 335 |
+
symbol TEXT NOT NULL,
|
| 336 |
+
entry_date DATE,
|
| 337 |
+
entry_open REAL,
|
| 338 |
+
max_close_5d REAL,
|
| 339 |
+
actual_return REAL,
|
| 340 |
+
label INTEGER CHECK (label IN (0, 1)),
|
| 341 |
+
PRIMARY KEY (timestamp, symbol)
|
| 342 |
+
);
|
docker/entrypoint.sh
DELETED
|
@@ -1,8 +0,0 @@
|
|
| 1 |
-
#!/bin/sh
|
| 2 |
-
set -eu
|
| 3 |
-
|
| 4 |
-
echo "Production scheduler starting..."
|
| 5 |
-
echo "Timezone: ${TZ:-Asia/Kolkata}"
|
| 6 |
-
echo "Schedule: Monday-Friday at 16:00 IST"
|
| 7 |
-
|
| 8 |
-
exec python -m jobs.scheduler
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
evaluation/README.md
DELETED
|
@@ -1,40 +0,0 @@
|
|
| 1 |
-
# Prediction Resolver
|
| 2 |
-
|
| 3 |
-
The production contract is fixed:
|
| 4 |
-
|
| 5 |
-
```text
|
| 6 |
-
Prediction D
|
| 7 |
-
|
|
| 8 |
-
+--> D+1 OPEN = entry price
|
| 9 |
-
|
|
| 10 |
-
+--> D+1 CLOSE
|
| 11 |
-
+--> D+2 CLOSE
|
| 12 |
-
+--> D+3 CLOSE
|
| 13 |
-
+--> D+4 CLOSE
|
| 14 |
-
+--> D+5 CLOSE
|
| 15 |
-
|
|
| 16 |
-
v
|
| 17 |
-
MAX(CLOSE)
|
| 18 |
-
|
|
| 19 |
-
v
|
| 20 |
-
actual_return = (max_close / entry_open) - 1
|
| 21 |
-
|
|
| 22 |
-
v
|
| 23 |
-
actual_label = 1 if actual_return >= 0.03 else 0
|
| 24 |
-
```
|
| 25 |
-
|
| 26 |
-
Important:
|
| 27 |
-
|
| 28 |
-
- `D` itself is never part of the return window.
|
| 29 |
-
- Entry is always the **next trading day's OPEN**.
|
| 30 |
-
- The maximum is taken over the **five closes D+1 through D+5**.
|
| 31 |
-
- A prediction with fewer than five future trading sessions remains unresolved.
|
| 32 |
-
- Exactly `3.0%` is a positive label.
|
| 33 |
-
- This resolver does not recalculate model features. It only resolves the outcome.
|
| 34 |
-
- The OHLCV price source must use the same adjustment convention as training.
|
| 35 |
-
|
| 36 |
-
Run tests from the project root:
|
| 37 |
-
|
| 38 |
-
```bash
|
| 39 |
-
pytest -q tests/test_resolver.py
|
| 40 |
-
```
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
evaluation/resolver.py
DELETED
|
@@ -1,9 +0,0 @@
|
|
| 1 |
-
"""Compatibility import for the canonical resolution calculation layer."""
|
| 2 |
-
|
| 3 |
-
from jobs.resolver import (
|
| 4 |
-
FORWARD_TRADING_DAYS,
|
| 5 |
-
TARGET_RETURN,
|
| 6 |
-
Resolution,
|
| 7 |
-
resolve_from_ohlcv_rows,
|
| 8 |
-
resolve_prediction_rows,
|
| 9 |
-
)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
feature_engine/engine.py
CHANGED
|
@@ -1,8 +1,8 @@
|
|
|
|
|
|
|
|
| 1 |
from __future__ import annotations
|
| 2 |
|
| 3 |
import json
|
| 4 |
-
|
| 5 |
-
import duckdb
|
| 6 |
import pandas as pd
|
| 7 |
|
| 8 |
from app.config import settings
|
|
@@ -16,78 +16,61 @@ class FeatureEngine:
|
|
| 16 |
with settings.METADATA_PATH.open() as handle:
|
| 17 |
self.required_features = set(json.load(handle)["feature_cols"])
|
| 18 |
|
| 19 |
-
def _connection(self):
|
| 20 |
-
con = duckdb.connect()
|
| 21 |
-
con.execute("PRAGMA threads=2")
|
| 22 |
-
con.execute("PRAGMA memory_limit='4GB'")
|
| 23 |
-
con.execute("PRAGMA temp_directory='/tmp/duckdb_feature_engine'")
|
| 24 |
-
return con
|
| 25 |
-
|
| 26 |
@staticmethod
|
| 27 |
-
def _read_table(
|
| 28 |
-
return pd.
|
| 29 |
|
| 30 |
-
|
| 31 |
-
|
| 32 |
-
|
|
|
|
| 33 |
|
| 34 |
-
|
| 35 |
-
|
| 36 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 37 |
|
| 38 |
-
|
| 39 |
-
|
| 40 |
-
|
| 41 |
-
|
| 42 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 43 |
try:
|
| 44 |
-
|
| 45 |
-
|
| 46 |
-
|
| 47 |
-
|
| 48 |
-
|
| 49 |
finally:
|
| 50 |
-
|
|
|
|
|
|
|
|
|
|
| 51 |
|
| 52 |
-
|
| 53 |
-
|
| 54 |
-
|
| 55 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 56 |
missing = sorted(self.required_features - set(df.columns))
|
| 57 |
if missing:
|
| 58 |
raise RuntimeError(f"Feature-store merge is missing model columns: {missing}")
|
| 59 |
return df
|
| 60 |
-
|
| 61 |
-
def _load_and_merge(self, ohlcv_df, nifty_df, vix_df, macro_df, us_wide, start_date, end_date):
|
| 62 |
-
con = self._connection()
|
| 63 |
-
try:
|
| 64 |
-
con.register("ohlcv_df", ohlcv_df)
|
| 65 |
-
con.register("nifty_df", nifty_df)
|
| 66 |
-
con.register("vix_df", vix_df)
|
| 67 |
-
con.register("macro_df", macro_df)
|
| 68 |
-
con.register("us_wide", us_wide)
|
| 69 |
-
|
| 70 |
-
nifty_columns = self._columns(con, "nifty_df")
|
| 71 |
-
vix_columns = self._columns(con, "vix_df")
|
| 72 |
-
macro_columns = self._columns(con, "macro_df")
|
| 73 |
-
|
| 74 |
-
nifty_select = [f'n."{col}" AS "nifty_{col}"' for col in nifty_columns if f"nifty_{col}" in self.required_features]
|
| 75 |
-
vix_select = [f'v."{col}"' for col in vix_columns if col != "timestamp" and col in self.required_features]
|
| 76 |
-
if "close_1" in self.required_features and "close" in vix_columns:
|
| 77 |
-
vix_select.append('v."close" AS "close_1"')
|
| 78 |
-
macro_select = [f'm."{col}"' for col in macro_columns if col != "timestamp" and col in self.required_features]
|
| 79 |
-
us_select = [f'u."{col}"' for col in us_wide.columns if col != "timestamp" and col in self.required_features]
|
| 80 |
-
selects = ["o.*", *nifty_select, *vix_select, *macro_select, *us_select]
|
| 81 |
-
query = f"""
|
| 82 |
-
SELECT {", ".join(selects)}
|
| 83 |
-
FROM ohlcv_df o
|
| 84 |
-
ASOF LEFT JOIN nifty_df n ON o.timestamp >= n.timestamp
|
| 85 |
-
ASOF LEFT JOIN vix_df v ON o.timestamp >= v.timestamp
|
| 86 |
-
ASOF LEFT JOIN macro_df m ON o.timestamp >= m.timestamp
|
| 87 |
-
ASOF LEFT JOIN us_wide u ON o.timestamp >= u.timestamp
|
| 88 |
-
WHERE o.timestamp >= ? AND o.timestamp <= ?
|
| 89 |
-
ORDER BY o.symbol, o.timestamp
|
| 90 |
-
"""
|
| 91 |
-
return con.execute(query, [start_date.date(), end_date.date()]).df()
|
| 92 |
-
finally:
|
| 93 |
-
con.close()
|
|
|
|
| 1 |
+
"""Build and persist model features using SQLite-backed feature stores only."""
|
| 2 |
+
|
| 3 |
from __future__ import annotations
|
| 4 |
|
| 5 |
import json
|
|
|
|
|
|
|
| 6 |
import pandas as pd
|
| 7 |
|
| 8 |
from app.config import settings
|
|
|
|
| 16 |
with settings.METADATA_PATH.open() as handle:
|
| 17 |
self.required_features = set(json.load(handle)["feature_cols"])
|
| 18 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 19 |
@staticmethod
|
| 20 |
+
def _read_table(conn, table_name: str) -> pd.DataFrame:
|
| 21 |
+
return pd.read_sql_query(f'SELECT * FROM "{table_name}"', conn, parse_dates=["timestamp"])
|
| 22 |
|
| 23 |
+
def _merge(self, ohlcv_df, nifty_df, vix_df, macro_df, us_features_df) -> pd.DataFrame:
|
| 24 |
+
"""Attach only information available on or before each NSE session."""
|
| 25 |
+
us_wide = build_us_wide(us_features_df)
|
| 26 |
+
base = ohlcv_df.sort_values(["timestamp", "symbol"]).copy()
|
| 27 |
|
| 28 |
+
def attach(left, right, columns):
|
| 29 |
+
if not columns:
|
| 30 |
+
return left
|
| 31 |
+
right = right[["timestamp", *columns]].sort_values("timestamp")
|
| 32 |
+
return pd.merge_asof(left.sort_values("timestamp"), right, on="timestamp", direction="backward")
|
| 33 |
+
|
| 34 |
+
nifty_cols = [c for c in nifty_df if c != "timestamp" and f"nifty_{c}" in self.required_features]
|
| 35 |
+
nifty = nifty_df.rename(columns={c: f"nifty_{c}" for c in nifty_cols})
|
| 36 |
+
base = attach(base, nifty, [f"nifty_{c}" for c in nifty_cols])
|
| 37 |
|
| 38 |
+
vix_cols = [c for c in vix_df if c != "timestamp" and c in self.required_features]
|
| 39 |
+
if "close_1" in self.required_features and "close" in vix_df:
|
| 40 |
+
vix_df = vix_df.rename(columns={"close": "close_1"})
|
| 41 |
+
vix_cols.append("close_1")
|
| 42 |
+
base = attach(base, vix_df, list(dict.fromkeys(vix_cols)))
|
| 43 |
+
base = attach(base, macro_df, [c for c in macro_df if c != "timestamp" and c in self.required_features])
|
| 44 |
+
base = attach(base, us_wide, [c for c in us_wide if c != "timestamp" and c in self.required_features])
|
| 45 |
+
return add_correlation_features(base.sort_values(["symbol", "timestamp"]).reset_index(drop=True))
|
| 46 |
+
|
| 47 |
+
def build_all_features(self, conn=None) -> pd.DataFrame:
|
| 48 |
+
owns_connection = conn is None
|
| 49 |
+
conn = conn or connect(settings.DATABASE_URL)
|
| 50 |
+
original_row_factory = conn.row_factory
|
| 51 |
+
conn.row_factory = None
|
| 52 |
try:
|
| 53 |
+
frames = [self._read_table(conn, name) for name in (
|
| 54 |
+
settings.OHLCV_FEATURES_TABLE, settings.NIFTY_FEATURES_TABLE,
|
| 55 |
+
settings.VIX_FEATURES_TABLE, settings.MACRO_FEATURES_TABLE,
|
| 56 |
+
settings.US_FEATURES_TABLE,
|
| 57 |
+
)]
|
| 58 |
finally:
|
| 59 |
+
conn.row_factory = original_row_factory
|
| 60 |
+
if owns_connection:
|
| 61 |
+
conn.close()
|
| 62 |
+
return self._merge(*frames)
|
| 63 |
|
| 64 |
+
def build_features_for_date(self, prediction_date: pd.Timestamp) -> pd.DataFrame:
|
| 65 |
+
prediction_date = pd.Timestamp(prediction_date).normalize()
|
| 66 |
+
conn = connect(settings.DATABASE_URL)
|
| 67 |
+
conn.row_factory = None
|
| 68 |
+
try:
|
| 69 |
+
df = pd.read_sql_query('SELECT * FROM merged_features WHERE timestamp = ?', conn,
|
| 70 |
+
params=(prediction_date.date().isoformat(),), parse_dates=["timestamp"])
|
| 71 |
+
finally:
|
| 72 |
+
conn.close()
|
| 73 |
missing = sorted(self.required_features - set(df.columns))
|
| 74 |
if missing:
|
| 75 |
raise RuntimeError(f"Feature-store merge is missing model columns: {missing}")
|
| 76 |
return df
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
feature_engine/macro.py
DELETED
|
File without changes
|
feature_engine/nifty.py
DELETED
|
@@ -1,27 +0,0 @@
|
|
| 1 |
-
from pathlib import Path
|
| 2 |
-
import duckdb
|
| 3 |
-
import pandas as pd
|
| 4 |
-
|
| 5 |
-
|
| 6 |
-
def load_nifty(
|
| 7 |
-
path: Path,
|
| 8 |
-
start_date: pd.Timestamp,
|
| 9 |
-
end_date: pd.Timestamp,
|
| 10 |
-
) -> pd.DataFrame:
|
| 11 |
-
|
| 12 |
-
con = duckdb.connect()
|
| 13 |
-
|
| 14 |
-
try:
|
| 15 |
-
return con.execute(
|
| 16 |
-
f"""
|
| 17 |
-
SELECT *
|
| 18 |
-
FROM read_parquet('{path.as_posix()}')
|
| 19 |
-
WHERE
|
| 20 |
-
timestamp <= '{end_date.date()}'
|
| 21 |
-
AND timestamp >= '{start_date.date()}'
|
| 22 |
-
ORDER BY timestamp
|
| 23 |
-
"""
|
| 24 |
-
).df()
|
| 25 |
-
|
| 26 |
-
finally:
|
| 27 |
-
con.close()
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
feature_engine/stock.py
DELETED
|
@@ -1,29 +0,0 @@
|
|
| 1 |
-
from pathlib import Path
|
| 2 |
-
|
| 3 |
-
import duckdb
|
| 4 |
-
import pandas as pd
|
| 5 |
-
|
| 6 |
-
|
| 7 |
-
def load_stock_features(
|
| 8 |
-
path: Path,
|
| 9 |
-
start_date: pd.Timestamp,
|
| 10 |
-
end_date: pd.Timestamp,
|
| 11 |
-
) -> pd.DataFrame:
|
| 12 |
-
|
| 13 |
-
con = duckdb.connect()
|
| 14 |
-
|
| 15 |
-
try:
|
| 16 |
-
|
| 17 |
-
query = f"""
|
| 18 |
-
SELECT *
|
| 19 |
-
FROM read_parquet('{path.as_posix()}')
|
| 20 |
-
WHERE
|
| 21 |
-
timestamp >= '{start_date.date()}'
|
| 22 |
-
AND timestamp <= '{end_date.date()}'
|
| 23 |
-
ORDER BY symbol, timestamp
|
| 24 |
-
"""
|
| 25 |
-
|
| 26 |
-
return con.execute(query).df()
|
| 27 |
-
|
| 28 |
-
finally:
|
| 29 |
-
con.close()
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
feature_engine/us_market.py
CHANGED
|
@@ -1,76 +1,17 @@
|
|
| 1 |
-
import duckdb
|
| 2 |
import pandas as pd
|
| 3 |
|
| 4 |
|
| 5 |
-
def build_us_wide(
|
| 6 |
-
|
| 7 |
-
|
| 8 |
-
|
| 9 |
-
)
|
| 10 |
-
|
| 11 |
-
|
| 12 |
-
|
| 13 |
-
|
| 14 |
-
|
| 15 |
-
|
| 16 |
-
|
| 17 |
-
|
| 18 |
-
|
| 19 |
-
AVG(ret_5d) AS us_ret_5d,
|
| 20 |
-
AVG(ret_20d) AS us_ret_20d,
|
| 21 |
-
AVG(ret_60d) AS us_ret_60d,
|
| 22 |
-
|
| 23 |
-
AVG(
|
| 24 |
-
CASE
|
| 25 |
-
WHEN ret_1d > 0 THEN 1.0
|
| 26 |
-
ELSE 0.0
|
| 27 |
-
END
|
| 28 |
-
) AS us_breadth_1d,
|
| 29 |
-
|
| 30 |
-
AVG(
|
| 31 |
-
CASE
|
| 32 |
-
WHEN ret_5d > 0 THEN 1.0
|
| 33 |
-
ELSE 0.0
|
| 34 |
-
END
|
| 35 |
-
) AS us_breadth_5d,
|
| 36 |
-
|
| 37 |
-
AVG(
|
| 38 |
-
CASE
|
| 39 |
-
WHEN ret_20d > 0 THEN 1.0
|
| 40 |
-
ELSE 0.0
|
| 41 |
-
END
|
| 42 |
-
) AS us_breadth_20d,
|
| 43 |
-
|
| 44 |
-
STDDEV(ret_1d) AS us_dispersion_1d,
|
| 45 |
-
STDDEV(ret_5d) AS us_dispersion_5d,
|
| 46 |
-
|
| 47 |
-
AVG(vol_ratio) AS us_avg_vol_ratio,
|
| 48 |
-
|
| 49 |
-
AVG(
|
| 50 |
-
CASE
|
| 51 |
-
WHEN close_sma20_ratio > 0
|
| 52 |
-
THEN 1.0
|
| 53 |
-
ELSE 0.0
|
| 54 |
-
END
|
| 55 |
-
) AS us_pct_above_sma20,
|
| 56 |
-
|
| 57 |
-
AVG(rsi_14) AS us_avg_rsi,
|
| 58 |
-
|
| 59 |
-
AVG(distance_from_52w_high)
|
| 60 |
-
AS us_avg_dist_52w_high
|
| 61 |
-
|
| 62 |
-
FROM us_features
|
| 63 |
-
|
| 64 |
-
WHERE
|
| 65 |
-
timestamp >= '{start_date.date()}'
|
| 66 |
-
AND timestamp <= '{end_date.date()}'
|
| 67 |
-
|
| 68 |
-
GROUP BY timestamp
|
| 69 |
-
|
| 70 |
-
ORDER BY timestamp
|
| 71 |
-
"""
|
| 72 |
-
|
| 73 |
-
try:
|
| 74 |
-
return con.execute(query).df()
|
| 75 |
-
finally:
|
| 76 |
-
con.close()
|
|
|
|
|
|
|
| 1 |
import pandas as pd
|
| 2 |
|
| 3 |
|
| 4 |
+
def build_us_wide(us_features_df: pd.DataFrame) -> pd.DataFrame:
|
| 5 |
+
"""Aggregate US stock features by session without DuckDB."""
|
| 6 |
+
return us_features_df.groupby("timestamp", as_index=False).agg(
|
| 7 |
+
us_ret_1d=("ret_1d", "mean"), us_ret_5d=("ret_5d", "mean"),
|
| 8 |
+
us_ret_20d=("ret_20d", "mean"), us_ret_60d=("ret_60d", "mean"),
|
| 9 |
+
us_breadth_1d=("ret_1d", lambda s: (s > 0).mean()),
|
| 10 |
+
us_breadth_5d=("ret_5d", lambda s: (s > 0).mean()),
|
| 11 |
+
us_breadth_20d=("ret_20d", lambda s: (s > 0).mean()),
|
| 12 |
+
us_dispersion_1d=("ret_1d", "std"), us_dispersion_5d=("ret_5d", "std"),
|
| 13 |
+
us_avg_vol_ratio=("vol_ratio", "mean"),
|
| 14 |
+
us_pct_above_sma20=("close_sma20_ratio", lambda s: (s > 0).mean()),
|
| 15 |
+
us_avg_rsi=("rsi_14", "mean"),
|
| 16 |
+
us_avg_dist_52w_high=("distance_from_52w_high", "mean"),
|
| 17 |
+
).sort_values("timestamp")
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
feature_engine/vix.py
DELETED
|
File without changes
|
jobs/healthcheck.py
DELETED
|
@@ -1,34 +0,0 @@
|
|
| 1 |
-
"""
|
| 2 |
-
Small process-level health check for container/orchestrator use.
|
| 3 |
-
"""
|
| 4 |
-
|
| 5 |
-
from __future__ import annotations
|
| 6 |
-
|
| 7 |
-
import os
|
| 8 |
-
import sys
|
| 9 |
-
|
| 10 |
-
from database.connection import connect
|
| 11 |
-
|
| 12 |
-
|
| 13 |
-
def main() -> int:
|
| 14 |
-
database_url = os.getenv("DATABASE_URL")
|
| 15 |
-
|
| 16 |
-
if not database_url:
|
| 17 |
-
print("UNHEALTHY: DATABASE_URL is not configured")
|
| 18 |
-
return 1
|
| 19 |
-
|
| 20 |
-
try:
|
| 21 |
-
with connect(database_url) as conn:
|
| 22 |
-
with conn.cursor() as cur:
|
| 23 |
-
cur.execute("SELECT 1")
|
| 24 |
-
cur.fetchone()
|
| 25 |
-
except Exception as exc:
|
| 26 |
-
print(f"UNHEALTHY: SQLite connection failed: {exc}")
|
| 27 |
-
return 1
|
| 28 |
-
|
| 29 |
-
print("HEALTHY")
|
| 30 |
-
return 0
|
| 31 |
-
|
| 32 |
-
|
| 33 |
-
if __name__ == "__main__":
|
| 34 |
-
sys.exit(main())
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
jobs/market_ingestion.py
CHANGED
|
@@ -61,7 +61,9 @@ def ingest_market_data(conn, lookback_days: int = 800, overlap_days: int = 7) ->
|
|
| 61 |
inserted = 0
|
| 62 |
for ticker in rows:
|
| 63 |
latest_date = latest_dates.get(ticker["id"])
|
| 64 |
-
|
|
|
|
|
|
|
| 65 |
history = yf.Ticker(ticker["yfinance_symbol"]).history(start=start.isoformat(), auto_adjust=False)
|
| 66 |
if history.empty:
|
| 67 |
continue
|
|
@@ -132,9 +134,7 @@ def ingest_market_data(conn, lookback_days: int = 800, overlap_days: int = 7) ->
|
|
| 132 |
volume_value = int(volume)
|
| 133 |
|
| 134 |
upsert_ohlcv(
|
| 135 |
-
conn,
|
| 136 |
-
ticker["id"],
|
| 137 |
-
{
|
| 138 |
"trading_date": timestamp.date(),
|
| 139 |
"open": open_price,
|
| 140 |
"high": high_price,
|
|
|
|
| 61 |
inserted = 0
|
| 62 |
for ticker in rows:
|
| 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
|
|
|
|
| 134 |
volume_value = int(volume)
|
| 135 |
|
| 136 |
upsert_ohlcv(
|
| 137 |
+
conn, ticker, {
|
|
|
|
|
|
|
| 138 |
"trading_date": timestamp.date(),
|
| 139 |
"open": open_price,
|
| 140 |
"high": high_price,
|
jobs/rebuild_feature_stores.py
CHANGED
|
@@ -7,6 +7,7 @@ from scrapper_service.logger import logger
|
|
| 7 |
from app.config import settings
|
| 8 |
from database.connection import connect
|
| 9 |
from feature_engine.training_transforms import macro, nifty, ohlcv, vix
|
|
|
|
| 10 |
|
| 11 |
|
| 12 |
def _load_candles(conn, market: str | None = None, symbols: tuple[str, ...] | None = None) -> pd.DataFrame:
|
|
@@ -20,7 +21,7 @@ def _load_candles(conn, market: str | None = None, symbols: tuple[str, ...] | No
|
|
| 20 |
params.extend(symbols)
|
| 21 |
query = f"""
|
| 22 |
SELECT o.trading_date AS timestamp, t.symbol, o.open, o.high, o.low, o.close, o.volume
|
| 23 |
-
FROM
|
| 24 |
WHERE {" AND ".join(clauses)}
|
| 25 |
ORDER BY t.symbol, o.trading_date
|
| 26 |
"""
|
|
@@ -40,6 +41,24 @@ def _write_table(df: pd.DataFrame, conn, table_name: str) -> None:
|
|
| 40 |
raise
|
| 41 |
|
| 42 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 43 |
def rebuild_feature_stores() -> dict[str, int]:
|
| 44 |
|
| 45 |
logger.info(
|
|
@@ -211,6 +230,12 @@ def rebuild_feature_stores() -> dict[str, int]:
|
|
| 211 |
settings.MACRO_FEATURES_TABLE,
|
| 212 |
)
|
| 213 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 214 |
conn.commit()
|
| 215 |
|
| 216 |
except Exception:
|
|
@@ -251,6 +276,8 @@ def rebuild_feature_stores() -> dict[str, int]:
|
|
| 251 |
settings.NIFTY_FEATURES_TABLE,
|
| 252 |
settings.VIX_FEATURES_TABLE,
|
| 253 |
settings.MACRO_FEATURES_TABLE,
|
|
|
|
|
|
|
| 254 |
]
|
| 255 |
|
| 256 |
missing = [
|
|
@@ -272,6 +299,7 @@ def rebuild_feature_stores() -> dict[str, int]:
|
|
| 272 |
"nifty": len(nifty_features),
|
| 273 |
"vix": len(vix_features),
|
| 274 |
"macro": len(macro_features),
|
|
|
|
| 275 |
}
|
| 276 |
|
| 277 |
|
|
|
|
| 7 |
from app.config import settings
|
| 8 |
from database.connection import connect
|
| 9 |
from feature_engine.training_transforms import macro, nifty, ohlcv, vix
|
| 10 |
+
from feature_engine.engine import FeatureEngine
|
| 11 |
|
| 12 |
|
| 13 |
def _load_candles(conn, market: str | None = None, symbols: tuple[str, ...] | None = None) -> pd.DataFrame:
|
|
|
|
| 21 |
params.extend(symbols)
|
| 22 |
query = f"""
|
| 23 |
SELECT o.trading_date AS timestamp, t.symbol, o.open, o.high, o.low, o.close, o.volume
|
| 24 |
+
FROM all_market_ohlcv o JOIN market_tickers t ON t.id = o.ticker_id
|
| 25 |
WHERE {" AND ".join(clauses)}
|
| 26 |
ORDER BY t.symbol, o.trading_date
|
| 27 |
"""
|
|
|
|
| 41 |
raise
|
| 42 |
|
| 43 |
|
| 44 |
+
def _build_labels(conn) -> pd.DataFrame:
|
| 45 |
+
"""Create auditable D+1-open / next-five-closes training labels in SQLite."""
|
| 46 |
+
raw = pd.read_sql_query("""
|
| 47 |
+
SELECT t.symbol, o.trading_date AS timestamp, o.open, o.close
|
| 48 |
+
FROM ohlcv o JOIN market_tickers t ON t.id = o.ticker_id
|
| 49 |
+
WHERE t.active ORDER BY t.symbol, o.trading_date
|
| 50 |
+
""", conn, parse_dates=["timestamp"])
|
| 51 |
+
raw["entry_date"] = raw.groupby("symbol")["timestamp"].shift(-1)
|
| 52 |
+
raw["entry_open"] = raw.groupby("symbol")["open"].shift(-1)
|
| 53 |
+
future_closes = [raw.groupby("symbol")["close"].shift(-i) for i in range(1, 6)]
|
| 54 |
+
raw["max_close_5d"] = pd.concat(future_closes, axis=1).max(axis=1)
|
| 55 |
+
complete = pd.concat(future_closes, axis=1).notna().all(axis=1) & raw["entry_open"].gt(0)
|
| 56 |
+
raw["actual_return"] = raw["max_close_5d"] / raw["entry_open"] - 1
|
| 57 |
+
raw["label"] = pd.NA
|
| 58 |
+
raw.loc[complete, "label"] = (raw.loc[complete, "actual_return"] >= settings.TARGET_THRESHOLD).astype(int)
|
| 59 |
+
return raw[["timestamp", "symbol", "entry_date", "entry_open", "max_close_5d", "actual_return", "label"]]
|
| 60 |
+
|
| 61 |
+
|
| 62 |
def rebuild_feature_stores() -> dict[str, int]:
|
| 63 |
|
| 64 |
logger.info(
|
|
|
|
| 230 |
settings.MACRO_FEATURES_TABLE,
|
| 231 |
)
|
| 232 |
|
| 233 |
+
# The merged matrix is also persisted in SQLite so serving never
|
| 234 |
+
# needs an in-memory DuckDB query.
|
| 235 |
+
merged_features = FeatureEngine().build_all_features(conn)
|
| 236 |
+
_write_table(merged_features, conn, "merged_features")
|
| 237 |
+
_write_table(_build_labels(conn), conn, "training_labels")
|
| 238 |
+
|
| 239 |
conn.commit()
|
| 240 |
|
| 241 |
except Exception:
|
|
|
|
| 276 |
settings.NIFTY_FEATURES_TABLE,
|
| 277 |
settings.VIX_FEATURES_TABLE,
|
| 278 |
settings.MACRO_FEATURES_TABLE,
|
| 279 |
+
"merged_features",
|
| 280 |
+
"training_labels",
|
| 281 |
]
|
| 282 |
|
| 283 |
missing = [
|
|
|
|
| 299 |
"nifty": len(nifty_features),
|
| 300 |
"vix": len(vix_features),
|
| 301 |
"macro": len(macro_features),
|
| 302 |
+
"merged": len(merged_features),
|
| 303 |
}
|
| 304 |
|
| 305 |
|
jobs/resolve_predictions.py
CHANGED
|
@@ -1,4 +1,4 @@
|
|
| 1 |
-
"""Resolve mature predictions using the
|
| 2 |
|
| 3 |
from __future__ import annotations
|
| 4 |
|
|
@@ -16,6 +16,3 @@ def main() -> None:
|
|
| 16 |
resolved = resolve_pending_predictions(conn)
|
| 17 |
print(f"Resolution complete: {resolved} predictions resolved")
|
| 18 |
|
| 19 |
-
|
| 20 |
-
if __name__ == "__main__":
|
| 21 |
-
main()
|
|
|
|
| 1 |
+
"""Resolve mature predictions using the India-stock OHLCV table."""
|
| 2 |
|
| 3 |
from __future__ import annotations
|
| 4 |
|
|
|
|
| 16 |
resolved = resolve_pending_predictions(conn)
|
| 17 |
print(f"Resolution complete: {resolved} predictions resolved")
|
| 18 |
|
|
|
|
|
|
|
|
|
jobs/run_once.py
DELETED
|
@@ -1,13 +0,0 @@
|
|
| 1 |
-
"""
|
| 2 |
-
Run the complete Phase 5 pipeline once.
|
| 3 |
-
|
| 4 |
-
Useful for:
|
| 5 |
-
- deployment smoke tests
|
| 6 |
-
- manually replaying a production day
|
| 7 |
-
- debugging without waiting for 16:00 IST
|
| 8 |
-
"""
|
| 9 |
-
|
| 10 |
-
from jobs.daily_pipeline import run_pipeline
|
| 11 |
-
|
| 12 |
-
if __name__ == "__main__":
|
| 13 |
-
run_pipeline()
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
pyproject.toml
DELETED
|
@@ -1,3 +0,0 @@
|
|
| 1 |
-
[tool.pytest.ini_options]
|
| 2 |
-
pythonpath = ["."]
|
| 3 |
-
testpaths = ["tests"]
|
|
|
|
|
|
|
|
|
|
|
|
requirements.txt
CHANGED
|
@@ -20,7 +20,6 @@ python-dotenv==1.2.2
|
|
| 20 |
|
| 21 |
pandas
|
| 22 |
numpy
|
| 23 |
-
duckdb
|
| 24 |
xgboost
|
| 25 |
scikit-learn
|
| 26 |
|
|
@@ -80,4 +79,4 @@ lxml==6.1.0
|
|
| 80 |
# ------------------------------------------------------------
|
| 81 |
|
| 82 |
uvloop==0.22.1 ; sys_platform != "win32"
|
| 83 |
-
huggingface_hub
|
|
|
|
| 20 |
|
| 21 |
pandas
|
| 22 |
numpy
|
|
|
|
| 23 |
xgboost
|
| 24 |
scikit-learn
|
| 25 |
|
|
|
|
| 79 |
# ------------------------------------------------------------
|
| 80 |
|
| 81 |
uvloop==0.22.1 ; sys_platform != "win32"
|
| 82 |
+
huggingface_hub
|
tests/manual_test.py
DELETED
|
@@ -1,29 +0,0 @@
|
|
| 1 |
-
def run_manual_test(prediction_date):
|
| 2 |
-
|
| 3 |
-
# 1. Generate today's feature rows
|
| 4 |
-
features = build_live_features(
|
| 5 |
-
as_of_date=prediction_date
|
| 6 |
-
)
|
| 7 |
-
|
| 8 |
-
# 2. Validate against model metadata
|
| 9 |
-
validate_features(features)
|
| 10 |
-
|
| 11 |
-
# 3. Run XGBoost
|
| 12 |
-
predictions = predict(features)
|
| 13 |
-
|
| 14 |
-
# 4. Rank
|
| 15 |
-
predictions = rank_predictions(predictions)
|
| 16 |
-
|
| 17 |
-
# 5. Store
|
| 18 |
-
save_predictions(predictions)
|
| 19 |
-
|
| 20 |
-
# 6. Resolve using historical future data
|
| 21 |
-
resolve_predictions(
|
| 22 |
-
as_of_date=prediction_date
|
| 23 |
-
)
|
| 24 |
-
|
| 25 |
-
# 7. Calculate metrics
|
| 26 |
-
calculate_metrics()
|
| 27 |
-
|
| 28 |
-
# 8. Check retraining condition
|
| 29 |
-
check_retrain()
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
tests/test_metrics.py
DELETED
|
@@ -1,40 +0,0 @@
|
|
| 1 |
-
from datetime import date
|
| 2 |
-
import pytest
|
| 3 |
-
from evaluation.metrics import (
|
| 4 |
-
precision_at_threshold, hit_rate, positive_rate,
|
| 5 |
-
average_return, binary_pr_auc, rank_deciles, calculate_metrics
|
| 6 |
-
)
|
| 7 |
-
|
| 8 |
-
def test_precision():
|
| 9 |
-
assert precision_at_threshold([.9,.8,.4,.2], [1,0,1,0], .5) == .5
|
| 10 |
-
|
| 11 |
-
def test_rates():
|
| 12 |
-
assert hit_rate([1,0,1,0,0]) == .4
|
| 13 |
-
assert positive_rate([1,0,1,0,0]) == .4
|
| 14 |
-
|
| 15 |
-
def test_average_return():
|
| 16 |
-
assert average_return([.03,.05,-.01]) == pytest.approx(.0233333333)
|
| 17 |
-
|
| 18 |
-
def test_pr_auc():
|
| 19 |
-
assert binary_pr_auc([.9,.8,.2,.1], [1,1,0,0]) == pytest.approx(1.0)
|
| 20 |
-
|
| 21 |
-
def test_deciles():
|
| 22 |
-
probs = list(reversed([i/100 for i in range(1,101)]))
|
| 23 |
-
deciles = rank_deciles(probs, [1]*100, [.03]*100)
|
| 24 |
-
assert len(deciles) == 10
|
| 25 |
-
assert all(d["count"] == 10 for d in deciles)
|
| 26 |
-
|
| 27 |
-
def test_complete_metrics():
|
| 28 |
-
rows = [
|
| 29 |
-
{"prediction_date":date(2026,7,1),"symbol":"AAA","predicted_probability":.9,"actual_label":1,"actual_return":.05},
|
| 30 |
-
{"prediction_date":date(2026,7,1),"symbol":"BBB","predicted_probability":.4,"actual_label":0,"actual_return":-.02},
|
| 31 |
-
{"prediction_date":date(2026,7,2),"symbol":"AAA","predicted_probability":.8,"actual_label":1,"actual_return":.04},
|
| 32 |
-
{"prediction_date":date(2026,7,2),"symbol":"BBB","predicted_probability":.3,"actual_label":0,"actual_return":-.01},
|
| 33 |
-
]
|
| 34 |
-
result = calculate_metrics(rows, threshold=.5)
|
| 35 |
-
assert result.prediction_days == 2
|
| 36 |
-
assert result.resolved_predictions == 4
|
| 37 |
-
assert result.precision == 1.0
|
| 38 |
-
assert result.hit_rate == .5
|
| 39 |
-
assert result.average_return == pytest.approx(.015)
|
| 40 |
-
assert len(result.deciles) == 10
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
tests/test_resolver.py
DELETED
|
@@ -1,76 +0,0 @@
|
|
| 1 |
-
from datetime import date
|
| 2 |
-
|
| 3 |
-
# from evaluation.resolver import resolve_prediction_rows
|
| 4 |
-
|
| 5 |
-
from jobs import resolver
|
| 6 |
-
|
| 7 |
-
|
| 8 |
-
def test_positive_prediction():
|
| 9 |
-
result = resolver.resolve_prediction_rows(
|
| 10 |
-
prediction_date=date(2026, 8, 3),
|
| 11 |
-
symbol="ABC",
|
| 12 |
-
future_rows=[
|
| 13 |
-
{"date": date(2026, 8, 4), "open": 100.0, "close": 101.0},
|
| 14 |
-
{"date": date(2026, 8, 5), "open": 101.0, "close": 102.0},
|
| 15 |
-
{"date": date(2026, 8, 6), "open": 102.0, "close": 103.0},
|
| 16 |
-
{"date": date(2026, 8, 7), "open": 103.0, "close": 104.0},
|
| 17 |
-
{"date": date(2026, 8, 10), "open": 104.0, "close": 104.0},
|
| 18 |
-
],
|
| 19 |
-
)
|
| 20 |
-
|
| 21 |
-
assert result is not None
|
| 22 |
-
assert result.entry_date == date(2026, 8, 4)
|
| 23 |
-
assert result.entry_open == 100.0
|
| 24 |
-
assert result.max_close == 104.0
|
| 25 |
-
assert result.actual_return == 0.04
|
| 26 |
-
assert result.actual_label == 1
|
| 27 |
-
|
| 28 |
-
|
| 29 |
-
def test_exact_three_percent_is_positive():
|
| 30 |
-
result = resolver.resolve_prediction_rows(
|
| 31 |
-
prediction_date=date(2026, 8, 3),
|
| 32 |
-
symbol="ABC",
|
| 33 |
-
future_rows=[
|
| 34 |
-
{"date": date(2026, 8, 4), "open": 100.0, "close": 103.0},
|
| 35 |
-
{"date": date(2026, 8, 5), "open": 103.0, "close": 101.0},
|
| 36 |
-
{"date": date(2026, 8, 6), "open": 101.0, "close": 100.0},
|
| 37 |
-
{"date": date(2026, 8, 7), "open": 100.0, "close": 99.0},
|
| 38 |
-
{"date": date(2026, 8, 10), "open": 99.0, "close": 98.0},
|
| 39 |
-
],
|
| 40 |
-
)
|
| 41 |
-
|
| 42 |
-
assert result is not None
|
| 43 |
-
assert result.actual_return == 0.03
|
| 44 |
-
assert result.actual_label == 1
|
| 45 |
-
|
| 46 |
-
|
| 47 |
-
def test_below_three_percent_is_negative():
|
| 48 |
-
result = resolver.resolve_prediction_rows(
|
| 49 |
-
prediction_date=date(2026, 8, 3),
|
| 50 |
-
symbol="ABC",
|
| 51 |
-
future_rows=[
|
| 52 |
-
{"date": date(2026, 8, 4), "open": 100.0, "close": 102.99},
|
| 53 |
-
{"date": date(2026, 8, 5), "open": 102.0, "close": 102.5},
|
| 54 |
-
{"date": date(2026, 8, 6), "open": 102.0, "close": 101.0},
|
| 55 |
-
{"date": date(2026, 8, 7), "open": 101.0, "close": 100.0},
|
| 56 |
-
{"date": date(2026, 8, 10), "open": 100.0, "close": 99.0},
|
| 57 |
-
],
|
| 58 |
-
)
|
| 59 |
-
|
| 60 |
-
assert result is not None
|
| 61 |
-
assert result.actual_return < 0.03
|
| 62 |
-
assert result.actual_label == 0
|
| 63 |
-
|
| 64 |
-
|
| 65 |
-
def test_incomplete_window_stays_unresolved():
|
| 66 |
-
result = resolver.resolve_prediction_rows(
|
| 67 |
-
prediction_date=date(2026, 8, 3),
|
| 68 |
-
symbol="ABC",
|
| 69 |
-
future_rows=[
|
| 70 |
-
{"date": date(2026, 8, 4), "open": 100.0, "close": 105.0},
|
| 71 |
-
{"date": date(2026, 8, 5), "open": 105.0, "close": 105.0},
|
| 72 |
-
{"date": date(2026, 8, 6), "open": 105.0, "close": 105.0},
|
| 73 |
-
],
|
| 74 |
-
)
|
| 75 |
-
|
| 76 |
-
assert result is None
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
tests/test_retrain_trigger.py
DELETED
|
@@ -1,27 +0,0 @@
|
|
| 1 |
-
import pytest
|
| 2 |
-
from evaluation.retrain_trigger import (
|
| 3 |
-
calculate_deterioration, evaluate_retrain_trigger
|
| 4 |
-
)
|
| 5 |
-
|
| 6 |
-
def test_15_percent_deterioration_triggers():
|
| 7 |
-
result = evaluate_retrain_trigger(0.60, 0.51)
|
| 8 |
-
assert result.should_flag is True
|
| 9 |
-
assert result.deterioration == pytest.approx(0.15)
|
| 10 |
-
|
| 11 |
-
def test_less_than_15_percent_deterioration_does_not_trigger():
|
| 12 |
-
result = evaluate_retrain_trigger(0.60, 0.52)
|
| 13 |
-
assert result.should_flag is False
|
| 14 |
-
assert result.deterioration == pytest.approx(0.1333333333)
|
| 15 |
-
|
| 16 |
-
def test_improvement_does_not_trigger():
|
| 17 |
-
result = evaluate_retrain_trigger(0.60, 0.65)
|
| 18 |
-
assert result.should_flag is False
|
| 19 |
-
assert result.deterioration < 0
|
| 20 |
-
|
| 21 |
-
def test_no_metric_does_not_trigger():
|
| 22 |
-
result = evaluate_retrain_trigger(0.60, None)
|
| 23 |
-
assert result.should_flag is False
|
| 24 |
-
assert result.deterioration is None
|
| 25 |
-
|
| 26 |
-
def test_deterioration_formula():
|
| 27 |
-
assert calculate_deterioration(0.50, 0.425) == pytest.approx(0.15)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|