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:
@@ -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
@@ -7,6 +7,7 @@ import fastifyStatic from '@fastify/static';
|
||||
import type { WebSocket } from 'ws';
|
||||
import { TournamentState } from './tournament';
|
||||
import { startLiveLoop } from './liveLoop';
|
||||
import { ModelEngine } from './model';
|
||||
import type { MatchStatus, ServerMessage } from '../../src/lib/types';
|
||||
|
||||
const PORT = Number(process.env.PORT ?? 8787);
|
||||
@@ -27,20 +28,30 @@ export function buildServer() {
|
||||
});
|
||||
|
||||
const state = new TournamentState(STATIC_DIR);
|
||||
const model = new ModelEngine(STATIC_DIR);
|
||||
model.recompute(state.allFixtures());
|
||||
const clients = new Set<WebSocket>();
|
||||
|
||||
const snapshotMessage = (): string =>
|
||||
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 msg = snapshotMessage();
|
||||
const sendAll = (msg: string): void => {
|
||||
for (const ws of clients) {
|
||||
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/predictions', async () => model.current());
|
||||
app.get('/healthz', async () => ({ ok: true }));
|
||||
|
||||
// ---- 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; }
|
||||
clients.add(socket);
|
||||
try { socket.send(snapshotMessage()); } catch { /* closed */ }
|
||||
try { socket.send(snapshotMessage()); socket.send(predictionsMessage()); } catch { /* closed */ }
|
||||
|
||||
let alive = true;
|
||||
socket.on('pong', () => { alive = true; });
|
||||
|
||||
Binary file not shown.
@@ -63,6 +63,11 @@ export class TournamentState {
|
||||
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
|
||||
* poll the APIs often during match windows and rarely when nothing is on. */
|
||||
hasLiveActivity(windowMs: number): boolean {
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -4,7 +4,7 @@ import { useTournamentStore } from '@/stores/tournamentStore';
|
||||
import { teamFlag } from '@/lib/teams';
|
||||
import { kickoffDay, kickoffTime } from '@/lib/format';
|
||||
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 }[] = [
|
||||
{ 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 showProbs = !!probs && !!f.home.team && !!f.away.team && f.status !== 'finished';
|
||||
return (
|
||||
<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!} />
|
||||
<div className="h-px bg-line" />
|
||||
<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">
|
||||
M{f.num} · {f.status === 'finished' ? 'FT' : `${kickoffDay(f.kickoff)} ${kickoffTime(f.kickoff)}`}
|
||||
</div>
|
||||
@@ -48,6 +65,11 @@ function BracketMatch({ f }: { f: Fixture }) {
|
||||
|
||||
export function BracketPage() {
|
||||
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 fixtures = snapshot?.fixtures ?? [];
|
||||
@@ -69,7 +91,7 @@ export function BracketPage() {
|
||||
<div key={col.stage} className="flex shrink-0 flex-col gap-3">
|
||||
<h2 className="smallcaps">{col.label}</h2>
|
||||
{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>
|
||||
))}
|
||||
|
||||
@@ -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 { 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 (
|
||||
<div>
|
||||
<PageHeader title="Predict" subtitle="Elo + Dixon-Coles + Monte Carlo — who lifts the trophy?" />
|
||||
<Placeholder icon={<TrendingUp size={28} />}>
|
||||
Match win/draw/loss bars, simulated championship odds and an odds-over-time chart arrive
|
||||
in Phase 2.
|
||||
</Placeholder>
|
||||
<div className="space-y-2">
|
||||
{top.map((t, i) => (
|
||||
<div key={t.team} className="flex items-center gap-3">
|
||||
<span className="w-4 text-right text-xs font-bold tabular-nums text-faint">{i + 1}</span>
|
||||
<span aria-hidden className="text-lg leading-none">{teamFlag(t.team)}</span>
|
||||
<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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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
@@ -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 };
|
||||
|
||||
@@ -1,11 +1,12 @@
|
||||
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';
|
||||
|
||||
export type ConnStatus = 'connecting' | 'live' | 'offline';
|
||||
|
||||
interface TournamentStore {
|
||||
snapshot: Snapshot | null;
|
||||
model: ModelSnapshot | null;
|
||||
status: ConnStatus;
|
||||
init: () => void;
|
||||
}
|
||||
@@ -40,6 +41,7 @@ async function loadStaticSeed(): Promise<Snapshot | null> {
|
||||
|
||||
export const useTournamentStore = create<TournamentStore>((set) => ({
|
||||
snapshot: null,
|
||||
model: null,
|
||||
status: 'connecting',
|
||||
|
||||
init: () => {
|
||||
@@ -57,6 +59,7 @@ export const useTournamentStore = create<TournamentStore>((set) => ({
|
||||
try {
|
||||
const msg = JSON.parse(ev.data as string) as ServerMessage;
|
||||
if (msg.t === 'snapshot') set({ snapshot: msg.snapshot, status: 'live' });
|
||||
else if (msg.t === 'predictions') set({ model: msg.model });
|
||||
} catch { /* ignore malformed frame */ }
|
||||
};
|
||||
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.
|
||||
void (async () => {
|
||||
try {
|
||||
const res = await fetch('/api/snapshot');
|
||||
if (res.ok) set({ snapshot: (await res.json()) as Snapshot });
|
||||
const [snap, preds] = await Promise.all([fetch('/api/snapshot'), fetch('/api/predictions')]);
|
||||
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 */ }
|
||||
if (!useTournamentStore.getState().snapshot) {
|
||||
const seed = await loadStaticSeed();
|
||||
|
||||
Reference in New Issue
Block a user