v3: Model-vs-Market scoreboard — frozen forecasts, fair scoring, live page

- prediction_snapshots: model + de-vigged market frozen ≤15min pre-kickoff
  (or at first sight of a live match, flagged 'late' and excluded from totals).
- src/lib/model/scoring.ts: ONE shared RPS/Brier/log-loss implementation for
  the backtest and the live scoreboard.
- /api/scoreboard + /scoreboard page: per-match model-vs-bookmaker bars, RPS
  for both after FT, 'closer' badges, running head-to-head with honest rules
  printed (small-sample caveat included). Nav: 'vs Market'.
- Methodology page now shows the three-way split + ensemble bake-off variants.
- Verified end-to-end with a simulated match: snapshot froze model 80.7% and
  DraftKings 67.0% (de-vig correct), both scored at FT, late-flag honored.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-06-11 17:31:09 +02:00
parent f3b2a69b31
commit dc52a908c0
10 changed files with 381 additions and 11 deletions
+33
View File
@@ -85,6 +85,14 @@ CREATE TABLE IF NOT EXISTS odds_history (
);
CREATE INDEX IF NOT EXISTS idx_odds_fixture ON odds_history(fixture_num, captured_at);
CREATE TABLE IF NOT EXISTS prediction_snapshots (
fixture_num INTEGER PRIMARY KEY,
taken_at INTEGER NOT NULL,
late INTEGER NOT NULL DEFAULT 0,
model_json TEXT NOT NULL,
market_json TEXT
);
CREATE TABLE IF NOT EXISTS ingest_log (
id INTEGER PRIMARY KEY AUTOINCREMENT,
source TEXT NOT NULL,
@@ -229,6 +237,31 @@ export function latestOddsAll(): OddsRow[] {
.all() as unknown as OddsRow[];
}
// ---- frozen pre-kickoff forecasts (Model-vs-Market scoreboard) ----
export interface SnapshotRow {
fixture_num: number;
taken_at: number;
late: number;
model_json: string;
market_json: string | null;
}
export function hasSnapshot(fixtureNum: number): boolean {
return !!db().prepare('SELECT 1 FROM prediction_snapshots WHERE fixture_num = ?').get(fixtureNum);
}
export function saveSnapshot(fixtureNum: number, late: boolean, model: unknown, market: unknown | null): void {
db()
.prepare('INSERT OR IGNORE INTO prediction_snapshots (fixture_num, taken_at, late, model_json, market_json) VALUES (?, ?, ?, ?, ?)')
.run(fixtureNum, Date.now(), late ? 1 : 0, JSON.stringify(model), market ? JSON.stringify(market) : null);
}
export function allSnapshots(): SnapshotRow[] {
return db()
.prepare('SELECT fixture_num, taken_at, late, model_json, market_json FROM prediction_snapshots ORDER BY fixture_num')
.all() as unknown as SnapshotRow[];
}
// ---- per-fixture enrichment blob ----
export function setMatchExt(fixtureNum: number, json: unknown): void {
db().prepare('INSERT OR REPLACE INTO match_ext (fixture_num, json, updated_at) VALUES (?, ?, ?)')
+7 -1
View File
@@ -9,6 +9,7 @@ import { TournamentState } from './tournament';
import { startScheduler } from './ingest/scheduler';
import { ModelEngine } from './model';
import { buildPreview, buildTeamProfile } from './preview';
import { buildScoreboard, snapshotCheck } from './scoreboard';
import { db, healthAll, oddsFor, latestOddsAll } from './db/db';
import { canonicalTeam } from '../../src/lib/teams';
import type { MatchStatus, ServerMessage } from '../../src/lib/types';
@@ -59,6 +60,7 @@ export function buildServer() {
app.get('/api/sources/health', async () => ({ sources: healthAll() }));
// Bookmaker odds (benchmark for the Model-vs-Market scoreboard; not a model input)
app.get('/api/odds', async () => ({ odds: latestOddsAll() }));
app.get('/api/scoreboard', async () => buildScoreboard(state));
app.get('/api/odds/:num', async (req, reply) => {
const num = Number((req.params as { num: string }).num);
if (!Number.isFinite(num)) return reply.code(400).send({ error: 'bad num' });
@@ -93,6 +95,7 @@ export function buildServer() {
'football-data',
);
if (!f) return reply.code(404).send({ error: 'no such fixture' });
snapshotCheck(state, model);
broadcast();
return { ok: true, fixture: f };
});
@@ -125,7 +128,10 @@ export function buildServer() {
});
// ---- ingestion scheduler (ESPN primary; football-data / SofaScore fallback) ----
const stopIngest = startScheduler(state, () => broadcast());
const stopIngest = startScheduler(state, () => broadcast(), () => {
const taken = snapshotCheck(state, model);
if (taken) console.log(`[scoreboard] froze ${taken} pre-kickoff forecast(s)`);
});
app.addHook('onClose', async () => stopIngest());
// ---- static SPA (prod) with history fallback ----
+6 -1
View File
@@ -23,7 +23,11 @@ function dateUTC(iso: string): string {
return `${d.getUTCFullYear()}${String(d.getUTCMonth() + 1).padStart(2, '0')}${String(d.getUTCDate()).padStart(2, '0')}`;
}
export function startScheduler(state: TournamentState, onChange: () => void): () => void {
export function startScheduler(
state: TournamentState,
onChange: () => void,
onTick?: () => void,
): () => void {
const token = process.env.FOOTBALL_DATA_TOKEN?.trim();
const sofa = process.env.ENABLE_SOFASCORE === 'true';
let stopped = false;
@@ -88,6 +92,7 @@ export function startScheduler(state: TournamentState, onChange: () => void): ()
const changed = state.mergeLive(matches, source);
if (changed.length) { console.log(`[ingest] ${changed.length} fixture(s) updated via ${source}`); onChange(); }
}
onTick?.(); // e.g. freeze pre-kickoff forecast snapshots
// Keep live matches' stats + event timeline fresh at the live cadence.
for (const f of state.allFixtures()) {
+113
View File
@@ -0,0 +1,113 @@
import { allSnapshots, hasSnapshot, saveSnapshot, oddsFor } from './db/db';
import { deVig } from '../../src/lib/odds';
import { brierScore, outcomeOfScore, rpsScore } from '../../src/lib/model/scoring';
import type { TournamentState } from './tournament';
import type { ModelEngine } from './model';
import type { MarketSnapshot, MatchProbs, ScoreboardData, ScoreboardRow } from '../../src/lib/types';
// The Model-vs-Market scoreboard. Fairness rules:
// - the model's forecast is FROZEN at most SNAPSHOT_BEFORE_MS before kickoff
// (whatever the model believed then — no hindsight);
// - the market benchmark is the latest captured pre-kickoff bookmaker line,
// de-vigged to fair probabilities;
// - snapshots taken after kickoff are stored but flagged `late` and excluded
// from the head-to-head totals.
const SNAPSHOT_BEFORE_MS = 15 * 60 * 1000;
function latestPreKickoffMarket(num: number, kickoffT: number): MarketSnapshot | null {
const history = oddsFor(num).filter((o) => o.captured_at <= kickoffT + 60_000);
const last = history[history.length - 1];
if (!last) return null;
const probs = deVig(last.home_ml, last.draw_ml, last.away_ml);
if (!probs) return null;
return {
provider: last.provider,
probs,
homeMl: last.home_ml!,
drawMl: last.draw_ml!,
awayMl: last.away_ml!,
};
}
/** Freeze forecasts for any fixture at/inside the snapshot window. Runs on every
* live tick — cheap (indexed point lookups, no network). */
export function snapshotCheck(state: TournamentState, model: ModelEngine): number {
const now = Date.now();
let taken = 0;
for (const f of state.allFixtures()) {
if (!f.home.team || !f.away.team) continue;
const kickoffT = new Date(f.kickoff).getTime();
// Inside the pre-kickoff window — or the match already started regardless of
// the scheduled clock (reschedules); the `late` flag keeps totals honest.
if (now < kickoffT - SNAPSHOT_BEFORE_MS && f.status === 'scheduled') continue;
if (hasSnapshot(f.num)) continue;
const pred = model.current().matches.find((m) => m.num === f.num);
if (!pred) continue; // finished before we ever saw it — nothing honest to freeze
const late = f.status !== 'scheduled';
saveSnapshot(f.num, late, { probs: pred.probs, lambdaHome: pred.lambdaHome, lambdaAway: pred.lambdaAway }, latestPreKickoffMarket(f.num, kickoffT));
taken++;
}
return taken;
}
export function buildScoreboard(state: TournamentState): ScoreboardData {
const byNum = new Map(state.allFixtures().map((f) => [f.num, f]));
const rows: ScoreboardRow[] = [];
let n = 0, mR = 0, kR = 0, mB = 0, kB = 0, mCloser = 0, kCloser = 0;
for (const s of allSnapshots()) {
const f = byNum.get(s.fixture_num);
if (!f || !f.home.team || !f.away.team) continue;
const modelStored = JSON.parse(s.model_json) as { probs: MatchProbs };
const market = s.market_json ? (JSON.parse(s.market_json) as MarketSnapshot) : null;
let scored: ScoreboardRow['scored'] = null;
if (f.status === 'finished' && f.homeScore != null && f.awayScore != null) {
const o = outcomeOfScore(f.homeScore, f.awayScore);
scored = {
outcome: o,
modelRps: +rpsScore(modelStored.probs, o).toFixed(4),
marketRps: market ? +rpsScore(market.probs, o).toFixed(4) : null,
modelBrier: +brierScore(modelStored.probs, o).toFixed(4),
marketBrier: market ? +brierScore(market.probs, o).toFixed(4) : null,
};
if (!s.late && market && scored.marketRps != null) {
n++;
mR += scored.modelRps; kR += scored.marketRps;
mB += scored.modelBrier; kB += scored.marketBrier!;
if (scored.modelRps < scored.marketRps) mCloser++;
else if (scored.marketRps < scored.modelRps) kCloser++;
}
}
rows.push({
num: f.num,
home: f.home.team,
away: f.away.team,
group: f.group,
kickoff: f.kickoff,
status: f.status,
homeScore: f.homeScore,
awayScore: f.awayScore,
takenAt: s.taken_at,
late: !!s.late,
model: modelStored.probs,
market,
scored,
});
}
rows.sort((a, b) => a.kickoff.localeCompare(b.kickoff));
return {
rows,
totals: n
? {
n,
modelRps: +(mR / n).toFixed(4), marketRps: +(kR / n).toFixed(4),
modelBrier: +(mB / n).toFixed(4), marketBrier: +(kB / n).toFixed(4),
modelCloser: mCloser, marketCloser: kCloser,
}
: null,
};
}