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
+146
View File
@@ -0,0 +1,146 @@
// international-results.csv → public/data/ratings.json
// Walks every men's international 1872→now in date order, maintaining a
// World-Football-Elo rating per team. Then calibrates the goals model
// (goals-per-Elo slope, mean goals) on the modern era and MLE-fits Dixon-Coles
// rho. The runtime re-uses these ratings + params to predict and simulate.
import { readFileSync, writeFileSync, mkdirSync } from 'node:fs';
import { fileURLToPath } from 'node:url';
import { dirname, join } from 'node:path';
import { canonicalTeam, ALL_TEAMS } from '../src/lib/teams';
import { HOME_ADV_ELO, importanceWeight, eloDelta, expectedHome } from '../src/lib/model/elo';
import { poissonPmf } from '../src/lib/model/poisson';
const ROOT = join(dirname(fileURLToPath(import.meta.url)), '..');
const CSV = join(ROOT, 'data', 'raw', 'international-results.csv');
const OUT_DIR = join(ROOT, 'public', 'data');
const START_ELO = 1500;
const CALIB_FROM = '2010-01-01'; // modern-era window for the goals calibration
interface Row {
date: string; home: string; away: string; hs: number; as: number;
tournament: string; neutral: boolean;
}
function parseCsv(text: string): Row[] {
const lines = text.split('\n');
const rows: Row[] = [];
for (let i = 1; i < lines.length; i++) {
const line = lines[i];
if (!line) continue;
const c = line.split(','); // this dataset has no embedded commas
if (c.length < 9) continue;
const hs = Number(c[3]);
const as = Number(c[4]);
if (!Number.isFinite(hs) || !Number.isFinite(as)) continue; // skip NA / future fixtures
rows.push({
date: c[0]!, home: c[1]!, away: c[2]!, hs, as,
tournament: c[5]!, neutral: c[8]!.trim().toUpperCase() === 'TRUE',
});
}
rows.sort((a, b) => a.date.localeCompare(b.date));
return rows;
}
function dcTau(h: number, a: number, lh: number, la: number, rho: number): number {
if (h === 0 && a === 0) return 1 - lh * la * rho;
if (h === 0 && a === 1) return 1 + lh * rho;
if (h === 1 && a === 0) return 1 + la * rho;
if (h === 1 && a === 1) return 1 - rho;
return 1;
}
function main(): void {
const rows = parseCsv(readFileSync(CSV, 'utf8'));
const elo = new Map<string, number>();
const get = (t: string) => elo.get(t) ?? START_ELO;
// Calibration accumulators (modern era only).
let sumTotal = 0, nCalib = 0;
let sxy = 0, sxx = 0; // regression of goal-diff on effective Elo diff
const calib: { d: number; h: number; a: number }[] = [];
let lastDate = '';
for (const r of rows) {
const home = canonicalTeam(r.home);
const away = canonicalTeam(r.away);
const eh = get(home);
const ea = get(away);
const homeAdv = r.neutral ? 0 : HOME_ADV_ELO;
const effDiff = eh - ea + homeAdv;
if (r.date >= CALIB_FROM) {
const gd = r.hs - r.as;
sumTotal += r.hs + r.as; nCalib++;
sxy += effDiff * gd; sxx += effDiff * effDiff;
calib.push({ d: effDiff, h: r.hs, a: r.as });
}
const k = importanceWeight(r.tournament);
const delta = eloDelta(r.hs, r.as, eh, ea, k, homeAdv);
elo.set(home, eh + delta);
elo.set(away, ea - delta);
lastDate = r.date;
}
const avgGoals = sumTotal / Math.max(1, nCalib);
const goalsPerElo = sxy / Math.max(1e-9, sxx);
// MLE-fit Dixon-Coles rho on the modern era (classic DC likelihood: the four
// low-score cells, no renormalization). Grid search keeps it simple + robust.
let bestRho = 0, bestLL = -Infinity;
for (let rho = -0.2; rho <= 0.1 + 1e-9; rho += 0.01) {
let ll = 0;
for (const m of calib) {
const sup = goalsPerElo * m.d;
const lh = Math.min(8, Math.max(0.15, avgGoals / 2 + sup / 2));
const la = Math.min(8, Math.max(0.15, avgGoals / 2 - sup / 2));
const p = poissonPmf(lh, m.h) * poissonPmf(la, m.a) * dcTau(m.h, m.a, lh, la, rho);
ll += Math.log(Math.max(1e-12, p));
}
if (ll > bestLL) { bestLL = ll; bestRho = rho; }
}
const rho = Math.round(bestRho * 100) / 100;
// Sanity: home win rate the model expects vs reality, on the calib window.
let expHome = 0, n2 = 0;
for (const r of rows) {
if (r.date < CALIB_FROM) continue;
const homeAdv = r.neutral ? 0 : HOME_ADV_ELO;
// recompute pre-match would need stored ratings; use final as a rough proxy
expHome += expectedHome(get(canonicalTeam(r.home)), get(canonicalTeam(r.away)), homeAdv);
n2++;
}
const ratings: Record<string, number> = {};
for (const [team, r] of [...elo.entries()].sort((a, b) => b[1] - a[1])) {
ratings[team] = Math.round(r * 10) / 10;
}
// Ensure every WC team has a rating (default for any never seen).
for (const t of ALL_TEAMS) if (!(t in ratings)) ratings[t] = START_ELO;
const out = {
generatedAt: new Date().toISOString(),
asOf: lastDate,
matchesProcessed: rows.length,
calibrationFrom: CALIB_FROM,
calibrationMatches: nCalib,
params: {
goalsPerElo: Math.round(goalsPerElo * 1e6) / 1e6,
avgGoals: Math.round(avgGoals * 1000) / 1000,
rho,
homeAdvElo: HOME_ADV_ELO,
},
ratings,
};
mkdirSync(OUT_DIR, { recursive: true });
writeFileSync(join(OUT_DIR, 'ratings.json'), JSON.stringify(out));
const top = Object.entries(ratings).slice(0, 12).map(([t, r]) => `${t} ${Math.round(r)}`).join(', ');
console.log(`ratings → public/data/ratings.json (${rows.length} matches, asOf ${lastDate})`);
console.log(`params: goalsPerElo=${out.params.goalsPerElo} avgGoals=${out.params.avgGoals} rho=${rho} homeAdvElo=${HOME_ADV_ELO}`);
console.log(`top: ${top}`);
}
main();