""" Capture Closing Odds — snapshot #2 of the minimal 2-snapshot CLV system. ======================================================================= WHY: CLV (closing line value) is the only reliable proof of betting edge. This codebase never captured it: odds are stored as a single static snapshot and `odds_history` is empty. But the live sync (DataFetcherTask CRON 1) DOES refresh `live_matches.odds` every 15 min before kickoff, and prediction_runs already store the bet-time odds blob (odds_snapshot.odds, source=live_match). This script supplies the missing half: just before kickoff it copies the *current* live odds blob onto the match's latest prediction_run as `odds_snapshot.closing_odds`. Later, CLV per bet = bet-time pick odds vs closing pick odds (computed in live_scoreboard.py once enough data exists). Run it every ~15 min (e.g. alongside the existing sync, or its own cron): python scripts/capture_closing_odds.py # default 25-min window python scripts/capture_closing_odds.py --window-min 20 --dry-run Structure-agnostic: stores the whole live odds blob; no pick parsing here. Idempotent: skips runs that already have closing_odds. Only ADDS a JSON key, never deletes. Safe to run repeatedly. ⚠️ Needs one supervised test run against a live DB with upcoming matches before scheduling (DB was down at authoring time). """ from __future__ import annotations import argparse import json import os import sys import time from datetime import datetime, timezone if sys.stdout and hasattr(sys.stdout, "reconfigure"): try: sys.stdout.reconfigure(encoding="utf-8") except Exception: pass SCRIPT_DIR = os.path.dirname(os.path.abspath(__file__)) AI_ENGINE_DIR = os.path.dirname(SCRIPT_DIR) sys.path.insert(0, AI_ENGINE_DIR) from data.db import get_clean_dsn # noqa: E402 import psycopg2 # noqa: E402 from psycopg2.extras import RealDictCursor # noqa: E402 def main() -> int: ap = argparse.ArgumentParser(description=__doc__) ap.add_argument("--window-min", type=int, default=25, help="Capture matches kicking off within the next N minutes (default 25)") ap.add_argument("--grace-min", type=int, default=10, help="Also include matches that kicked off up to N min ago (default 10)") ap.add_argument("--dry-run", action="store_true", help="Report what would be captured without writing") args = ap.parse_args() now_ms = int(time.time() * 1000) lo_ms = now_ms - args.grace_min * 60 * 1000 hi_ms = now_ms + args.window_min * 60 * 1000 captured = skipped = no_run = 0 with psycopg2.connect(get_clean_dsn()) as conn: with conn.cursor(cursor_factory=RealDictCursor) as cur: # Upcoming/just-started live matches that still hold pre-kickoff odds. cur.execute( """ SELECT id, mst_utc, odds FROM live_matches WHERE odds IS NOT NULL AND mst_utc BETWEEN %s AND %s ORDER BY mst_utc ASC """, (lo_ms, hi_ms), ) matches = cur.fetchall() print(f"[capture_closing_odds] window={args.window_min}m grace={args.grace_min}m " f"upcoming_with_odds={len(matches)} dry_run={args.dry_run}") for m in matches: mid = m["id"] cur.execute( """ SELECT id, odds_snapshot FROM prediction_runs WHERE match_id = %s ORDER BY generated_at DESC LIMIT 1 """, (mid,), ) run = cur.fetchone() if not run: no_run += 1 continue snap = run["odds_snapshot"] or {} if isinstance(snap, str): try: snap = json.loads(snap) except Exception: snap = {} if snap.get("closing_odds") is not None: skipped += 1 continue patch = { "closing_odds": m["odds"], "closing_captured_at": datetime.now(timezone.utc).isoformat(), "closing_mst_utc": m["mst_utc"], "closing_source": "live_match", } if args.dry_run: captured += 1 print(f" would capture match={mid} run_id={run['id']} mst_utc={m['mst_utc']}") continue cur.execute( """ UPDATE prediction_runs SET odds_snapshot = COALESCE(odds_snapshot, '{}'::jsonb) || %s::jsonb WHERE id = %s """, (json.dumps(patch, default=str), run["id"]), ) captured += 1 if not args.dry_run: conn.commit() print(f"[capture_closing_odds] captured={captured} already_had={skipped} " f"no_prediction_run={no_run}") return 0 if __name__ == "__main__": raise SystemExit(main())