v3 Phase A (urgent): bookmaker odds capture — the Model-vs-Market benchmark

- docs/V3-GIGA-PLAN.md: the v3 plan (data lake to 7+GB, GIGA ensemble, scoreboard,
  UI clarity) with the honest reframe: perfect accuracy = perfect calibration;
  'beat the betting sites' = a live public head-to-head, scored with proper rules.
- src/lib/odds.ts: American-ml → decimal, de-vig (normalize away the margin),
  overround; 3 vitest cases incl. tonight's real opening lines.
- ESPN core odds adapter (server/src/ingest/espnOdds.ts): captures DraftKings
  1X2 moneylines + O/U + spread per fixture; follows $ref pointer items.
- odds_history table (insert-if-changed → clean line-movement history);
  scheduler odds sweep every 3h over a 14-day horizon, first sweep at boot;
  /api/odds + /api/odds/:num. Odds are a benchmark ONLY — never a model input.
- Boot speed-up: fixture↔event mapping skips dates already fully mapped.
- Verified: 54 fixtures captured with real moneylines locally.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-06-11 17:03:15 +02:00
parent 51dfe00216
commit d887664fce
8 changed files with 295 additions and 5 deletions
+56
View File
@@ -72,6 +72,19 @@ CREATE TABLE IF NOT EXISTS goalscorers (
CREATE INDEX IF NOT EXISTS idx_goalscorers_scorer ON goalscorers(scorer);
CREATE INDEX IF NOT EXISTS idx_goalscorers_team ON goalscorers(team);
CREATE TABLE IF NOT EXISTS odds_history (
id INTEGER PRIMARY KEY AUTOINCREMENT,
fixture_num INTEGER NOT NULL,
provider TEXT NOT NULL,
captured_at INTEGER NOT NULL,
home_ml REAL,
draw_ml REAL,
away_ml REAL,
over_under REAL,
spread REAL
);
CREATE INDEX IF NOT EXISTS idx_odds_fixture ON odds_history(fixture_num, captured_at);
CREATE TABLE IF NOT EXISTS ingest_log (
id INTEGER PRIMARY KEY AUTOINCREMENT,
source TEXT NOT NULL,
@@ -173,6 +186,49 @@ export function getSourceMap(fixtureNum: number, source: string): string | null
return row?.event_id ?? null;
}
// ---- bookmaker odds history (benchmark only, never a model input) ----
export interface OddsRow {
fixture_num: number;
provider: string;
captured_at: number;
home_ml: number | null;
draw_ml: number | null;
away_ml: number | null;
over_under: number | null;
spread: number | null;
}
/** Insert a snapshot only if the lines moved since the last capture. */
export function recordOdds(o: Omit<OddsRow, 'captured_at'>): boolean {
const last = db()
.prepare('SELECT home_ml, draw_ml, away_ml, over_under, spread FROM odds_history WHERE fixture_num = ? AND provider = ? ORDER BY captured_at DESC LIMIT 1')
.get(o.fixture_num, o.provider) as Omit<OddsRow, 'fixture_num' | 'provider' | 'captured_at'> | undefined;
if (
last &&
last.home_ml === o.home_ml && last.draw_ml === o.draw_ml && last.away_ml === o.away_ml &&
last.over_under === o.over_under && last.spread === o.spread
) return false;
db()
.prepare('INSERT INTO odds_history (fixture_num, provider, captured_at, home_ml, draw_ml, away_ml, over_under, spread) VALUES (?, ?, ?, ?, ?, ?, ?, ?)')
.run(o.fixture_num, o.provider, Date.now(), o.home_ml, o.draw_ml, o.away_ml, o.over_under, o.spread);
return true;
}
export function oddsFor(fixtureNum: number): OddsRow[] {
return db()
.prepare('SELECT fixture_num, provider, captured_at, home_ml, draw_ml, away_ml, over_under, spread FROM odds_history WHERE fixture_num = ? ORDER BY captured_at')
.all(fixtureNum) as unknown as OddsRow[];
}
export function latestOddsAll(): OddsRow[] {
return db()
.prepare(`SELECT o.* FROM odds_history o
JOIN (SELECT fixture_num, provider, MAX(captured_at) m FROM odds_history GROUP BY fixture_num, provider) t
ON o.fixture_num = t.fixture_num AND o.provider = t.provider AND o.captured_at = t.m
ORDER BY o.fixture_num`)
.all() as unknown as OddsRow[];
}
// ---- 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 (?, ?, ?)')
+8 -1
View File
@@ -9,7 +9,7 @@ import { TournamentState } from './tournament';
import { startScheduler } from './ingest/scheduler';
import { ModelEngine } from './model';
import { buildPreview, buildTeamProfile } from './preview';
import { db, healthAll } from './db/db';
import { db, healthAll, oddsFor, latestOddsAll } from './db/db';
import { canonicalTeam } from '../../src/lib/teams';
import type { MatchStatus, ServerMessage } from '../../src/lib/types';
@@ -57,6 +57,13 @@ export function buildServer() {
app.get('/api/snapshot', async () => state.snapshot());
app.get('/api/predictions', async () => model.current());
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/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' });
return { history: oddsFor(num) };
});
app.get('/api/preview/:num', async (req, reply) => {
const num = Number((req.params as { num: string }).num);
const preview = Number.isFinite(num) ? buildPreview(num, state, model) : null;
+60
View File
@@ -0,0 +1,60 @@
import { cachedFetch } from './fetcher';
// ESPN's core API exposes real bookmaker odds (DraftKings et al.) per event —
// free and reachable from the VPS. Captured into odds_history as the public
// BENCHMARK for the Model-vs-Market scoreboard. Never fed into the model.
const CORE = 'https://sports.core.api.espn.com/v2/sports/soccer/leagues/fifa.world';
const SOURCE = 'espn-odds';
interface RawOddsItem {
/** The core API sometimes returns pointer items instead of inline objects. */
$ref?: string;
provider?: { name?: string };
overUnder?: number;
spread?: number;
homeTeamOdds?: { moneyLine?: number };
awayTeamOdds?: { moneyLine?: number };
drawOdds?: { moneyLine?: number };
}
export interface CapturedOdds {
provider: string;
homeMl: number | null;
drawMl: number | null;
awayMl: number | null;
overUnder: number | null;
spread: number | null;
}
export async function fetchEspnOdds(eventId: string): Promise<CapturedOdds[]> {
const body = await cachedFetch<{ items?: RawOddsItem[] }>(
SOURCE,
`espn:odds:${eventId}`,
`${CORE}/events/${eventId}/competitions/${eventId}/odds`,
{ ttl: 30 * 60_000, minInterval: 1_500 },
);
const out: CapturedOdds[] = [];
for (let it of body.items ?? []) {
// Pointer item → dereference (force https; ESPN refs come back as http).
if (it.$ref && !it.provider) {
const url = it.$ref.replace(/^http:/, 'https:');
try {
it = await cachedFetch<RawOddsItem>(SOURCE, `espn:oddsref:${eventId}:${url.slice(-40)}`, url, {
ttl: 30 * 60_000,
minInterval: 1_500,
});
} catch {
continue;
}
}
out.push({
provider: it.provider?.name ?? 'unknown',
homeMl: it.homeTeamOdds?.moneyLine ?? null,
drawMl: it.drawOdds?.moneyLine ?? null,
awayMl: it.awayTeamOdds?.moneyLine ?? null,
overUnder: it.overUnder ?? null,
spread: it.spread ?? null,
});
}
return out;
}
+54 -4
View File
@@ -1,8 +1,9 @@
import { TournamentState, type LiveMatch, type Source } from '../tournament';
import { fetchEspnScoreboard, fetchEspnSummary } from './espn';
import { fetchEspnOdds } from './espnOdds';
import { fetchFootballData } from '../footballData';
import { fetchSofascore } from '../sofascore';
import { setSourceMap, getSourceMap, setMatchExt, logIngest } from '../db/db';
import { setSourceMap, getSourceMap, setMatchExt, recordOdds, logIngest } from '../db/db';
import type { Fixture } from '../../../src/lib/types';
// The ingestion orchestrator. Replaces the old live loop: ESPN is the primary
@@ -14,6 +15,8 @@ const LIVE_WINDOW_MS = 3 * 60 * 60 * 1000;
const POLL_LIVE_MS = 20_000;
const POLL_IDLE_MS = 10 * 60_000;
const ENRICH_MS = 20 * 60_000;
const ODDS_MS = 3 * 60 * 60 * 1000; // odds sweep cadence (line-movement history)
const ODDS_HORIZON_MS = 14 * 24 * 60 * 60 * 1000;
function dateUTC(iso: string): string {
const d = new Date(iso);
@@ -29,9 +32,17 @@ export function startScheduler(state: TournamentState, onChange: () => void): ()
console.log(`[ingest] scheduler — espn:on football-data:${token ? 'on' : 'off'} sofascore:${sofa ? 'on' : 'off'}`);
// ---- one-time: map every fixture to its ESPN event id (across all dates) ----
// ---- one-time: map every fixture to its ESPN event id (across all dates).
// Mappings persist in source_map, so on restarts we only fetch dates that
// still contain unmapped fixtures — boots get fast, odds capture starts early.
const mapAllFixtures = async (): Promise<void> => {
const dates = [...new Set(state.allFixtures().map((f) => dateUTC(f.kickoff)))];
const byDate = new Map<string, Fixture[]>();
for (const f of state.allFixtures()) {
const d = dateUTC(f.kickoff);
if (!byDate.has(d)) byDate.set(d, []);
byDate.get(d)!.push(f);
}
const dates = [...byDate.keys()].filter((d) => byDate.get(d)!.some((f) => !getSourceMap(f.num, 'espn')));
let mapped = 0;
for (const date of dates) {
if (stopped) return;
@@ -109,6 +120,41 @@ export function startScheduler(state: TournamentState, onChange: () => void): ()
}
};
// ---- odds capture: every upcoming fixture inside the horizon, plus imminent
// ones near kickoff. Insert-if-changed keeps a clean line-movement history.
const oddsTick = async (): Promise<void> => {
const now = Date.now();
const relevant = state.allFixtures().filter((f) => {
if (f.status === 'finished') return false;
const k = new Date(f.kickoff).getTime();
return k > now - 3 * 60 * 60 * 1000 && k < now + ODDS_HORIZON_MS;
});
let captured = 0;
const t0 = Date.now();
for (const f of relevant) {
if (stopped) return;
const eid = getSourceMap(f.num, 'espn');
if (!eid) continue;
try {
for (const o of await fetchEspnOdds(eid)) {
if (recordOdds({
fixture_num: f.num, provider: o.provider,
home_ml: o.homeMl, draw_ml: o.drawMl, away_ml: o.awayMl,
over_under: o.overUnder, spread: o.spread,
})) captured++;
}
} catch { /* breaker/health track it */ }
}
logIngest('espn-odds', 'sweep', true, Date.now() - t0, `${relevant.length} fixtures, ${captured} new lines`);
if (captured) console.log(`[ingest] odds: ${captured} new line(s) captured`);
};
let oddsTimer: ReturnType<typeof setTimeout> | undefined;
const scheduleOdds = (): void => {
if (stopped) return;
oddsTimer = setTimeout(async () => { await oddsTick().catch(() => {}); scheduleOdds(); }, ODDS_MS);
};
const scheduleLive = (): void => {
if (stopped) return;
const delay = state.hasLiveActivity(LIVE_WINDOW_MS) ? POLL_LIVE_MS : POLL_IDLE_MS;
@@ -119,18 +165,22 @@ export function startScheduler(state: TournamentState, onChange: () => void): ()
enrichTimer = setTimeout(async () => { await enrichTick().catch(() => {}); scheduleEnrich(); }, ENRICH_MS);
};
// boot: map ids, then run the loops
// boot: map ids, then run the loops (odds first after mapping — line history
// is unrecoverable, so capture as early as possible)
void (async () => {
await mapAllFixtures().catch(() => {});
await oddsTick().catch(() => {});
await liveTick().catch(() => {});
await enrichTick().catch(() => {});
scheduleLive();
scheduleEnrich();
scheduleOdds();
})();
return () => {
stopped = true;
if (liveTimer) clearTimeout(liveTimer);
if (enrichTimer) clearTimeout(enrichTimer);
if (oddsTimer) clearTimeout(oddsTimer);
};
}