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,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 };
|
||||
|
||||
Reference in New Issue
Block a user