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 (?, ?, ?)')