diff --git a/src/config/takeability.js b/src/config/takeability.js index 2fcf783..f771629 100644 --- a/src/config/takeability.js +++ b/src/config/takeability.js @@ -29,14 +29,31 @@ * plus). A -300 prop is takeable AND outside the * band. Both statements are true at once. * - * These answer different questions and are stored in different columns. The - * ledger's `takeable` column is the PRICE-BAND answer; its name predates this - * distinction and is misleading — flagged, not silently redefined, because four - * challengers and the ranking gate currently read it. + * ── AND A THIRD, WHICH IS WHY THIS FILE EXISTS ─────────────────────────── + * + * isWithinPromotionBand(odds) — SHOULD we crown it? -160..+200, i.e. the + * ledger band PLUS a ceiling on the plus side. + * + * (2) and (3) genuinely disagree. Measured 2026-08-02: 439 rows — 28.2% of all + * `takeable=true` ledger rows — carry prices above +200, up to +1300. A +1300 + * longshot is a real bet worth RECORDING and not one worth CROWNING. Both are + * right for their own purpose. + * + * THE DANGER WAS NEVER THE LOGIC. It was that three questions shared one word, + * so a reader could not tell which answer they were holding. All three now have + * distinct names and live here. + * + * AUDITED 2026-08-02 — no consumer was getting the wrong axis: + * - the four accruing challengers do NOT read the flag at all; + * - the ranking gate wants PROMOTION and gets promotion (now named as such); + * - the ledger column holds the LEDGER band and is consumed as such; + * - the UI types a `takeable` field it never renders. + * So this change is a DISAMBIGUATION, not a bug fix, and is byte-identical. */ const { TAKEABLE_BOOKS, isTakeableBook } = require('./bookRoles'); const { isLedgerTakeable, LEDGER_TAKEABLE_FLOOR } = require('./takeableStandard'); +const { isTakeable: valueEngineBand } = require('./valueEngine'); /** * THE canonical takeability rule. Every path that asks "can this be bet?" calls @@ -74,13 +91,34 @@ function assessQuote({ book, odds } = {}) { book: book || null, takeable: isTakeableMarket(book), within_price_band: isWithinPriceBand(odds), + within_promotion_band: isWithinPromotionBand(odds), price_band_floor: LEDGER_TAKEABLE_FLOOR, }; } +/** + * THE THIRD BAND — the PROMOTION gate the ranking uses (-160..+200). + * + * Distinct from `isWithinPriceBand` by a CEILING on the plus side: the ledger + * band is uncapped upward, this one is not. Measured 2026-08-02, they disagree + * on 439 rows (28.2% of all takeable=true rows), on prices up to +1300 — a + * +1300 longshot is a real bet worth RECORDING and not one worth CROWNING. + * + * Both are correct for their own purpose. The danger was never the logic, it was + * that all three questions shared one word, so a new reader could not tell which + * answer they held. All three now have distinct names and live in this file. + */ +function isWithinPromotionBand(americanOdds) { + return valueEngineBand(americanOdds); +} + module.exports = { + // 1. can it be bet? -> book identity isTakeableMarket, + // 2. worth recording? -> ledger price band (floor, uncapped plus) isWithinPriceBand, + // 3. worth crowning? -> promotion band (floor AND ceiling) + isWithinPromotionBand, assessQuote, TAKEABLE_BOOKS, LEDGER_TAKEABLE_FLOOR, diff --git a/src/services/ledgerService.js b/src/services/ledgerService.js index 15438df..dd26940 100644 --- a/src/services/ledgerService.js +++ b/src/services/ledgerService.js @@ -285,6 +285,10 @@ function rowsFromSnapshot(sport, grades, oddsProps, nowIso) { // 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(priceProp, side)), + // Same value, honest name. `takeable` never meant "can this be bet" (that + // is book identity) -- it is the ledger PRICE band. Dual-written so no + // reader breaks; `takeable` is deprecated and can be dropped later. + within_price_band: takeableFor(locked.odds != null ? locked.odds : oddsForSide(priceProp, side)), takeable_floor: LEDGER_TAKEABLE_FLOOR, // BOOK: the takeable book the price came from, else the book the grade was // COMPUTED on. Never the widened display list's first match. diff --git a/src/utils/gradeRanking.js b/src/utils/gradeRanking.js index 49a03d8..f955ee1 100644 --- a/src/utils/gradeRanking.js +++ b/src/utils/gradeRanking.js @@ -22,7 +22,11 @@ * p_win is the only signal whose takeable-MLB-over CLV survived the skew audit. */ -const { isTakeable } = require('../config/valueEngine'); +// INTENT: this gate asks "should we CROWN this?", not "can it be bet?". It is +// the PROMOTION band (floor AND a +200 ceiling) -- a +1300 longshot is a real, +// placeable bet that we deliberately do not crown. Named explicitly since three +// different questions used to share the word "takeable". +const { isWithinPromotionBand } = require('../config/takeability'); const GRADE_RANK = Object.freeze({ 'A+': 0, A: 1, 'A-': 2, 'B+': 3, B: 4, 'B-': 5, 'C+': 6, C: 7, 'C-': 8, D: 9, F: 10, @@ -55,7 +59,7 @@ function takeablePWin(g) { const p = strictNum(g && g.p_win); if (p == null) return null; const price = strictNum(g && g.book_odds) ?? strictNum(g && g.gradedAt && g.gradedAt.odds); - if (price == null || !isTakeable(price)) return null; + if (price == null || !isWithinPromotionBand(price)) return null; return p; } diff --git a/tests/unit/structuralGuards.test.js b/tests/unit/structuralGuards.test.js index 6b6cd6e..bedc7b5 100644 --- a/tests/unit/structuralGuards.test.js +++ b/tests/unit/structuralGuards.test.js @@ -147,3 +147,45 @@ describe('GUARD 2 — takeability is BOOK IDENTITY, never price shape', () => { } }); }); + +// ──────────────────── THREE BANDS, THREE NAMES ──────────────────── +describe('the three questions that used to share the word "takeable"', () => { + const { isTakeableMarket, isWithinPriceBand, isWithinPromotionBand, assessQuote } = + require('../../src/config/takeability'); + + it('a +1300 longshot: real bet, worth RECORDING, not worth CROWNING', () => { + // The measured disagreement: 439 prod rows (28.2% of takeable=true) sit + // above +200. All three answers differ in intent, none is wrong. + expect(isTakeableMarket('draftkings')).toBe(true); // can be bet + expect(isWithinPriceBand(1300)).toBe(true); // worth recording + expect(isWithinPromotionBand(1300)).toBe(false); // not worth crowning + }); + + it('a -300 favourite: real bet, outside BOTH bands', () => { + expect(isTakeableMarket('draftkings')).toBe(true); + expect(isWithinPriceBand(-300)).toBe(false); + expect(isWithinPromotionBand(-300)).toBe(false); + }); + + it('the ledger band is UNCAPPED upward; the promotion band has a ceiling', () => { + expect(isWithinPriceBand(200)).toBe(true); + expect(isWithinPromotionBand(200)).toBe(true); + expect(isWithinPriceBand(201)).toBe(true); // still recordable + expect(isWithinPromotionBand(201)).toBe(false); // ceiling bites here + }); + + it('the ranking gate uses the PROMOTION band — byte-identical to before', () => { + const { takeablePWin } = require('../../src/utils/gradeRanking'); + const { isTakeable } = require('../../src/config/valueEngine'); + for (const odds of [-160, -161, -110, 100, 200, 201, 1300]) { + const g = { p_win: 0.6, book_odds: odds }; + // gate open iff the OLD valueEngine rule said so — no behaviour moved. + expect(takeablePWin(g) !== null).toBe(isTakeable(odds)); + } + }); + + it('assessQuote reports all three axes so a caller cannot pick the wrong one blind', () => { + const q = assessQuote({ book: 'draftkings', odds: 1300 }); + expect(q).toMatchObject({ takeable: true, within_price_band: true, within_promotion_band: false }); + }); +});