d604bb14ff
- 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>
147 lines
5.4 KiB
TypeScript
147 lines
5.4 KiB
TypeScript
// 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();
|