137 lines
5.2 KiB
Python
137 lines
5.2 KiB
Python
"""
|
|
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())
|