Files
iddaai-be/ai-engine/scripts/capture_closing_odds.py
T
fahricansecer 9e41407cb5
Deploy Iddaai Backend / build-and-deploy (push) Successful in 35s
gg3
2026-06-05 00:36:24 +03:00

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())