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
+69
View File
@@ -0,0 +1,69 @@
import { useMemo } from 'react';
import { LineChart, Line, XAxis, YAxis, Tooltip, ResponsiveContainer, CartesianGrid, Legend } from 'recharts';
import { teamFlag } from '@/lib/teams';
import type { OddsHistoryPoint } from '@/lib/types';
const PALETTE = [
'var(--app-accent)',
'var(--app-gold)',
'var(--app-info)',
'var(--app-loss)',
'var(--app-success)',
'var(--app-warning)',
];
/** Championship odds over time — one line per top contender, a point per result
* that moved the model. */
export function OddsChart({ history }: { history: OddsHistoryPoint[] }) {
const { data, teams } = useMemo(() => {
const last = history[history.length - 1];
const teams = (last?.top ?? []).slice(0, 6).map((t) => t.team);
const data = history.map((h, i) => {
const row: Record<string, number | string> = { i: i + 1, label: h.label };
for (const t of h.top) if (teams.includes(t.team)) row[t.team] = Math.round(t.champion * 1000) / 10;
return row;
});
return { data, teams };
}, [history]);
return (
<div className="h-72 w-full">
<ResponsiveContainer width="100%" height="100%">
<LineChart data={data} margin={{ top: 8, right: 16, bottom: 4, left: -8 }}>
<CartesianGrid stroke="var(--app-line)" strokeDasharray="3 3" vertical={false} />
<XAxis dataKey="i" tick={{ fill: 'var(--app-faint)', fontSize: 11 }} stroke="var(--app-line)" />
<YAxis
tick={{ fill: 'var(--app-faint)', fontSize: 11 }}
stroke="var(--app-line)"
unit="%"
width={42}
/>
<Tooltip
contentStyle={{
background: 'var(--app-panel)',
border: '1px solid var(--app-line)',
borderRadius: 10,
fontSize: 12,
color: 'var(--app-ink)',
}}
labelFormatter={(i) => data[Number(i) - 1]?.label ?? ''}
formatter={(v: number, name: string) => [`${v}%`, `${teamFlag(name)} ${name}`]}
/>
<Legend formatter={(name: string) => `${teamFlag(name)} ${name}`} wrapperStyle={{ fontSize: 12 }} />
{teams.map((t, idx) => (
<Line
key={t}
type="monotone"
dataKey={t}
stroke={PALETTE[idx % PALETTE.length]}
strokeWidth={2}
dot={{ r: 2 }}
connectNulls
isAnimationActive={false}
/>
))}
</LineChart>
</ResponsiveContainer>
</div>
);
}