Spaces:
Running
Running
| #!/usr/bin/env python3 | |
| """ | |
| database.py — SQLite persistence layer for the paper trading platform. | |
| Tables: watchlist, trades, signal_accuracy | |
| DB file: paper_trading.db in the project directory | |
| """ | |
| import sqlite3 | |
| import os | |
| import shutil | |
| import threading | |
| import time | |
| from datetime import datetime, timezone | |
| from typing import Optional | |
| def _data_dir() -> str: | |
| """Return persistent data directory. Uses /data on HF Spaces, else project root.""" | |
| hf_data = "/data" | |
| if os.path.isdir(hf_data) and os.access(hf_data, os.W_OK): | |
| return hf_data | |
| return os.path.dirname(os.path.abspath(__file__)) | |
| DB_PATH = os.path.join(_data_dir(), "paper_trading.db") | |
| # Calibrated snapshot ranges (must match ai_forecast._BULL_RANGE / _BEAR_RANGE / _NEUT_RANGE). | |
| # These are applied in save_prediction_snapshot() so the DB stores what the backtest measures, | |
| # not the wide AI-generated estimates which are already in snapshot_data JSON. | |
| # INTRADAY entries MUST match ai_forecast._BULL_RANGE/_BEAR_RANGE/_NEUT_RANGE["INTRADAY"]. | |
| # Cost-clearing floors — MUST match ai_forecast._BULL_RANGE/_BEAR_RANGE/_NEUT_RANGE. | |
| _SNAP_BULL = {"INTRADAY": (0.15, 1.00), "1D": (0.25, 1.30), "3D": (0.30, 2.20), "5D": (0.40, 3.00)} | |
| _SNAP_BEAR = {"INTRADAY": (-1.00, -0.15), "1D": (-1.30, -0.25), "3D": (-2.20, -0.30), "5D": (-3.00, -0.40)} | |
| _SNAP_NEUT = {"INTRADAY": (-0.50, 0.50), "1D": (-1.5, 1.5), "3D": (-1.0, 1.0), "5D": (-1.0, 1.0)} | |
| def _calibrated_snap_range(direction: str, timeframe: str, current_price: float): | |
| """Return (target_price_lo, target_price_hi) using calibrated % ranges.""" | |
| d = (direction or "NEUTRAL").upper() | |
| tf = timeframe if timeframe in _SNAP_BULL else "1D" | |
| if d in ("BULLISH", "SLIGHTLY BULLISH"): | |
| lo_pct, hi_pct = _SNAP_BULL[tf] | |
| elif d == "BEARISH": | |
| lo_pct, hi_pct = _SNAP_BEAR[tf] | |
| else: | |
| lo_pct, hi_pct = _SNAP_NEUT[tf] | |
| return ( | |
| round(current_price * (1 + lo_pct / 100), 2), | |
| round(current_price * (1 + hi_pct / 100), 2), | |
| ) | |
| # --- HF Hub persistence (for HF Spaces free tier which has no persistent /data) --- | |
| _HF_REPO_ID = os.environ.get("HF_DATA_REPO_ID", "V1deh/papertrade-data") | |
| _HF_FILENAME = "paper_trading.db" | |
| _BACKUP_INTERVAL = 300 # upload every 5 minutes | |
| def _hf_token(): | |
| return os.environ.get("HF_TOKEN") or os.environ.get("HUGGINGFACE_HUB_TOKEN") | |
| def _hf_ensure_repo(token): | |
| try: | |
| from huggingface_hub import HfApi | |
| HfApi().create_repo( | |
| repo_id=_HF_REPO_ID, | |
| repo_type="dataset", | |
| private=True, | |
| token=token, | |
| exist_ok=True, | |
| ) | |
| except Exception: | |
| pass | |
| def _clear_journal_siblings(db_path: str) -> None: | |
| """Remove -wal/-shm siblings so a fresh main DB is never paired with a stale journal.""" | |
| for _ext in ("-wal", "-shm"): | |
| try: | |
| os.remove(db_path + _ext) | |
| except OSError: | |
| pass | |
| def _hf_download_db(): | |
| token = _hf_token() | |
| if not token: | |
| return | |
| try: | |
| from huggingface_hub import hf_hub_download | |
| # Only ensure repo exists on first-ever run; skip the extra round-trip on warm restarts. | |
| if not os.path.exists(DB_PATH): | |
| _hf_ensure_repo(token) | |
| local = hf_hub_download( | |
| repo_id=_HF_REPO_ID, | |
| filename=_HF_FILENAME, | |
| repo_type="dataset", | |
| token=token, | |
| force_download=True, # always fetch latest; stale cache causes malformed-image errors | |
| ) | |
| # Clear any stale -wal/-shm from the previous container BEFORE and AFTER overwriting the | |
| # main file. Pairing a freshly downloaded main DB with a leftover journal makes SQLite | |
| # replay an old WAL onto a different image → "database disk image is malformed". | |
| _clear_journal_siblings(DB_PATH) | |
| shutil.copy2(local, DB_PATH) | |
| _clear_journal_siblings(DB_PATH) | |
| print(f"[DB] Restored from HF Hub → {DB_PATH}", flush=True) | |
| except Exception as e: | |
| print(f"[DB] HF download skipped ({e})", flush=True) | |
| def _atomic_snapshot(dest: str) -> bool: | |
| """Write a consistent snapshot of the live DB to `dest` via checkpoint + VACUUM INTO. | |
| Uploading the live file mid-write (the old behaviour) captured torn pages → malformed | |
| restores. wal_checkpoint folds the WAL into the main file; VACUUM INTO then produces an | |
| atomic, self-consistent copy safe to upload while writers continue. | |
| """ | |
| try: | |
| if os.path.exists(dest): | |
| os.remove(dest) | |
| with _conn() as c: | |
| c.execute("PRAGMA wal_checkpoint(TRUNCATE)") | |
| c.execute("VACUUM INTO ?", (dest,)) | |
| return os.path.exists(dest) | |
| except Exception as e: | |
| print(f"[DB] snapshot failed ({e})", flush=True) | |
| return False | |
| def _backup_enabled() -> bool: | |
| """Only the real HF Space should back the DB up to the shared Hub repo. | |
| Otherwise a stray local `python app.py` that has the production HF_TOKEN in .env will | |
| also run the backup loop and clobber the Space's data (multi-writer race → data loss). | |
| HF Spaces always set SPACE_ID / SPACE_HOST. Set FORCE_DB_BACKUP=1 to override for a | |
| single, intentional non-Space writer. | |
| """ | |
| if os.environ.get("FORCE_DB_BACKUP") == "1": | |
| return True | |
| return bool(os.environ.get("SPACE_ID") or os.environ.get("SPACE_HOST")) | |
| def hf_upload_db(): | |
| if not _backup_enabled(): | |
| return | |
| token = _hf_token() | |
| if not token or not os.path.exists(DB_PATH): | |
| return | |
| snap = DB_PATH + ".backup" | |
| upload_path = snap if _atomic_snapshot(snap) else DB_PATH # fall back to live file if snapshot fails | |
| try: | |
| from huggingface_hub import HfApi | |
| HfApi().upload_file( | |
| path_or_fileobj=upload_path, | |
| path_in_repo=_HF_FILENAME, | |
| repo_id=_HF_REPO_ID, | |
| repo_type="dataset", | |
| token=token, | |
| commit_message="auto-backup", | |
| ) | |
| print("[DB] Backed up to HF Hub", flush=True) | |
| except Exception as e: | |
| print(f"[DB] HF upload failed ({e})", flush=True) | |
| finally: | |
| if upload_path == snap: | |
| try: | |
| os.remove(snap) | |
| except OSError: | |
| pass | |
| def _backup_loop(): | |
| while True: | |
| time.sleep(_BACKUP_INTERVAL) | |
| hf_upload_db() | |
| def _checkpoint_on_exit(): | |
| """Fold the WAL into the main DB on clean shutdown so no uncheckpointed -wal is left behind | |
| (a leftover -wal replayed against a restored main file is the malformed-image trigger).""" | |
| try: | |
| with _conn() as c: | |
| c.execute("PRAGMA wal_checkpoint(TRUNCATE)") | |
| except Exception: | |
| pass | |
| def setup_hf_persistence(): | |
| """Download DB from HF Hub on startup; start background upload thread.""" | |
| _hf_download_db() | |
| import atexit | |
| atexit.register(_checkpoint_on_exit) | |
| if not _backup_enabled(): | |
| print("[DB] Backup loop disabled (not an HF Space; set FORCE_DB_BACKUP=1 to override). " | |
| "Startup restore still ran; this instance will NOT upload to HF Hub.", flush=True) | |
| return | |
| threading.Thread(target=_backup_loop, daemon=True).start() | |
| def _conn() -> sqlite3.Connection: | |
| # timeout + busy_timeout give writers 5s to wait out a lock instead of erroring immediately | |
| # (Flask is threaded and several background daemons write concurrently). synchronous=NORMAL is | |
| # the safe WAL pairing. These reduce the lock-contention + torn-write surface behind corruption. | |
| conn = sqlite3.connect(DB_PATH, timeout=5.0) | |
| conn.row_factory = sqlite3.Row | |
| conn.execute("PRAGMA journal_mode=WAL") | |
| conn.execute("PRAGMA busy_timeout=5000") | |
| conn.execute("PRAGMA synchronous=NORMAL") | |
| return conn | |
| def init_db() -> None: | |
| """Create tables if they don't exist. Called on app startup.""" | |
| with _conn() as conn: | |
| conn.executescript(""" | |
| CREATE TABLE IF NOT EXISTS watchlist ( | |
| ticker TEXT PRIMARY KEY, | |
| name TEXT, | |
| added_at TEXT DEFAULT (datetime('now')) | |
| ); | |
| CREATE TABLE IF NOT EXISTS trades ( | |
| id INTEGER PRIMARY KEY AUTOINCREMENT, | |
| ticker TEXT NOT NULL, | |
| name TEXT, | |
| direction TEXT CHECK(direction IN ('LONG','SHORT')), | |
| order_type TEXT DEFAULT 'MARKET' CHECK(order_type IN ('MARKET','LIMIT')), | |
| entry_price REAL NOT NULL, | |
| shares INTEGER NOT NULL, | |
| stop_loss REAL, | |
| target REAL, | |
| strategy TEXT, | |
| timeframe TEXT, | |
| prediction_data TEXT, | |
| opened_at TEXT DEFAULT (datetime('now')), | |
| closed_at TEXT, | |
| exit_price REAL, | |
| status TEXT DEFAULT 'OPEN' CHECK(status IN ('OPEN','CLOSED','PENDING','CANCELLED')), | |
| pnl REAL, | |
| pnl_pct REAL, | |
| notes TEXT, | |
| live_price_at_entry REAL, | |
| price_deviation_pct REAL, | |
| merged_into_trade_id INTEGER, | |
| merge_confirmed_at TEXT | |
| ); | |
| CREATE TABLE IF NOT EXISTS signal_accuracy ( | |
| id INTEGER PRIMARY KEY AUTOINCREMENT, | |
| signal TEXT, | |
| timeframe TEXT, | |
| won INTEGER, | |
| pnl_pct REAL, | |
| logged_at TEXT DEFAULT (datetime('now')) | |
| ); | |
| CREATE TABLE IF NOT EXISTS prediction_snapshots ( | |
| id INTEGER PRIMARY KEY AUTOINCREMENT, | |
| ticker TEXT NOT NULL, | |
| timeframe TEXT NOT NULL, | |
| direction TEXT, | |
| confidence TEXT, | |
| target_price_lo REAL, | |
| target_price_hi REAL, | |
| predicted_return_lo REAL, | |
| predicted_return_hi REAL, | |
| current_price REAL, | |
| snapshot_source TEXT, | |
| created_at TEXT DEFAULT (datetime('now')), | |
| snapshot_data TEXT, | |
| validation_target_date TEXT, | |
| validation_status TEXT DEFAULT 'PENDING' CHECK(validation_status IN ('PENDING', 'EXPIRED', 'VALIDATED')), | |
| validation_result TEXT CHECK(validation_result IN ('HIT', 'MISS', NULL)), | |
| actual_price_at_validation REAL, | |
| actual_return_at_validation REAL, | |
| window_high REAL, | |
| window_low REAL, | |
| hit_grade TEXT, | |
| point_reached REAL, | |
| validated_at TEXT | |
| ); | |
| CREATE INDEX IF NOT EXISTS idx_trades_status ON trades(status); | |
| CREATE INDEX IF NOT EXISTS idx_trades_ticker ON trades(ticker); | |
| CREATE INDEX IF NOT EXISTS idx_signal_acc_signal ON signal_accuracy(signal); | |
| CREATE INDEX IF NOT EXISTS idx_pred_snap_ticker ON prediction_snapshots(ticker); | |
| CREATE INDEX IF NOT EXISTS idx_pred_snap_created ON prediction_snapshots(created_at); | |
| """) | |
| _migrate() | |
| def _migrate() -> None: | |
| """Upgrade an existing DB to the current schema.""" | |
| with _conn() as conn: | |
| # Check for price validation fields in trades | |
| trade_cols = {row[1] for row in conn.execute("PRAGMA table_info(trades)").fetchall()} | |
| if trade_cols: | |
| try: | |
| if "live_price_at_entry" not in trade_cols: | |
| conn.execute("ALTER TABLE trades ADD COLUMN live_price_at_entry REAL") | |
| if "price_deviation_pct" not in trade_cols: | |
| conn.execute("ALTER TABLE trades ADD COLUMN price_deviation_pct REAL") | |
| if "merged_into_trade_id" not in trade_cols: | |
| conn.execute("ALTER TABLE trades ADD COLUMN merged_into_trade_id INTEGER") | |
| if "merge_confirmed_at" not in trade_cols: | |
| conn.execute("ALTER TABLE trades ADD COLUMN merge_confirmed_at TEXT") | |
| except Exception: | |
| pass | |
| # Check for validation fields in prediction_snapshots | |
| pred_cols = {row[1] for row in conn.execute("PRAGMA table_info(prediction_snapshots)").fetchall()} | |
| if pred_cols: | |
| try: | |
| if "validation_target_date" not in pred_cols: | |
| conn.execute("ALTER TABLE prediction_snapshots ADD COLUMN validation_target_date TEXT") | |
| if "validation_status" not in pred_cols: | |
| conn.execute("ALTER TABLE prediction_snapshots ADD COLUMN validation_status TEXT DEFAULT 'PENDING'") | |
| if "validation_result" not in pred_cols: | |
| conn.execute("ALTER TABLE prediction_snapshots ADD COLUMN validation_result TEXT") | |
| if "actual_price_at_validation" not in pred_cols: | |
| conn.execute("ALTER TABLE prediction_snapshots ADD COLUMN actual_price_at_validation REAL") | |
| if "actual_return_at_validation" not in pred_cols: | |
| conn.execute("ALTER TABLE prediction_snapshots ADD COLUMN actual_return_at_validation REAL") | |
| if "validated_at" not in pred_cols: | |
| conn.execute("ALTER TABLE prediction_snapshots ADD COLUMN validated_at TEXT") | |
| if "window_high" not in pred_cols: | |
| conn.execute("ALTER TABLE prediction_snapshots ADD COLUMN window_high REAL") | |
| if "window_low" not in pred_cols: | |
| conn.execute("ALTER TABLE prediction_snapshots ADD COLUMN window_low REAL") | |
| # Graded price-prediction validation (midpoint-hit priority + reached point) | |
| if "hit_grade" not in pred_cols: | |
| conn.execute("ALTER TABLE prediction_snapshots ADD COLUMN hit_grade TEXT") | |
| if "point_reached" not in pred_cols: | |
| conn.execute("ALTER TABLE prediction_snapshots ADD COLUMN point_reached REAL") | |
| # Backfill validation status/date for old rows created before this migration. | |
| conn.execute(""" | |
| UPDATE prediction_snapshots | |
| SET validation_status = 'PENDING' | |
| WHERE validation_status IS NULL OR TRIM(validation_status) = '' | |
| """) | |
| conn.execute(""" | |
| UPDATE prediction_snapshots | |
| SET validation_target_date = CASE UPPER(timeframe) | |
| WHEN '1D' THEN DATE(created_at, '+1 day') | |
| WHEN '3D' THEN DATE(created_at, '+3 day') | |
| WHEN '5D' THEN DATE(created_at, '+5 day') | |
| ELSE DATE(created_at, '+1 day') | |
| END | |
| WHERE validation_target_date IS NULL OR TRIM(validation_target_date) = '' | |
| """) | |
| conn.execute("CREATE INDEX IF NOT EXISTS idx_pred_snap_val_target ON prediction_snapshots(validation_target_date)") | |
| conn.execute("CREATE INDEX IF NOT EXISTS idx_pred_snap_val_status ON prediction_snapshots(validation_status)") | |
| except Exception: | |
| pass # Columns may already exist | |
| # Remove same-day duplicate snapshots (same ticker/TF/direction/target_date created | |
| # on the same IST day). Use IST offset (+5:30) so predictions before/after UTC midnight | |
| # but on the same IST trading day are correctly deduplicated. | |
| # Keep the lowest id. Safe to run repeatedly — idempotent. | |
| try: | |
| conn.execute(""" | |
| DELETE FROM prediction_snapshots | |
| WHERE id NOT IN ( | |
| SELECT MIN(id) | |
| FROM prediction_snapshots | |
| GROUP BY ticker, timeframe, direction, validation_target_date, | |
| date(created_at, '+5 hours', '+30 minutes') | |
| ) | |
| """) | |
| except Exception: | |
| pass | |
| # Fix any PENDING snapshots whose target date landed on a weekend. | |
| # Saturday → +2 days (Monday), Sunday → +1 day (Monday). Idempotent. | |
| try: | |
| conn.execute(""" | |
| UPDATE prediction_snapshots | |
| SET validation_target_date = date(validation_target_date, '+2 days') | |
| WHERE validation_status = 'PENDING' | |
| AND strftime('%w', validation_target_date) = '6' | |
| """) | |
| conn.execute(""" | |
| UPDATE prediction_snapshots | |
| SET validation_target_date = date(validation_target_date, '+1 day') | |
| WHERE validation_status = 'PENDING' | |
| AND strftime('%w', validation_target_date) = '0' | |
| """) | |
| except Exception: | |
| pass | |
| # Fix any PENDING snapshots whose target date landed on an NSE weekday holiday. | |
| # SQL cannot access _NSE_HOLIDAYS, so we use a Python loop with next_trading_day(). | |
| # Idempotent — rows already on a trading day are skipped. | |
| try: | |
| from market_calendar import next_trading_day as _ntd | |
| from datetime import date as _date | |
| _holiday_rows = conn.execute( | |
| "SELECT id, validation_target_date FROM prediction_snapshots " | |
| "WHERE validation_status = 'PENDING'" | |
| ).fetchall() | |
| for _row in _holiday_rows: | |
| _raw = _row[1] | |
| if not _raw: | |
| continue | |
| try: | |
| _d = _date.fromisoformat(_raw) | |
| except ValueError: | |
| continue | |
| _fixed = _ntd(_d) | |
| if _fixed != _d: | |
| conn.execute( | |
| "UPDATE prediction_snapshots SET validation_target_date = ? WHERE id = ?", | |
| (_fixed.isoformat(), _row[0]), | |
| ) | |
| except Exception: | |
| pass | |
| cols = {row[1] for row in conn.execute("PRAGMA table_info(trades)").fetchall()} | |
| if "snapshot_id" not in cols: | |
| conn.execute("ALTER TABLE trades ADD COLUMN snapshot_id INTEGER") | |
| if "auto_close_date" not in cols: | |
| conn.execute("ALTER TABLE trades ADD COLUMN auto_close_date TEXT") | |
| if "realized_pnl" not in cols: | |
| conn.execute("ALTER TABLE trades ADD COLUMN realized_pnl REAL DEFAULT 0.0") | |
| if "cost" not in cols: | |
| conn.execute("ALTER TABLE trades ADD COLUMN cost REAL DEFAULT 0.0") | |
| if "order_type" not in cols: | |
| # Rebuild trades table to add order_type and extend the status CHECK. | |
| conn.execute("PRAGMA foreign_keys = OFF") | |
| conn.execute("ALTER TABLE trades RENAME TO _trades_old") | |
| conn.execute(""" | |
| CREATE TABLE trades ( | |
| id INTEGER PRIMARY KEY AUTOINCREMENT, | |
| ticker TEXT NOT NULL, | |
| name TEXT, | |
| direction TEXT CHECK(direction IN ('LONG','SHORT')), | |
| order_type TEXT DEFAULT 'MARKET' CHECK(order_type IN ('MARKET','LIMIT')), | |
| entry_price REAL NOT NULL, | |
| shares INTEGER NOT NULL, | |
| stop_loss REAL, | |
| target REAL, | |
| strategy TEXT, | |
| timeframe TEXT, | |
| prediction_data TEXT, | |
| opened_at TEXT DEFAULT (datetime('now')), | |
| closed_at TEXT, | |
| exit_price REAL, | |
| status TEXT DEFAULT 'OPEN' CHECK(status IN ('OPEN','CLOSED','PENDING','CANCELLED')), | |
| pnl REAL, | |
| pnl_pct REAL, | |
| notes TEXT | |
| ) | |
| """) | |
| conn.execute(""" | |
| INSERT INTO trades | |
| (id, ticker, name, direction, order_type, entry_price, shares, | |
| stop_loss, target, strategy, timeframe, prediction_data, | |
| opened_at, closed_at, exit_price, status, pnl, pnl_pct, notes) | |
| SELECT | |
| id, ticker, name, direction, 'MARKET', entry_price, shares, | |
| stop_loss, target, strategy, timeframe, prediction_data, | |
| opened_at, closed_at, exit_price, status, pnl, pnl_pct, notes | |
| FROM _trades_old | |
| """) | |
| conn.execute("DROP TABLE _trades_old") | |
| conn.execute("PRAGMA foreign_keys = ON") | |
| conn.execute("CREATE INDEX IF NOT EXISTS idx_trades_status ON trades(status)") | |
| conn.execute("CREATE INDEX IF NOT EXISTS idx_trades_ticker ON trades(ticker)") | |
| # ── WATCHLIST ───────────────────────────────────────────────────────────────── | |
| def get_watchlist() -> list[dict]: | |
| with _conn() as conn: | |
| rows = conn.execute( | |
| "SELECT ticker, name, added_at FROM watchlist ORDER BY added_at DESC" | |
| ).fetchall() | |
| return [dict(r) for r in rows] | |
| def add_to_watchlist(ticker: str, name: str) -> dict: | |
| ticker = ticker.upper().strip() | |
| if "." not in ticker: | |
| ticker += ".NS" # default to NSE if no exchange specified | |
| with _conn() as conn: | |
| conn.execute( | |
| "INSERT OR REPLACE INTO watchlist (ticker, name) VALUES (?, ?)", | |
| (ticker, name), | |
| ) | |
| return {"ticker": ticker, "name": name} | |
| def remove_from_watchlist(ticker: str) -> bool: | |
| ticker = ticker.upper().strip() | |
| with _conn() as conn: | |
| cur = conn.execute("DELETE FROM watchlist WHERE ticker = ?", (ticker,)) | |
| return cur.rowcount > 0 | |
| # ── TRADES ──────────────────────────────────────────────────────────────────── | |
| def get_open_position_value(ticker: str) -> float: | |
| """Sum of entry_price * shares for all OPEN trades of this ticker.""" | |
| ticker = ticker.upper().strip() | |
| with _conn() as c: | |
| result = c.execute(""" | |
| SELECT COALESCE(SUM(entry_price * shares), 0) | |
| FROM trades | |
| WHERE ticker = ? AND status = 'OPEN' | |
| """, (ticker,)).fetchone() | |
| return result[0] if result else 0.0 | |
| def open_trade( | |
| ticker: str, | |
| name: str, | |
| direction: str, | |
| entry_price: float, | |
| shares: int, | |
| stop_loss: Optional[float] = None, | |
| target: Optional[float] = None, | |
| strategy: Optional[str] = None, | |
| timeframe: Optional[str] = None, | |
| prediction_data: Optional[str] = None, | |
| order_type: str = "MARKET", | |
| status: str = "OPEN", | |
| snapshot_id: Optional[int] = None, | |
| auto_close_date: Optional[str] = None, | |
| live_price_at_entry: Optional[float] = None, | |
| price_deviation_pct: Optional[float] = None, | |
| merged_into_trade_id: Optional[int] = None, | |
| merge_confirmed_at: Optional[str] = None, | |
| ) -> dict: | |
| ticker = ticker.upper().strip() | |
| direction = direction.upper() | |
| order_type = order_type.upper() | |
| status = status.upper() | |
| with _conn() as conn: | |
| cur = conn.execute( | |
| """ | |
| INSERT INTO trades | |
| (ticker, name, direction, order_type, entry_price, shares, stop_loss, | |
| target, strategy, timeframe, prediction_data, status, | |
| snapshot_id, auto_close_date, live_price_at_entry, price_deviation_pct, | |
| merged_into_trade_id, merge_confirmed_at) | |
| VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) | |
| """, | |
| (ticker, name, direction, order_type, entry_price, shares, stop_loss, | |
| target, strategy, timeframe, prediction_data, status, | |
| snapshot_id, auto_close_date, live_price_at_entry, price_deviation_pct, | |
| merged_into_trade_id, merge_confirmed_at), | |
| ) | |
| trade_id = cur.lastrowid | |
| return get_trade(trade_id) | |
| def get_trade(trade_id: int) -> dict: | |
| with _conn() as conn: | |
| row = conn.execute("SELECT * FROM trades WHERE id = ?", (trade_id,)).fetchone() | |
| return dict(row) if row else {} | |
| def fill_order(trade_id: int, fill_price: Optional[float] = None) -> dict: | |
| """Transition a PENDING limit order to OPEN (filled). | |
| If fill_price is provided (the actual market price at fill time), it | |
| overwrites entry_price so that P&L calculations use the real fill price | |
| rather than the original limit price when the market gapped past it. | |
| """ | |
| filled_at = datetime.now(timezone.utc).strftime("%Y-%m-%d %H:%M:%S") | |
| with _conn() as conn: | |
| if fill_price is not None: | |
| conn.execute( | |
| "UPDATE trades SET status = 'OPEN', opened_at = ?, entry_price = ? " | |
| "WHERE id = ? AND status = 'PENDING'", | |
| (filled_at, round(fill_price, 2), trade_id), | |
| ) | |
| else: | |
| conn.execute( | |
| "UPDATE trades SET status = 'OPEN', opened_at = ? WHERE id = ? AND status = 'PENDING'", | |
| (filled_at, trade_id), | |
| ) | |
| return get_trade(trade_id) | |
| def cancel_order(trade_id: int) -> dict: | |
| """Cancel a PENDING limit order.""" | |
| with _conn() as conn: | |
| conn.execute( | |
| "UPDATE trades SET status = 'CANCELLED' WHERE id = ? AND status = 'PENDING'", | |
| (trade_id,), | |
| ) | |
| return get_trade(trade_id) | |
| def get_pending_orders() -> list[dict]: | |
| with _conn() as conn: | |
| rows = conn.execute( | |
| "SELECT * FROM trades WHERE status = 'PENDING' ORDER BY opened_at DESC" | |
| ).fetchall() | |
| return [dict(r) for r in rows] | |
| def merge_into_position(trade_id: int, add_shares: int, add_price: float) -> dict: | |
| """Average a new buy into an existing OPEN position. | |
| Recalculates weighted-average entry price and increments share count. | |
| The SELECT and UPDATE are in the same connection/transaction so SQLite's | |
| WAL serialization prevents concurrent adds from losing shares. | |
| Returns the updated trade dict. | |
| """ | |
| with _conn() as conn: | |
| row = conn.execute( | |
| "SELECT shares, entry_price, status FROM trades WHERE id = ?", (trade_id,) | |
| ).fetchone() | |
| if not row or row["status"] != "OPEN": | |
| return get_trade(trade_id) | |
| old_shares = row["shares"] | |
| old_price = row["entry_price"] | |
| total = old_shares + add_shares | |
| if total <= 0: | |
| return get_trade(trade_id) | |
| avg_price = round((old_shares * old_price + add_shares * add_price) / total, 2) | |
| conn.execute( | |
| "UPDATE trades SET shares = ?, entry_price = ? WHERE id = ? AND status = 'OPEN'", | |
| (total, avg_price, trade_id), | |
| ) | |
| return get_trade(trade_id) | |
| def close_trade(trade_id: int, exit_price: float, close_shares: Optional[int] = None) -> dict: | |
| """Close all or part of an OPEN position. | |
| If close_shares is None or equals total shares, the position is fully closed | |
| (status → CLOSED, P&L recorded). If close_shares < total shares, only that | |
| portion is exited: the share count is reduced and the position stays OPEN. | |
| signal_accuracy is written only on a full close so win-rate stays clean. | |
| """ | |
| trade = get_trade(trade_id) | |
| if not trade or trade["status"] != "OPEN": | |
| return trade | |
| entry = trade["entry_price"] | |
| total_sh = trade["shares"] | |
| direction = trade["direction"] | |
| close_sh = int(close_shares) if close_shares else total_sh | |
| close_sh = max(1, min(close_sh, total_sh)) # clamp to [1, total] | |
| if direction == "LONG": | |
| gross_pnl = (exit_price - entry) * close_sh | |
| gross_pct = (exit_price - entry) / entry * 100 | |
| else: | |
| gross_pnl = (entry - exit_price) * close_sh | |
| gross_pct = (entry - exit_price) / entry * 100 | |
| # Deduct realistic NSE round-trip transaction costs (price prediction ≠ profit). | |
| # 1D/3D swings are held overnight → delivery rates; INTRADAY → intraday rates. | |
| try: | |
| from costs import cost_pct_for_timeframe | |
| _tf = (trade.get("timeframe") or "").upper() | |
| cost_pct = cost_pct_for_timeframe("INTRADAY" if _tf == "INTRADAY" else "1D") | |
| except Exception: | |
| cost_pct = 0.0 | |
| cost_rupees = round(cost_pct / 100 * entry * close_sh, 2) | |
| pnl = round(gross_pnl - cost_rupees, 2) | |
| pnl_pct = round(gross_pct - cost_pct, 2) | |
| remaining = total_sh - close_sh | |
| if remaining > 0: | |
| # Partial close — reduce shares, accumulate realized P&L, keep OPEN. | |
| with _conn() as conn: | |
| conn.execute( | |
| """ | |
| UPDATE trades | |
| SET shares = ?, | |
| realized_pnl = COALESCE(realized_pnl, 0.0) + ? | |
| WHERE id = ? AND status = 'OPEN' | |
| """, | |
| (remaining, round(pnl, 2), trade_id), | |
| ) | |
| return get_trade(trade_id) | |
| # Full close — guard against double-close race condition by requiring | |
| # status = 'OPEN' in the UPDATE predicate. rowcount == 0 means another | |
| # thread already closed this trade; skip the signal_accuracy insert. | |
| closed_at = datetime.now(timezone.utc).strftime("%Y-%m-%d %H:%M:%S") | |
| strategy = trade.get("strategy", "") | |
| timeframe = trade.get("timeframe", "") | |
| won = 1 if pnl >= 0 else 0 | |
| signals = [s.strip() for s in strategy.split(",") if s.strip()] if strategy else ["Manual"] | |
| with _conn() as conn: | |
| cur = conn.execute( | |
| """ | |
| UPDATE trades | |
| SET exit_price = ?, closed_at = ?, status = 'CLOSED', | |
| pnl = ?, pnl_pct = ?, cost = ? | |
| WHERE id = ? AND status = 'OPEN' | |
| """, | |
| (exit_price, closed_at, round(pnl, 2), round(pnl_pct, 2), cost_rupees, trade_id), | |
| ) | |
| if cur.rowcount == 1: | |
| for sig in signals: | |
| conn.execute( | |
| "INSERT INTO signal_accuracy (signal, timeframe, won, pnl_pct) VALUES (?, ?, ?, ?)", | |
| (sig, timeframe, won, round(pnl_pct, 2)), | |
| ) | |
| return get_trade(trade_id) | |
| def save_postmortem(trade_id: int, notes: str) -> None: | |
| with _conn() as conn: | |
| conn.execute("UPDATE trades SET notes = ? WHERE id = ?", (notes, trade_id)) | |
| def get_open_trade(ticker: str, direction: str) -> Optional[dict]: | |
| """Fetch a single open trade for ticker + direction, or None if none exists.""" | |
| ticker = ticker.upper().strip() | |
| direction = direction.upper().strip() | |
| with _conn() as conn: | |
| row = conn.execute( | |
| "SELECT * FROM trades WHERE ticker = ? AND direction = ? AND status = 'OPEN' LIMIT 1", | |
| (ticker, direction) | |
| ).fetchone() | |
| return dict(row) if row else None | |
| def get_open_trades() -> list[dict]: | |
| with _conn() as conn: | |
| rows = conn.execute( | |
| "SELECT * FROM trades WHERE status = 'OPEN' ORDER BY opened_at DESC" | |
| ).fetchall() | |
| return [dict(r) for r in rows] | |
| def get_open_trades_with_live_prices() -> list[dict]: | |
| """ | |
| Fetch open trades and enrich each with current live price. | |
| Uses ThreadPoolExecutor to parallelize live price fetches (~4 workers). | |
| Includes retry logic (up to 3 attempts) for robustness. | |
| Returns trades with 'current_price' field populated for display. | |
| """ | |
| from concurrent.futures import ThreadPoolExecutor, as_completed | |
| from data_sources import fetch_live_price | |
| import logging | |
| import time as _time | |
| trades = get_open_trades() | |
| if not trades: | |
| return trades | |
| def enrich_trade(trade): | |
| """Fetch live price for a trade with retries.""" | |
| ticker = trade.get("ticker") | |
| max_retries = 3 | |
| for attempt in range(max_retries): | |
| try: | |
| # Skip if already has a price (e.g., closed trades with exit_price) | |
| if trade.get("current_price") is None: | |
| # Prefer strict real-time sources first. | |
| live_price = fetch_live_price(ticker, allow_delayed=False) | |
| if live_price is None: | |
| # Fallback to freshness-gated delayed sources (same-day | |
| # only, filtered in data_sources) so UI doesn't go blank | |
| # during temporary NSE access blocks. | |
| live_price = fetch_live_price(ticker, allow_delayed=True) | |
| if live_price is not None: | |
| trade["current_price"] = live_price | |
| return trade | |
| elif attempt < max_retries - 1: | |
| # Retry with backoff | |
| _time.sleep(0.1 * (attempt + 1)) | |
| continue | |
| else: | |
| trade["current_price"] = None | |
| return trade | |
| except Exception as e: | |
| if attempt < max_retries - 1: | |
| _time.sleep(0.1 * (attempt + 1)) | |
| continue | |
| else: | |
| logging.warning(f"Failed to fetch live price for {ticker} after {max_retries} attempts: {e}") | |
| trade["current_price"] = None | |
| return trade | |
| return trade | |
| # Parallelize live price fetches with 4 workers (increased pool for better concurrency) | |
| with ThreadPoolExecutor(max_workers=min(len(trades), 4)) as pool: | |
| futures = {pool.submit(enrich_trade, t): i for i, t in enumerate(trades)} | |
| enriched = [None] * len(trades) | |
| for fut in as_completed(futures): | |
| idx = futures[fut] | |
| try: | |
| enriched[idx] = fut.result() | |
| except Exception as e: | |
| logging.error(f"Exception in enrichment thread: {e}") | |
| enriched[idx] = trades[idx] | |
| enriched[idx]["current_price"] = None | |
| return enriched | |
| def get_trade_history() -> list[dict]: | |
| with _conn() as conn: | |
| rows = conn.execute( | |
| "SELECT * FROM trades WHERE status = 'CLOSED' ORDER BY closed_at DESC" | |
| ).fetchall() | |
| return [dict(r) for r in rows] | |
| def get_portfolio_summary() -> dict: | |
| open_trades = get_open_trades() | |
| history = get_trade_history() | |
| pending = get_pending_orders() | |
| total_invested = sum(t["entry_price"] * t["shares"] for t in open_trades) | |
| closed_pnl = sum((t["pnl"] or 0) for t in history) | |
| wins = sum(1 for t in history if (t["pnl"] or 0) >= 0) | |
| losses = len(history) - wins | |
| win_rate = round(wins / len(history) * 100, 1) if history else 0.0 | |
| return { | |
| "open_count": len(open_trades), | |
| "pending_count": len(pending), | |
| "total_invested": round(total_invested, 2), | |
| "closed_pnl": round(closed_pnl, 2), | |
| "total_trades": len(history), | |
| "wins": wins, | |
| "losses": losses, | |
| "win_rate": win_rate, | |
| } | |
| def get_ticker_history(ticker: str, n: int = 5) -> list[dict]: | |
| """Return the last n closed trades for ticker, newest first.""" | |
| ticker = ticker.upper().strip() | |
| with _conn() as conn: | |
| rows = conn.execute( | |
| """ | |
| SELECT direction, entry_price, exit_price, pnl_pct, strategy, timeframe, closed_at | |
| FROM trades | |
| WHERE ticker = ? AND status = 'CLOSED' | |
| ORDER BY closed_at DESC | |
| LIMIT ? | |
| """, | |
| (ticker, n), | |
| ).fetchall() | |
| return [dict(r) for r in rows] | |
| # ── SIGNAL ACCURACY ─────────────────────────────────────────────────────────── | |
| def get_signal_accuracy() -> list[dict]: | |
| with _conn() as conn: | |
| rows = conn.execute( | |
| """ | |
| SELECT signal, timeframe, | |
| COUNT(*) as total, | |
| SUM(won) as wins, | |
| ROUND(AVG(won)*100, 1) as win_rate, | |
| ROUND(AVG(pnl_pct), 2) as avg_pnl_pct | |
| FROM signal_accuracy | |
| GROUP BY signal, timeframe | |
| ORDER BY signal, timeframe | |
| """ | |
| ).fetchall() | |
| return [dict(r) for r in rows] | |
| # ── POSTMORTEMS ─────────────────────────────────────────────────────────────── | |
| def get_postmortems() -> list[dict]: | |
| with _conn() as conn: | |
| rows = conn.execute( | |
| """ | |
| SELECT id, ticker, name, direction, entry_price, exit_price, | |
| pnl, pnl_pct, strategy, timeframe, closed_at, notes, prediction_data | |
| FROM trades | |
| WHERE status = 'CLOSED' | |
| ORDER BY closed_at DESC | |
| LIMIT 50 | |
| """ | |
| ).fetchall() | |
| return [dict(r) for r in rows] | |
| # ── PREDICTION SNAPSHOTS (audit trail) ─────────────────────────────────────── | |
| def _trading_deadline(timeframe: str) -> str: | |
| """Return the ISO date string when a prediction for the given timeframe expires (weekends + NSE holidays skipped).""" | |
| from datetime import datetime, timedelta, timezone | |
| from market_calendar import next_trading_day | |
| tf_offset = {"INTRADAY": 0, "1D": 1, "3D": 3, "5D": 5, "1W": 7} | |
| days_offset = tf_offset.get(timeframe, 1) | |
| now_ist = datetime.now(timezone.utc).astimezone(timezone(timedelta(hours=5, minutes=30))) | |
| target_dt = now_ist + timedelta(days=days_offset) | |
| return next_trading_day(target_dt.date()).isoformat() | |
| def save_prediction_snapshot( | |
| ticker: str, | |
| timeframe: str, | |
| direction: str, | |
| confidence: str, | |
| target_price_lo: float, | |
| target_price_hi: float, | |
| predicted_return_lo: float, | |
| predicted_return_hi: float, | |
| current_price: float, | |
| snapshot_source: str = "watchlist", | |
| snapshot_data: Optional[str] = None, | |
| ) -> Optional[int]: | |
| """Save a prediction snapshot for audit trail with validation target date. Returns the | |
| snapshot ID, or None if the snapshot was intentionally skipped (see INTRADAY cutoff below).""" | |
| from datetime import datetime, timedelta, timezone | |
| import json | |
| ticker = ticker.upper().strip() | |
| if "." not in ticker: | |
| ticker += ".NS" | |
| # Calculate validation target date based on timeframe, skipping weekends and NSE holidays. | |
| # INTRADAY (offset 0) validates same-day — target_date == today (a trading day). | |
| from market_calendar import next_trading_day | |
| tf_offset = {"INTRADAY": 0, "1D": 1, "3D": 3, "5D": 5, "1W": 7} | |
| days_offset = tf_offset.get(timeframe, 1) | |
| now_utc = datetime.now(timezone.utc) | |
| now_ist = now_utc.astimezone(timezone(timedelta(hours=5, minutes=30))) | |
| # INTRADAY predictions made at/after the 15:00 IST grading cutoff (matches | |
| # app.py::_intraday_cutoff_passed) have no honest same-day window left to validate | |
| # against. Per product policy ("no next-day rollover" — see CLAUDE.md), skip saving the | |
| # audit snapshot entirely rather than silently grading it against a DIFFERENT day's | |
| # session (previously rolled validation_target_date forward a day, which scored a | |
| # narrow same-day-calibrated target against a whole extra trading day — see WHEELS.NS | |
| # 2026-07-30 incident). | |
| if timeframe == "INTRADAY" and days_offset == 0: | |
| if (now_ist.hour, now_ist.minute) >= (15, 0): | |
| return None | |
| target_dt = now_ist + timedelta(days=days_offset) | |
| target_date = next_trading_day(target_dt.date()).isoformat() | |
| with _conn() as conn: | |
| # Dedup: skip if the same ticker/timeframe/direction/target_date was already saved today. | |
| # Intentionally excludes current_price — the live price drifts throughout the day and | |
| # caused up to 4 identical-looking snapshots per ticker per session. | |
| existing = conn.execute( | |
| """ | |
| SELECT id FROM prediction_snapshots | |
| WHERE ticker = ? AND timeframe = ? AND direction = ? AND validation_target_date = ? | |
| AND date(created_at) = date('now') | |
| LIMIT 1 | |
| """, | |
| (ticker, timeframe, direction, target_date), | |
| ).fetchone() | |
| if existing: | |
| return existing["id"] | |
| cur = conn.execute( | |
| """ | |
| INSERT INTO prediction_snapshots | |
| (ticker, timeframe, direction, confidence, target_price_lo, target_price_hi, | |
| predicted_return_lo, predicted_return_hi, current_price, snapshot_source, snapshot_data, | |
| validation_target_date, validation_status) | |
| VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 'PENDING') | |
| """, | |
| (ticker, timeframe, direction, confidence, | |
| target_price_lo, target_price_hi, | |
| predicted_return_lo, predicted_return_hi, | |
| current_price, snapshot_source, snapshot_data, target_date), | |
| ) | |
| return cur.lastrowid | |
| def get_prediction_snapshots( | |
| ticker: Optional[str] = None, | |
| days: Optional[int] = 30, | |
| limit: int = 100, | |
| ) -> list[dict]: | |
| """Retrieve prediction snapshots. If ticker is None, fetch all recent snapshots. | |
| Pass days=None (or days<=0) to fetch the ENTIRE history with no time window.""" | |
| where = [] | |
| params = [] | |
| if ticker: | |
| ticker = ticker.upper().strip() | |
| if "." not in ticker: | |
| ticker += ".NS" | |
| where.append("ticker = ?") | |
| params.append(ticker) | |
| # Only apply a time window when days is a positive number; None/0 = all history. | |
| if days is not None and days > 0: | |
| where.append("datetime(created_at) > datetime('now', '-' || ? || ' days')") | |
| params.append(days) | |
| where_clause = ("WHERE " + " AND ".join(where)) if where else "" | |
| params.append(limit) | |
| with _conn() as conn: | |
| rows = conn.execute( | |
| f""" | |
| SELECT id, ticker, timeframe, direction, confidence, | |
| target_price_lo, target_price_hi, | |
| predicted_return_lo, predicted_return_hi, | |
| current_price, snapshot_source, created_at, snapshot_data, | |
| validation_target_date, validation_status, validation_result, | |
| actual_price_at_validation, actual_return_at_validation, | |
| window_high, window_low, validated_at | |
| FROM prediction_snapshots | |
| {where_clause} | |
| ORDER BY created_at DESC | |
| LIMIT ? | |
| """, | |
| params, | |
| ).fetchall() | |
| return [dict(r) for r in rows] | |
| def get_prediction_misses(days: int = 30, min_confidence: str = "MEDIUM") -> list[dict]: | |
| """Get predictions where actual price exceeded target_price_hi (miss detection).""" | |
| confidence_rank = {"LOW": 0, "MEDIUM": 1, "HIGH": 2} | |
| min_rank = confidence_rank.get(min_confidence, 1) | |
| confidence_vals = [k for k, v in confidence_rank.items() if v >= min_rank] | |
| with _conn() as conn: | |
| rows = conn.execute( | |
| f""" | |
| SELECT id, ticker, timeframe, direction, confidence, | |
| target_price_lo, target_price_hi, predicted_return_lo, predicted_return_hi, | |
| current_price, snapshot_source, created_at | |
| FROM prediction_snapshots | |
| WHERE datetime(created_at) > datetime('now', '-' || ? || ' days') | |
| AND confidence IN ({','.join(['?']*len(confidence_vals))}) | |
| ORDER BY created_at DESC | |
| LIMIT 200 | |
| """, | |
| [days] + confidence_vals, | |
| ).fetchall() | |
| return [dict(r) for r in rows] | |
| # ── VALIDATION TRACKING ──────────────────────────────────────────────────────── | |
| def get_validation_pending(limit: int = 100, due_only: bool = True) -> list[dict]: | |
| """Get pending predictions; optionally only those due by today (IST).""" | |
| from datetime import datetime, timezone, timedelta | |
| now_utc = datetime.now(timezone.utc) | |
| now_ist = now_utc.astimezone(timezone(timedelta(hours=5, minutes=30))) | |
| today_str = now_ist.strftime("%Y-%m-%d") | |
| with _conn() as conn: | |
| if due_only: | |
| rows = conn.execute( | |
| """ | |
| SELECT id, ticker, timeframe, direction, confidence, | |
| target_price_lo, target_price_hi, | |
| predicted_return_lo, predicted_return_hi, | |
| current_price, snapshot_source, created_at, validation_target_date, validation_status | |
| FROM prediction_snapshots | |
| WHERE validation_status = 'PENDING' | |
| AND validation_target_date <= ? | |
| ORDER BY validation_target_date ASC, ticker ASC | |
| LIMIT ? | |
| """, | |
| (today_str, limit), | |
| ).fetchall() | |
| else: | |
| rows = conn.execute( | |
| """ | |
| SELECT id, ticker, timeframe, direction, confidence, | |
| target_price_lo, target_price_hi, | |
| predicted_return_lo, predicted_return_hi, | |
| current_price, snapshot_source, created_at, validation_target_date, validation_status | |
| FROM prediction_snapshots | |
| WHERE validation_status = 'PENDING' | |
| ORDER BY validation_target_date ASC, ticker ASC | |
| LIMIT ? | |
| """, | |
| (limit,), | |
| ).fetchall() | |
| return [dict(r) for r in rows] | |
| def get_validation_pending_count(due_only: bool = True) -> int: | |
| """Return count of PENDING prediction snapshots; optionally only those due by today (IST).""" | |
| from datetime import datetime, timezone, timedelta | |
| now_utc = datetime.now(timezone.utc) | |
| now_ist = now_utc.astimezone(timezone(timedelta(hours=5, minutes=30))) | |
| today_str = now_ist.strftime("%Y-%m-%d") | |
| with _conn() as conn: | |
| if due_only: | |
| row = conn.execute( | |
| "SELECT COUNT(*) FROM prediction_snapshots WHERE validation_status = 'PENDING' AND validation_target_date <= ?", | |
| (today_str,), | |
| ).fetchone() | |
| else: | |
| row = conn.execute( | |
| "SELECT COUNT(*) FROM prediction_snapshots WHERE validation_status = 'PENDING'" | |
| ).fetchone() | |
| return row[0] if row else 0 | |
| def validate_prediction( | |
| snapshot_id: int, | |
| actual_price: float, | |
| actual_return: float, | |
| target_hit: bool, | |
| window_high: float = None, | |
| window_low: float = None, | |
| hit_grade: str = None, | |
| point_reached: float = None, | |
| ) -> dict: | |
| """Update a prediction snapshot with validation result. | |
| hit_grade: "MIDPOINT_HIT" | "RANGE_HIT" | "MISS" (graded price-prediction | |
| result). point_reached: the extreme price the stock actually reached toward | |
| the target. validation_result stays HIT/MISS (HIT = midpoint or range hit) | |
| for backward-compatible summaries. | |
| """ | |
| from datetime import datetime, timezone | |
| validation_result = "HIT" if target_hit else "MISS" | |
| validated_at = datetime.now(timezone.utc).strftime("%Y-%m-%d %H:%M:%S") | |
| with _conn() as conn: | |
| conn.execute( | |
| """ | |
| UPDATE prediction_snapshots | |
| SET validation_status = 'VALIDATED', | |
| validation_result = ?, | |
| actual_price_at_validation = ?, | |
| actual_return_at_validation = ?, | |
| window_high = ?, | |
| window_low = ?, | |
| hit_grade = ?, | |
| point_reached = ?, | |
| validated_at = ? | |
| WHERE id = ? | |
| """, | |
| (validation_result, actual_price, actual_return, window_high, window_low, | |
| hit_grade, point_reached, validated_at, snapshot_id), | |
| ) | |
| row = conn.execute("SELECT * FROM prediction_snapshots WHERE id = ?", (snapshot_id,)).fetchone() | |
| return dict(row) if row else {} | |
| def mark_prediction_skipped(snapshot_id: int) -> None: | |
| """Mark a prediction snapshot as SKIPPED (NO TRADE / zero-width range — not validatable).""" | |
| from datetime import datetime, timezone | |
| validated_at = datetime.now(timezone.utc).strftime("%Y-%m-%d %H:%M:%S") | |
| with _conn() as conn: | |
| conn.execute( | |
| """ | |
| UPDATE prediction_snapshots | |
| SET validation_status = 'VALIDATED', | |
| validation_result = 'SKIPPED', | |
| validated_at = ? | |
| WHERE id = ? | |
| """, | |
| (validated_at, snapshot_id), | |
| ) | |
| def mark_prediction_expired(snapshot_id: int) -> None: | |
| """Mark a prediction snapshot as EXPIRED — its price data could not be fetched after | |
| repeated attempts over multiple days (e.g. delisted/illiquid ticker, data source outage). | |
| Removes it from the PENDING queue so it stops appearing as a perpetually "overdue" | |
| validation with a stale date; does not record a HIT/MISS since no price was ever obtained.""" | |
| from datetime import datetime, timezone | |
| validated_at = datetime.now(timezone.utc).strftime("%Y-%m-%d %H:%M:%S") | |
| with _conn() as conn: | |
| conn.execute( | |
| """ | |
| UPDATE prediction_snapshots | |
| SET validation_status = 'EXPIRED', | |
| validated_at = ? | |
| WHERE id = ? | |
| """, | |
| (validated_at, snapshot_id), | |
| ) | |
| def get_validation_summary() -> dict: | |
| """Get validation hit rate summary across all history in the DB. | |
| Returns { | |
| "all": {tf: {total, hits, misses, hit_rate_pct}}, # all predictions incl. NEUTRAL | |
| "directional": {tf: {total, hits, misses, hit_rate_pct}}, # BULLISH + BEARISH only | |
| "high_conf": {tf: {total, hits, misses, hit_rate_pct}}, # HIGH-confidence directional only | |
| } | |
| `hit_rate_pct` counts a HIT as midpoint-hit OR range-hit. `midpoint_rate_pct` | |
| (when present) counts only exact midpoint hits — the strict price-prediction score. | |
| Backtest: HIGH-confidence directional calls hit ~95-97% and are the profit bucket, | |
| so the high_conf block is the one to watch for the >85% target. | |
| """ | |
| base_where = ( | |
| "validation_status = 'VALIDATED'" | |
| " AND UPPER(COALESCE(direction, '')) NOT IN ('NO TRADE', 'N/A', '', 'SKIPPED')" | |
| " AND validation_result IN ('HIT', 'MISS')" | |
| ) | |
| directional_where = base_where + " AND UPPER(direction) IN ('BULLISH', 'BEARISH', 'SLIGHTLY BULLISH', 'SLIGHTLY BEARISH')" | |
| high_conf_where = directional_where + " AND UPPER(COALESCE(confidence, '')) = 'HIGH'" | |
| # Source split: 'ml' = the standalone quantile model; anything else = the AI/LLM path. | |
| ml_where = directional_where + " AND LOWER(COALESCE(snapshot_source, '')) = 'ml'" | |
| ai_where = directional_where + " AND LOWER(COALESCE(snapshot_source, '')) <> 'ml'" | |
| with _conn() as conn: | |
| def _query(where: str): | |
| rows = conn.execute( | |
| f""" | |
| SELECT timeframe, | |
| COUNT(*) as total, | |
| SUM(CASE WHEN validation_result = 'HIT' THEN 1 ELSE 0 END) as hits, | |
| SUM(CASE WHEN validation_result = 'MISS' THEN 1 ELSE 0 END) as misses, | |
| ROUND(AVG(CASE WHEN validation_result = 'HIT' THEN 100.0 ELSE 0 END), 1) as hit_rate_pct, | |
| ROUND(AVG(CASE WHEN hit_grade = 'MIDPOINT_HIT' THEN 100.0 ELSE 0 END), 1) as midpoint_rate_pct | |
| FROM prediction_snapshots | |
| WHERE {where} | |
| GROUP BY timeframe | |
| ORDER BY timeframe | |
| """ | |
| ).fetchall() | |
| return {row["timeframe"]: dict(row) for row in rows} | |
| # Agreement bucket: ML and AI made the SAME directional call on the same | |
| # ticker/timeframe/target_date, and BOTH were validated. hit_rate_pct = how | |
| # often both hit — the highest-quality consensus signal. | |
| agree_rows = conn.execute( | |
| """ | |
| SELECT m.timeframe AS timeframe, | |
| COUNT(*) AS total, | |
| SUM(CASE WHEN m.validation_result = 'HIT' AND a.validation_result = 'HIT' THEN 1 ELSE 0 END) AS hits, | |
| ROUND(AVG(CASE WHEN m.validation_result = 'HIT' AND a.validation_result = 'HIT' THEN 100.0 ELSE 0 END), 1) AS hit_rate_pct | |
| FROM prediction_snapshots m | |
| JOIN prediction_snapshots a | |
| ON m.ticker = a.ticker AND m.timeframe = a.timeframe | |
| AND m.validation_target_date = a.validation_target_date | |
| AND UPPER(m.direction) = UPPER(a.direction) | |
| WHERE LOWER(COALESCE(m.snapshot_source, '')) = 'ml' | |
| AND LOWER(COALESCE(a.snapshot_source, '')) <> 'ml' | |
| AND m.validation_status = 'VALIDATED' AND a.validation_status = 'VALIDATED' | |
| AND m.validation_result IN ('HIT', 'MISS') AND a.validation_result IN ('HIT', 'MISS') | |
| AND UPPER(m.direction) IN ('BULLISH', 'BEARISH', 'SLIGHTLY BULLISH', 'SLIGHTLY BEARISH') | |
| GROUP BY m.timeframe | |
| ORDER BY m.timeframe | |
| """ | |
| ).fetchall() | |
| agreement = {r["timeframe"]: dict(r) for r in agree_rows} | |
| return { | |
| "all": _query(base_where), | |
| "directional": _query(directional_where), | |
| "high_conf": _query(high_conf_where), | |
| "by_source": {"ml": _query(ml_where), "ai": _query(ai_where)}, | |
| "agreement": agreement, | |
| } | |
| def get_validation_history(timeframe: Optional[str] = None, limit: int = 100) -> list[dict]: | |
| """Get validated predictions with results.""" | |
| tf_filter = "" | |
| params = [] | |
| if timeframe: | |
| tf_filter = "AND timeframe = ?" | |
| params.append(timeframe) | |
| params.append(limit) | |
| with _conn() as conn: | |
| rows = conn.execute( | |
| f""" | |
| SELECT id, ticker, timeframe, direction, confidence, | |
| target_price_lo, target_price_hi, | |
| predicted_return_lo, predicted_return_hi, | |
| current_price, actual_price_at_validation, actual_return_at_validation, | |
| window_high, window_low, hit_grade, point_reached, | |
| snapshot_source, | |
| validation_result, created_at, validated_at, validation_target_date | |
| FROM prediction_snapshots | |
| WHERE validation_status = 'VALIDATED' | |
| {tf_filter} | |
| ORDER BY validated_at DESC | |
| LIMIT ? | |
| """, | |
| params, | |
| ).fetchall() | |
| return [dict(r) for r in rows] | |
| def prune_validated_snapshots(keep_days: int = 365) -> int: | |
| """Delete VALIDATED/EXPIRED prediction_snapshots older than keep_days. | |
| Keeps a full year in the DB so stat cards always reflect cumulative history. | |
| Learnings.json also accumulates these records as a secondary backup. | |
| """ | |
| with _conn() as conn: | |
| cur = conn.execute( | |
| "DELETE FROM prediction_snapshots " | |
| "WHERE validation_status IN ('VALIDATED', 'EXPIRED') " | |
| "AND datetime(validated_at) < datetime('now', ?)", | |
| (f"-{int(keep_days)} days",), | |
| ) | |
| return cur.rowcount | |
| def recalibrate_all_snapshots() -> dict: | |
| """Retroactively update all snapshots to use calibrated target ranges. | |
| For VALIDATED snapshots that have window_high/window_low, re-evaluates the hit result. | |
| For PENDING snapshots, just updates lo/hi so future validation uses calibrated ranges. | |
| Returns {updated_targets, revalidated, flipped_to_hit, flipped_to_miss}. | |
| """ | |
| from datetime import datetime, timezone | |
| def _hit(direction, window_high, window_low, actual_price, lo, hi): | |
| # Range intersection: intraday band [window_low, window_high] overlaps [lo, hi] | |
| if window_high is None or window_low is None: | |
| return actual_price is not None and lo <= actual_price <= hi | |
| return window_high >= lo and window_low <= hi | |
| now = datetime.now(timezone.utc).strftime("%Y-%m-%d %H:%M:%S") | |
| revalidated = flipped_to_hit = flipped_to_miss = 0 | |
| with _conn() as conn: | |
| rows = conn.execute( | |
| """ | |
| SELECT id, direction, validation_status, validation_result, | |
| window_high, window_low, actual_price_at_validation, | |
| target_price_lo, target_price_hi | |
| FROM prediction_snapshots | |
| WHERE UPPER(COALESCE(direction, '')) NOT IN ('NO TRADE', 'N/A', '') | |
| AND validation_status = 'VALIDATED' | |
| AND validation_result IN ('HIT', 'MISS') | |
| """ | |
| ).fetchall() | |
| for row in rows: | |
| snap_id = row["id"] | |
| direction = row["direction"] | |
| lo = row["target_price_lo"] | |
| hi = row["target_price_hi"] | |
| wh = row["window_high"] | |
| wl = row["window_low"] | |
| ap = row["actual_price_at_validation"] | |
| if not lo or not hi or lo == hi: | |
| continue | |
| new_hit = _hit(direction, wh, wl, ap, lo, hi) | |
| new_result = "HIT" if new_hit else "MISS" | |
| old_result = row["validation_result"] | |
| if new_result != old_result: | |
| conn.execute( | |
| "UPDATE prediction_snapshots SET validation_result=?, validated_at=? WHERE id=?", | |
| (new_result, now, snap_id), | |
| ) | |
| if new_result == "HIT": | |
| flipped_to_hit += 1 | |
| else: | |
| flipped_to_miss += 1 | |
| revalidated += 1 | |
| return { | |
| "updated_targets": 0, | |
| "revalidated": revalidated, | |
| "flipped_to_hit": flipped_to_hit, | |
| "flipped_to_miss": flipped_to_miss, | |
| } | |