diff --git a/src/services/ledgerService.js b/src/services/ledgerService.js index 604a2c7..15438df 100644 --- a/src/services/ledgerService.js +++ b/src/services/ledgerService.js @@ -168,11 +168,33 @@ function teamOpponentFor(g, prop) { * Session 61 — prefer a book row with BOTH sides priced (same rule as * snapshotService.indexOdds): fewer genuinely-absent locked/closing odds * when another book carried the side. Real rows only, never synthesized. */ -function indexProps(props) { +/** + * Index odds rows by player|stat. + * + * `takeableOnly` (2026-08-02) splits ONE index into two roles, because the row + * needs two different things from a prop and they have different correctness + * rules: + * + * PRICE / BOOK / TAKEABLE — must come from a book a bettor could ACTUALLY + * have taken. Gated on TAKEABLE_BOOKS, not MODEL_BOOKS: `pinnacle` is + * model-eligible and deliberately not takeable, so a MODEL gate would + * re-break the moment pinnacle's feed recovers. + * GAME FACTS (game_time, game_date, team/opponent) — book-INDEPENDENT. First + * pitch is first pitch whichever book listed it, so these may come from any + * book. Gating them too would drop otherwise-valid rows for no gain. + * + * Collapsing those two into one index is exactly the bug this fixes: the + * display widening on 2026-08-01 turned the shared index into a DFS/exchange + * source for the lock price, and the ledger's non-takeable share went 0% -> + * 47.9% overnight. + */ +function indexProps(props, takeableOnly = false) { + const { isTakeableBook } = require('../config/bookRoles'); const map = {}; const bothSides = (p) => p && p.over_odds != null && p.under_odds != null; for (const p of props || []) { if (!p || !p.player || !p.stat_type) continue; + if (takeableOnly && !isTakeableBook(p.book)) continue; const k = `${nameKey(p.player)}|${String(p.stat_type).toLowerCase()}`; if (!map[k] || (!bothSides(map[k]) && bothSides(p))) map[k] = p; } @@ -211,7 +233,11 @@ function archetypeVectorOf(g) { function rowsFromSnapshot(sport, grades, oddsProps, nowIso) { const sp = String(sport || '').toLowerCase(); let skippedUnbound = 0; + // TWO indexes, two roles — see indexProps. `byKey` supplies GAME FACTS from + // any book; `byTakeable` supplies the PRICE/BOOK/TAKEABLE anchor and admits + // takeable books only. const byKey = indexProps(oddsProps); + const byTakeable = indexProps(oddsProps, true); const rows = []; for (const g of grades || []) { if (!g || !g.grade || g.insufficient_data) continue; @@ -223,7 +249,9 @@ function rowsFromSnapshot(sport, grades, oddsProps, nowIso) { const locked = g.gradedAt || {}; const line = numOrNull(locked.line) ?? numOrNull(g.line); if (line == null) continue; // no real captured line → no row - const prop = byKey[`${nameKey(player)}|${stat}`] || null; + const propAnyBook = byKey[`${nameKey(player)}|${stat}`] || null; + const prop = propAnyBook; // game facts only + const priceProp = byTakeable[`${nameKey(player)}|${stat}`] || null; // price anchor const gradedTs = locked.timestamp || nowIso; // Session 64 (Order 1.5) — a game date comes from the GAME, never from the // grade clock. The old `|| dateET(gradedTs) || todayET()` fallback is what @@ -244,7 +272,10 @@ function rowsFromSnapshot(sport, grades, oddsProps, nowIso) { stat, line, side, - locked_odds: locked.odds != null ? String(locked.odds) : oddsForSide(prop, side), + // PRICE ANCHOR: the takeable book only. `locked.odds` is itself + // takeable-gated upstream (snapshotService.indexOdds); the fallback now + // reads the takeable index instead of whatever book indexed first. + locked_odds: locked.odds != null ? String(locked.odds) : oddsForSide(priceProp, side), // TAKEABLE TAG (2026-07-31, specs/takeable-tagging.md) — was this a price a // bettor could actually have taken? FLOOR on the minus side, UNCAPPED plus. // 🔴 NOT `valueEngine.isTakeable` (the -160..+200 PROMOTION band the hero and @@ -253,9 +284,11 @@ function rowsFromSnapshot(sport, grades, oddsProps, nowIso) { // bucket's ROI interval contained zero), so each row records the floor it was // tagged under and a re-derivation can re-tag safely. Absent price → NULL, an // honest absence, never false. - takeable: takeableFor(locked.odds != null ? locked.odds : oddsForSide(prop, side)), + takeable: takeableFor(locked.odds != null ? locked.odds : oddsForSide(priceProp, side)), takeable_floor: LEDGER_TAKEABLE_FLOOR, - book: (prop && prop.book) || g.book || null, + // BOOK: the takeable book the price came from, else the book the grade was + // COMPUTED on. Never the widened display list's first match. + book: (priceProp && priceProp.book) || g.book || null, grade: g.grade, edge: numOrNull(g.edge_pct), confidence: numOrNull(g.confidence), diff --git a/src/services/snapshotService.js b/src/services/snapshotService.js index 0832ea2..c5686d9 100644 --- a/src/services/snapshotService.js +++ b/src/services/snapshotService.js @@ -57,10 +57,28 @@ async function mapLimit(items, concurrency, fn) { * sometimes carried only one side (e.g. betmgm SB unders with no juice), * which locked a NULL odds even though another book priced it. Still a * REAL book row, never synthesized. */ +/** + * Index the odds rows the LOCKED PRICE is read from. + * + * TAKEABLE-ONLY (2026-08-02). This used to index the FULL props list, which + * became the display-widened list on 2026-08-01 — so `gradedAt.odds`, the price + * a grade is locked at, could be a DFS pick'em or exchange price. Measured: the + * ledger's non-takeable share went 0% -> 47.9% overnight. + * + * The lock price must be one a bettor could ACTUALLY have taken, so this gates + * on TAKEABLE_BOOKS — not MODEL_BOOKS. `pinnacle` is model-eligible and + * deliberately NOT takeable, so gating on MODEL would re-break this the moment + * pinnacle's feed recovers. + * + * No takeable quote → the key is ABSENT and the price stays null. An honest + * missing price beats a price from a book you cannot bet. + */ function indexOdds(props) { + const { isTakeableBook } = require('../config/bookRoles'); const map = {}; const bothSides = (p) => p && p.over_odds != null && p.under_odds != null; for (const p of props || []) { + if (!isTakeableBook(p && p.book)) continue; const k = `${norm(p.player)}|${String(p.stat_type || '').toLowerCase()}`; if (!map[k] || (!bothSides(map[k]) && bothSides(p))) map[k] = p; } diff --git a/tests/unit/takeableLedgerAnchor.test.js b/tests/unit/takeableLedgerAnchor.test.js new file mode 100644 index 0000000..fd93a73 --- /dev/null +++ b/tests/unit/takeableLedgerAnchor.test.js @@ -0,0 +1,76 @@ +'use strict'; + +/** + * TAKEABLE ANCHOR in the ledger write (2026-08-02). + * + * Self-inflicted regression: widening the books for DISPLAY leaked into the + * ledger, which indexed the widened list and stamped book / locked_odds / and + * the `takeable` flag itself from DFS, offshore and exchange books. Non-takeable + * share went 0% -> 47.9% overnight. + * + * The invariant: the PRICE ANCHOR is takeable-only; GAME FACTS may come from any + * book. Collapsing those two roles into one index is the bug. + */ + +const { __internals } = require('../../src/services/ledgerService'); +const { indexProps } = __internals; +const snap = require('../../src/services/snapshotService').__internals; + +const p = (book, over, under, extra = {}) => ({ + player: 'Kyle Schwarber', stat_type: 'hits', line: 1.5, book, + over_odds: over, under_odds: under, game_time: '2026-08-02T23:10:00Z', ...extra, +}); + +describe('indexProps — two roles, two indexes', () => { + const props = [ + p('dabble', -119, -119), // DFS pick'em — FIRST in the list, as in prod + p('kalshi', 1100, -1400), // exchange + p('draftkings', -140, 120), // takeable + ]; + + it('the PRICE index admits takeable books ONLY, whatever indexed first', () => { + const byTakeable = indexProps(props, true); + expect(byTakeable['kyle schwarber|hits'].book).toBe('draftkings'); + }); + + it('the GAME-FACTS index still accepts any book — first pitch is book-independent', () => { + const byAny = indexProps(props); + expect(byAny['kyle schwarber|hits']).toBeTruthy(); + expect(byAny['kyle schwarber|hits'].game_time).toBe('2026-08-02T23:10:00Z'); + }); + + it('gates on TAKEABLE, not MODEL — pinnacle is model-eligible but not takeable', () => { + // A MODEL gate would let pinnacle anchor the price and re-break this the + // moment its feed recovers. + const withPinnacle = indexProps([p('pinnacle', -130, 110)], true); + expect(withPinnacle['kyle schwarber|hits']).toBeUndefined(); + }); + + it('NO takeable quote leaves the key ABSENT — an honest missing price', () => { + const none = indexProps([p('dabble', -119, -119), p('bovada', -150, 130)], true); + expect(none['kyle schwarber|hits']).toBeUndefined(); + }); + + it('still prefers a two-sided takeable quote over a one-sided one', () => { + const out = indexProps([p('draftkings', -140, null), p('betmgm', -135, 115)], true); + expect(out['kyle schwarber|hits'].book).toBe('betmgm'); + }); +}); + +describe('snapshotService.indexOdds — the locked PRICE is takeable-gated', () => { + it('skips DFS/exchange rows entirely', () => { + const map = snap.indexOdds([ + { player: 'Kyle Schwarber', stat_type: 'hits', book: 'dabble', over_odds: -119, under_odds: -119 }, + { player: 'Kyle Schwarber', stat_type: 'hits', book: 'draftkings', over_odds: -140, under_odds: 120 }, + ]); + const k = Object.keys(map)[0]; + expect(map[k].book).toBe('draftkings'); + }); + + it('yields NO entry when only non-takeable books quote the prop', () => { + const map = snap.indexOdds([ + { player: 'X', stat_type: 'hits', book: 'kalshi', over_odds: 1100, under_odds: -1400 }, + ]); + expect(Object.keys(map)).toHaveLength(0); + }); +});