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
+29
View File
@@ -0,0 +1,29 @@
// Proper scoring rules — ONE implementation shared by the offline backtest and
// the live Model-vs-Market scoreboard, so the numbers are always comparable.
import type { MatchProbs } from '../types';
export type Outcome3 = 'home' | 'draw' | 'away';
export const outcomeOfScore = (hs: number, as: number): Outcome3 =>
hs > as ? 'home' : hs < as ? 'away' : 'draw';
/** Multiclass Brier (sum of squared errors over the 3 outcomes; 0 best, 2 worst). */
export function brierScore(p: MatchProbs, o: Outcome3): number {
return (
(p.home - (o === 'home' ? 1 : 0)) ** 2 +
(p.draw - (o === 'draw' ? 1 : 0)) ** 2 +
(p.away - (o === 'away' ? 1 : 0)) ** 2
);
}
/** Ranked Probability Score for the ordered outcomes home > draw > away (0 best). */
export function rpsScore(p: MatchProbs, o: Outcome3): number {
const y1 = o === 'home' ? 1 : 0;
const y2 = o === 'away' ? 0 : 1; // cumulative: home+draw
return ((p.home - y1) ** 2 + (p.home + p.draw - y2) ** 2) / 2;
}
export function logLoss(p: MatchProbs, o: Outcome3): number {
return -Math.log(Math.max(1e-12, p[o]));
}
+40
View File
@@ -165,16 +165,56 @@ export interface TeamProfile {
qualifyOdds: number | null;
}
// ---- Model-vs-Market scoreboard ----
export interface MarketSnapshot {
provider: string;
probs: MatchProbs; // de-vigged
homeMl: number;
drawMl: number;
awayMl: number;
}
export interface ScoreboardRow {
num: number;
home: string;
away: string;
group: string | null;
kickoff: string;
status: MatchStatus;
homeScore: number | null;
awayScore: number | null;
takenAt: number;
/** Snapshot was taken after kickoff — excluded from the fair totals. */
late: boolean;
model: MatchProbs;
market: MarketSnapshot | null;
/** Scores, present once the match finished (regulation result). */
scored: { outcome: 'home' | 'draw' | 'away'; modelRps: number; marketRps: number | null; modelBrier: number; marketBrier: number | null } | null;
}
export interface ScoreboardData {
rows: ScoreboardRow[];
/** Aggregates over finished, fair (pre-kickoff, with-market) matches. */
totals: { n: number; modelRps: number; marketRps: number; modelBrier: number; marketBrier: number; modelCloser: number; marketCloser: number } | null;
}
// ---- model backtest (Methodology page) ----
export interface BacktestScores { brier: number; logloss: number; rps: number; accuracy: number }
export interface BacktestReport {
generatedAt: string;
trainEnd: string;
/** Test window start (validation ends here). */
testFrom?: string;
testTo: string;
tested: number;
params: { goalsPerElo: number; avgGoals: number; rho: number; homeAdvElo: number };
/** Hyper-params tuned on the validation window (never on test). */
validation?: { from: string; to: string; tuned: { xi: number; shrinkK: number; ensembleW: number } };
model: BacktestScores;
/** Bake-off: every candidate scored on the same untouched test window. */
variants?: ({ name: string } & BacktestScores)[];
baselines: { uniform: BacktestScores; baseRate: BacktestScores; eloOnly: BacktestScores };
reliability: { predicted: number; observed: number; count: number }[];
ece: number;