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:
@@ -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;
|
||||
}
|
||||
@@ -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);
|
||||
};
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user