/** * =================================================== * HISTORICAL PREDICTION BACKFILL * =================================================== * Replays v28-pro-max (or any engine) against historical * finished matches to populate prediction_runs with * payload_summary + odds_snapshot + eventual_outcome + * unit_profit, so we can compute true ROI / calibration. * * Requires ENABLE_BACKFILL=true to prevent accidental run. * * Usage: * ENABLE_BACKFILL=true npx ts-node --transpile-only \ * -r tsconfig-paths/register src/scripts/backfill-prediction-runs.ts \ * --from 2023-05-01 --to 2025-12-31 --limit 50000 --sport football */ import { PrismaClient } from "@prisma/client"; import axios from "axios"; import { resolveOutcomeForPick, computeUnitProfit, type MatchResult, } from "../tasks/prediction-settlement.market-resolver"; const prisma = new PrismaClient(); const AI_ENGINE_URL = process.env.AI_ENGINE_URL || "http://127.0.0.1:3005"; const REQUEST_DELAY_MS = Number(process.env.BACKFILL_DELAY_MS ?? 300); const CONCURRENCY = Number(process.env.BACKFILL_CONCURRENCY ?? 2); function parseArgs() { const args = process.argv.slice(2); const opts: Record = {}; for (let i = 0; i < args.length; i += 2) { opts[args[i].replace(/^--/, "")] = args[i + 1]; } return { from: opts.from ?? "2023-05-01", to: opts.to ?? new Date().toISOString().slice(0, 10), limit: Number(opts.limit ?? 50000), sport: (opts.sport ?? "football") as "football" | "basketball", engine: opts.engine ?? "v28-pro-max", }; } function sleep(ms: number) { return new Promise((r) => setTimeout(r, ms)); } async function runOne(matchId: string, engine: string): Promise { try { const resp = await axios.post( `${AI_ENGINE_URL}/v20plus/analyze/${matchId}`, { engine_version: engine }, { timeout: 25000 }, ); return resp.data; } catch (err: any) { console.error( ` āœ— ${matchId} ${err?.response?.status ?? ""} ${err?.message ?? ""}`, ); return null; } } function buildPayloadSummary(payload: any): Record { const main = payload?.main_pick ?? null; const value = payload?.value_pick ?? null; const advice = payload?.bet_advice ?? {}; return { model_version: payload?.model_version, decision_trace_id: payload?.decision_trace_id ?? null, main_pick: main ? { market: main.market, pick: main.pick, playable: main.playable, bet_grade: main.bet_grade, calibrated_confidence: main.calibrated_confidence, ev_edge: main.ev_edge ?? 0, stake_units: main.stake_units, odds: main.odds ?? null, } : null, value_pick: value ? { market: value.market, pick: value.pick, playable: value.playable, calibrated_confidence: value.calibrated_confidence, ev_edge: value.ev_edge ?? 0, odds: value.odds ?? null, } : null, bet_advice: { playable: advice.playable ?? false, suggested_stake_units: advice.suggested_stake_units ?? 0, }, }; } function settleFromPayload( payload: any, result: MatchResult, ): { outcome: string; unitProfit: number } { const advice = payload?.bet_advice ?? {}; if (advice.playable !== true) return { outcome: "NO_BET", unitProfit: 0 }; const main = payload?.main_pick; if (!main || !main.playable) return { outcome: "NO_BET", unitProfit: 0 }; const pickRef = { market: String(main.market), pick: String(main.pick), stake_units: Number(main.stake_units ?? advice.suggested_stake_units ?? 1), odds: Number(main.odds ?? 0) || null, }; const won = resolveOutcomeForPick(pickRef, result); if (won === null) { return { outcome: "NO_BET", unitProfit: 0 }; } return { outcome: `${won ? "WON" : "LOST"}:${pickRef.market}:${pickRef.pick}`, unitProfit: computeUnitProfit(won, pickRef.stake_units, pickRef.odds), }; } async function main() { if (process.env.ENABLE_BACKFILL !== "true") { console.error( "āœ— Backfill is gated. Re-run with ENABLE_BACKFILL=true to proceed.", ); process.exit(1); } const opts = parseArgs(); console.log("šŸ“¦ Backfill prediction_runs"); console.log(` Range: ${opts.from} → ${opts.to}`); console.log(` Sport: ${opts.sport}`); console.log(` Engine: ${opts.engine}`); console.log(` Limit: ${opts.limit}`); console.log(` AI engine: ${AI_ENGINE_URL}`); const fromMs = BigInt(new Date(opts.from).getTime()); const toMs = BigInt(new Date(opts.to).getTime()); const matches = await prisma.match.findMany({ where: { sport: opts.sport as any, status: "FT", scoreHome: { not: null }, scoreAway: { not: null }, mstUtc: { gte: fromMs, lte: toMs }, }, orderBy: { mstUtc: "asc" }, take: opts.limit, select: { id: true, mstUtc: true, scoreHome: true, scoreAway: true, htScoreHome: true, htScoreAway: true, }, }); console.log(`\n ${matches.length} finished matches in range\n`); let predicted = 0; let written = 0; let skipped = 0; for (let i = 0; i < matches.length; i += CONCURRENCY) { const batch = matches.slice(i, i + CONCURRENCY); await Promise.all( batch.map(async (m) => { const existing = await prisma.predictionRun.findFirst({ where: { matchId: m.id, engineVersion: opts.engine }, select: { id: true }, }); if (existing) { skipped += 1; return; } const payload = await runOne(m.id, opts.engine); if (!payload) return; predicted += 1; const summary = buildPayloadSummary(payload); const result: MatchResult = { scoreHome: m.scoreHome!, scoreAway: m.scoreAway!, htScoreHome: m.htScoreHome, htScoreAway: m.htScoreAway, }; const settled = settleFromPayload(payload, result); await prisma.predictionRun.create({ data: { matchId: m.id, engineVersion: payload?.model_version ?? opts.engine, decisionTraceId: payload?.decision_trace_id ?? null, oddsSnapshot: payload?.odds_snapshot ?? null, payloadSummary: summary, eventualOutcome: settled.outcome, unitProfit: settled.unitProfit, }, }); written += 1; }), ); if ((i / CONCURRENCY) % 25 === 0) { console.log( ` [${i + batch.length}/${matches.length}] predicted=${predicted} written=${written} skipped=${skipped}`, ); } await sleep(REQUEST_DELAY_MS); } console.log("\nāœ… Backfill complete"); console.log(` Predicted: ${predicted}`); console.log(` Written: ${written}`); console.log(` Skipped: ${skipped}`); await prisma.$disconnect(); } main().catch(async (err) => { console.error(err); await prisma.$disconnect(); process.exit(1); });