Phase 2: predictive model — Elo + Dixon-Coles + Monte Carlo

- buildRatings.ts: walks 49k historical results → World-Football-Elo per team,
  data-calibrated goals model (goals-per-Elo slope, mean goals) + MLE-fit
  Dixon-Coles rho. Top: Spain/Argentina/France (the real 2026 favourites)
- src/lib/model: elo, poisson/dixon-coles, predict, host-advantage, monteCarlo
  (full 48-team sim — group sampling, best-third bipartite allocation, knockout
  advance probs). 20 vitest cases incl. exact per-round count invariants
- Server ModelEngine: live Elo re-rating after each result, per-match W/D/L,
  20k-sim odds, odds-over-time history; broadcast on finished-result changes
- Client: championship board, heat-shaded odds table, lazy-loaded title-race
  chart (Recharts split to its own chunk), match-prediction bars, bracket
  advance overlay
- Verified: odds render, chart populates as injected results re-rate teams live

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-06-11 13:25:53 +02:00
parent 9a31e9f4db
commit d604bb14ff
19 changed files with 1263 additions and 21 deletions
+54 -2
View File
@@ -69,9 +69,61 @@ export interface Snapshot {
tables: Record<string, StandingRow[]>;
}
// ---- model / predictions ----
/** Per-team tournament odds from the Monte Carlo simulation. */
export interface TeamOdds {
team: string;
winGroup: number;
qualify: number;
reachR16: number;
reachQF: number;
reachSF: number;
reachFinal: number;
champion: number;
}
export interface MatchProbs {
home: number;
draw: number;
away: number;
}
/** A single per-match prediction (group + resolved knockout games). */
export interface MatchPrediction {
num: number;
home: string;
away: string;
probs: MatchProbs;
lambdaHome: number;
lambdaAway: number;
topScore: { home: number; away: number; p: number };
}
/** A point on the championship-odds-over-time chart. */
export interface OddsHistoryPoint {
t: string;
label: string;
top: { team: string; champion: number }[];
}
export interface ModelSnapshot {
generatedAt: string;
/** Date the underlying ratings reflect (advances as results re-rate teams). */
asOf: string;
iterations: number;
matches: MatchPrediction[];
odds: TeamOdds[];
history: OddsHistoryPoint[];
}
// ---- WebSocket protocol ----
// The snapshot is small (~104 fixtures), so the server simply re-pushes the full
// snapshot on every change — no diffing, no client-side merge races.
// snapshot on every change — no diffing, no client-side merge races. Predictions
// are heavier to compute, so they ride a separate message sent only when the set
// of finished results changes.
/** Server → client. */
export type ServerMessage = { t: 'snapshot'; snapshot: Snapshot };
export type ServerMessage =
| { t: 'snapshot'; snapshot: Snapshot }
| { t: 'predictions'; model: ModelSnapshot };