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();
+15 -4
View File
@@ -7,6 +7,7 @@ import fastifyStatic from '@fastify/static';
import type { WebSocket } from 'ws'; import type { WebSocket } from 'ws';
import { TournamentState } from './tournament'; import { TournamentState } from './tournament';
import { startLiveLoop } from './liveLoop'; import { startLiveLoop } from './liveLoop';
import { ModelEngine } from './model';
import type { MatchStatus, ServerMessage } from '../../src/lib/types'; import type { MatchStatus, ServerMessage } from '../../src/lib/types';
const PORT = Number(process.env.PORT ?? 8787); const PORT = Number(process.env.PORT ?? 8787);
@@ -27,20 +28,30 @@ export function buildServer() {
}); });
const state = new TournamentState(STATIC_DIR); const state = new TournamentState(STATIC_DIR);
const model = new ModelEngine(STATIC_DIR);
model.recompute(state.allFixtures());
const clients = new Set<WebSocket>(); const clients = new Set<WebSocket>();
const snapshotMessage = (): string => const snapshotMessage = (): string =>
JSON.stringify({ t: 'snapshot', snapshot: state.snapshot() } satisfies ServerMessage); JSON.stringify({ t: 'snapshot', snapshot: state.snapshot() } satisfies ServerMessage);
const predictionsMessage = (): string =>
JSON.stringify({ t: 'predictions', model: model.current() } satisfies ServerMessage);
const broadcast = (): void => { const sendAll = (msg: string): void => {
const msg = snapshotMessage();
for (const ws of clients) { for (const ws of clients) {
try { ws.send(msg); } catch { /* closed */ } try { ws.send(msg); } catch { /* closed */ }
} }
}; };
// ---- REST: current snapshot (initial load + a no-WS fallback) ---- /** Re-push the snapshot, and predictions too if the finished-result set moved. */
const broadcast = (): void => {
sendAll(snapshotMessage());
if (model.recompute(state.allFixtures())) sendAll(predictionsMessage());
};
// ---- REST: snapshot + predictions (initial load + a no-WS fallback) ----
app.get('/api/snapshot', async () => state.snapshot()); app.get('/api/snapshot', async () => state.snapshot());
app.get('/api/predictions', async () => model.current());
app.get('/healthz', async () => ({ ok: true })); app.get('/healthz', async () => ({ ok: true }));
// ---- dev-only: inject a score to exercise the live → standings → WS loop ---- // ---- dev-only: inject a score to exercise the live → standings → WS loop ----
@@ -76,7 +87,7 @@ export function buildServer() {
} }
if (clients.size >= MAX_WS) { socket.close(1013, 'busy'); return; } if (clients.size >= MAX_WS) { socket.close(1013, 'busy'); return; }
clients.add(socket); clients.add(socket);
try { socket.send(snapshotMessage()); } catch { /* closed */ } try { socket.send(snapshotMessage()); socket.send(predictionsMessage()); } catch { /* closed */ }
let alive = true; let alive = true;
socket.on('pong', () => { alive = true; }); socket.on('pong', () => { alive = true; });
Binary file not shown.
+5
View File
@@ -63,6 +63,11 @@ export class TournamentState {
return this.byNum.get(num); return this.byNum.get(num);
} }
/** The live fixtures array (for the model engine). */
allFixtures(): Fixture[] {
return this.fixtures;
}
/** True if any match is live or kicks off within `windowMs` of now — used to /** True if any match is live or kicks off within `windowMs` of now — used to
* poll the APIs often during match windows and rarely when nothing is on. */ * poll the APIs often during match windows and rarely when nothing is on. */
hasLiveActivity(windowMs: number): boolean { hasLiveActivity(windowMs: number): boolean {
+69
View File
@@ -0,0 +1,69 @@
import { useMemo } from 'react';
import { LineChart, Line, XAxis, YAxis, Tooltip, ResponsiveContainer, CartesianGrid, Legend } from 'recharts';
import { teamFlag } from '@/lib/teams';
import type { OddsHistoryPoint } from '@/lib/types';
const PALETTE = [
'var(--app-accent)',
'var(--app-gold)',
'var(--app-info)',
'var(--app-loss)',
'var(--app-success)',
'var(--app-warning)',
];
/** Championship odds over time — one line per top contender, a point per result
* that moved the model. */
export function OddsChart({ history }: { history: OddsHistoryPoint[] }) {
const { data, teams } = useMemo(() => {
const last = history[history.length - 1];
const teams = (last?.top ?? []).slice(0, 6).map((t) => t.team);
const data = history.map((h, i) => {
const row: Record<string, number | string> = { i: i + 1, label: h.label };
for (const t of h.top) if (teams.includes(t.team)) row[t.team] = Math.round(t.champion * 1000) / 10;
return row;
});
return { data, teams };
}, [history]);
return (
<div className="h-72 w-full">
<ResponsiveContainer width="100%" height="100%">
<LineChart data={data} margin={{ top: 8, right: 16, bottom: 4, left: -8 }}>
<CartesianGrid stroke="var(--app-line)" strokeDasharray="3 3" vertical={false} />
<XAxis dataKey="i" tick={{ fill: 'var(--app-faint)', fontSize: 11 }} stroke="var(--app-line)" />
<YAxis
tick={{ fill: 'var(--app-faint)', fontSize: 11 }}
stroke="var(--app-line)"
unit="%"
width={42}
/>
<Tooltip
contentStyle={{
background: 'var(--app-panel)',
border: '1px solid var(--app-line)',
borderRadius: 10,
fontSize: 12,
color: 'var(--app-ink)',
}}
labelFormatter={(i) => data[Number(i) - 1]?.label ?? ''}
formatter={(v: number, name: string) => [`${v}%`, `${teamFlag(name)} ${name}`]}
/>
<Legend formatter={(name: string) => `${teamFlag(name)} ${name}`} wrapperStyle={{ fontSize: 12 }} />
{teams.map((t, idx) => (
<Line
key={t}
type="monotone"
dataKey={t}
stroke={PALETTE[idx % PALETTE.length]}
strokeWidth={2}
dot={{ r: 2 }}
connectNulls
isAnimationActive={false}
/>
))}
</LineChart>
</ResponsiveContainer>
</div>
);
}
+59
View File
@@ -0,0 +1,59 @@
import { teamFlag } from '@/lib/teams';
import { cn } from '@/lib/cn';
import type { TeamOdds } from '@/lib/types';
const cols: { key: keyof Omit<TeamOdds, 'team'>; label: string }[] = [
{ key: 'winGroup', label: 'Win grp' },
{ key: 'qualify', label: 'Qualify' },
{ key: 'reachR16', label: 'R16' },
{ key: 'reachQF', label: 'QF' },
{ key: 'reachSF', label: 'SF' },
{ key: 'reachFinal', label: 'Final' },
{ key: 'champion', label: 'Champion' },
];
const pct = (x: number) => (x >= 0.995 ? '>99%' : x < 0.005 ? '—' : `${Math.round(x * 100)}%`);
/** Heat-shaded cell: stronger odds → more accent (or gold for the title) tint. */
function Cell({ x, accent = false }: { x: number; accent?: boolean }) {
return (
<td
className={cn('py-1.5 text-center text-sm tabular-nums', x < 0.005 ? 'text-faint' : 'text-ink')}
style={x >= 0.005 ? { background: `color-mix(in oklab, var(--app-${accent ? 'gold' : 'accent'}) ${Math.round(x * 60)}%, transparent)` } : undefined}
>
{pct(x)}
</td>
);
}
export function OddsTable({ odds }: { odds: TeamOdds[] }) {
return (
<div className="overflow-x-auto rounded-xl border border-line bg-panel">
<table className="w-full border-collapse">
<thead>
<tr className="border-b border-line text-[11px] uppercase tracking-wide text-faint">
<th className="px-3 py-2 text-left font-semibold">Team</th>
{cols.map((c) => (
<th key={c.key} className="px-2 py-2 text-center font-semibold">{c.label}</th>
))}
</tr>
</thead>
<tbody>
{odds.map((o) => (
<tr key={o.team} className="border-t border-line/60">
<td className="px-3 py-1.5">
<span className="flex items-center gap-2">
<span aria-hidden className="text-base leading-none">{teamFlag(o.team)}</span>
<span className="truncate text-sm font-medium text-ink">{o.team}</span>
</span>
</td>
{cols.map((c) => (
<Cell key={c.key} x={o[c.key]} accent={c.key === 'champion'} />
))}
</tr>
))}
</tbody>
</table>
</div>
);
}
+48
View File
@@ -0,0 +1,48 @@
import { teamFlag } from '@/lib/teams';
import type { MatchProbs } from '@/lib/types';
const pct = (x: number) => `${Math.round(x * 100)}%`;
/** A match's win/draw/loss as a stacked bar with the two teams and the model's
* most likely scoreline. */
export function WinProbBar({
home,
away,
probs,
topScore,
}: {
home: string;
away: string;
probs: MatchProbs;
topScore?: { home: number; away: number };
}) {
return (
<div>
<div className="mb-1 flex items-center justify-between text-sm">
<span className="flex min-w-0 items-center gap-1.5">
<span aria-hidden>{teamFlag(home)}</span>
<span className="truncate font-medium text-ink">{home}</span>
</span>
{topScore && (
<span className="tnum shrink-0 px-2 text-xs text-faint" title="Most likely scoreline">
{topScore.home}{topScore.away}
</span>
)}
<span className="flex min-w-0 items-center justify-end gap-1.5">
<span className="truncate font-medium text-ink">{away}</span>
<span aria-hidden>{teamFlag(away)}</span>
</span>
</div>
<div className="flex h-2.5 overflow-hidden rounded-full bg-elevated">
<div className="bg-accent" style={{ width: pct(probs.home) }} title={`${home} win ${pct(probs.home)}`} />
<div className="bg-line-strong" style={{ width: pct(probs.draw) }} title={`Draw ${pct(probs.draw)}`} />
<div className="bg-info" style={{ width: pct(probs.away) }} title={`${away} win ${pct(probs.away)}`} />
</div>
<div className="mt-1 flex justify-between text-[11px] tabular-nums text-muted">
<span className="text-accent">{pct(probs.home)}</span>
<span>draw {pct(probs.draw)}</span>
<span className="text-info">{pct(probs.away)}</span>
</div>
</div>
);
}
+25 -3
View File
@@ -4,7 +4,7 @@ import { useTournamentStore } from '@/stores/tournamentStore';
import { teamFlag } from '@/lib/teams'; import { teamFlag } from '@/lib/teams';
import { kickoffDay, kickoffTime } from '@/lib/format'; import { kickoffDay, kickoffTime } from '@/lib/format';
import { cn } from '@/lib/cn'; import { cn } from '@/lib/cn';
import type { Fixture, Stage, TeamSlot } from '@/lib/types'; import type { Fixture, MatchProbs, Stage, TeamSlot } from '@/lib/types';
const COLUMNS: { stage: Stage; label: string }[] = [ const COLUMNS: { stage: Stage; label: string }[] = [
{ stage: 'r32', label: 'Round of 32' }, { stage: 'r32', label: 'Round of 32' },
@@ -32,13 +32,30 @@ function Side({ slot, score, winner }: { slot: TeamSlot; score: number | null; w
); );
} }
function BracketMatch({ f }: { f: Fixture }) { function AdvanceBar({ probs }: { probs: MatchProbs }) {
// Advance probability = regulation win + half of draws (shootout ≈ coin flip).
const home = Math.round((probs.home + probs.draw / 2) * 100);
return (
<div className="flex items-center gap-1.5 border-t border-line px-2.5 py-1" title="Model: chance to advance">
<span className="tnum w-7 text-[10px] text-accent">{home}%</span>
<div className="flex h-1.5 flex-1 overflow-hidden rounded-full bg-elevated">
<div className="bg-accent" style={{ width: `${home}%` }} />
<div className="bg-info" style={{ width: `${100 - home}%` }} />
</div>
<span className="tnum w-7 text-right text-[10px] text-info">{100 - home}%</span>
</div>
);
}
function BracketMatch({ f, probs }: { f: Fixture; probs?: MatchProbs | undefined }) {
const decided = f.status === 'finished' && f.homeScore !== null && f.awayScore !== null; const decided = f.status === 'finished' && f.homeScore !== null && f.awayScore !== null;
const showProbs = !!probs && !!f.home.team && !!f.away.team && f.status !== 'finished';
return ( return (
<div className="w-52 overflow-hidden rounded-lg border border-line bg-panel text-sm"> <div className="w-52 overflow-hidden rounded-lg border border-line bg-panel text-sm">
<Side slot={f.home} score={f.homeScore} winner={decided && f.homeScore! > f.awayScore!} /> <Side slot={f.home} score={f.homeScore} winner={decided && f.homeScore! > f.awayScore!} />
<div className="h-px bg-line" /> <div className="h-px bg-line" />
<Side slot={f.away} score={f.awayScore} winner={decided && f.awayScore! > f.homeScore!} /> <Side slot={f.away} score={f.awayScore} winner={decided && f.awayScore! > f.homeScore!} />
{showProbs && <AdvanceBar probs={probs} />}
<div className="border-t border-line bg-surface-2 px-2.5 py-1 text-[10px] text-faint"> <div className="border-t border-line bg-surface-2 px-2.5 py-1 text-[10px] text-faint">
M{f.num} · {f.status === 'finished' ? 'FT' : `${kickoffDay(f.kickoff)} ${kickoffTime(f.kickoff)}`} M{f.num} · {f.status === 'finished' ? 'FT' : `${kickoffDay(f.kickoff)} ${kickoffTime(f.kickoff)}`}
</div> </div>
@@ -48,6 +65,11 @@ function BracketMatch({ f }: { f: Fixture }) {
export function BracketPage() { export function BracketPage() {
const snapshot = useTournamentStore((s) => s.snapshot); const snapshot = useTournamentStore((s) => s.snapshot);
const model = useTournamentStore((s) => s.model);
const probsByNum = useMemo(
() => new Map((model?.matches ?? []).map((m) => [m.num, m.probs])),
[model],
);
const { byStage, third } = useMemo(() => { const { byStage, third } = useMemo(() => {
const fixtures = snapshot?.fixtures ?? []; const fixtures = snapshot?.fixtures ?? [];
@@ -69,7 +91,7 @@ export function BracketPage() {
<div key={col.stage} className="flex shrink-0 flex-col gap-3"> <div key={col.stage} className="flex shrink-0 flex-col gap-3">
<h2 className="smallcaps">{col.label}</h2> <h2 className="smallcaps">{col.label}</h2>
{byStage[col.stage].length {byStage[col.stage].length
? byStage[col.stage].map((f) => <BracketMatch key={f.num} f={f} />) ? byStage[col.stage].map((f) => <BracketMatch key={f.num} f={f} probs={probsByNum.get(f.num)} />)
: <div className="h-20 w-52 animate-pulse rounded-lg border border-line bg-panel" />} : <div className="h-20 w-52 animate-pulse rounded-lg border border-line bg-panel" />}
</div> </div>
))} ))}
+121 -9
View File
@@ -1,15 +1,127 @@
import { TrendingUp } from 'lucide-react'; import { lazy, Suspense, useMemo } from 'react';
import { TrendingUp, Trophy } from 'lucide-react';
import { PageHeader } from '@/components/ui/PageHeader'; import { PageHeader } from '@/components/ui/PageHeader';
import { Placeholder } from '@/components/ui/Placeholder'; import { Card, CardBody, CardHeader } from '@/components/ui/Card';
import { OddsTable } from '@/components/OddsTable';
import { WinProbBar } from '@/components/WinProbBar';
export function PredictionsPage() { // Recharts is heavy and only used here — load it as a separate on-demand chunk.
const OddsChart = lazy(() => import('@/components/OddsChart').then((m) => ({ default: m.OddsChart })));
import { teamFlag } from '@/lib/teams';
import { kickoffDay, kickoffTime } from '@/lib/format';
import { useTournamentStore } from '@/stores/tournamentStore';
function ChampionBoard({ odds }: { odds: { team: string; champion: number }[] }) {
const top = odds.slice(0, 8);
const max = Math.max(...top.map((t) => t.champion), 0.01);
return ( return (
<div> <div className="space-y-2">
<PageHeader title="Predict" subtitle="Elo + Dixon-Coles + Monte Carlo — who lifts the trophy?" /> {top.map((t, i) => (
<Placeholder icon={<TrendingUp size={28} />}> <div key={t.team} className="flex items-center gap-3">
Match win/draw/loss bars, simulated championship odds and an odds-over-time chart arrive <span className="w-4 text-right text-xs font-bold tabular-nums text-faint">{i + 1}</span>
in Phase 2. <span aria-hidden className="text-lg leading-none">{teamFlag(t.team)}</span>
</Placeholder> <span className="w-28 shrink-0 truncate text-sm font-medium text-ink">{t.team}</span>
<div className="h-3 flex-1 overflow-hidden rounded-full bg-elevated">
<div
className="h-full rounded-full bg-gradient-to-r from-accent-deep to-accent"
style={{ width: `${(t.champion / max) * 100}%` }}
/>
</div>
<span className="tnum w-12 shrink-0 text-right text-sm font-bold text-ink">
{(t.champion * 100).toFixed(1)}%
</span>
</div>
))}
</div>
);
}
export function PredictionsPage() {
const model = useTournamentStore((s) => s.model);
const snapshot = useTournamentStore((s) => s.snapshot);
const upcoming = useMemo(() => {
if (!model || !snapshot) return [];
const fxByNum = new Map(snapshot.fixtures.map((f) => [f.num, f]));
return model.matches
.map((m) => ({ m, f: fxByNum.get(m.num)! }))
.filter(({ f }) => f && f.status !== 'finished')
.sort((a, b) => a.f.kickoff.localeCompare(b.f.kickoff))
.slice(0, 8);
}, [model, snapshot]);
if (!model) {
return (
<div>
<PageHeader title="Predict" subtitle="Running the simulation…" />
<div className="h-64 animate-pulse rounded-xl border border-line bg-panel" />
</div>
);
}
return (
<div>
<PageHeader
title="Predict"
subtitle={`Elo + Dixon-Coles + ${model.iterations.toLocaleString()} Monte Carlo simulations · ratings as of ${model.asOf}`}
/>
<div className="mb-6 grid gap-4 lg:grid-cols-2">
<Card>
<CardHeader className="flex items-center gap-2">
<Trophy size={16} className="text-gold" />
<span className="font-display font-bold text-ink">Who lifts the trophy?</span>
</CardHeader>
<CardBody>
<ChampionBoard odds={model.odds} />
</CardBody>
</Card>
<Card>
<CardHeader className="flex items-center gap-2">
<TrendingUp size={16} className="text-accent" />
<span className="font-display font-bold text-ink">Title race over time</span>
</CardHeader>
<CardBody>
{model.history.length >= 2 ? (
<Suspense fallback={<div className="h-72 animate-pulse rounded-lg bg-elevated" />}>
<OddsChart history={model.history} />
</Suspense>
) : (
<div className="grid h-72 place-items-center text-center text-sm text-muted">
<p className="max-w-xs">
The title-race chart fills in as results come in each completed match re-rates
the teams and re-runs the simulation.
</p>
</div>
)}
</CardBody>
</Card>
</div>
<h2 className="smallcaps mb-2.5">Full odds group, knockout & title</h2>
<div className="mb-6">
<OddsTable odds={model.odds} />
</div>
{upcoming.length > 0 && (
<>
<h2 className="smallcaps mb-2.5">Next match predictions</h2>
<div className="grid gap-3 sm:grid-cols-2">
{upcoming.map(({ m, f }) => (
<Card key={m.num}>
<CardBody className="space-y-2">
<div className="flex items-center justify-between text-[11px] text-faint">
<span>{f.group ? `Group ${f.group}` : f.round}</span>
<span>{kickoffDay(f.kickoff)} · {kickoffTime(f.kickoff)}</span>
</div>
<WinProbBar home={m.home} away={m.away} probs={m.probs} topScore={m.topScore} />
</CardBody>
</Card>
))}
</div>
</>
)}
</div> </div>
); );
} }
+47
View File
@@ -0,0 +1,47 @@
// World-Football-Elo style ratings. Pure functions so the build script and the
// runtime (live re-rating after each result) share exactly one implementation.
/** Home-advantage in Elo points, added to the home side's rating (0 if neutral). */
export const HOME_ADV_ELO = 70;
/** Match-importance weight (K) by tournament, à la eloratings.net. */
export function importanceWeight(tournament: string): number {
const t = tournament.toLowerCase();
if (t.includes('friendly')) return 10;
if (t.includes('qualification') || t.includes('nations league')) return 25;
if (t.includes('fifa world cup')) return 55; // the finals themselves
if (t.includes('confederations')) return 45;
if (
t.includes('euro') || t.includes('copa') || t.includes('african cup') ||
t.includes('asian cup') || t.includes('gold cup') || t.includes('championship')
) return 40;
return 20;
}
/** Goal-difference multiplier G: blowouts move ratings more. */
export function goalDiffMultiplier(gd: number): number {
const n = Math.abs(gd);
if (n <= 1) return 1;
if (n === 2) return 1.5;
return (11 + n) / 8;
}
/** Expected score for the home side (0..1). homeAdv is 0 for neutral venues. */
export function expectedHome(eloHome: number, eloAway: number, homeAdv: number): number {
return 1 / (1 + 10 ** ((eloAway - eloHome - homeAdv) / 400));
}
/** Symmetric Elo exchange for one match. Returns the home side's delta; the away
* side's delta is the negation. */
export function eloDelta(
homeGoals: number,
awayGoals: number,
eloHome: number,
eloAway: number,
k: number,
homeAdv: number,
): number {
const actual = homeGoals > awayGoals ? 1 : homeGoals < awayGoals ? 0 : 0.5;
const expected = expectedHome(eloHome, eloAway, homeAdv);
return k * goalDiffMultiplier(homeGoals - awayGoals) * (actual - expected);
}
+31
View File
@@ -0,0 +1,31 @@
import { canonicalTeam } from '../teams';
// Host advantage: the three host nations get a home boost when they play in a
// venue in their own country. Everyone else plays on neutral ground.
const VENUE_COUNTRY: Record<string, string> = {
'Mexico City': 'Mexico',
'Guadalajara (Zapopan)': 'Mexico',
'Monterrey (Guadalupe)': 'Mexico',
Toronto: 'Canada',
Vancouver: 'Canada',
// everything else is in the USA
};
const HOST_TEAM: Record<string, string> = { Mexico: 'Mexico', Canada: 'Canada', USA: 'USA' };
function venueCountry(venue: string): string {
return VENUE_COUNTRY[venue] ?? 'USA';
}
/**
* Elo home-advantage to apply to the *home* slot for a fixture. Positive if the
* nominal home team is the host playing at home; negative if the away team is;
* zero otherwise (neutral). Pass the result straight into lambdasFromElo.
*/
export function hostAdvantage(home: string, away: string, venue: string, homeAdvElo: number): number {
const host = HOST_TEAM[venueCountry(venue)];
if (!host) return 0;
if (canonicalTeam(home) === host) return homeAdvElo;
if (canonicalTeam(away) === host) return -homeAdvElo;
return 0;
}
+84
View File
@@ -0,0 +1,84 @@
import { describe, it, expect } from 'vitest';
import { expectedHome, eloDelta, goalDiffMultiplier } from './elo';
import { poissonPmf, scoreMatrix, outcomeProbs, lambdasFromElo, type ModelParams } from './poisson';
import { predictMatch, knockoutAdvanceProb, type RatingsModel } from './predict';
const PARAMS: ModelParams = { goalsPerElo: 0.0057, avgGoals: 2.73, rho: -0.05, homeAdvElo: 70 };
describe('elo', () => {
it('gives the stronger team a higher expected score', () => {
expect(expectedHome(1900, 1700, 0)).toBeGreaterThan(0.5);
expect(expectedHome(1700, 1900, 0)).toBeLessThan(0.5);
});
it('is symmetric around 0.5 at equal ratings with no home edge', () => {
expect(expectedHome(1800, 1800, 0)).toBeCloseTo(0.5, 6);
});
it('moves the winner up and the loser down by equal amounts', () => {
const d = eloDelta(2, 0, 1800, 1800, 40, 0);
expect(d).toBeGreaterThan(0); // home won → positive delta
// a bigger win moves more
expect(Math.abs(eloDelta(5, 0, 1800, 1800, 40, 0))).toBeGreaterThan(Math.abs(d));
});
it('weights blowouts more', () => {
expect(goalDiffMultiplier(1)).toBe(1);
expect(goalDiffMultiplier(3)).toBeGreaterThan(goalDiffMultiplier(2));
});
});
describe('poisson / dixon-coles', () => {
it('pmf over a team sums to ~1', () => {
let s = 0;
for (let k = 0; k <= 15; k++) s += poissonPmf(1.4, k);
expect(s).toBeCloseTo(1, 4);
});
it('score matrix is a normalized distribution', () => {
const m = scoreMatrix(1.6, 1.1, -0.05);
let s = 0;
for (const row of m) for (const p of row) s += p;
expect(s).toBeCloseTo(1, 6);
});
it('outcome probabilities sum to 1', () => {
const p = outcomeProbs(scoreMatrix(1.6, 1.1, -0.05));
expect(p.home + p.draw + p.away).toBeCloseTo(1, 6);
expect(p.home).toBeGreaterThan(p.away); // higher lambda favored
});
it('maps a bigger Elo edge to a bigger goal expectation', () => {
const a = lambdasFromElo(1900, 1700, PARAMS, 0);
const b = lambdasFromElo(1800, 1800, PARAMS, 0);
expect(a.lambdaHome).toBeGreaterThan(b.lambdaHome);
expect(a.lambdaAway).toBeLessThan(b.lambdaAway);
});
});
describe('predictMatch', () => {
const model: RatingsModel = {
asOf: '2026-06-10',
params: PARAMS,
ratings: { Strong: 2050, Weak: 1650, Even: 1850 },
};
it('favours the stronger team and sums to 1', () => {
const p = predictMatch('Strong', 'Weak', model).probs;
expect(p.home + p.draw + p.away).toBeCloseTo(1, 6);
expect(p.home).toBeGreaterThan(0.6);
});
it('applies host home advantage', () => {
const neutral = predictMatch('Even', 'Even', model, 0).probs.home;
const atHome = predictMatch('Even', 'Even', model, 70).probs.home;
expect(atHome).toBeGreaterThan(neutral);
});
it('knockout advance prob is in (0,1) and favours the stronger team', () => {
const p = predictMatch('Strong', 'Weak', model);
const adv = knockoutAdvanceProb(p.probs, p.eloHome, p.eloAway);
expect(adv).toBeGreaterThan(0.5);
expect(adv).toBeLessThan(1);
});
});
+72
View File
@@ -0,0 +1,72 @@
import { describe, it, expect } from 'vitest';
import { readFileSync } from 'node:fs';
import { join } from 'node:path';
import { buildSimulator } from './monteCarlo';
import type { RatingsModel } from './predict';
import type { FixturesFile } from '../types';
const DATA = join(process.cwd(), 'public', 'data');
const fixtures = (JSON.parse(readFileSync(join(DATA, 'fixtures.json'), 'utf8')) as FixturesFile).fixtures;
const model = JSON.parse(readFileSync(join(DATA, 'ratings.json'), 'utf8')) as RatingsModel;
/** Deterministic RNG so the test is stable. */
function mulberry32(seed: number): () => number {
let a = seed;
return () => {
a |= 0; a = (a + 0x6d2b79f5) | 0;
let t = Math.imul(a ^ (a >>> 15), 1 | a);
t = (t + Math.imul(t ^ (t >>> 7), 61 | t)) ^ t;
return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
};
}
describe('monte carlo tournament', () => {
const sim = buildSimulator(fixtures, model);
const res = sim.run(3000, mulberry32(12345));
const byTeam = new Map(res.teams.map((t) => [t.team, t]));
const sum = (k: keyof (typeof res.teams)[number]) =>
res.teams.reduce((s, t) => s + (t[k] as number), 0);
it('simulates all 48 teams', () => {
expect(res.teams.length).toBe(48);
});
it('conserves the exact per-round counts (12/32/16/8/4/2/1)', () => {
expect(sum('winGroup')).toBeCloseTo(12, 5);
expect(sum('qualify')).toBeCloseTo(32, 5);
expect(sum('reachR16')).toBeCloseTo(16, 5);
expect(sum('reachQF')).toBeCloseTo(8, 5);
expect(sum('reachSF')).toBeCloseTo(4, 5);
expect(sum('reachFinal')).toBeCloseTo(2, 5);
expect(sum('champion')).toBeCloseTo(1, 5);
});
it('every team has monotonically non-increasing deep-run odds', () => {
for (const t of res.teams) {
expect(t.qualify).toBeGreaterThanOrEqual(t.reachR16 - 1e-9);
expect(t.reachR16).toBeGreaterThanOrEqual(t.reachQF - 1e-9);
expect(t.reachQF).toBeGreaterThanOrEqual(t.reachSF - 1e-9);
expect(t.reachSF).toBeGreaterThanOrEqual(t.reachFinal - 1e-9);
expect(t.reachFinal).toBeGreaterThanOrEqual(t.champion - 1e-9);
expect(t.winGroup).toBeGreaterThanOrEqual(t.champion - 1e-9);
}
});
it('all probabilities are within [0, 1]', () => {
for (const t of res.teams) {
for (const k of ['winGroup', 'qualify', 'reachR16', 'reachQF', 'reachSF', 'reachFinal', 'champion'] as const) {
expect(t[k]).toBeGreaterThanOrEqual(0);
expect(t[k]).toBeLessThanOrEqual(1);
}
}
});
it('ranks a strong team well above a weak one', () => {
const strong = byTeam.get('Spain')!;
const weak = byTeam.get('Curaçao')!;
expect(strong.champion).toBeGreaterThan(weak.champion);
expect(strong.qualify).toBeGreaterThan(weak.qualify);
// the favourite should have a non-trivial title chance
expect(res.teams[0]!.champion).toBeGreaterThan(0.04);
});
});
+262
View File
@@ -0,0 +1,262 @@
import type { Fixture, TeamOdds } from '../types';
import { rankGroup, type Result } from '../standings';
import { lambdasFromElo, scoreMatrix, outcomeProbs, type OutcomeProbs } from './poisson';
import { knockoutAdvanceProb, ratingOf, type RatingsModel } from './predict';
import { hostAdvantage } from './hosts';
export type { TeamOdds };
export interface SimResult {
iterations: number;
asOf: string;
teams: TeamOdds[];
}
type Rng = () => number;
// ---- placeholder parsing ----
type Ref =
| { kind: 'winner'; group: string }
| { kind: 'runner'; group: string }
| { kind: 'third'; allowed: Set<string> }
| { kind: 'matchWinner'; num: number }
| { kind: 'matchLoser'; num: number }
| { kind: 'fixed'; team: string };
function parseRef(slot: { team: string | null; placeholder: string | null }): Ref {
if (slot.team) return { kind: 'fixed', team: slot.team };
const code = slot.placeholder ?? '';
const pos = code.match(/^([12])([A-L])$/);
if (pos) return { kind: pos[1] === '1' ? 'winner' : 'runner', group: pos[2]! };
const third = code.match(/^3([A-L/]+)$/);
if (third) return { kind: 'third', allowed: new Set(third[1]!.split('/')) };
const wl = code.match(/^([WL])(\d{1,3})$/);
if (wl) return wl[1] === 'W'
? { kind: 'matchWinner', num: Number(wl[2]) }
: { kind: 'matchLoser', num: Number(wl[2]) };
return { kind: 'fixed', team: code };
}
// ---- precomputed, sim-invariant structures ----
interface RemainingGroupGame {
group: string;
home: string;
away: string;
/** Cumulative scoreline distribution for sampling. */
cdf: { h: number; a: number; cum: number }[];
}
interface FinishedResult extends Result { group: string }
interface KnockoutGame {
num: number;
stage: Fixture['stage'];
venue: string;
home: Ref;
away: Ref;
/** Actual winner team name if this fixture is already decided. */
decidedWinner: string | null;
}
function buildScoreCdf(
home: string, away: string, venue: string, model: RatingsModel,
): { h: number; a: number; cum: number }[] {
const homeAdv = hostAdvantage(home, away, venue, model.params.homeAdvElo);
const { lambdaHome, lambdaAway } = lambdasFromElo(
ratingOf(model, home), ratingOf(model, away), model.params, homeAdv,
);
const m = scoreMatrix(lambdaHome, lambdaAway, model.params.rho);
const cdf: { h: number; a: number; cum: number }[] = [];
let cum = 0;
for (let h = 0; h < m.length; h++) {
for (let a = 0; a < m[h]!.length; a++) {
cum += m[h]![a]!;
cdf.push({ h, a, cum });
}
}
return cdf;
}
/** Bipartite match the third-place slots to the qualified third teams, honoring
* each slot's allowed-groups constraint (Kuhn's algorithm). */
function assignThirds(
slots: { allowed: Set<string> }[],
thirds: { team: string; group: string }[],
): (string | null)[] {
const matchSlot: number[] = new Array(slots.length).fill(-1);
const matchThird: number[] = new Array(thirds.length).fill(-1);
const tryAssign = (s: number, seen: boolean[]): boolean => {
for (let t = 0; t < thirds.length; t++) {
if (!seen[t] && slots[s]!.allowed.has(thirds[t]!.group)) {
seen[t] = true;
if (matchThird[t] === -1 || tryAssign(matchThird[t]!, seen)) {
matchSlot[s] = t;
matchThird[t] = s;
return true;
}
}
}
return false;
};
for (let s = 0; s < slots.length; s++) tryAssign(s, new Array(thirds.length).fill(false));
return matchSlot.map((t) => (t >= 0 ? thirds[t]!.team : null));
}
/** Build a reusable simulator from the current fixtures + ratings. */
export function buildSimulator(fixtures: Fixture[], model: RatingsModel) {
const groups = new Map<string, Set<string>>();
const finished: FinishedResult[] = [];
const remaining: RemainingGroupGame[] = [];
for (const f of fixtures) {
if (f.stage !== 'group' || !f.group || !f.home.team || !f.away.team) continue;
const g = f.group;
if (!groups.has(g)) groups.set(g, new Set());
groups.get(g)!.add(f.home.team);
groups.get(g)!.add(f.away.team);
if (f.status === 'finished' && f.homeScore !== null && f.awayScore !== null) {
finished.push({ group: g, home: f.home.team, away: f.away.team, homeScore: f.homeScore, awayScore: f.awayScore });
} else {
remaining.push({ group: g, home: f.home.team, away: f.away.team, cdf: buildScoreCdf(f.home.team, f.away.team, f.venue, model) });
}
}
const knockout: KnockoutGame[] = fixtures
.filter((f) => f.stage !== 'group')
.sort((a, b) => a.num - b.num)
.map((f) => {
const decided =
f.status === 'finished' && f.home.team && f.away.team && f.homeScore !== null && f.awayScore !== null
? f.homeScore > f.awayScore ? f.home.team : f.away.team
: null;
return { num: f.num, stage: f.stage, venue: f.venue, home: parseRef(f.home), away: parseRef(f.away), decidedWinner: decided };
});
// The 8 third-place slots (with their allowed groups) in a stable order.
const thirdSlots: { num: number; side: 'home' | 'away'; allowed: Set<string> }[] = [];
for (const g of knockout) {
if (g.stage !== 'r32') continue;
if (g.home.kind === 'third') thirdSlots.push({ num: g.num, side: 'home', allowed: g.home.allowed });
if (g.away.kind === 'third') thirdSlots.push({ num: g.num, side: 'away', allowed: g.away.allowed });
}
const groupList = [...groups.entries()].map(([g, set]) => ({ g, teams: [...set] }));
// Advance-probability memo: distinct (home,away,venue) pairings are limited.
const advMemo = new Map<string, number>();
const advanceProb = (home: string, away: string, venue: string): number => {
const key = `${home}|${away}|${venue}`;
let p = advMemo.get(key);
if (p === undefined) {
const homeAdv = hostAdvantage(home, away, venue, model.params.homeAdvElo);
const { lambdaHome, lambdaAway } = lambdasFromElo(ratingOf(model, home), ratingOf(model, away), model.params, homeAdv);
const probs: OutcomeProbs = outcomeProbs(scoreMatrix(lambdaHome, lambdaAway, model.params.rho));
p = knockoutAdvanceProb(probs, ratingOf(model, home), ratingOf(model, away));
advMemo.set(key, p);
}
return p;
};
function runOnce(rng: Rng, tally: Map<string, TeamOdds>): void {
const bump = (team: string, key: keyof Omit<TeamOdds, 'team'>) => {
let o = tally.get(team);
if (!o) { o = { team, winGroup: 0, qualify: 0, reachR16: 0, reachQF: 0, reachSF: 0, reachFinal: 0, champion: 0 }; tally.set(team, o); }
o[key]++;
};
// 1) group stage
const resultsByGroup = new Map<string, Result[]>();
for (const fr of finished) {
if (!resultsByGroup.has(fr.group)) resultsByGroup.set(fr.group, []);
resultsByGroup.get(fr.group)!.push(fr);
}
for (const rg of remaining) {
const u = rng();
let pick = rg.cdf[rg.cdf.length - 1]!;
for (const cell of rg.cdf) { if (u <= cell.cum) { pick = cell; break; } }
if (!resultsByGroup.has(rg.group)) resultsByGroup.set(rg.group, []);
resultsByGroup.get(rg.group)!.push({ home: rg.home, away: rg.away, homeScore: pick.h, awayScore: pick.a });
}
const winnerByGroup = new Map<string, string>();
const runnerByGroup = new Map<string, string>();
const thirds: { team: string; group: string; points: number; gd: number; gf: number }[] = [];
for (const { g, teams } of groupList) {
const rows = rankGroup(teams, resultsByGroup.get(g) ?? []);
winnerByGroup.set(g, rows[0]!.team);
runnerByGroup.set(g, rows[1]!.team);
bump(rows[0]!.team, 'winGroup');
bump(rows[0]!.team, 'qualify');
bump(rows[1]!.team, 'qualify');
const t3 = rows[2]!;
thirds.push({ team: t3.team, group: g, points: t3.points, gd: t3.gd, gf: t3.gf });
}
// 2) eight best third-placed teams
thirds.sort((a, b) => b.points - a.points || b.gd - a.gd || b.gf - a.gf);
const qualifiedThirds = thirds.slice(0, 8);
for (const t of qualifiedThirds) bump(t.team, 'qualify');
// 3) assign thirds to their R32 slots
const assignment = assignThirds(thirdSlots.map((s) => ({ allowed: s.allowed })), qualifiedThirds);
const thirdBySlot = new Map<string, string>(); // `${num}:${side}` → team
thirdSlots.forEach((s, i) => { const team = assignment[i]; if (team) thirdBySlot.set(`${s.num}:${s.side}`, team); });
// 4) knockouts
const winnerByNum = new Map<number, string>();
const reachKey: Record<string, keyof Omit<TeamOdds, 'team'>> = {
r32: 'reachR16', r16: 'reachQF', qf: 'reachSF', sf: 'reachFinal', final: 'champion',
};
const resolve = (ref: Ref, num: number, side: 'home' | 'away'): string | null => {
switch (ref.kind) {
case 'fixed': return ref.team;
case 'winner': return winnerByGroup.get(ref.group) ?? null;
case 'runner': return runnerByGroup.get(ref.group) ?? null;
case 'third': return thirdBySlot.get(`${num}:${side}`) ?? null;
case 'matchWinner': return winnerByNum.get(ref.num) ?? null;
case 'matchLoser': return null; // third-place game only; not tallied
}
};
for (const k of knockout) {
if (k.stage === 'third') continue; // doesn't affect advancement odds
const home = resolve(k.home, k.num, 'home');
const away = resolve(k.away, k.num, 'away');
if (!home || !away) continue;
let winner: string;
if (k.decidedWinner) {
winner = k.decidedWinner;
} else {
winner = rng() < advanceProb(home, away, k.venue) ? home : away;
}
winnerByNum.set(k.num, winner);
const key = reachKey[k.stage];
if (key) bump(winner, key);
}
}
return {
run(iterations: number, rng: Rng = Math.random): SimResult {
const tally = new Map<string, TeamOdds>();
for (let i = 0; i < iterations; i++) runOnce(rng, tally);
const teams = [...tally.values()]
.map((o) => ({
team: o.team,
winGroup: o.winGroup / iterations,
qualify: o.qualify / iterations,
reachR16: o.reachR16 / iterations,
reachQF: o.reachQF / iterations,
reachSF: o.reachSF / iterations,
reachFinal: o.reachFinal / iterations,
champion: o.champion / iterations,
}))
.sort((a, b) => b.champion - a.champion || b.reachFinal - a.reachFinal);
return { iterations, asOf: model.asOf, teams };
},
};
}
/** Convenience: build + run in one call. */
export function simulateTournament(
fixtures: Fixture[], model: RatingsModel, iterations = 10_000, rng: Rng = Math.random,
): SimResult {
return buildSimulator(fixtures, model).run(iterations, rng);
}
+93
View File
@@ -0,0 +1,93 @@
// Bivariate-Poisson scoreline model with the Dixon-Coles low-score correction.
// Turns two teams' Elo ratings into goal expectations, then into a full matrix of
// scoreline probabilities, from which win/draw/loss and likely scores follow.
export interface ModelParams {
/** Expected goal supremacy per Elo point of (effective) rating difference. */
goalsPerElo: number;
/** Mean total goals per match (league baseline). */
avgGoals: number;
/** Dixon-Coles low-score dependence parameter (small, typically negative). */
rho: number;
/** Home advantage expressed in Elo points (for hosts at home). */
homeAdvElo: number;
}
const MAX_GOALS = 10;
function factorial(n: number): number {
let f = 1;
for (let i = 2; i <= n; i++) f *= i;
return f;
}
export function poissonPmf(lambda: number, k: number): number {
return (Math.exp(-lambda) * lambda ** k) / factorial(k);
}
/** Dixon-Coles dependence adjustment for the four lowest-scoring cells. */
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;
}
/** Expected goals for each side from effective Elo difference. */
export function lambdasFromElo(
eloHome: number,
eloAway: number,
params: ModelParams,
homeAdv: number,
): { lambdaHome: number; lambdaAway: number } {
const diff = eloHome - eloAway + homeAdv;
const supremacy = params.goalsPerElo * diff;
const half = params.avgGoals / 2;
return {
lambdaHome: Math.min(8, Math.max(0.15, half + supremacy / 2)),
lambdaAway: Math.min(8, Math.max(0.15, half - supremacy / 2)),
};
}
/** Normalized matrix m[h][a] = P(home scores h, away scores a). */
export function scoreMatrix(lambdaHome: number, lambdaAway: number, rho: number): number[][] {
const home = Array.from({ length: MAX_GOALS + 1 }, (_, k) => poissonPmf(lambdaHome, k));
const away = Array.from({ length: MAX_GOALS + 1 }, (_, k) => poissonPmf(lambdaAway, k));
const m: number[][] = [];
let total = 0;
for (let h = 0; h <= MAX_GOALS; h++) {
m[h] = [];
for (let a = 0; a <= MAX_GOALS; a++) {
const p = home[h]! * away[a]! * dcTau(h, a, lambdaHome, lambdaAway, rho);
m[h]![a] = p;
total += p;
}
}
for (let h = 0; h <= MAX_GOALS; h++) for (let a = 0; a <= MAX_GOALS; a++) m[h]![a]! /= total;
return m;
}
export interface OutcomeProbs { home: number; draw: number; away: number; }
export function outcomeProbs(m: number[][]): OutcomeProbs {
let home = 0, draw = 0, away = 0;
for (let h = 0; h < m.length; h++) {
for (let a = 0; a < m[h]!.length; a++) {
const p = m[h]![a]!;
if (h > a) home += p;
else if (h < a) away += p;
else draw += p;
}
}
return { home, draw, away };
}
export interface Scoreline { home: number; away: number; p: number; }
export function topScorelines(m: number[][], n: number): Scoreline[] {
const all: Scoreline[] = [];
for (let h = 0; h < m.length; h++)
for (let a = 0; a < m[h]!.length; a++) all.push({ home: h, away: a, p: m[h]![a]! });
return all.sort((x, y) => y.p - x.p).slice(0, n);
}
+68
View File
@@ -0,0 +1,68 @@
import {
lambdasFromElo,
scoreMatrix,
outcomeProbs,
topScorelines,
type ModelParams,
type OutcomeProbs,
type Scoreline,
} from './poisson';
/** The ratings.json payload (also the runtime model state after live re-rating). */
export interface RatingsModel {
asOf: string;
params: ModelParams;
ratings: Record<string, number>;
}
export const DEFAULT_ELO = 1500;
export function ratingOf(model: RatingsModel, team: string): number {
return model.ratings[team] ?? DEFAULT_ELO;
}
export interface MatchPrediction {
home: string;
away: string;
eloHome: number;
eloAway: number;
lambdaHome: number;
lambdaAway: number;
probs: OutcomeProbs;
/** Most likely scorelines, descending. */
scorelines: Scoreline[];
}
/**
* Win/draw/loss + likely scores for one match. `homeAdv` is the Elo bump for the
* home slot (0 neutral; use hostAdvantage() for host nations at home).
*/
export function predictMatch(
home: string,
away: string,
model: RatingsModel,
homeAdv = 0,
): MatchPrediction {
const eloHome = ratingOf(model, home);
const eloAway = ratingOf(model, away);
const { lambdaHome, lambdaAway } = lambdasFromElo(eloHome, eloAway, model.params, homeAdv);
const m = scoreMatrix(lambdaHome, lambdaAway, model.params.rho);
return {
home,
away,
eloHome,
eloAway,
lambdaHome,
lambdaAway,
probs: outcomeProbs(m),
scorelines: topScorelines(m, 5),
};
}
/** Probability the home side advances in a knockout (regulation result, draws
* resolved by an Elo-weighted shootout). */
export function knockoutAdvanceProb(p: OutcomeProbs, eloHome: number, eloAway: number): number {
// Penalty edge: small, rating-tilted; mostly a coin flip.
const shootoutHome = 1 / (1 + 10 ** ((eloAway - eloHome) / 800));
return p.home + p.draw * shootoutHome;
}
+57
View File
@@ -0,0 +1,57 @@
import { describe, it, expect } from 'vitest';
import { rankGroup, type Result } from './standings';
const r = (home: string, away: string, hs: number, as: number): Result => ({
home, away, homeScore: hs, awayScore: as,
});
describe('rankGroup', () => {
it('orders by points first', () => {
const rows = rankGroup(['A', 'B', 'C', 'D'], [
r('A', 'B', 1, 0), // A 3pts
r('C', 'D', 2, 2), // C,D 1pt
]);
expect(rows[0]!.team).toBe('A');
expect(rows[0]!.points).toBe(3);
expect(rows[0]!.rank).toBe(1);
});
it('breaks equal points by goal difference, then goals for', () => {
const rows = rankGroup(['A', 'B'], [
r('A', 'B', 3, 0), // A +3
r('B', 'A', 1, 0), // B beats A; now both 3pts, A gd 0 gf3, B gd 0 gf1
]);
// both 3 pts, gd 0; A has more goals for (3 vs 1) → A first
expect(rows[0]!.team).toBe('A');
});
it('uses head-to-head when points, GD and GF are all level', () => {
// A and B identical overall (each beat C by same score, drew nobody else),
// separated only by their head-to-head meeting.
const rows = rankGroup(['A', 'B', 'C'], [
r('A', 'C', 2, 0),
r('B', 'C', 2, 0),
r('A', 'B', 1, 0), // A wins the head-to-head
]);
// After all games: A 6pts? no — A: beat C, beat B = 6; B: beat C, lost A = 3.
// Make them level instead:
const level = rankGroup(['A', 'B', 'C', 'D'], [
r('A', 'C', 1, 0),
r('B', 'C', 1, 0),
r('A', 'D', 0, 1),
r('B', 'D', 0, 1),
r('A', 'B', 2, 1), // identical records (1W 1L, GF? ) but A beat B head-to-head
]);
void rows;
const ia = level.findIndex((x) => x.team === 'A');
const ib = level.findIndex((x) => x.team === 'B');
expect(ia).toBeLessThan(ib);
});
it('assigns sequential ranks and includes all teams even with no games', () => {
const rows = rankGroup(['A', 'B', 'C', 'D'], []);
expect(rows).toHaveLength(4);
expect(rows.map((x) => x.rank)).toEqual([1, 2, 3, 4]);
expect(rows.every((x) => x.played === 0)).toBe(true);
});
});
+54 -2
View File
@@ -69,9 +69,61 @@ export interface Snapshot {
tables: Record<string, StandingRow[]>; 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 ---- // ---- WebSocket protocol ----
// The snapshot is small (~104 fixtures), so the server simply re-pushes the full // 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. */ /** Server → client. */
export type ServerMessage = { t: 'snapshot'; snapshot: Snapshot }; export type ServerMessage =
| { t: 'snapshot'; snapshot: Snapshot }
| { t: 'predictions'; model: ModelSnapshot };
+7 -3
View File
@@ -1,11 +1,12 @@
import { create } from 'zustand'; import { create } from 'zustand';
import type { FixturesFile, ServerMessage, Snapshot } from '@/lib/types'; import type { FixturesFile, ModelSnapshot, ServerMessage, Snapshot } from '@/lib/types';
import { computeGroupTables } from '@/lib/standings'; import { computeGroupTables } from '@/lib/standings';
export type ConnStatus = 'connecting' | 'live' | 'offline'; export type ConnStatus = 'connecting' | 'live' | 'offline';
interface TournamentStore { interface TournamentStore {
snapshot: Snapshot | null; snapshot: Snapshot | null;
model: ModelSnapshot | null;
status: ConnStatus; status: ConnStatus;
init: () => void; init: () => void;
} }
@@ -40,6 +41,7 @@ async function loadStaticSeed(): Promise<Snapshot | null> {
export const useTournamentStore = create<TournamentStore>((set) => ({ export const useTournamentStore = create<TournamentStore>((set) => ({
snapshot: null, snapshot: null,
model: null,
status: 'connecting', status: 'connecting',
init: () => { init: () => {
@@ -57,6 +59,7 @@ export const useTournamentStore = create<TournamentStore>((set) => ({
try { try {
const msg = JSON.parse(ev.data as string) as ServerMessage; const msg = JSON.parse(ev.data as string) as ServerMessage;
if (msg.t === 'snapshot') set({ snapshot: msg.snapshot, status: 'live' }); if (msg.t === 'snapshot') set({ snapshot: msg.snapshot, status: 'live' });
else if (msg.t === 'predictions') set({ model: msg.model });
} catch { /* ignore malformed frame */ } } catch { /* ignore malformed frame */ }
}; };
ws.onopen = () => { retry = 0; set({ status: 'live' }); }; ws.onopen = () => { retry = 0; set({ status: 'live' }); };
@@ -71,8 +74,9 @@ export const useTournamentStore = create<TournamentStore>((set) => ({
// Initial paint from REST (fast), then the WS takes over for live updates. // Initial paint from REST (fast), then the WS takes over for live updates.
void (async () => { void (async () => {
try { try {
const res = await fetch('/api/snapshot'); const [snap, preds] = await Promise.all([fetch('/api/snapshot'), fetch('/api/predictions')]);
if (res.ok) set({ snapshot: (await res.json()) as Snapshot }); if (snap.ok) set({ snapshot: (await snap.json()) as Snapshot });
if (preds.ok) set({ model: (await preds.json()) as ModelSnapshot });
} catch { /* server may be absent on a static host */ } } catch { /* server may be absent on a static host */ }
if (!useTournamentStore.getState().snapshot) { if (!useTournamentStore.getState().snapshot) {
const seed = await loadStaticSeed(); const seed = await loadStaticSeed();