v2 Phase 4: live match center + in-play win probability

- src/lib/model/inplay.ts: in-play win probability — remaining goals modelled as
  time-scaled Poisson processes convolved with the current score; recomputed each
  tick. 4 vitest cases (locks at FT, lead grows decisive over time, sums to 1).
- ESPN summary normalizer extended with the live event timeline (keyEvents);
  preview now carries liveStats + events. Scheduler refreshes LIVE matches'
  summaries every live tick (~20s) for fresh stats/timeline.
- /match/:num is now a live match center: when live it shows in-play win
  probability (driven by the WS score/clock), match stats bars and an event
  timeline, polling while live; preview content for scheduled games, recap when
  finished. Verified with a simulated live state (94% live vs 81% pre-match).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-06-11 16:19:22 +02:00
parent 45c0a978fc
commit adbfb5d6d1
7 changed files with 326 additions and 155 deletions
+15 -1
View File
@@ -64,6 +64,7 @@ export function normalizeScoreboard(body: { events?: EspnEvent[] }): EspnMatch[]
export interface EspnH2HGame { date: string; homeTeamId: string; awayTeamId: string; homeScore: number; awayScore: number }
export interface EspnTeamRef { id: string; name: string; homeAway: 'home' | 'away' }
export interface EspnLineupPlayer { name: string; position: string | null; starter: boolean; number: number | null }
export interface EspnTimelineEvent { minute: number | null; type: string; text: string; teamId: string | null }
export interface EspnSummary {
fetchedAt: number;
venue: string | null;
@@ -74,6 +75,8 @@ export interface EspnSummary {
lineups: Record<string, EspnLineupPlayer[]>;
/** Team match stats (possession, shots, …) once the game is live/finished. */
stats: Record<string, Record<string, string>>;
/** Live/finished event timeline (goals, cards, subs). */
events: EspnTimelineEvent[];
}
export interface RawSummary {
@@ -85,6 +88,7 @@ export interface RawSummary {
teams?: { team?: { id: string }; statistics?: { name: string; displayValue: string }[] }[];
};
rosters?: { team?: { id: string }; roster?: { athlete?: { displayName?: string }; position?: { abbreviation?: string }; starter?: boolean; jersey?: string }[] }[];
keyEvents?: { clock?: { displayValue?: string }; type?: { text?: string }; text?: string; team?: { id?: string } }[];
}
export function normalizeSummary(raw: RawSummary): EspnSummary {
@@ -128,5 +132,15 @@ export function normalizeSummary(raw: RawSummary): EspnSummary {
stats[id] = Object.fromEntries((t.statistics ?? []).map((s) => [s.name, s.displayValue]));
}
return { fetchedAt: Date.now(), venue: raw.gameInfo?.venue?.fullName ?? null, teams, h2h, form, lineups, stats };
const events: EspnTimelineEvent[] = (raw.keyEvents ?? []).map((e) => {
const m = e.clock?.displayValue?.match(/(\d+)/);
return {
minute: m ? Number(m[1]) : null,
type: e.type?.text ?? '',
text: e.text ?? '',
teamId: e.team?.id ?? null,
};
});
return { fetchedAt: Date.now(), venue: raw.gameInfo?.venue?.fullName ?? null, teams, h2h, form, lineups, stats, events };
}
+8
View File
@@ -77,6 +77,14 @@ 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(); }
}
// Keep live matches' stats + event timeline fresh at the live cadence.
for (const f of state.allFixtures()) {
if (f.status !== 'live') continue;
const eid = getSourceMap(f.num, 'espn');
if (!eid) continue;
try { setMatchExt(f.num, await fetchEspnSummary(eid)); } catch { /* logged via health */ }
}
};
// ---- enrichment: ESPN summary for imminent/live fixtures ----
+10
View File
@@ -85,6 +85,16 @@ export function buildPreview(num: number, state: TournamentState, model: ModelEn
home: home ? scorersData()[home] ?? [] : [],
away: away ? scorersData()[away] ?? [] : [],
},
liveStats:
eh && ea && (Object.keys(summary!.stats[eh.id] ?? {}).length || Object.keys(summary!.stats[ea.id] ?? {}).length)
? { home: summary!.stats[eh.id] ?? {}, away: summary!.stats[ea.id] ?? {} }
: null,
events: (summary?.events ?? []).map((e) => ({
minute: e.minute,
type: e.type,
text: e.text,
team: e.teamId === eh?.id ? 'home' : e.teamId === ea?.id ? 'away' : null,
})),
dataNote: !home || !away
? 'Knockout participants are decided once the bracket resolves.'
: ext
+214 -154
View File
@@ -1,4 +1,4 @@
import { useEffect, useState } from 'react';
import { useEffect, useMemo, useState } from 'react';
import { Link, useParams } from '@tanstack/react-router';
import { ArrowLeft, Shirt } from 'lucide-react';
import { Card, CardBody, CardHeader } from '@/components/ui/Card';
@@ -6,175 +6,63 @@ import { WinProbBar } from '@/components/WinProbBar';
import { FormChips } from '@/components/FormChips';
import { teamFlag } from '@/lib/teams';
import { kickoffDay, kickoffTime } from '@/lib/format';
import type { KeyPlayer, MatchPreview } from '@/lib/types';
import { inPlayProbs } from '@/lib/model/inplay';
import { useTournamentStore } from '@/stores/tournamentStore';
import type { Fixture, KeyPlayer, MatchPreview } from '@/lib/types';
const STAGE: Record<string, string> = {
group: 'Group', r32: 'Round of 32', r16: 'Round of 16', qf: 'Quarter-final', sf: 'Semi-final', third: 'Third place', final: 'Final',
};
const STAT_ROWS: [string, string][] = [
['possessionPct', 'Possession'],
['totalShots', 'Shots'],
['shotsOnTarget', 'On target'],
['wonCorners', 'Corners'],
['foulsCommitted', 'Fouls'],
['saves', 'Saves'],
];
const num = (s: string | undefined) => (s ? Number(s.replace('%', '')) || 0 : 0);
function eventIcon(type: string): string {
const t = type.toLowerCase();
if (t.includes('goal') || t.includes('penalty - scored')) return '⚽';
if (t.includes('yellow')) return '🟨';
if (t.includes('red')) return '🟥';
if (t.includes('substitution')) return '🔁';
return '•';
}
function LiveStatRow({ label, home, away }: { label: string; home: number; away: number }) {
const total = home + away || 1;
return (
<div>
<div className="flex items-center justify-between text-sm">
<span className="tnum font-bold text-accent">{home}</span>
<span className="text-xs uppercase tracking-wide text-faint">{label}</span>
<span className="tnum font-bold text-info">{away}</span>
</div>
<div className="mt-1 flex h-1.5 overflow-hidden rounded-full bg-elevated">
<div className="bg-accent" style={{ width: `${(home / total) * 100}%` }} />
<div className="ml-auto bg-info" style={{ width: `${(away / total) * 100}%` }} />
</div>
</div>
);
}
function ScorerList({ team, players }: { team: string; players: KeyPlayer[] }) {
return (
<div>
<div className="mb-2 flex items-center gap-2 text-sm font-semibold text-ink">
<span aria-hidden>{teamFlag(team)}</span> {team}
</div>
<div className="mb-2 flex items-center gap-2 text-sm font-semibold text-ink"><span aria-hidden>{teamFlag(team)}</span> {team}</div>
{players.length ? (
<ul className="space-y-1">
{players.slice(0, 6).map((p) => (
<li key={p.name} className="flex items-center justify-between text-sm">
<span className="truncate text-ink-soft">{p.name}</span>
<span className="tnum shrink-0 text-faint">
{p.goals}{p.pens ? <span className="text-faint/70"> ({p.pens}p)</span> : null}
</span>
<span className="tnum shrink-0 text-faint">{p.goals}{p.pens ? ` (${p.pens}p)` : ''}</span>
</li>
))}
</ul>
) : (
<p className="text-xs text-faint">no data</p>
)}
</div>
);
}
export function MatchPreviewPage() {
const { num } = useParams({ strict: false }) as { num: string };
const [preview, setPreview] = useState<MatchPreview | null>(null);
const [failed, setFailed] = useState(false);
useEffect(() => {
let alive = true;
setPreview(null);
setFailed(false);
fetch(`/api/preview/${num}`)
.then((r) => (r.ok ? r.json() : Promise.reject()))
.then((d: MatchPreview) => alive && setPreview(d))
.catch(() => alive && setFailed(true));
return () => { alive = false; };
}, [num]);
if (failed) {
return (
<div className="py-16 text-center text-muted">
Couldn&apos;t load that match. <Link to="/" className="text-accent">Back to Live</Link>
</div>
);
}
if (!preview) return <div className="h-96 animate-pulse rounded-xl border border-line bg-panel" />;
const f = preview.fixture;
const hasScore = f.status === 'live' || f.status === 'finished';
const homeName = f.home.label;
const awayName = f.away.label;
return (
<div className="space-y-5">
<Link to="/" className="inline-flex items-center gap-1.5 text-sm text-muted hover:text-ink">
<ArrowLeft size={15} /> Back
</Link>
{/* hero */}
<Card>
<CardBody>
<div className="mb-3 text-center text-xs uppercase tracking-wide text-faint">
{f.group ? `Group ${f.group}` : STAGE[f.stage]} · {kickoffDay(f.kickoff)} · {kickoffTime(f.kickoff)} · {preview.venue}
</div>
<div className="grid grid-cols-[1fr_auto_1fr] items-center gap-3">
<Link to="/team/$name" params={{ name: f.home.team ?? '' }} className="flex flex-col items-center gap-1 hover:opacity-80">
<span aria-hidden className="text-4xl">{f.home.team ? teamFlag(f.home.team) : '⚽'}</span>
<span className="text-center text-sm font-semibold text-ink">{homeName}</span>
</Link>
<div className="text-center">
{hasScore ? (
<div className="tnum text-3xl font-extrabold text-ink">{f.homeScore ?? 0}{f.awayScore ?? 0}</div>
) : (
<div className="tnum text-lg font-bold text-muted">{kickoffTime(f.kickoff)}</div>
)}
{f.status === 'live' && <div className="text-[11px] font-bold uppercase text-live">{f.minute ? `${f.minute}'` : 'Live'}</div>}
</div>
<Link to="/team/$name" params={{ name: f.away.team ?? '' }} className="flex flex-col items-center gap-1 hover:opacity-80">
<span aria-hidden className="text-4xl">{f.away.team ? teamFlag(f.away.team) : '⚽'}</span>
<span className="text-center text-sm font-semibold text-ink">{awayName}</span>
</Link>
</div>
</CardBody>
</Card>
{/* model expectation */}
{preview.prediction && (
<Card>
<CardHeader><span className="font-display font-bold text-ink">Model expectation</span></CardHeader>
<CardBody className="space-y-2">
<WinProbBar home={preview.prediction.home} away={preview.prediction.away} probs={preview.prediction.probs} topScore={preview.prediction.topScore} />
<p className="text-xs text-faint">
Expected goals: {preview.prediction.lambdaHome.toFixed(2)} {preview.prediction.lambdaAway.toFixed(2)} ·
model odds, not betting advice
</p>
</CardBody>
</Card>
)}
{/* form */}
<Card>
<CardHeader><span className="font-display font-bold text-ink">Recent form</span></CardHeader>
<CardBody className="space-y-3">
<div className="flex items-center justify-between">
<span className="flex items-center gap-2 text-sm font-medium text-ink">{f.home.team && teamFlag(f.home.team)} {homeName}</span>
<FormChips form={preview.form.home} />
</div>
<div className="flex items-center justify-between">
<span className="flex items-center gap-2 text-sm font-medium text-ink">{f.away.team && teamFlag(f.away.team)} {awayName}</span>
<FormChips form={preview.form.away} />
</div>
</CardBody>
</Card>
{/* head to head */}
{preview.h2h && (
<Card>
<CardHeader><span className="font-display font-bold text-ink">Head to head</span></CardHeader>
<CardBody>
<div className="mb-3 grid grid-cols-3 items-center text-center">
<div><div className="tnum text-2xl font-extrabold text-accent">{preview.h2h.homeWins}</div><div className="text-[11px] text-faint">{homeName} wins</div></div>
<div><div className="tnum text-2xl font-extrabold text-muted">{preview.h2h.draws}</div><div className="text-[11px] text-faint">draws · {preview.h2h.games} total</div></div>
<div><div className="tnum text-2xl font-extrabold text-info">{preview.h2h.awayWins}</div><div className="text-[11px] text-faint">{awayName} wins</div></div>
</div>
<div className="border-t border-line pt-2">
<div className="mb-1 text-[11px] uppercase tracking-wide text-faint">Recent meetings</div>
<ul className="space-y-1 text-sm">
{preview.h2h.last.map((m, i) => (
<li key={i} className="flex items-center justify-between text-ink-soft">
<span className="text-faint">{m.date.slice(0, 10)}</span>
<span>{m.home} <span className="tnum font-semibold text-ink">{m.homeScore}{m.awayScore}</span> {m.away}</span>
</li>
))}
</ul>
</div>
</CardBody>
</Card>
)}
{/* lineups or key players */}
<Card>
<CardHeader className="flex items-center gap-2">
<Shirt size={16} className="text-accent" />
<span className="font-display font-bold text-ink">{preview.lineups ? 'Lineups' : 'Key players (all-time scorers)'}</span>
</CardHeader>
<CardBody className="grid grid-cols-2 gap-6">
{preview.lineups ? (
<>
<LineupCol team={homeName} players={preview.lineups.home} />
<LineupCol team={awayName} players={preview.lineups.away} />
</>
) : (
<>
<ScorerList team={homeName} players={preview.keyPlayers.home} />
<ScorerList team={awayName} players={preview.keyPlayers.away} />
</>
)}
</CardBody>
</Card>
<p className="text-center text-xs text-faint">{preview.dataNote}</p>
) : <p className="text-xs text-faint">no data</p>}
</div>
);
}
@@ -197,3 +85,175 @@ function LineupCol({ team, players }: { team: string; players: { name: string; p
</div>
);
}
export function MatchPreviewPage() {
const { num: numParam } = useParams({ strict: false }) as { num: string };
const [preview, setPreview] = useState<MatchPreview | null>(null);
const [failed, setFailed] = useState(false);
// Live score/minute arrive over the WebSocket snapshot — overlay them on the
// (less-frequently-fetched) preview.
const liveFixture = useTournamentStore((s) => s.snapshot?.fixtures.find((f) => f.num === Number(numParam)));
const isLive = (liveFixture?.status ?? preview?.fixture.status) === 'live';
useEffect(() => {
let alive = true;
setPreview(null);
setFailed(false);
const load = () =>
fetch(`/api/preview/${numParam}`)
.then((r) => (r.ok ? r.json() : Promise.reject()))
.then((d: MatchPreview) => alive && setPreview(d))
.catch(() => alive && setFailed(true));
void load();
const id = setInterval(() => { if (useTournamentStore.getState().snapshot?.fixtures.find((f) => f.num === Number(numParam))?.status === 'live') void load(); }, 20_000);
return () => { alive = false; clearInterval(id); };
}, [numParam]);
const f: Fixture | undefined = liveFixture ?? preview?.fixture;
const inPlay = useMemo(() => {
if (!preview?.prediction || !f || f.status !== 'live' || f.homeScore == null || f.awayScore == null) return null;
return inPlayProbs(preview.prediction.lambdaHome, preview.prediction.lambdaAway, f.minute ?? 0, f.homeScore, f.awayScore);
}, [preview, f]);
if (failed) return <div className="py-16 text-center text-muted">Couldn&apos;t load that match. <Link to="/" className="text-accent">Back to Live</Link></div>;
if (!preview || !f) return <div className="h-96 animate-pulse rounded-xl border border-line bg-panel" />;
const hasScore = f.status === 'live' || f.status === 'finished';
const homeName = f.home.label;
const awayName = f.away.label;
const finished = f.status === 'finished';
return (
<div className="space-y-5">
<Link to="/" className="inline-flex items-center gap-1.5 text-sm text-muted hover:text-ink"><ArrowLeft size={15} /> Back</Link>
{/* hero */}
<Card className={isLive ? 'border-live/40 ring-1 ring-live/30' : ''}>
<CardBody>
<div className="mb-3 text-center text-xs uppercase tracking-wide text-faint">
{f.group ? `Group ${f.group}` : STAGE[f.stage]} · {kickoffDay(f.kickoff)} · {kickoffTime(f.kickoff)} · {preview.venue}
</div>
<div className="grid grid-cols-[1fr_auto_1fr] items-center gap-3">
<Link to="/team/$name" params={{ name: f.home.team ?? '' }} className="flex flex-col items-center gap-1 hover:opacity-80">
<span aria-hidden className="text-4xl">{f.home.team ? teamFlag(f.home.team) : '⚽'}</span>
<span className="text-center text-sm font-semibold text-ink">{homeName}</span>
</Link>
<div className="text-center">
{hasScore ? <div className="tnum text-3xl font-extrabold text-ink">{f.homeScore ?? 0}{f.awayScore ?? 0}</div> : <div className="tnum text-lg font-bold text-muted">{kickoffTime(f.kickoff)}</div>}
{isLive && <div className="inline-flex items-center gap-1 text-[11px] font-bold uppercase text-live"><span className="live-dot h-1.5 w-1.5 rounded-full bg-live" />{f.minute ? `${f.minute}'` : 'Live'}</div>}
{finished && <div className="text-[11px] font-bold uppercase text-faint">Full time</div>}
</div>
<Link to="/team/$name" params={{ name: f.away.team ?? '' }} className="flex flex-col items-center gap-1 hover:opacity-80">
<span aria-hidden className="text-4xl">{f.away.team ? teamFlag(f.away.team) : '⚽'}</span>
<span className="text-center text-sm font-semibold text-ink">{awayName}</span>
</Link>
</div>
</CardBody>
</Card>
{/* LIVE: in-play win probability */}
{inPlay && preview.prediction && (
<Card className="border-live/30">
<CardHeader className="flex items-center gap-2">
<span className="live-dot h-2 w-2 rounded-full bg-live" />
<span className="font-display font-bold text-ink">Live win probability</span>
</CardHeader>
<CardBody>
<WinProbBar home={homeName} away={awayName} probs={inPlay} />
<p className="mt-2 text-xs text-faint">Updates with the score and clock remaining goals modelled from each side&apos;s pre-match expectation.</p>
</CardBody>
</Card>
)}
{/* LIVE/FT: stats */}
{preview.liveStats && (
<Card>
<CardHeader><span className="font-display font-bold text-ink">Match stats</span></CardHeader>
<CardBody className="space-y-3">
{STAT_ROWS.filter(([k]) => preview.liveStats!.home[k] != null || preview.liveStats!.away[k] != null).map(([k, label]) => (
<LiveStatRow key={k} label={label} home={num(preview.liveStats!.home[k])} away={num(preview.liveStats!.away[k])} />
))}
</CardBody>
</Card>
)}
{/* LIVE/FT: timeline */}
{preview.events.length > 0 && (
<Card>
<CardHeader><span className="font-display font-bold text-ink">Timeline</span></CardHeader>
<CardBody>
<ul className="space-y-1.5">
{preview.events.map((e, i) => (
<li key={i} className={`flex items-center gap-2 text-sm ${e.team === 'away' ? 'flex-row-reverse text-right' : ''}`}>
<span className="tnum w-8 shrink-0 text-xs text-faint">{e.minute != null ? `${e.minute}'` : ''}</span>
<span aria-hidden>{eventIcon(e.type)}</span>
<span className="truncate text-ink-soft">{e.text || e.type}</span>
</li>
))}
</ul>
</CardBody>
</Card>
)}
{/* model expectation */}
{preview.prediction && (
<Card>
<CardHeader><span className="font-display font-bold text-ink">{hasScore ? 'Pre-match model' : 'Model expectation'}</span></CardHeader>
<CardBody className="space-y-2">
<WinProbBar home={preview.prediction.home} away={preview.prediction.away} probs={preview.prediction.probs} topScore={preview.prediction.topScore} />
<p className="text-xs text-faint">Expected goals: {preview.prediction.lambdaHome.toFixed(2)} {preview.prediction.lambdaAway.toFixed(2)} · model odds, not betting advice</p>
</CardBody>
</Card>
)}
{/* form */}
{(preview.form.home.length > 0 || preview.form.away.length > 0) && (
<Card>
<CardHeader><span className="font-display font-bold text-ink">Recent form</span></CardHeader>
<CardBody className="space-y-3">
<div className="flex items-center justify-between"><span className="flex items-center gap-2 text-sm font-medium text-ink">{f.home.team && teamFlag(f.home.team)} {homeName}</span><FormChips form={preview.form.home} /></div>
<div className="flex items-center justify-between"><span className="flex items-center gap-2 text-sm font-medium text-ink">{f.away.team && teamFlag(f.away.team)} {awayName}</span><FormChips form={preview.form.away} /></div>
</CardBody>
</Card>
)}
{/* head to head */}
{preview.h2h && (
<Card>
<CardHeader><span className="font-display font-bold text-ink">Head to head</span></CardHeader>
<CardBody>
<div className="mb-3 grid grid-cols-3 items-center text-center">
<div><div className="tnum text-2xl font-extrabold text-accent">{preview.h2h.homeWins}</div><div className="text-[11px] text-faint">{homeName} wins</div></div>
<div><div className="tnum text-2xl font-extrabold text-muted">{preview.h2h.draws}</div><div className="text-[11px] text-faint">draws · {preview.h2h.games} total</div></div>
<div><div className="tnum text-2xl font-extrabold text-info">{preview.h2h.awayWins}</div><div className="text-[11px] text-faint">{awayName} wins</div></div>
</div>
<div className="border-t border-line pt-2">
<div className="mb-1 text-[11px] uppercase tracking-wide text-faint">Recent meetings</div>
<ul className="space-y-1 text-sm">
{preview.h2h.last.map((m, i) => (
<li key={i} className="flex items-center justify-between text-ink-soft"><span className="text-faint">{m.date.slice(0, 10)}</span><span>{m.home} <span className="tnum font-semibold text-ink">{m.homeScore}{m.awayScore}</span> {m.away}</span></li>
))}
</ul>
</div>
</CardBody>
</Card>
)}
{/* lineups or key players */}
<Card>
<CardHeader className="flex items-center gap-2"><Shirt size={16} className="text-accent" /><span className="font-display font-bold text-ink">{preview.lineups ? 'Lineups' : 'Key players (all-time scorers)'}</span></CardHeader>
<CardBody className="grid grid-cols-2 gap-6">
{preview.lineups ? (
<><LineupCol team={homeName} players={preview.lineups.home} /><LineupCol team={awayName} players={preview.lineups.away} /></>
) : (
<><ScorerList team={homeName} players={preview.keyPlayers.home} /><ScorerList team={awayName} players={preview.keyPlayers.away} /></>
)}
</CardBody>
</Card>
<p className="text-center text-xs text-faint">{preview.dataNote}</p>
</div>
);
}
+30
View File
@@ -0,0 +1,30 @@
import { describe, it, expect } from 'vitest';
import { inPlayProbs } from './inplay';
describe('inPlayProbs', () => {
it('sums to 1 and roughly matches pre-match at kickoff', () => {
const p = inPlayProbs(1.8, 0.9, 0, 0, 0);
expect(p.home + p.draw + p.away).toBeCloseTo(1, 6);
expect(p.home).toBeGreaterThan(p.away); // stronger side favoured
});
it('locks the result at full time', () => {
expect(inPlayProbs(1.5, 1.2, 90, 2, 1)).toEqual({ home: 1, draw: 0, away: 0 });
expect(inPlayProbs(1.5, 1.2, 95, 1, 1)).toEqual({ home: 0, draw: 1, away: 0 });
expect(inPlayProbs(1.5, 1.2, 90, 0, 2)).toEqual({ home: 0, draw: 0, away: 1 });
});
it('a lead becomes more decisive as time runs down', () => {
const early = inPlayProbs(1.4, 1.4, 20, 1, 0);
const late = inPlayProbs(1.4, 1.4, 80, 1, 0);
expect(late.home).toBeGreaterThan(early.home);
expect(late.away).toBeLessThan(early.away);
});
it('chasing team still has hope early but little late', () => {
const trailingEarly = inPlayProbs(1.3, 1.3, 30, 0, 1).home;
const trailingLate = inPlayProbs(1.3, 1.3, 85, 0, 1).home;
expect(trailingEarly).toBeGreaterThan(trailingLate);
expect(trailingLate).toBeLessThan(0.1);
});
});
+44
View File
@@ -0,0 +1,44 @@
import { poissonPmf, type OutcomeProbs } from './poisson';
// In-play win probability. Given each side's pre-match goal expectation, the
// current score, and the minute, the remaining goals are modelled as independent
// Poisson processes scaled to the time left — convolved with the current score to
// give live win/draw/loss. Cheap enough to recompute on every tick.
const REG_MINUTES = 90;
const MAX_GOALS = 12;
export function inPlayProbs(
lambdaHome: number,
lambdaAway: number,
minute: number,
scoreHome: number,
scoreAway: number,
): OutcomeProbs {
const remaining = Math.max(0, Math.min(1, (REG_MINUTES - minute) / REG_MINUTES));
if (remaining <= 0) {
if (scoreHome > scoreAway) return { home: 1, draw: 0, away: 0 };
if (scoreHome < scoreAway) return { home: 0, draw: 0, away: 1 };
return { home: 0, draw: 1, away: 0 };
}
const lh = lambdaHome * remaining;
const la = lambdaAway * remaining;
const ph = Array.from({ length: MAX_GOALS + 1 }, (_, k) => poissonPmf(lh, k));
const pa = Array.from({ length: MAX_GOALS + 1 }, (_, k) => poissonPmf(la, k));
let home = 0, draw = 0, away = 0;
for (let i = 0; i <= MAX_GOALS; i++) {
for (let j = 0; j <= MAX_GOALS; j++) {
const p = ph[i]! * pa[j]!;
const fh = scoreHome + i;
const fa = scoreAway + j;
if (fh > fa) home += p;
else if (fh < fa) away += p;
else draw += p;
}
}
const total = home + draw + away;
return { home: home / total, draw: draw / total, away: away / total };
}
+5
View File
@@ -133,6 +133,7 @@ export interface H2HSummary {
export interface KeyPlayer { name: string; goals: number; pens: number }
export interface LineupPlayer { name: string; position: string | null; starter: boolean; number: number | null }
export interface MatchLiveEvent { minute: number | null; type: string; text: string; team: 'home' | 'away' | null }
export interface MatchPreview {
num: number;
@@ -144,6 +145,10 @@ export interface MatchPreview {
h2h: H2HSummary | null;
lineups: { home: LineupPlayer[]; away: LineupPlayer[] } | null;
keyPlayers: { home: KeyPlayer[]; away: KeyPlayer[] };
/** Live/finished team stats (possession, shots, …), null pre-match. */
liveStats: { home: Record<string, string>; away: Record<string, string> } | null;
/** Live/finished event timeline (goals, cards, subs). */
events: MatchLiveEvent[];
/** Honest note on which data layers are populated yet. */
dataNote: string;
updatedAt: number | null;