235 lines
6.9 KiB
TypeScript
235 lines
6.9 KiB
TypeScript
/**
|
|
* ===================================================
|
|
* 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<string, string> = {};
|
|
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<any | null> {
|
|
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<string, any> {
|
|
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);
|
|
});
|