From d604bb14ffe6ba0b402434c8de4c50a45f677122 Mon Sep 17 00:00:00 2001 From: Nils Briggen Date: Thu, 11 Jun 2026 13:25:53 +0200 Subject: [PATCH] =?UTF-8?q?Phase=202:=20predictive=20model=20=E2=80=94=20E?= =?UTF-8?q?lo=20+=20Dixon-Coles=20+=20Monte=20Carlo?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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 --- scripts/buildRatings.ts | 146 +++++++++++ server/src/index.ts | 19 +- server/src/model.ts | Bin 0 -> 4542 bytes server/src/tournament.ts | 5 + src/components/OddsChart.tsx | 69 +++++ src/components/OddsTable.tsx | 59 +++++ src/components/WinProbBar.tsx | 48 ++++ src/features/bracket/BracketPage.tsx | 28 +- src/features/predictions/PredictionsPage.tsx | 130 ++++++++- src/lib/model/elo.ts | 47 ++++ src/lib/model/hosts.ts | 31 +++ src/lib/model/model.test.ts | 84 ++++++ src/lib/model/monteCarlo.test.ts | 72 +++++ src/lib/model/monteCarlo.ts | 262 +++++++++++++++++++ src/lib/model/poisson.ts | 93 +++++++ src/lib/model/predict.ts | 68 +++++ src/lib/standings.test.ts | 57 ++++ src/lib/types.ts | 56 +++- src/stores/tournamentStore.ts | 10 +- 19 files changed, 1263 insertions(+), 21 deletions(-) create mode 100644 scripts/buildRatings.ts create mode 100644 server/src/model.ts create mode 100644 src/components/OddsChart.tsx create mode 100644 src/components/OddsTable.tsx create mode 100644 src/components/WinProbBar.tsx create mode 100644 src/lib/model/elo.ts create mode 100644 src/lib/model/hosts.ts create mode 100644 src/lib/model/model.test.ts create mode 100644 src/lib/model/monteCarlo.test.ts create mode 100644 src/lib/model/monteCarlo.ts create mode 100644 src/lib/model/poisson.ts create mode 100644 src/lib/model/predict.ts create mode 100644 src/lib/standings.test.ts diff --git a/scripts/buildRatings.ts b/scripts/buildRatings.ts new file mode 100644 index 0000000..cfe8c2d --- /dev/null +++ b/scripts/buildRatings.ts @@ -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(); + 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 = {}; + 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(); diff --git a/server/src/index.ts b/server/src/index.ts index 76cecc6..617bc13 100644 --- a/server/src/index.ts +++ b/server/src/index.ts @@ -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(); 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; }); diff --git a/server/src/model.ts b/server/src/model.ts new file mode 100644 index 0000000000000000000000000000000000000000..82f589a64f4bf2a7e3821f1b08d100a2fecf5fe8 GIT binary patch literal 4542 zcmb7H+iu)O65VHgMJWg}q;|v^2UsAW*EY1iAlj_0zzX08!LWMBriL+xO>{RU55@!R zKkOgoOZHS>IHVLAyM~M*yQ{0~a_UsE-gHV^+L4x`d{Z~_a@!Vjl3!|L4L{SKDy=p& zYgH-p%FLb~)$xpM)GOIoF(2IFh zsMgAtLXTZ>rtj!Y{l)fL&dGW7tM{%}Ef(0H%T{z|t?ZmG%F_IdI4iyVRn_fdL1VRS z=24+qW9j_2*YD4MJHL2$NzdqAzgbD0bXpbCm`t{}+2#4$Pb`zt*RS;B^3!s;d>W2^ zc=_oU9J-&ZWcKF#%^7`Ax+&>p-_257R`qRRVKp^Mlp!C9u}Dnus@6FftC6BKpXO?J zMC8pZL|fLSu+rehD=ZUTf4>_P>=)P*3+)q~{ccoyJ&#h+o#L)c(*2_AS4~~~9|uYV z^%3!pPjN|w+_eVxN(zNi-%4^tm{Y^guNwu&PQCg$ zbyA;hxDFw&R$^5*wXLO@6OwsEC+va*iUECc*e9zH6S0v1ukm#Q$iM+vs7=>%@>Sbx z>257s+++I28Y9Wx2`WE@TyYv{;aV8@Rk7qnx8vtx<0}`a+_)6I;Vc zF{67;sONlI-t?8)zG$R>Z~m86bz{M|NtM#G=Tv3PXFVA18Bi3D z&FF_8u(ehjnOP||{#5`wqgUR&ER>dXjI$0Y?i7nn*VA~nL8DF*G~1QCTx4y(p;KD% zZ^)0g|Fc7NG$-4LE*cC5u2_7Z^TS}n{*+&bt3e=vPQJ#(GapW}&Q7^|V7=Rj*P)~a zuB=33q({zu8K4`-U0KAA;Rq>c)ABt^A35`NI&setlxr(a>3DpQW=Qkg5!~b;7z)=; zoBVzG4Aq?+S}e|wfB~Aa&=9SXHGLr8K(y>#<0L>b>RuLn038bCm!Y4$z2{`j9X@1S0KEQ6}J`14RMa{-N;s=rOEQ_e{fWo zi^_Myj31-RHR>G03Fs>yja+dzU@*QaOmd?D#pPgiKJmeC?tO|Pp@Q&u!O=SN=s&mt z_^xc|uV}xnQ}-PYRBn8cw^DBhBpznBK&srrDxm`j#ThM+LaZqYIjtbCLC9yU46*rK7oSyyUKgwW$#S@pYnSDo zyL9p7>ym~ge<6>(!`QHM+n6$4bV5})#Q2qY!~;3xxMGOVeT3yl5cY&Woez03;&B%A z$e);~W( { + 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 = { 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 ( +
+ + + + + + data[Number(i) - 1]?.label ?? ''} + formatter={(v: number, name: string) => [`${v}%`, `${teamFlag(name)} ${name}`]} + /> + `${teamFlag(name)} ${name}`} wrapperStyle={{ fontSize: 12 }} /> + {teams.map((t, idx) => ( + + ))} + + +
+ ); +} diff --git a/src/components/OddsTable.tsx b/src/components/OddsTable.tsx new file mode 100644 index 0000000..32dea25 --- /dev/null +++ b/src/components/OddsTable.tsx @@ -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; 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 ( + = 0.005 ? { background: `color-mix(in oklab, var(--app-${accent ? 'gold' : 'accent'}) ${Math.round(x * 60)}%, transparent)` } : undefined} + > + {pct(x)} + + ); +} + +export function OddsTable({ odds }: { odds: TeamOdds[] }) { + return ( +
+ + + + + {cols.map((c) => ( + + ))} + + + + {odds.map((o) => ( + + + {cols.map((c) => ( + + ))} + + ))} + +
Team{c.label}
+ + {teamFlag(o.team)} + {o.team} + +
+
+ ); +} diff --git a/src/components/WinProbBar.tsx b/src/components/WinProbBar.tsx new file mode 100644 index 0000000..7d9a35a --- /dev/null +++ b/src/components/WinProbBar.tsx @@ -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 ( +
+
+ + {teamFlag(home)} + {home} + + {topScore && ( + + {topScore.home}–{topScore.away} + + )} + + {away} + {teamFlag(away)} + +
+
+
+
+
+
+
+ {pct(probs.home)} + draw {pct(probs.draw)} + {pct(probs.away)} +
+
+ ); +} diff --git a/src/features/bracket/BracketPage.tsx b/src/features/bracket/BracketPage.tsx index 00533bd..3e87354 100644 --- a/src/features/bracket/BracketPage.tsx +++ b/src/features/bracket/BracketPage.tsx @@ -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 ( +
+ {home}% +
+
+
+
+ {100 - home}% +
+ ); +} + +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 (
f.awayScore!} />
f.homeScore!} /> + {showProbs && }
M{f.num} · {f.status === 'finished' ? 'FT' : `${kickoffDay(f.kickoff)} ${kickoffTime(f.kickoff)}`}
@@ -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() {

{col.label}

{byStage[col.stage].length - ? byStage[col.stage].map((f) => ) + ? byStage[col.stage].map((f) => ) :
}
))} diff --git a/src/features/predictions/PredictionsPage.tsx b/src/features/predictions/PredictionsPage.tsx index 15c93dc..592c72d 100644 --- a/src/features/predictions/PredictionsPage.tsx +++ b/src/features/predictions/PredictionsPage.tsx @@ -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 ( -
- - }> - Match win/draw/loss bars, simulated championship odds and an odds-over-time chart arrive - in Phase 2. - +
+ {top.map((t, i) => ( +
+ {i + 1} + {teamFlag(t.team)} + {t.team} +
+
+
+ + {(t.champion * 100).toFixed(1)}% + +
+ ))} +
+ ); +} + +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 ( +
+ +
+
+ ); + } + + return ( +
+ + +
+ + + + Who lifts the trophy? + + + + + + + + + + Title race over time + + + {model.history.length >= 2 ? ( + }> + + + ) : ( +
+

+ The title-race chart fills in as results come in — each completed match re-rates + the teams and re-runs the simulation. +

+
+ )} +
+
+
+ +

Full odds — group, knockout & title

+
+ +
+ + {upcoming.length > 0 && ( + <> +

Next match predictions

+
+ {upcoming.map(({ m, f }) => ( + + +
+ {f.group ? `Group ${f.group}` : f.round} + {kickoffDay(f.kickoff)} · {kickoffTime(f.kickoff)} +
+ +
+
+ ))} +
+ + )}
); } diff --git a/src/lib/model/elo.ts b/src/lib/model/elo.ts new file mode 100644 index 0000000..897dae7 --- /dev/null +++ b/src/lib/model/elo.ts @@ -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); +} diff --git a/src/lib/model/hosts.ts b/src/lib/model/hosts.ts new file mode 100644 index 0000000..0f4a9b6 --- /dev/null +++ b/src/lib/model/hosts.ts @@ -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 = { + 'Mexico City': 'Mexico', + 'Guadalajara (Zapopan)': 'Mexico', + 'Monterrey (Guadalupe)': 'Mexico', + Toronto: 'Canada', + Vancouver: 'Canada', + // everything else is in the USA +}; +const HOST_TEAM: Record = { 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; +} diff --git a/src/lib/model/model.test.ts b/src/lib/model/model.test.ts new file mode 100644 index 0000000..7329d02 --- /dev/null +++ b/src/lib/model/model.test.ts @@ -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); + }); +}); diff --git a/src/lib/model/monteCarlo.test.ts b/src/lib/model/monteCarlo.test.ts new file mode 100644 index 0000000..8c6f715 --- /dev/null +++ b/src/lib/model/monteCarlo.test.ts @@ -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); + }); +}); diff --git a/src/lib/model/monteCarlo.ts b/src/lib/model/monteCarlo.ts new file mode 100644 index 0000000..f1282c2 --- /dev/null +++ b/src/lib/model/monteCarlo.ts @@ -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 } + | { 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 }[], + 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>(); + 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 }[] = []; + 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(); + 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): void { + const bump = (team: string, key: keyof Omit) => { + 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(); + 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(); + const runnerByGroup = new Map(); + 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(); // `${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(); + const reachKey: Record> = { + 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(); + 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); +} diff --git a/src/lib/model/poisson.ts b/src/lib/model/poisson.ts new file mode 100644 index 0000000..5b09e66 --- /dev/null +++ b/src/lib/model/poisson.ts @@ -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); +} diff --git a/src/lib/model/predict.ts b/src/lib/model/predict.ts new file mode 100644 index 0000000..fda9838 --- /dev/null +++ b/src/lib/model/predict.ts @@ -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; +} + +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; +} diff --git a/src/lib/standings.test.ts b/src/lib/standings.test.ts new file mode 100644 index 0000000..8af8e0c --- /dev/null +++ b/src/lib/standings.test.ts @@ -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); + }); +}); diff --git a/src/lib/types.ts b/src/lib/types.ts index 774d87e..8b9ab77 100644 --- a/src/lib/types.ts +++ b/src/lib/types.ts @@ -69,9 +69,61 @@ export interface Snapshot { tables: Record; } +// ---- 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 }; diff --git a/src/stores/tournamentStore.ts b/src/stores/tournamentStore.ts index 046cac6..8617110 100644 --- a/src/stores/tournamentStore.ts +++ b/src/stores/tournamentStore.ts @@ -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 { export const useTournamentStore = create((set) => ({ snapshot: null, + model: null, status: 'connecting', init: () => { @@ -57,6 +59,7 @@ export const useTournamentStore = create((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((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();