Phase 2: predictive model — Elo + Dixon-Coles + Monte Carlo

- buildRatings.ts: walks 49k historical results → World-Football-Elo per team,
  data-calibrated goals model (goals-per-Elo slope, mean goals) + MLE-fit
  Dixon-Coles rho. Top: Spain/Argentina/France (the real 2026 favourites)
- src/lib/model: elo, poisson/dixon-coles, predict, host-advantage, monteCarlo
  (full 48-team sim — group sampling, best-third bipartite allocation, knockout
  advance probs). 20 vitest cases incl. exact per-round count invariants
- Server ModelEngine: live Elo re-rating after each result, per-match W/D/L,
  20k-sim odds, odds-over-time history; broadcast on finished-result changes
- Client: championship board, heat-shaded odds table, lazy-loaded title-race
  chart (Recharts split to its own chunk), match-prediction bars, bracket
  advance overlay
- Verified: odds render, chart populates as injected results re-rate teams live

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-06-11 13:25:53 +02:00
parent 9a31e9f4db
commit d604bb14ff
19 changed files with 1263 additions and 21 deletions
+47
View File
@@ -0,0 +1,47 @@
// World-Football-Elo style ratings. Pure functions so the build script and the
// runtime (live re-rating after each result) share exactly one implementation.
/** Home-advantage in Elo points, added to the home side's rating (0 if neutral). */
export const HOME_ADV_ELO = 70;
/** Match-importance weight (K) by tournament, à la eloratings.net. */
export function importanceWeight(tournament: string): number {
const t = tournament.toLowerCase();
if (t.includes('friendly')) return 10;
if (t.includes('qualification') || t.includes('nations league')) return 25;
if (t.includes('fifa world cup')) return 55; // the finals themselves
if (t.includes('confederations')) return 45;
if (
t.includes('euro') || t.includes('copa') || t.includes('african cup') ||
t.includes('asian cup') || t.includes('gold cup') || t.includes('championship')
) return 40;
return 20;
}
/** Goal-difference multiplier G: blowouts move ratings more. */
export function goalDiffMultiplier(gd: number): number {
const n = Math.abs(gd);
if (n <= 1) return 1;
if (n === 2) return 1.5;
return (11 + n) / 8;
}
/** Expected score for the home side (0..1). homeAdv is 0 for neutral venues. */
export function expectedHome(eloHome: number, eloAway: number, homeAdv: number): number {
return 1 / (1 + 10 ** ((eloAway - eloHome - homeAdv) / 400));
}
/** Symmetric Elo exchange for one match. Returns the home side's delta; the away
* side's delta is the negation. */
export function eloDelta(
homeGoals: number,
awayGoals: number,
eloHome: number,
eloAway: number,
k: number,
homeAdv: number,
): number {
const actual = homeGoals > awayGoals ? 1 : homeGoals < awayGoals ? 0 : 0.5;
const expected = expectedHome(eloHome, eloAway, homeAdv);
return k * goalDiffMultiplier(homeGoals - awayGoals) * (actual - expected);
}
+31
View File
@@ -0,0 +1,31 @@
import { canonicalTeam } from '../teams';
// Host advantage: the three host nations get a home boost when they play in a
// venue in their own country. Everyone else plays on neutral ground.
const VENUE_COUNTRY: Record<string, string> = {
'Mexico City': 'Mexico',
'Guadalajara (Zapopan)': 'Mexico',
'Monterrey (Guadalupe)': 'Mexico',
Toronto: 'Canada',
Vancouver: 'Canada',
// everything else is in the USA
};
const HOST_TEAM: Record<string, string> = { Mexico: 'Mexico', Canada: 'Canada', USA: 'USA' };
function venueCountry(venue: string): string {
return VENUE_COUNTRY[venue] ?? 'USA';
}
/**
* Elo home-advantage to apply to the *home* slot for a fixture. Positive if the
* nominal home team is the host playing at home; negative if the away team is;
* zero otherwise (neutral). Pass the result straight into lambdasFromElo.
*/
export function hostAdvantage(home: string, away: string, venue: string, homeAdvElo: number): number {
const host = HOST_TEAM[venueCountry(venue)];
if (!host) return 0;
if (canonicalTeam(home) === host) return homeAdvElo;
if (canonicalTeam(away) === host) return -homeAdvElo;
return 0;
}
+84
View File
@@ -0,0 +1,84 @@
import { describe, it, expect } from 'vitest';
import { expectedHome, eloDelta, goalDiffMultiplier } from './elo';
import { poissonPmf, scoreMatrix, outcomeProbs, lambdasFromElo, type ModelParams } from './poisson';
import { predictMatch, knockoutAdvanceProb, type RatingsModel } from './predict';
const PARAMS: ModelParams = { goalsPerElo: 0.0057, avgGoals: 2.73, rho: -0.05, homeAdvElo: 70 };
describe('elo', () => {
it('gives the stronger team a higher expected score', () => {
expect(expectedHome(1900, 1700, 0)).toBeGreaterThan(0.5);
expect(expectedHome(1700, 1900, 0)).toBeLessThan(0.5);
});
it('is symmetric around 0.5 at equal ratings with no home edge', () => {
expect(expectedHome(1800, 1800, 0)).toBeCloseTo(0.5, 6);
});
it('moves the winner up and the loser down by equal amounts', () => {
const d = eloDelta(2, 0, 1800, 1800, 40, 0);
expect(d).toBeGreaterThan(0); // home won → positive delta
// a bigger win moves more
expect(Math.abs(eloDelta(5, 0, 1800, 1800, 40, 0))).toBeGreaterThan(Math.abs(d));
});
it('weights blowouts more', () => {
expect(goalDiffMultiplier(1)).toBe(1);
expect(goalDiffMultiplier(3)).toBeGreaterThan(goalDiffMultiplier(2));
});
});
describe('poisson / dixon-coles', () => {
it('pmf over a team sums to ~1', () => {
let s = 0;
for (let k = 0; k <= 15; k++) s += poissonPmf(1.4, k);
expect(s).toBeCloseTo(1, 4);
});
it('score matrix is a normalized distribution', () => {
const m = scoreMatrix(1.6, 1.1, -0.05);
let s = 0;
for (const row of m) for (const p of row) s += p;
expect(s).toBeCloseTo(1, 6);
});
it('outcome probabilities sum to 1', () => {
const p = outcomeProbs(scoreMatrix(1.6, 1.1, -0.05));
expect(p.home + p.draw + p.away).toBeCloseTo(1, 6);
expect(p.home).toBeGreaterThan(p.away); // higher lambda favored
});
it('maps a bigger Elo edge to a bigger goal expectation', () => {
const a = lambdasFromElo(1900, 1700, PARAMS, 0);
const b = lambdasFromElo(1800, 1800, PARAMS, 0);
expect(a.lambdaHome).toBeGreaterThan(b.lambdaHome);
expect(a.lambdaAway).toBeLessThan(b.lambdaAway);
});
});
describe('predictMatch', () => {
const model: RatingsModel = {
asOf: '2026-06-10',
params: PARAMS,
ratings: { Strong: 2050, Weak: 1650, Even: 1850 },
};
it('favours the stronger team and sums to 1', () => {
const p = predictMatch('Strong', 'Weak', model).probs;
expect(p.home + p.draw + p.away).toBeCloseTo(1, 6);
expect(p.home).toBeGreaterThan(0.6);
});
it('applies host home advantage', () => {
const neutral = predictMatch('Even', 'Even', model, 0).probs.home;
const atHome = predictMatch('Even', 'Even', model, 70).probs.home;
expect(atHome).toBeGreaterThan(neutral);
});
it('knockout advance prob is in (0,1) and favours the stronger team', () => {
const p = predictMatch('Strong', 'Weak', model);
const adv = knockoutAdvanceProb(p.probs, p.eloHome, p.eloAway);
expect(adv).toBeGreaterThan(0.5);
expect(adv).toBeLessThan(1);
});
});
+72
View File
@@ -0,0 +1,72 @@
import { describe, it, expect } from 'vitest';
import { readFileSync } from 'node:fs';
import { join } from 'node:path';
import { buildSimulator } from './monteCarlo';
import type { RatingsModel } from './predict';
import type { FixturesFile } from '../types';
const DATA = join(process.cwd(), 'public', 'data');
const fixtures = (JSON.parse(readFileSync(join(DATA, 'fixtures.json'), 'utf8')) as FixturesFile).fixtures;
const model = JSON.parse(readFileSync(join(DATA, 'ratings.json'), 'utf8')) as RatingsModel;
/** Deterministic RNG so the test is stable. */
function mulberry32(seed: number): () => number {
let a = seed;
return () => {
a |= 0; a = (a + 0x6d2b79f5) | 0;
let t = Math.imul(a ^ (a >>> 15), 1 | a);
t = (t + Math.imul(t ^ (t >>> 7), 61 | t)) ^ t;
return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
};
}
describe('monte carlo tournament', () => {
const sim = buildSimulator(fixtures, model);
const res = sim.run(3000, mulberry32(12345));
const byTeam = new Map(res.teams.map((t) => [t.team, t]));
const sum = (k: keyof (typeof res.teams)[number]) =>
res.teams.reduce((s, t) => s + (t[k] as number), 0);
it('simulates all 48 teams', () => {
expect(res.teams.length).toBe(48);
});
it('conserves the exact per-round counts (12/32/16/8/4/2/1)', () => {
expect(sum('winGroup')).toBeCloseTo(12, 5);
expect(sum('qualify')).toBeCloseTo(32, 5);
expect(sum('reachR16')).toBeCloseTo(16, 5);
expect(sum('reachQF')).toBeCloseTo(8, 5);
expect(sum('reachSF')).toBeCloseTo(4, 5);
expect(sum('reachFinal')).toBeCloseTo(2, 5);
expect(sum('champion')).toBeCloseTo(1, 5);
});
it('every team has monotonically non-increasing deep-run odds', () => {
for (const t of res.teams) {
expect(t.qualify).toBeGreaterThanOrEqual(t.reachR16 - 1e-9);
expect(t.reachR16).toBeGreaterThanOrEqual(t.reachQF - 1e-9);
expect(t.reachQF).toBeGreaterThanOrEqual(t.reachSF - 1e-9);
expect(t.reachSF).toBeGreaterThanOrEqual(t.reachFinal - 1e-9);
expect(t.reachFinal).toBeGreaterThanOrEqual(t.champion - 1e-9);
expect(t.winGroup).toBeGreaterThanOrEqual(t.champion - 1e-9);
}
});
it('all probabilities are within [0, 1]', () => {
for (const t of res.teams) {
for (const k of ['winGroup', 'qualify', 'reachR16', 'reachQF', 'reachSF', 'reachFinal', 'champion'] as const) {
expect(t[k]).toBeGreaterThanOrEqual(0);
expect(t[k]).toBeLessThanOrEqual(1);
}
}
});
it('ranks a strong team well above a weak one', () => {
const strong = byTeam.get('Spain')!;
const weak = byTeam.get('Curaçao')!;
expect(strong.champion).toBeGreaterThan(weak.champion);
expect(strong.qualify).toBeGreaterThan(weak.qualify);
// the favourite should have a non-trivial title chance
expect(res.teams[0]!.champion).toBeGreaterThan(0.04);
});
});
+262
View File
@@ -0,0 +1,262 @@
import type { Fixture, TeamOdds } from '../types';
import { rankGroup, type Result } from '../standings';
import { lambdasFromElo, scoreMatrix, outcomeProbs, type OutcomeProbs } from './poisson';
import { knockoutAdvanceProb, ratingOf, type RatingsModel } from './predict';
import { hostAdvantage } from './hosts';
export type { TeamOdds };
export interface SimResult {
iterations: number;
asOf: string;
teams: TeamOdds[];
}
type Rng = () => number;
// ---- placeholder parsing ----
type Ref =
| { kind: 'winner'; group: string }
| { kind: 'runner'; group: string }
| { kind: 'third'; allowed: Set<string> }
| { kind: 'matchWinner'; num: number }
| { kind: 'matchLoser'; num: number }
| { kind: 'fixed'; team: string };
function parseRef(slot: { team: string | null; placeholder: string | null }): Ref {
if (slot.team) return { kind: 'fixed', team: slot.team };
const code = slot.placeholder ?? '';
const pos = code.match(/^([12])([A-L])$/);
if (pos) return { kind: pos[1] === '1' ? 'winner' : 'runner', group: pos[2]! };
const third = code.match(/^3([A-L/]+)$/);
if (third) return { kind: 'third', allowed: new Set(third[1]!.split('/')) };
const wl = code.match(/^([WL])(\d{1,3})$/);
if (wl) return wl[1] === 'W'
? { kind: 'matchWinner', num: Number(wl[2]) }
: { kind: 'matchLoser', num: Number(wl[2]) };
return { kind: 'fixed', team: code };
}
// ---- precomputed, sim-invariant structures ----
interface RemainingGroupGame {
group: string;
home: string;
away: string;
/** Cumulative scoreline distribution for sampling. */
cdf: { h: number; a: number; cum: number }[];
}
interface FinishedResult extends Result { group: string }
interface KnockoutGame {
num: number;
stage: Fixture['stage'];
venue: string;
home: Ref;
away: Ref;
/** Actual winner team name if this fixture is already decided. */
decidedWinner: string | null;
}
function buildScoreCdf(
home: string, away: string, venue: string, model: RatingsModel,
): { h: number; a: number; cum: number }[] {
const homeAdv = hostAdvantage(home, away, venue, model.params.homeAdvElo);
const { lambdaHome, lambdaAway } = lambdasFromElo(
ratingOf(model, home), ratingOf(model, away), model.params, homeAdv,
);
const m = scoreMatrix(lambdaHome, lambdaAway, model.params.rho);
const cdf: { h: number; a: number; cum: number }[] = [];
let cum = 0;
for (let h = 0; h < m.length; h++) {
for (let a = 0; a < m[h]!.length; a++) {
cum += m[h]![a]!;
cdf.push({ h, a, cum });
}
}
return cdf;
}
/** Bipartite match the third-place slots to the qualified third teams, honoring
* each slot's allowed-groups constraint (Kuhn's algorithm). */
function assignThirds(
slots: { allowed: Set<string> }[],
thirds: { team: string; group: string }[],
): (string | null)[] {
const matchSlot: number[] = new Array(slots.length).fill(-1);
const matchThird: number[] = new Array(thirds.length).fill(-1);
const tryAssign = (s: number, seen: boolean[]): boolean => {
for (let t = 0; t < thirds.length; t++) {
if (!seen[t] && slots[s]!.allowed.has(thirds[t]!.group)) {
seen[t] = true;
if (matchThird[t] === -1 || tryAssign(matchThird[t]!, seen)) {
matchSlot[s] = t;
matchThird[t] = s;
return true;
}
}
}
return false;
};
for (let s = 0; s < slots.length; s++) tryAssign(s, new Array(thirds.length).fill(false));
return matchSlot.map((t) => (t >= 0 ? thirds[t]!.team : null));
}
/** Build a reusable simulator from the current fixtures + ratings. */
export function buildSimulator(fixtures: Fixture[], model: RatingsModel) {
const groups = new Map<string, Set<string>>();
const finished: FinishedResult[] = [];
const remaining: RemainingGroupGame[] = [];
for (const f of fixtures) {
if (f.stage !== 'group' || !f.group || !f.home.team || !f.away.team) continue;
const g = f.group;
if (!groups.has(g)) groups.set(g, new Set());
groups.get(g)!.add(f.home.team);
groups.get(g)!.add(f.away.team);
if (f.status === 'finished' && f.homeScore !== null && f.awayScore !== null) {
finished.push({ group: g, home: f.home.team, away: f.away.team, homeScore: f.homeScore, awayScore: f.awayScore });
} else {
remaining.push({ group: g, home: f.home.team, away: f.away.team, cdf: buildScoreCdf(f.home.team, f.away.team, f.venue, model) });
}
}
const knockout: KnockoutGame[] = fixtures
.filter((f) => f.stage !== 'group')
.sort((a, b) => a.num - b.num)
.map((f) => {
const decided =
f.status === 'finished' && f.home.team && f.away.team && f.homeScore !== null && f.awayScore !== null
? f.homeScore > f.awayScore ? f.home.team : f.away.team
: null;
return { num: f.num, stage: f.stage, venue: f.venue, home: parseRef(f.home), away: parseRef(f.away), decidedWinner: decided };
});
// The 8 third-place slots (with their allowed groups) in a stable order.
const thirdSlots: { num: number; side: 'home' | 'away'; allowed: Set<string> }[] = [];
for (const g of knockout) {
if (g.stage !== 'r32') continue;
if (g.home.kind === 'third') thirdSlots.push({ num: g.num, side: 'home', allowed: g.home.allowed });
if (g.away.kind === 'third') thirdSlots.push({ num: g.num, side: 'away', allowed: g.away.allowed });
}
const groupList = [...groups.entries()].map(([g, set]) => ({ g, teams: [...set] }));
// Advance-probability memo: distinct (home,away,venue) pairings are limited.
const advMemo = new Map<string, number>();
const advanceProb = (home: string, away: string, venue: string): number => {
const key = `${home}|${away}|${venue}`;
let p = advMemo.get(key);
if (p === undefined) {
const homeAdv = hostAdvantage(home, away, venue, model.params.homeAdvElo);
const { lambdaHome, lambdaAway } = lambdasFromElo(ratingOf(model, home), ratingOf(model, away), model.params, homeAdv);
const probs: OutcomeProbs = outcomeProbs(scoreMatrix(lambdaHome, lambdaAway, model.params.rho));
p = knockoutAdvanceProb(probs, ratingOf(model, home), ratingOf(model, away));
advMemo.set(key, p);
}
return p;
};
function runOnce(rng: Rng, tally: Map<string, TeamOdds>): void {
const bump = (team: string, key: keyof Omit<TeamOdds, 'team'>) => {
let o = tally.get(team);
if (!o) { o = { team, winGroup: 0, qualify: 0, reachR16: 0, reachQF: 0, reachSF: 0, reachFinal: 0, champion: 0 }; tally.set(team, o); }
o[key]++;
};
// 1) group stage
const resultsByGroup = new Map<string, Result[]>();
for (const fr of finished) {
if (!resultsByGroup.has(fr.group)) resultsByGroup.set(fr.group, []);
resultsByGroup.get(fr.group)!.push(fr);
}
for (const rg of remaining) {
const u = rng();
let pick = rg.cdf[rg.cdf.length - 1]!;
for (const cell of rg.cdf) { if (u <= cell.cum) { pick = cell; break; } }
if (!resultsByGroup.has(rg.group)) resultsByGroup.set(rg.group, []);
resultsByGroup.get(rg.group)!.push({ home: rg.home, away: rg.away, homeScore: pick.h, awayScore: pick.a });
}
const winnerByGroup = new Map<string, string>();
const runnerByGroup = new Map<string, string>();
const thirds: { team: string; group: string; points: number; gd: number; gf: number }[] = [];
for (const { g, teams } of groupList) {
const rows = rankGroup(teams, resultsByGroup.get(g) ?? []);
winnerByGroup.set(g, rows[0]!.team);
runnerByGroup.set(g, rows[1]!.team);
bump(rows[0]!.team, 'winGroup');
bump(rows[0]!.team, 'qualify');
bump(rows[1]!.team, 'qualify');
const t3 = rows[2]!;
thirds.push({ team: t3.team, group: g, points: t3.points, gd: t3.gd, gf: t3.gf });
}
// 2) eight best third-placed teams
thirds.sort((a, b) => b.points - a.points || b.gd - a.gd || b.gf - a.gf);
const qualifiedThirds = thirds.slice(0, 8);
for (const t of qualifiedThirds) bump(t.team, 'qualify');
// 3) assign thirds to their R32 slots
const assignment = assignThirds(thirdSlots.map((s) => ({ allowed: s.allowed })), qualifiedThirds);
const thirdBySlot = new Map<string, string>(); // `${num}:${side}` → team
thirdSlots.forEach((s, i) => { const team = assignment[i]; if (team) thirdBySlot.set(`${s.num}:${s.side}`, team); });
// 4) knockouts
const winnerByNum = new Map<number, string>();
const reachKey: Record<string, keyof Omit<TeamOdds, 'team'>> = {
r32: 'reachR16', r16: 'reachQF', qf: 'reachSF', sf: 'reachFinal', final: 'champion',
};
const resolve = (ref: Ref, num: number, side: 'home' | 'away'): string | null => {
switch (ref.kind) {
case 'fixed': return ref.team;
case 'winner': return winnerByGroup.get(ref.group) ?? null;
case 'runner': return runnerByGroup.get(ref.group) ?? null;
case 'third': return thirdBySlot.get(`${num}:${side}`) ?? null;
case 'matchWinner': return winnerByNum.get(ref.num) ?? null;
case 'matchLoser': return null; // third-place game only; not tallied
}
};
for (const k of knockout) {
if (k.stage === 'third') continue; // doesn't affect advancement odds
const home = resolve(k.home, k.num, 'home');
const away = resolve(k.away, k.num, 'away');
if (!home || !away) continue;
let winner: string;
if (k.decidedWinner) {
winner = k.decidedWinner;
} else {
winner = rng() < advanceProb(home, away, k.venue) ? home : away;
}
winnerByNum.set(k.num, winner);
const key = reachKey[k.stage];
if (key) bump(winner, key);
}
}
return {
run(iterations: number, rng: Rng = Math.random): SimResult {
const tally = new Map<string, TeamOdds>();
for (let i = 0; i < iterations; i++) runOnce(rng, tally);
const teams = [...tally.values()]
.map((o) => ({
team: o.team,
winGroup: o.winGroup / iterations,
qualify: o.qualify / iterations,
reachR16: o.reachR16 / iterations,
reachQF: o.reachQF / iterations,
reachSF: o.reachSF / iterations,
reachFinal: o.reachFinal / iterations,
champion: o.champion / iterations,
}))
.sort((a, b) => b.champion - a.champion || b.reachFinal - a.reachFinal);
return { iterations, asOf: model.asOf, teams };
},
};
}
/** Convenience: build + run in one call. */
export function simulateTournament(
fixtures: Fixture[], model: RatingsModel, iterations = 10_000, rng: Rng = Math.random,
): SimResult {
return buildSimulator(fixtures, model).run(iterations, rng);
}
+93
View File
@@ -0,0 +1,93 @@
// Bivariate-Poisson scoreline model with the Dixon-Coles low-score correction.
// Turns two teams' Elo ratings into goal expectations, then into a full matrix of
// scoreline probabilities, from which win/draw/loss and likely scores follow.
export interface ModelParams {
/** Expected goal supremacy per Elo point of (effective) rating difference. */
goalsPerElo: number;
/** Mean total goals per match (league baseline). */
avgGoals: number;
/** Dixon-Coles low-score dependence parameter (small, typically negative). */
rho: number;
/** Home advantage expressed in Elo points (for hosts at home). */
homeAdvElo: number;
}
const MAX_GOALS = 10;
function factorial(n: number): number {
let f = 1;
for (let i = 2; i <= n; i++) f *= i;
return f;
}
export function poissonPmf(lambda: number, k: number): number {
return (Math.exp(-lambda) * lambda ** k) / factorial(k);
}
/** Dixon-Coles dependence adjustment for the four lowest-scoring cells. */
function dcTau(h: number, a: number, lh: number, la: number, rho: number): number {
if (h === 0 && a === 0) return 1 - lh * la * rho;
if (h === 0 && a === 1) return 1 + lh * rho;
if (h === 1 && a === 0) return 1 + la * rho;
if (h === 1 && a === 1) return 1 - rho;
return 1;
}
/** Expected goals for each side from effective Elo difference. */
export function lambdasFromElo(
eloHome: number,
eloAway: number,
params: ModelParams,
homeAdv: number,
): { lambdaHome: number; lambdaAway: number } {
const diff = eloHome - eloAway + homeAdv;
const supremacy = params.goalsPerElo * diff;
const half = params.avgGoals / 2;
return {
lambdaHome: Math.min(8, Math.max(0.15, half + supremacy / 2)),
lambdaAway: Math.min(8, Math.max(0.15, half - supremacy / 2)),
};
}
/** Normalized matrix m[h][a] = P(home scores h, away scores a). */
export function scoreMatrix(lambdaHome: number, lambdaAway: number, rho: number): number[][] {
const home = Array.from({ length: MAX_GOALS + 1 }, (_, k) => poissonPmf(lambdaHome, k));
const away = Array.from({ length: MAX_GOALS + 1 }, (_, k) => poissonPmf(lambdaAway, k));
const m: number[][] = [];
let total = 0;
for (let h = 0; h <= MAX_GOALS; h++) {
m[h] = [];
for (let a = 0; a <= MAX_GOALS; a++) {
const p = home[h]! * away[a]! * dcTau(h, a, lambdaHome, lambdaAway, rho);
m[h]![a] = p;
total += p;
}
}
for (let h = 0; h <= MAX_GOALS; h++) for (let a = 0; a <= MAX_GOALS; a++) m[h]![a]! /= total;
return m;
}
export interface OutcomeProbs { home: number; draw: number; away: number; }
export function outcomeProbs(m: number[][]): OutcomeProbs {
let home = 0, draw = 0, away = 0;
for (let h = 0; h < m.length; h++) {
for (let a = 0; a < m[h]!.length; a++) {
const p = m[h]![a]!;
if (h > a) home += p;
else if (h < a) away += p;
else draw += p;
}
}
return { home, draw, away };
}
export interface Scoreline { home: number; away: number; p: number; }
export function topScorelines(m: number[][], n: number): Scoreline[] {
const all: Scoreline[] = [];
for (let h = 0; h < m.length; h++)
for (let a = 0; a < m[h]!.length; a++) all.push({ home: h, away: a, p: m[h]![a]! });
return all.sort((x, y) => y.p - x.p).slice(0, n);
}
+68
View File
@@ -0,0 +1,68 @@
import {
lambdasFromElo,
scoreMatrix,
outcomeProbs,
topScorelines,
type ModelParams,
type OutcomeProbs,
type Scoreline,
} from './poisson';
/** The ratings.json payload (also the runtime model state after live re-rating). */
export interface RatingsModel {
asOf: string;
params: ModelParams;
ratings: Record<string, number>;
}
export const DEFAULT_ELO = 1500;
export function ratingOf(model: RatingsModel, team: string): number {
return model.ratings[team] ?? DEFAULT_ELO;
}
export interface MatchPrediction {
home: string;
away: string;
eloHome: number;
eloAway: number;
lambdaHome: number;
lambdaAway: number;
probs: OutcomeProbs;
/** Most likely scorelines, descending. */
scorelines: Scoreline[];
}
/**
* Win/draw/loss + likely scores for one match. `homeAdv` is the Elo bump for the
* home slot (0 neutral; use hostAdvantage() for host nations at home).
*/
export function predictMatch(
home: string,
away: string,
model: RatingsModel,
homeAdv = 0,
): MatchPrediction {
const eloHome = ratingOf(model, home);
const eloAway = ratingOf(model, away);
const { lambdaHome, lambdaAway } = lambdasFromElo(eloHome, eloAway, model.params, homeAdv);
const m = scoreMatrix(lambdaHome, lambdaAway, model.params.rho);
return {
home,
away,
eloHome,
eloAway,
lambdaHome,
lambdaAway,
probs: outcomeProbs(m),
scorelines: topScorelines(m, 5),
};
}
/** Probability the home side advances in a knockout (regulation result, draws
* resolved by an Elo-weighted shootout). */
export function knockoutAdvanceProb(p: OutcomeProbs, eloHome: number, eloAway: number): number {
// Penalty edge: small, rating-tilted; mostly a coin flip.
const shootoutHome = 1 / (1 + 10 ** ((eloAway - eloHome) / 800));
return p.home + p.draw * shootoutHome;
}
+57
View File
@@ -0,0 +1,57 @@
import { describe, it, expect } from 'vitest';
import { rankGroup, type Result } from './standings';
const r = (home: string, away: string, hs: number, as: number): Result => ({
home, away, homeScore: hs, awayScore: as,
});
describe('rankGroup', () => {
it('orders by points first', () => {
const rows = rankGroup(['A', 'B', 'C', 'D'], [
r('A', 'B', 1, 0), // A 3pts
r('C', 'D', 2, 2), // C,D 1pt
]);
expect(rows[0]!.team).toBe('A');
expect(rows[0]!.points).toBe(3);
expect(rows[0]!.rank).toBe(1);
});
it('breaks equal points by goal difference, then goals for', () => {
const rows = rankGroup(['A', 'B'], [
r('A', 'B', 3, 0), // A +3
r('B', 'A', 1, 0), // B beats A; now both 3pts, A gd 0 gf3, B gd 0 gf1
]);
// both 3 pts, gd 0; A has more goals for (3 vs 1) → A first
expect(rows[0]!.team).toBe('A');
});
it('uses head-to-head when points, GD and GF are all level', () => {
// A and B identical overall (each beat C by same score, drew nobody else),
// separated only by their head-to-head meeting.
const rows = rankGroup(['A', 'B', 'C'], [
r('A', 'C', 2, 0),
r('B', 'C', 2, 0),
r('A', 'B', 1, 0), // A wins the head-to-head
]);
// After all games: A 6pts? no — A: beat C, beat B = 6; B: beat C, lost A = 3.
// Make them level instead:
const level = rankGroup(['A', 'B', 'C', 'D'], [
r('A', 'C', 1, 0),
r('B', 'C', 1, 0),
r('A', 'D', 0, 1),
r('B', 'D', 0, 1),
r('A', 'B', 2, 1), // identical records (1W 1L, GF? ) but A beat B head-to-head
]);
void rows;
const ia = level.findIndex((x) => x.team === 'A');
const ib = level.findIndex((x) => x.team === 'B');
expect(ia).toBeLessThan(ib);
});
it('assigns sequential ranks and includes all teams even with no games', () => {
const rows = rankGroup(['A', 'B', 'C', 'D'], []);
expect(rows).toHaveLength(4);
expect(rows.map((x) => x.rank)).toEqual([1, 2, 3, 4]);
expect(rows.every((x) => x.played === 0)).toBe(true);
});
});
+54 -2
View File
@@ -69,9 +69,61 @@ export interface Snapshot {
tables: Record<string, StandingRow[]>;
}
// ---- 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 };