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
+38 -5
View File
@@ -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),
+18
View File
@@ -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;
}
+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);
});
});