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:
2026-06-11 17:31:09 +02:00
parent f3b2a69b31
commit dc52a908c0
10 changed files with 381 additions and 11 deletions
+140
View File
@@ -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>
);
}