Market-movement chart: the bookmaker line vs our model, per match
The odds history we have been capturing every 3h finally gets a face: de-vigged DraftKings probabilities drift as solid lines toward kickoff, with the model's numbers as dashed references — the honest benchmark framing, right on the match page. Renders only when at least two pre-kickoff lines exist. EN/DE copy included. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,76 @@
|
||||
import { useMemo } from 'react';
|
||||
import { deVig } from '@/lib/odds';
|
||||
import { useT } from '@/lib/i18n';
|
||||
import type { MatchProbs, OddsPoint } from '@/lib/types';
|
||||
|
||||
/** Bookmaker line movement (de-vigged) vs the model's pre-match probabilities.
|
||||
* Market = solid lines drifting toward kickoff; model = dashed references.
|
||||
* Benchmark only — the model never sees these numbers. */
|
||||
export function OddsMovement({ history, model, kickoff }: {
|
||||
history: OddsPoint[];
|
||||
model: MatchProbs;
|
||||
kickoff: string;
|
||||
}) {
|
||||
const t = useT();
|
||||
const W = 600;
|
||||
const H = 200;
|
||||
const PAD = { l: 30, r: 8, t: 8, b: 20 };
|
||||
|
||||
const data = useMemo(() => {
|
||||
const koT = new Date(kickoff).getTime();
|
||||
const pts = history
|
||||
.map((h) => ({ at: h.captured_at, probs: deVig(h.home_ml, h.draw_ml, h.away_ml) }))
|
||||
.filter((p): p is { at: number; probs: MatchProbs } => p.probs != null && p.at <= koT)
|
||||
.sort((a, b) => a.at - b.at);
|
||||
if (pts.length < 2) return null;
|
||||
|
||||
const firstAt = pts[0]!.at;
|
||||
const span = Math.max(1, koT - firstAt);
|
||||
const x = (at: number) => PAD.l + ((at - firstAt) / span) * (W - PAD.l - PAD.r);
|
||||
const maxP = Math.min(1, Math.max(...pts.flatMap((p) => [p.probs.home, p.probs.draw, p.probs.away]), model.home, model.draw, model.away) + 0.08);
|
||||
const y = (p: number) => PAD.t + (1 - p / maxP) * (H - PAD.t - PAD.b);
|
||||
const line = (sel: (p: MatchProbs) => number) => pts.map((p) => `${x(p.at).toFixed(1)},${y(sel(p.probs)).toFixed(1)}`).join(' ');
|
||||
|
||||
// hour ticks: kickoff plus a few nice -24h steps back
|
||||
const hoursSpan = span / 3_600_000;
|
||||
const step = hoursSpan > 96 ? 48 : hoursSpan > 36 ? 24 : hoursSpan > 12 ? 12 : 6;
|
||||
const ticks: { px: number; label: string }[] = [];
|
||||
for (let h = 0; h <= hoursSpan; h += step) {
|
||||
ticks.push({ px: x(koT - h * 3_600_000), label: h === 0 ? '0h' : `-${h}h` });
|
||||
}
|
||||
const yTicks = [0.25, 0.5, 0.75].filter((p) => p < maxP).map((p) => ({ py: y(p), label: `${Math.round(p * 100)}%` }));
|
||||
return {
|
||||
home: line((p) => p.home), draw: line((p) => p.draw), away: line((p) => p.away),
|
||||
yModel: { home: y(model.home), draw: y(model.draw), away: y(model.away) },
|
||||
ticks, yTicks,
|
||||
};
|
||||
}, [history, model, kickoff]);
|
||||
|
||||
if (!data) return null;
|
||||
|
||||
return (
|
||||
<div>
|
||||
<svg viewBox={`0 0 ${W} ${H}`} className="w-full" role="img" aria-label={t.match.oddsMovement}>
|
||||
<rect x={PAD.l} y={PAD.t} width={W - PAD.l - PAD.r} height={H - PAD.t - PAD.b} className="fill-[var(--app-elevated)]" rx={4} />
|
||||
{data.yTicks.map((yt) => (
|
||||
<g key={yt.label}>
|
||||
<line x1={PAD.l} x2={W - PAD.r} y1={yt.py} y2={yt.py} className="stroke-[var(--app-line)]" strokeWidth={1} />
|
||||
<text x={PAD.l - 4} y={yt.py + 3} textAnchor="end" className="fill-[var(--app-faint)] text-[10px]">{yt.label}</text>
|
||||
</g>
|
||||
))}
|
||||
{/* model references (dashed) */}
|
||||
<line x1={PAD.l} x2={W - PAD.r} y1={data.yModel.home} y2={data.yModel.home} className="stroke-[var(--app-accent)]" strokeDasharray="5 4" strokeWidth={1.5} opacity={0.7} />
|
||||
<line x1={PAD.l} x2={W - PAD.r} y1={data.yModel.draw} y2={data.yModel.draw} className="stroke-[var(--app-line-strong)]" strokeDasharray="5 4" strokeWidth={1.5} opacity={0.9} />
|
||||
<line x1={PAD.l} x2={W - PAD.r} y1={data.yModel.away} y2={data.yModel.away} className="stroke-[var(--app-info)]" strokeDasharray="5 4" strokeWidth={1.5} opacity={0.7} />
|
||||
{/* market lines */}
|
||||
<polyline points={data.home} fill="none" className="stroke-[var(--app-accent)]" strokeWidth={2.5} />
|
||||
<polyline points={data.draw} fill="none" className="stroke-[var(--app-line-strong)]" strokeWidth={2.5} />
|
||||
<polyline points={data.away} fill="none" className="stroke-[var(--app-info)]" strokeWidth={2.5} />
|
||||
{data.ticks.map((tk) => (
|
||||
<text key={tk.label} x={tk.px} y={H - 6} textAnchor="middle" className="fill-[var(--app-faint)] text-[10px]">{tk.label}</text>
|
||||
))}
|
||||
</svg>
|
||||
<p className="mt-1.5 text-xs text-faint">{t.match.oddsMovementNote}</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -4,12 +4,13 @@ import { ArrowLeft, ArrowRightLeft, CircleDot, Shield, Shirt, Volleyball } from
|
||||
import { Card, CardBody, CardHeader } from '@/components/ui/Card';
|
||||
import { WinProbBar } from '@/components/WinProbBar';
|
||||
import { WinProbTimeline, type TimelinePoint } from '@/components/WinProbTimeline';
|
||||
import { OddsMovement } from '@/components/OddsMovement';
|
||||
import { FormChips } from '@/components/FormChips';
|
||||
import { teamFlag } from '@/lib/teams';
|
||||
import { fmt, useFormat, useT } from '@/lib/i18n';
|
||||
import { inPlayProbs } from '@/lib/model/inplay';
|
||||
import { useTournamentStore } from '@/stores/tournamentStore';
|
||||
import type { Fixture, KeyPlayer, MatchPreview } from '@/lib/types';
|
||||
import type { Fixture, KeyPlayer, MatchPreview, OddsPoint } from '@/lib/types';
|
||||
|
||||
const num = (s: string | undefined) => (s ? Number(s.replace('%', '')) || 0 : 0);
|
||||
|
||||
@@ -84,6 +85,7 @@ export function MatchPreviewPage() {
|
||||
const { t, kickoffDay, kickoffTime } = useFormat();
|
||||
const [preview, setPreview] = useState<MatchPreview | null>(null);
|
||||
const [timeline, setTimeline] = useState<TimelinePoint[]>([]);
|
||||
const [oddsHistory, setOddsHistory] = useState<OddsPoint[]>([]);
|
||||
const [failed, setFailed] = useState(false);
|
||||
|
||||
// Live score/minute arrive over the WebSocket snapshot — overlay them on the
|
||||
@@ -108,6 +110,11 @@ export function MatchPreviewPage() {
|
||||
.catch(() => {});
|
||||
void load();
|
||||
void loadTimeline();
|
||||
// line history changes on a 3h sweep — once per visit is plenty
|
||||
fetch(`/api/odds/${numParam}`)
|
||||
.then((r) => (r.ok ? r.json() : Promise.reject()))
|
||||
.then((d: { history: OddsPoint[] }) => alive && setOddsHistory(d.history))
|
||||
.catch(() => {});
|
||||
const id = setInterval(() => {
|
||||
if (useTournamentStore.getState().snapshot?.fixtures.find((f) => f.num === Number(numParam))?.status === 'live') {
|
||||
void load();
|
||||
@@ -140,6 +147,8 @@ export function MatchPreviewPage() {
|
||||
const homeName = f.home.label;
|
||||
const awayName = f.away.label;
|
||||
const finished = f.status === 'finished';
|
||||
const koT = new Date(f.kickoff).getTime();
|
||||
const hasOddsChart = oddsHistory.filter((o) => o.home_ml != null && o.draw_ml != null && o.away_ml != null && o.captured_at <= koT).length >= 2;
|
||||
|
||||
return (
|
||||
<div className="space-y-5">
|
||||
@@ -239,6 +248,16 @@ export function MatchPreviewPage() {
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{/* bookmaker line movement vs the model (benchmark only) */}
|
||||
{preview.prediction && hasOddsChart && (
|
||||
<Card>
|
||||
<CardHeader><span className="font-display font-bold text-ink">{t.match.oddsMovement}</span></CardHeader>
|
||||
<CardBody>
|
||||
<OddsMovement history={oddsHistory} model={preview.prediction.probs} kickoff={f.kickoff} />
|
||||
</CardBody>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{/* form */}
|
||||
{(preview.form.home.length > 0 || preview.form.away.length > 0) && (
|
||||
<Card>
|
||||
|
||||
@@ -86,6 +86,8 @@ export const de: Dict = {
|
||||
},
|
||||
timeline: "Spielverlauf",
|
||||
momentum: "So schwankte die Siegwahrscheinlichkeit",
|
||||
oddsMovement: "Marktbewegung vs. Modell",
|
||||
oddsMovementNote: "Durchgezogene Linien: die Buchmacherquoten ohne Marge, im Verlauf bis zum Anstoß. Gestrichelt: die Wahrscheinlichkeiten unseres Modells. Der Markt ist nur Vergleichsmaßstab — das Modell sieht ihn nie.",
|
||||
preMatchModel: "Modell vor Anstoß",
|
||||
modelExpectation: "Modell-Prognose",
|
||||
expectedGoalsLine: "Erwartete Tore (xG): {home} – {away} · Modell-Wahrscheinlichkeiten, keine Wettempfehlung",
|
||||
|
||||
@@ -85,6 +85,8 @@ export const en = {
|
||||
},
|
||||
timeline: "Timeline",
|
||||
momentum: "How the win probability swung",
|
||||
oddsMovement: "Market movement vs the model",
|
||||
oddsMovementNote: "Solid lines: the bookmaker's odds with the margin removed, drifting toward kickoff. Dashed lines: our model's probabilities. The market is a benchmark — the model never sees it.",
|
||||
preMatchModel: "Pre-match model",
|
||||
modelExpectation: "Model expectation",
|
||||
expectedGoalsLine: "Expected goals: {home} – {away} · model probabilities, not betting advice",
|
||||
|
||||
@@ -100,6 +100,16 @@ export interface MatchPrediction {
|
||||
topScore: { home: number; away: number; p: number };
|
||||
}
|
||||
|
||||
/** One captured bookmaker line — history for the market-movement chart. */
|
||||
export interface OddsPoint {
|
||||
fixture_num: number;
|
||||
provider: string;
|
||||
captured_at: number;
|
||||
home_ml: number | null;
|
||||
draw_ml: number | null;
|
||||
away_ml: number | null;
|
||||
}
|
||||
|
||||
/** A point on the championship-odds-over-time chart. */
|
||||
export interface OddsHistoryPoint {
|
||||
t: string;
|
||||
|
||||
Reference in New Issue
Block a user