v3: Model-vs-Market scoreboard — frozen forecasts, fair scoring, live page
- prediction_snapshots: model + de-vigged market frozen ≤15min pre-kickoff (or at first sight of a live match, flagged 'late' and excluded from totals). - src/lib/model/scoring.ts: ONE shared RPS/Brier/log-loss implementation for the backtest and the live scoreboard. - /api/scoreboard + /scoreboard page: per-match model-vs-bookmaker bars, RPS for both after FT, 'closer' badges, running head-to-head with honest rules printed (small-sample caveat included). Nav: 'vs Market'. - Methodology page now shows the three-way split + ensemble bake-off variants. - Verified end-to-end with a simulated match: snapshot froze model 80.7% and DraftKings 67.0% (de-vig correct), both scored at FT, late-flag honored. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -85,6 +85,14 @@ CREATE TABLE IF NOT EXISTS odds_history (
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_odds_fixture ON odds_history(fixture_num, captured_at);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS prediction_snapshots (
|
||||
fixture_num INTEGER PRIMARY KEY,
|
||||
taken_at INTEGER NOT NULL,
|
||||
late INTEGER NOT NULL DEFAULT 0,
|
||||
model_json TEXT NOT NULL,
|
||||
market_json TEXT
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS ingest_log (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
source TEXT NOT NULL,
|
||||
@@ -229,6 +237,31 @@ export function latestOddsAll(): OddsRow[] {
|
||||
.all() as unknown as OddsRow[];
|
||||
}
|
||||
|
||||
// ---- frozen pre-kickoff forecasts (Model-vs-Market scoreboard) ----
|
||||
export interface SnapshotRow {
|
||||
fixture_num: number;
|
||||
taken_at: number;
|
||||
late: number;
|
||||
model_json: string;
|
||||
market_json: string | null;
|
||||
}
|
||||
|
||||
export function hasSnapshot(fixtureNum: number): boolean {
|
||||
return !!db().prepare('SELECT 1 FROM prediction_snapshots WHERE fixture_num = ?').get(fixtureNum);
|
||||
}
|
||||
|
||||
export function saveSnapshot(fixtureNum: number, late: boolean, model: unknown, market: unknown | null): void {
|
||||
db()
|
||||
.prepare('INSERT OR IGNORE INTO prediction_snapshots (fixture_num, taken_at, late, model_json, market_json) VALUES (?, ?, ?, ?, ?)')
|
||||
.run(fixtureNum, Date.now(), late ? 1 : 0, JSON.stringify(model), market ? JSON.stringify(market) : null);
|
||||
}
|
||||
|
||||
export function allSnapshots(): SnapshotRow[] {
|
||||
return db()
|
||||
.prepare('SELECT fixture_num, taken_at, late, model_json, market_json FROM prediction_snapshots ORDER BY fixture_num')
|
||||
.all() as unknown as SnapshotRow[];
|
||||
}
|
||||
|
||||
// ---- per-fixture enrichment blob ----
|
||||
export function setMatchExt(fixtureNum: number, json: unknown): void {
|
||||
db().prepare('INSERT OR REPLACE INTO match_ext (fixture_num, json, updated_at) VALUES (?, ?, ?)')
|
||||
|
||||
+7
-1
@@ -9,6 +9,7 @@ import { TournamentState } from './tournament';
|
||||
import { startScheduler } from './ingest/scheduler';
|
||||
import { ModelEngine } from './model';
|
||||
import { buildPreview, buildTeamProfile } from './preview';
|
||||
import { buildScoreboard, snapshotCheck } from './scoreboard';
|
||||
import { db, healthAll, oddsFor, latestOddsAll } from './db/db';
|
||||
import { canonicalTeam } from '../../src/lib/teams';
|
||||
import type { MatchStatus, ServerMessage } from '../../src/lib/types';
|
||||
@@ -59,6 +60,7 @@ export function buildServer() {
|
||||
app.get('/api/sources/health', async () => ({ sources: healthAll() }));
|
||||
// Bookmaker odds (benchmark for the Model-vs-Market scoreboard; not a model input)
|
||||
app.get('/api/odds', async () => ({ odds: latestOddsAll() }));
|
||||
app.get('/api/scoreboard', async () => buildScoreboard(state));
|
||||
app.get('/api/odds/:num', async (req, reply) => {
|
||||
const num = Number((req.params as { num: string }).num);
|
||||
if (!Number.isFinite(num)) return reply.code(400).send({ error: 'bad num' });
|
||||
@@ -93,6 +95,7 @@ export function buildServer() {
|
||||
'football-data',
|
||||
);
|
||||
if (!f) return reply.code(404).send({ error: 'no such fixture' });
|
||||
snapshotCheck(state, model);
|
||||
broadcast();
|
||||
return { ok: true, fixture: f };
|
||||
});
|
||||
@@ -125,7 +128,10 @@ export function buildServer() {
|
||||
});
|
||||
|
||||
// ---- ingestion scheduler (ESPN primary; football-data / SofaScore fallback) ----
|
||||
const stopIngest = startScheduler(state, () => broadcast());
|
||||
const stopIngest = startScheduler(state, () => broadcast(), () => {
|
||||
const taken = snapshotCheck(state, model);
|
||||
if (taken) console.log(`[scoreboard] froze ${taken} pre-kickoff forecast(s)`);
|
||||
});
|
||||
app.addHook('onClose', async () => stopIngest());
|
||||
|
||||
// ---- static SPA (prod) with history fallback ----
|
||||
|
||||
@@ -23,7 +23,11 @@ function dateUTC(iso: string): string {
|
||||
return `${d.getUTCFullYear()}${String(d.getUTCMonth() + 1).padStart(2, '0')}${String(d.getUTCDate()).padStart(2, '0')}`;
|
||||
}
|
||||
|
||||
export function startScheduler(state: TournamentState, onChange: () => void): () => void {
|
||||
export function startScheduler(
|
||||
state: TournamentState,
|
||||
onChange: () => void,
|
||||
onTick?: () => void,
|
||||
): () => void {
|
||||
const token = process.env.FOOTBALL_DATA_TOKEN?.trim();
|
||||
const sofa = process.env.ENABLE_SOFASCORE === 'true';
|
||||
let stopped = false;
|
||||
@@ -88,6 +92,7 @@ export function startScheduler(state: TournamentState, onChange: () => void): ()
|
||||
const changed = state.mergeLive(matches, source);
|
||||
if (changed.length) { console.log(`[ingest] ${changed.length} fixture(s) updated via ${source}`); onChange(); }
|
||||
}
|
||||
onTick?.(); // e.g. freeze pre-kickoff forecast snapshots
|
||||
|
||||
// Keep live matches' stats + event timeline fresh at the live cadence.
|
||||
for (const f of state.allFixtures()) {
|
||||
|
||||
@@ -0,0 +1,113 @@
|
||||
import { allSnapshots, hasSnapshot, saveSnapshot, oddsFor } from './db/db';
|
||||
import { deVig } from '../../src/lib/odds';
|
||||
import { brierScore, outcomeOfScore, rpsScore } from '../../src/lib/model/scoring';
|
||||
import type { TournamentState } from './tournament';
|
||||
import type { ModelEngine } from './model';
|
||||
import type { MarketSnapshot, MatchProbs, ScoreboardData, ScoreboardRow } from '../../src/lib/types';
|
||||
|
||||
// The Model-vs-Market scoreboard. Fairness rules:
|
||||
// - the model's forecast is FROZEN at most SNAPSHOT_BEFORE_MS before kickoff
|
||||
// (whatever the model believed then — no hindsight);
|
||||
// - the market benchmark is the latest captured pre-kickoff bookmaker line,
|
||||
// de-vigged to fair probabilities;
|
||||
// - snapshots taken after kickoff are stored but flagged `late` and excluded
|
||||
// from the head-to-head totals.
|
||||
|
||||
const SNAPSHOT_BEFORE_MS = 15 * 60 * 1000;
|
||||
|
||||
function latestPreKickoffMarket(num: number, kickoffT: number): MarketSnapshot | null {
|
||||
const history = oddsFor(num).filter((o) => o.captured_at <= kickoffT + 60_000);
|
||||
const last = history[history.length - 1];
|
||||
if (!last) return null;
|
||||
const probs = deVig(last.home_ml, last.draw_ml, last.away_ml);
|
||||
if (!probs) return null;
|
||||
return {
|
||||
provider: last.provider,
|
||||
probs,
|
||||
homeMl: last.home_ml!,
|
||||
drawMl: last.draw_ml!,
|
||||
awayMl: last.away_ml!,
|
||||
};
|
||||
}
|
||||
|
||||
/** Freeze forecasts for any fixture at/inside the snapshot window. Runs on every
|
||||
* live tick — cheap (indexed point lookups, no network). */
|
||||
export function snapshotCheck(state: TournamentState, model: ModelEngine): number {
|
||||
const now = Date.now();
|
||||
let taken = 0;
|
||||
for (const f of state.allFixtures()) {
|
||||
if (!f.home.team || !f.away.team) continue;
|
||||
const kickoffT = new Date(f.kickoff).getTime();
|
||||
// Inside the pre-kickoff window — or the match already started regardless of
|
||||
// the scheduled clock (reschedules); the `late` flag keeps totals honest.
|
||||
if (now < kickoffT - SNAPSHOT_BEFORE_MS && f.status === 'scheduled') continue;
|
||||
if (hasSnapshot(f.num)) continue;
|
||||
const pred = model.current().matches.find((m) => m.num === f.num);
|
||||
if (!pred) continue; // finished before we ever saw it — nothing honest to freeze
|
||||
const late = f.status !== 'scheduled';
|
||||
saveSnapshot(f.num, late, { probs: pred.probs, lambdaHome: pred.lambdaHome, lambdaAway: pred.lambdaAway }, latestPreKickoffMarket(f.num, kickoffT));
|
||||
taken++;
|
||||
}
|
||||
return taken;
|
||||
}
|
||||
|
||||
export function buildScoreboard(state: TournamentState): ScoreboardData {
|
||||
const byNum = new Map(state.allFixtures().map((f) => [f.num, f]));
|
||||
const rows: ScoreboardRow[] = [];
|
||||
let n = 0, mR = 0, kR = 0, mB = 0, kB = 0, mCloser = 0, kCloser = 0;
|
||||
|
||||
for (const s of allSnapshots()) {
|
||||
const f = byNum.get(s.fixture_num);
|
||||
if (!f || !f.home.team || !f.away.team) continue;
|
||||
const modelStored = JSON.parse(s.model_json) as { probs: MatchProbs };
|
||||
const market = s.market_json ? (JSON.parse(s.market_json) as MarketSnapshot) : null;
|
||||
|
||||
let scored: ScoreboardRow['scored'] = null;
|
||||
if (f.status === 'finished' && f.homeScore != null && f.awayScore != null) {
|
||||
const o = outcomeOfScore(f.homeScore, f.awayScore);
|
||||
scored = {
|
||||
outcome: o,
|
||||
modelRps: +rpsScore(modelStored.probs, o).toFixed(4),
|
||||
marketRps: market ? +rpsScore(market.probs, o).toFixed(4) : null,
|
||||
modelBrier: +brierScore(modelStored.probs, o).toFixed(4),
|
||||
marketBrier: market ? +brierScore(market.probs, o).toFixed(4) : null,
|
||||
};
|
||||
if (!s.late && market && scored.marketRps != null) {
|
||||
n++;
|
||||
mR += scored.modelRps; kR += scored.marketRps;
|
||||
mB += scored.modelBrier; kB += scored.marketBrier!;
|
||||
if (scored.modelRps < scored.marketRps) mCloser++;
|
||||
else if (scored.marketRps < scored.modelRps) kCloser++;
|
||||
}
|
||||
}
|
||||
|
||||
rows.push({
|
||||
num: f.num,
|
||||
home: f.home.team,
|
||||
away: f.away.team,
|
||||
group: f.group,
|
||||
kickoff: f.kickoff,
|
||||
status: f.status,
|
||||
homeScore: f.homeScore,
|
||||
awayScore: f.awayScore,
|
||||
takenAt: s.taken_at,
|
||||
late: !!s.late,
|
||||
model: modelStored.probs,
|
||||
market,
|
||||
scored,
|
||||
});
|
||||
}
|
||||
|
||||
rows.sort((a, b) => a.kickoff.localeCompare(b.kickoff));
|
||||
return {
|
||||
rows,
|
||||
totals: n
|
||||
? {
|
||||
n,
|
||||
modelRps: +(mR / n).toFixed(4), marketRps: +(kR / n).toFixed(4),
|
||||
modelBrier: +(mB / n).toFixed(4), marketBrier: +(kB / n).toFixed(4),
|
||||
modelCloser: mCloser, marketCloser: kCloser,
|
||||
}
|
||||
: null,
|
||||
};
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
import { useEffect } from 'react';
|
||||
import { Link, Outlet } from '@tanstack/react-router';
|
||||
import { Gauge, GitMerge, Moon, Radio, Sparkles, Sun, Table, TrendingUp, Trophy } from 'lucide-react';
|
||||
import { Gauge, GitMerge, Moon, Radio, Scale, Sparkles, Sun, Table, TrendingUp, Trophy } from 'lucide-react';
|
||||
import type { LucideIcon } from 'lucide-react';
|
||||
import { useUiStore } from '@/stores/uiStore';
|
||||
import { useTournamentStore } from '@/stores/tournamentStore';
|
||||
@@ -11,6 +11,7 @@ const NAV: { to: string; label: string; exact: boolean; icon: LucideIcon }[] = [
|
||||
{ to: '/groups', label: 'Groups', exact: false, icon: Table },
|
||||
{ to: '/bracket', label: 'Bracket', exact: false, icon: GitMerge },
|
||||
{ to: '/predict', label: 'Predict', exact: false, icon: TrendingUp },
|
||||
{ to: '/scoreboard', label: 'vs Market', exact: false, icon: Scale },
|
||||
{ to: '/methodology', label: 'Model', exact: false, icon: Gauge },
|
||||
{ to: '/story', label: 'Story', exact: false, icon: Sparkles },
|
||||
];
|
||||
|
||||
@@ -7,8 +7,8 @@ import type { BacktestReport } from '@/lib/types';
|
||||
|
||||
const STEPS = [
|
||||
{ icon: TrendingUp, title: 'Elo ratings', body: '150 years of international results (49,000 matches) build a strength rating for every nation, updated after each game and weighted by match importance.' },
|
||||
{ icon: Activity, title: 'Expected goals', body: "Each team's goal expectation comes from the rating gap, calibrated on how Elo differences have actually translated into goals — plus host home advantage." },
|
||||
{ icon: Gauge, title: 'Dixon-Coles', body: 'A bivariate-Poisson scoreline model (with the Dixon-Coles low-score correction) turns those goal expectations into the full distribution of scorelines and win/draw/loss.' },
|
||||
{ icon: Activity, title: 'Two goal models', body: 'Elo-based goal expectations are blended with an independent attack/defence Dixon-Coles model (time-decayed, fit on the last 15 years) — an ensemble that beat both members in testing.' },
|
||||
{ icon: Gauge, title: 'Dixon-Coles scorelines', body: 'A bivariate-Poisson scoreline model (with the Dixon-Coles low-score correction) turns the blended expectations into the full distribution of scorelines and win/draw/loss.' },
|
||||
{ icon: Dices, title: 'Monte Carlo', body: 'The whole 48-team tournament is simulated 20,000 times — sampling every remaining match — to produce championship and advancement odds that update after each result.' },
|
||||
];
|
||||
|
||||
@@ -72,8 +72,8 @@ export function MethodologyPage() {
|
||||
{bt ? (
|
||||
<>
|
||||
<p className="text-sm text-muted">
|
||||
Model parameters were fit on internationals before {bt.trainEnd.slice(0, 4)}, then tested <span className="font-semibold text-ink">walk-forward</span> on{' '}
|
||||
<span className="font-semibold text-ink">{bt.tested.toLocaleString()}</span> matches it had never seen ({bt.trainEnd.slice(0, 4)}–{bt.testTo.slice(0, 4)}) — predicting each game using only prior data.
|
||||
Strict three-way split: parameters fit before {bt.trainEnd.slice(0, 4)}, ensemble settings tuned on {bt.validation ? `${bt.validation.from.slice(0, 4)}–${bt.validation.to.slice(0, 4)}` : 'a validation window'}, and the numbers below come from{' '}
|
||||
<span className="font-semibold text-ink">{bt.tested.toLocaleString()}</span> untouched matches ({(bt.testFrom ?? bt.trainEnd).slice(0, 4)}–{bt.testTo.slice(0, 4)}) — each predicted <span className="font-semibold text-ink">walk-forward</span> using only prior data.
|
||||
</p>
|
||||
<div className="grid grid-cols-3 gap-3 rounded-lg border border-line bg-surface-2 py-3">
|
||||
<Stat value={`${(bt.model.accuracy * 100).toFixed(0)}%`} label="outcome accuracy" />
|
||||
@@ -81,10 +81,11 @@ export function MethodologyPage() {
|
||||
<Stat value={`${(bt.worldCup.accuracy * 100).toFixed(0)}%`} label={`World Cup acc. (${bt.worldCup.tested})`} />
|
||||
</div>
|
||||
<div>
|
||||
<div className="mb-2 text-xs uppercase tracking-wide text-faint">Ranked Probability Score — lower is better, vs baselines</div>
|
||||
<div className="mb-2 text-xs uppercase tracking-wide text-faint">Ranked Probability Score — lower is better, vs candidates & baselines</div>
|
||||
<div className="space-y-1.5">
|
||||
<RpsBar label="This model" rps={bt.model.rps} best={bestRps} highlight />
|
||||
<RpsBar label="Elo only" rps={bt.baselines.eloOnly.rps} best={bestRps} />
|
||||
{(bt.variants ?? [{ name: 'This model', ...bt.model }]).map((v, i) => (
|
||||
<RpsBar key={v.name} label={v.name} rps={v.rps} best={bestRps} highlight={i === 0} />
|
||||
))}
|
||||
<RpsBar label="Base rates" rps={bt.baselines.baseRate.rps} best={bestRps} />
|
||||
<RpsBar label="Coin flip" rps={bt.baselines.uniform.rps} best={bestRps} />
|
||||
</div>
|
||||
|
||||
@@ -0,0 +1,140 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { Link } from '@tanstack/react-router';
|
||||
import { Scale } from 'lucide-react';
|
||||
import { PageHeader } from '@/components/ui/PageHeader';
|
||||
import { Card, CardBody, CardHeader } from '@/components/ui/Card';
|
||||
import { teamFlag } from '@/lib/teams';
|
||||
import { kickoffDay, kickoffTime } from '@/lib/format';
|
||||
import { cn } from '@/lib/cn';
|
||||
import type { MatchProbs, ScoreboardData, ScoreboardRow } from '@/lib/types';
|
||||
|
||||
const pct = (x: number) => `${Math.round(x * 100)}%`;
|
||||
|
||||
function ProbTriple({ label, probs, tone }: { label: string; probs: MatchProbs; tone: 'model' | 'market' }) {
|
||||
const color = tone === 'model' ? 'bg-accent' : 'bg-gold';
|
||||
return (
|
||||
<div className="flex items-center gap-2">
|
||||
<span className={cn('w-12 shrink-0 text-[10px] font-bold uppercase tracking-wide', tone === 'model' ? 'text-accent' : 'text-gold')}>{label}</span>
|
||||
<div className="flex h-2 flex-1 overflow-hidden rounded-full bg-elevated">
|
||||
<div className={color} style={{ width: pct(probs.home) }} />
|
||||
<div className="bg-line-strong" style={{ width: pct(probs.draw) }} />
|
||||
<div className="bg-info" style={{ width: pct(probs.away) }} />
|
||||
</div>
|
||||
<span className="tnum w-24 shrink-0 text-right text-[11px] text-muted">
|
||||
{pct(probs.home)} / {pct(probs.draw)} / {pct(probs.away)}
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function Row({ r }: { r: ScoreboardRow }) {
|
||||
const closer =
|
||||
r.scored && r.scored.marketRps != null
|
||||
? r.scored.modelRps < r.scored.marketRps ? 'model' : r.scored.marketRps < r.scored.modelRps ? 'market' : 'tie'
|
||||
: null;
|
||||
return (
|
||||
<Card className={r.late ? 'opacity-60' : ''}>
|
||||
<CardBody className="space-y-2">
|
||||
<div className="flex flex-wrap items-center justify-between gap-2 text-sm">
|
||||
<Link to="/match/$num" params={{ num: String(r.num) }} className="flex items-center gap-2 font-medium text-ink hover:text-accent">
|
||||
<span aria-hidden>{teamFlag(r.home)}</span> {r.home}
|
||||
<span className="tnum text-faint">{r.status !== 'scheduled' && r.homeScore != null ? `${r.homeScore}–${r.awayScore}` : 'vs'}</span>
|
||||
{r.away} <span aria-hidden>{teamFlag(r.away)}</span>
|
||||
</Link>
|
||||
<span className="text-[11px] text-faint">
|
||||
{r.group ? `Group ${r.group} · ` : ''}{kickoffDay(r.kickoff)} {kickoffTime(r.kickoff)}
|
||||
{r.late && ' · snapshot late — excluded from totals'}
|
||||
</span>
|
||||
</div>
|
||||
<ProbTriple label="Model" probs={r.model} tone="model" />
|
||||
{r.market ? (
|
||||
<ProbTriple label={r.market.provider} probs={r.market.probs} tone="market" />
|
||||
) : (
|
||||
<p className="text-[11px] text-faint">no bookmaker line captured before kickoff</p>
|
||||
)}
|
||||
{r.scored && (
|
||||
<div className="flex items-center gap-3 border-t border-line pt-2 text-[11px]">
|
||||
<span className="text-faint">result: <span className="font-semibold text-ink">{r.scored.outcome}</span></span>
|
||||
<span className="tnum text-muted">model RPS {r.scored.modelRps}</span>
|
||||
{r.scored.marketRps != null && <span className="tnum text-muted">market RPS {r.scored.marketRps}</span>}
|
||||
{closer && closer !== 'tie' && (
|
||||
<span className={cn('ml-auto rounded-full border px-2 py-0.5 font-bold uppercase tracking-wide', closer === 'model' ? 'border-accent/40 bg-accent-glow text-accent' : 'border-gold/40 bg-gold/15 text-gold')}>
|
||||
{closer === 'model' ? 'model closer' : 'market closer'}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</CardBody>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
export function ScoreboardPage() {
|
||||
const [data, setData] = useState<ScoreboardData | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
let alive = true;
|
||||
const load = () => fetch('/api/scoreboard').then((r) => r.json()).then((d: ScoreboardData) => alive && setData(d)).catch(() => {});
|
||||
void load();
|
||||
const id = setInterval(load, 60_000);
|
||||
return () => { alive = false; clearInterval(id); };
|
||||
}, []);
|
||||
|
||||
const t = data?.totals ?? null;
|
||||
const lead = t ? (t.modelRps < t.marketRps ? 'model' : t.marketRps < t.modelRps ? 'market' : 'tie') : null;
|
||||
|
||||
return (
|
||||
<div className="space-y-5">
|
||||
<PageHeader
|
||||
title="Model vs Market"
|
||||
subtitle="Our forecast, frozen before kickoff, against the bookmaker's de-vigged line — scored after every match. Lower RPS = closer to what happened."
|
||||
/>
|
||||
|
||||
<Card>
|
||||
<CardHeader className="flex items-center gap-2">
|
||||
<Scale size={16} className="text-accent" />
|
||||
<span className="font-display font-bold text-ink">Running head-to-head</span>
|
||||
</CardHeader>
|
||||
<CardBody>
|
||||
{t ? (
|
||||
<div className="grid grid-cols-3 items-center gap-3 text-center">
|
||||
<div>
|
||||
<div className={cn('font-display text-3xl font-extrabold', lead === 'model' ? 'text-accent' : 'text-ink')}>{t.modelRps.toFixed(4)}</div>
|
||||
<div className="text-[11px] uppercase tracking-wide text-faint">our model · RPS</div>
|
||||
<div className="tnum mt-1 text-xs text-muted">closer {t.modelCloser}×</div>
|
||||
</div>
|
||||
<div className="text-sm text-faint">
|
||||
{t.n} match{t.n === 1 ? '' : 'es'} scored
|
||||
<div className="mt-1 text-xs">{lead === 'tie' ? 'dead level' : lead === 'model' ? 'model ahead' : 'market ahead'}</div>
|
||||
</div>
|
||||
<div>
|
||||
<div className={cn('font-display text-3xl font-extrabold', lead === 'market' ? 'text-gold' : 'text-ink')}>{t.marketRps.toFixed(4)}</div>
|
||||
<div className="text-[11px] uppercase tracking-wide text-faint">bookmaker · RPS</div>
|
||||
<div className="tnum mt-1 text-xs text-muted">closer {t.marketCloser}×</div>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<p className="py-4 text-center text-sm text-muted">
|
||||
The head-to-head starts with the first final whistle — forecasts are frozen before each kickoff and scored when the match ends.
|
||||
</p>
|
||||
)}
|
||||
<p className="mt-3 border-t border-line pt-2 text-xs text-faint">
|
||||
Honest rules: both forecasts frozen pre-kickoff; bookmaker margin removed (de-vig); scored with the Ranked Probability
|
||||
Score; late snapshots excluded. Beating the market over a small sample is noise — watch the gap, not the lead.
|
||||
</p>
|
||||
</CardBody>
|
||||
</Card>
|
||||
|
||||
{data ? (
|
||||
<div className="space-y-3">
|
||||
{data.rows.length === 0 && (
|
||||
<p className="py-8 text-center text-sm text-muted">No frozen forecasts yet — the first snapshots are taken 15 minutes before kickoff.</p>
|
||||
)}
|
||||
{data.rows.map((r) => <Row key={r.num} r={r} />)}
|
||||
</div>
|
||||
) : (
|
||||
<div className="h-48 animate-pulse rounded-xl border border-line bg-panel" />
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
// Proper scoring rules — ONE implementation shared by the offline backtest and
|
||||
// the live Model-vs-Market scoreboard, so the numbers are always comparable.
|
||||
|
||||
import type { MatchProbs } from '../types';
|
||||
|
||||
export type Outcome3 = 'home' | 'draw' | 'away';
|
||||
|
||||
export const outcomeOfScore = (hs: number, as: number): Outcome3 =>
|
||||
hs > as ? 'home' : hs < as ? 'away' : 'draw';
|
||||
|
||||
/** Multiclass Brier (sum of squared errors over the 3 outcomes; 0 best, 2 worst). */
|
||||
export function brierScore(p: MatchProbs, o: Outcome3): number {
|
||||
return (
|
||||
(p.home - (o === 'home' ? 1 : 0)) ** 2 +
|
||||
(p.draw - (o === 'draw' ? 1 : 0)) ** 2 +
|
||||
(p.away - (o === 'away' ? 1 : 0)) ** 2
|
||||
);
|
||||
}
|
||||
|
||||
/** Ranked Probability Score for the ordered outcomes home > draw > away (0 best). */
|
||||
export function rpsScore(p: MatchProbs, o: Outcome3): number {
|
||||
const y1 = o === 'home' ? 1 : 0;
|
||||
const y2 = o === 'away' ? 0 : 1; // cumulative: home+draw
|
||||
return ((p.home - y1) ** 2 + (p.home + p.draw - y2) ** 2) / 2;
|
||||
}
|
||||
|
||||
export function logLoss(p: MatchProbs, o: Outcome3): number {
|
||||
return -Math.log(Math.max(1e-12, p[o]));
|
||||
}
|
||||
@@ -165,16 +165,56 @@ export interface TeamProfile {
|
||||
qualifyOdds: number | null;
|
||||
}
|
||||
|
||||
// ---- Model-vs-Market scoreboard ----
|
||||
|
||||
export interface MarketSnapshot {
|
||||
provider: string;
|
||||
probs: MatchProbs; // de-vigged
|
||||
homeMl: number;
|
||||
drawMl: number;
|
||||
awayMl: number;
|
||||
}
|
||||
|
||||
export interface ScoreboardRow {
|
||||
num: number;
|
||||
home: string;
|
||||
away: string;
|
||||
group: string | null;
|
||||
kickoff: string;
|
||||
status: MatchStatus;
|
||||
homeScore: number | null;
|
||||
awayScore: number | null;
|
||||
takenAt: number;
|
||||
/** Snapshot was taken after kickoff — excluded from the fair totals. */
|
||||
late: boolean;
|
||||
model: MatchProbs;
|
||||
market: MarketSnapshot | null;
|
||||
/** Scores, present once the match finished (regulation result). */
|
||||
scored: { outcome: 'home' | 'draw' | 'away'; modelRps: number; marketRps: number | null; modelBrier: number; marketBrier: number | null } | null;
|
||||
}
|
||||
|
||||
export interface ScoreboardData {
|
||||
rows: ScoreboardRow[];
|
||||
/** Aggregates over finished, fair (pre-kickoff, with-market) matches. */
|
||||
totals: { n: number; modelRps: number; marketRps: number; modelBrier: number; marketBrier: number; modelCloser: number; marketCloser: number } | null;
|
||||
}
|
||||
|
||||
// ---- model backtest (Methodology page) ----
|
||||
|
||||
export interface BacktestScores { brier: number; logloss: number; rps: number; accuracy: number }
|
||||
export interface BacktestReport {
|
||||
generatedAt: string;
|
||||
trainEnd: string;
|
||||
/** Test window start (validation ends here). */
|
||||
testFrom?: string;
|
||||
testTo: string;
|
||||
tested: number;
|
||||
params: { goalsPerElo: number; avgGoals: number; rho: number; homeAdvElo: number };
|
||||
/** Hyper-params tuned on the validation window (never on test). */
|
||||
validation?: { from: string; to: string; tuned: { xi: number; shrinkK: number; ensembleW: number } };
|
||||
model: BacktestScores;
|
||||
/** Bake-off: every candidate scored on the same untouched test window. */
|
||||
variants?: ({ name: string } & BacktestScores)[];
|
||||
baselines: { uniform: BacktestScores; baseRate: BacktestScores; eloOnly: BacktestScores };
|
||||
reliability: { predicted: number; observed: number; count: number }[];
|
||||
ece: number;
|
||||
|
||||
+3
-1
@@ -10,6 +10,7 @@ import { MatchPreviewPage } from './features/match/MatchPreviewPage';
|
||||
import { TeamProfilePage } from './features/team/TeamProfilePage';
|
||||
import { MethodologyPage } from './features/methodology/MethodologyPage';
|
||||
import { TeamsPage } from './features/teams/TeamsPage';
|
||||
import { ScoreboardPage } from './features/scoreboard/ScoreboardPage';
|
||||
|
||||
const rootRoute = createRootRoute({
|
||||
component: RootLayout,
|
||||
@@ -25,8 +26,9 @@ const matchRoute = createRoute({ getParentRoute: () => rootRoute, path: '/match/
|
||||
const teamRoute = createRoute({ getParentRoute: () => rootRoute, path: '/team/$name', component: TeamProfilePage });
|
||||
const methodologyRoute = createRoute({ getParentRoute: () => rootRoute, path: '/methodology', component: MethodologyPage });
|
||||
const teamsRoute = createRoute({ getParentRoute: () => rootRoute, path: '/teams', component: TeamsPage });
|
||||
const scoreboardRoute = createRoute({ getParentRoute: () => rootRoute, path: '/scoreboard', component: ScoreboardPage });
|
||||
|
||||
const routeTree = rootRoute.addChildren([indexRoute, groupsRoute, bracketRoute, predictRoute, storyRoute, matchRoute, teamRoute, methodologyRoute, teamsRoute]);
|
||||
const routeTree = rootRoute.addChildren([indexRoute, groupsRoute, bracketRoute, predictRoute, storyRoute, matchRoute, teamRoute, methodologyRoute, teamsRoute, scoreboardRoute]);
|
||||
|
||||
export const router = createRouter({ routeTree, defaultPreload: 'intent' });
|
||||
|
||||
|
||||
Reference in New Issue
Block a user