Files
cup26/server/src/model.ts
T
NilsBriggen d604bb14ff 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>
2026-06-11 13:25:53 +02:00

114 lines
4.4 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { readFileSync, existsSync } from 'node:fs';
import { eloDelta, importanceWeight } from '../../src/lib/model/elo';
import { predictMatch, type RatingsModel } from '../../src/lib/model/predict';
import { hostAdvantage } from '../../src/lib/model/hosts';
import { buildSimulator } from '../../src/lib/model/monteCarlo';
import type { Fixture, MatchPrediction, ModelSnapshot, OddsHistoryPoint } from '../../src/lib/types';
const ITERATIONS = Number(process.env.SIM_ITER) || 20_000;
const WC_K = importanceWeight('FIFA World Cup');
function loadRatings(staticDir: string): RatingsModel {
const candidates = [
`${staticDir}/data/ratings.json`,
`${process.cwd()}/public/data/ratings.json`,
`${process.cwd()}/dist/data/ratings.json`,
];
for (const p of candidates) {
if (existsSync(p)) return JSON.parse(readFileSync(p, 'utf8')) as RatingsModel;
}
throw new Error(`ratings.json not found. Run "npm run data:ratings". Looked in: ${candidates.join(', ')}`);
}
/** Owns the predictive model: live Elo re-rating, per-match probabilities, and
* the Monte Carlo tournament simulation. Recomputes only when results change. */
export class ModelEngine {
private readonly base: RatingsModel;
private history: OddsHistoryPoint[] = [];
private finishedKey = ''; // sentinel so the first recompute always runs
private snapshot: ModelSnapshot | null = null;
constructor(staticDir: string) {
this.base = loadRatings(staticDir);
}
current(): ModelSnapshot {
if (!this.snapshot) throw new Error('ModelEngine.recompute() not called yet');
return this.snapshot;
}
/** Recompute predictions if the set of finished results changed. Returns true
* when it actually recomputed (so the caller can broadcast). */
recompute(fixtures: Fixture[]): boolean {
const finished = fixtures
.filter((f) => f.status === 'finished' && f.home.team && f.away.team && f.homeScore !== null && f.awayScore !== null)
.sort((a, b) => a.num - b.num);
const key = finished.map((f) => `${f.num}:${f.homeScore}-${f.awayScore}`).join('|');
if (this.snapshot && key === this.finishedKey) return false;
this.finishedKey = key;
const live = this.reRate(finished);
const matches = this.predictMatches(fixtures, live);
const sim = buildSimulator(fixtures, live).run(ITERATIONS);
const last = finished[finished.length - 1];
const label = last
? `${last.home.team} ${last.homeScore}${last.awayScore} ${last.away.team}`
: 'Pre-tournament';
this.history.push({
t: new Date().toISOString(),
label,
top: sim.teams.slice(0, 8).map((o) => ({ team: o.team, champion: o.champion })),
});
if (this.history.length > 200) this.history.shift();
this.snapshot = {
generatedAt: new Date().toISOString(),
asOf: live.asOf,
iterations: ITERATIONS,
matches,
odds: sim.teams,
history: this.history,
};
return true;
}
/** Base ratings + an Elo update for every finished World Cup result so far. */
private reRate(finished: Fixture[]): RatingsModel {
const ratings: Record<string, number> = { ...this.base.ratings };
const elo = (t: string) => ratings[t] ?? 1500;
let asOf = this.base.asOf;
for (const f of finished) {
const home = f.home.team!;
const away = f.away.team!;
const homeAdv = hostAdvantage(home, away, f.venue, this.base.params.homeAdvElo);
const d = eloDelta(f.homeScore!, f.awayScore!, elo(home), elo(away), WC_K, homeAdv);
ratings[home] = elo(home) + d;
ratings[away] = elo(away) - d;
const day = f.kickoff.slice(0, 10);
if (day > asOf) asOf = day;
}
return { asOf, params: this.base.params, ratings };
}
private predictMatches(fixtures: Fixture[], live: RatingsModel): MatchPrediction[] {
const out: MatchPrediction[] = [];
for (const f of fixtures) {
if (!f.home.team || !f.away.team || f.status === 'finished') continue;
const homeAdv = hostAdvantage(f.home.team, f.away.team, f.venue, live.params.homeAdvElo);
const p = predictMatch(f.home.team, f.away.team, live, homeAdv);
const top = p.scorelines[0] ?? { home: 0, away: 0, p: 0 };
out.push({
num: f.num,
home: f.home.team,
away: f.away.team,
probs: p.probs,
lambdaHome: Math.round(p.lambdaHome * 100) / 100,
lambdaAway: Math.round(p.lambdaAway * 100) / 100,
topScore: { home: top.home, away: top.away, p: top.p },
});
}
return out;
}
}