URGENT: anchor the ledger price/book/takeable to TAKEABLE books

Ships before tonight's settle. Served path, champion, ranking and the
reference ruler are untouched.

TWO leaks, not one. The audit found ledgerService.indexProps; tracing the
lock price found that snapshotService.indexOdds has the SAME defect -- it
also indexed the full props list, so gradedAt.odds (the price a grade is
locked at) could itself be a DFS or exchange price. Fixing only the ledger
would have left the contamination flowing in through the lock.

Both now gate on TAKEABLE_BOOKS -- deliberately NOT MODEL_BOOKS. pinnacle
is model-eligible and correctly not takeable, so a MODEL gate would
re-break this the moment pinnacle's feed recovers. A test asserts pinnacle
cannot anchor a price.

TWO INDEXES, TWO ROLES, because the row needs two different things from a
prop and they have different correctness rules:
  PRICE / BOOK / TAKEABLE -- takeable books only.
  GAME FACTS (game_time, game_date, team/opponent) -- book-INDEPENDENT.
    First pitch is first pitch whichever book listed it, so these still
    come from any book. Gating them too would drop otherwise-valid rows
    for no gain.
Collapsing those roles into one index is precisely the bug.

No takeable quote leaves the key ABSENT and the price null. An honest
missing price beats a price from a book you cannot bet -- and it keeps the
takeable flag from being computed off a DFS number, which is what made it
wrong on its own terms rather than merely mislabelled.

Gates: 4,111 tests / 330 suites green; next build exit 0.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QJs13VsyiSKYQP6rj3NNmc
This commit is contained in:
Kev
2026-08-02 14:17:51 -04:00
parent 08e5c908e6
commit 5de464330c
3 changed files with 132 additions and 5 deletions
+76
View File
@@ -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);
});
});