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
+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;