@@ -0,0 +1,136 @@
|
||||
"""
|
||||
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())
|
||||
Reference in New Issue
Block a user