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