diff --git a/src/services/consensusRuler.js b/src/services/consensusRuler.js index ab13127..fb88493 100644 --- a/src/services/consensusRuler.js +++ b/src/services/consensusRuler.js @@ -124,15 +124,27 @@ function consensusFairProb(quotes, line, side, opts = {}) { } /** - * CHALLENGER DELTA. The incumbent is "first row wins" — literally the first - * quote in feed order, whatever book that is. This reproduces it faithfully so - * the comparison measures the real change and not an idealised one. + * CHALLENGER DELTA — the incumbent, reproduced FAITHFULLY. + * + * The live chain is: PropLine -> `normalizeProps` (which applies the + * ALLOWED_BOOKS filter FIRST) -> `gradeSlateService.dedupeProps` (first row per + * player+stat+line wins). So the incumbent is the first quote from an ADMITTED + * book — not the first quote in the raw feed. + * + * This distinction is load-bearing and easy to get wrong in the alarming + * direction: ignoring the allow-list makes it look as though DFS pick'em has + * been pricing the model (prizepicks alone is 47% of raw first-rows). It has + * not — the allow-list, for all the coverage it costs, does keep DFS out of the + * incumbent. Overstating the incumbent's badness would be as dishonest as + * understating it. */ -function incumbentFairProb(quotes, line, side) { +function incumbentFairProb(quotes, line, side, allowedBooks) { const want = String(side || 'over').toLowerCase() === 'under' ? 'under' : 'over'; const target = num(line); + const allow = allowedBooks || require('../utils/oddsNormalizer').ALLOWED_BOOKS; for (const q of quotes || []) { if (!q || !q.book) continue; + if (allow && !allow.has(String(q.book).toLowerCase())) continue; if (target != null && num(q.line) !== target) continue; const over = num(q.over_odds); const under = num(q.under_odds); @@ -150,7 +162,7 @@ function incumbentFairProb(quotes, line, side) { /** Both rulers plus the signed delta, for a single prop. */ function compareRulers(quotes, line, side, opts = {}) { - const incumbent = incumbentFairProb(quotes, line, side); + const incumbent = incumbentFairProb(quotes, line, side, opts.allowedBooks); const consensus = consensusFairProb(quotes, line, side, opts); const delta = (incumbent.fair_prob != null && consensus.fair_prob != null) ? round4(consensus.fair_prob - incumbent.fair_prob) diff --git a/src/services/proplineVerify.js b/src/services/proplineVerify.js index 55b2011..2dbf818 100644 --- a/src/services/proplineVerify.js +++ b/src/services/proplineVerify.js @@ -260,7 +260,7 @@ const REFERENCE_POLICIES = Object.freeze({ * incumbent is first-row-wins, so it reports WHICH KIND of book has been acting * as "the market" — and DFS pick'em has the highest coverage in the feed. */ -function rulerDelta(raw) { +function rulerDelta(raw, allowed) { const { compareRulers } = require('./consensusRuler'); const { roleOf } = require('../config/bookRoles'); @@ -289,6 +289,7 @@ function rulerDelta(raw) { const deltas = []; const roleCounts = {}; + let gradeable = 0; let consensusAvailable = 0; let bothAvailable = 0; let disagree2pts = 0; @@ -296,9 +297,10 @@ function rulerDelta(raw) { for (const [key, quotes] of byPropLine.entries()) { const line = quotes[0].line; - const c = compareRulers(quotes, line, 'over'); + const c = compareRulers(quotes, line, 'over', { allowedBooks: allowed }); if (c.consensus.source === 'consensus') consensusAvailable += 1; if (c.incumbent.book) { + gradeable += 1; const r = roleOf(c.incumbent.book); roleCounts[r] = (roleCounts[r] || 0) + 1; roleCounts[`book:${c.incumbent.book}`] = (roleCounts[`book:${c.incumbent.book}`] || 0) + 1; @@ -317,6 +319,8 @@ function rulerDelta(raw) { return { prop_line_groups: byPropLine.size, + gradeable_groups: gradeable, + gradeable_pct: byPropLine.size ? round2((100 * gradeable) / byPropLine.size) : null, consensus_available: consensusAvailable, consensus_available_pct: byPropLine.size ? round2((100 * consensusAvailable) / byPropLine.size) : null, comparable: bothAvailable, @@ -435,7 +439,7 @@ async function verify(opts = {}) { continue; } out.per_sport[sport] = analyseBreadth(raw, allowed); - out.per_sport[sport].ruler_delta = rulerDelta(raw); + out.per_sport[sport].ruler_delta = rulerDelta(raw, allowed); const ev = raw.find((e) => e && e.id && Array.isArray(e.bookmakers) && e.bookmakers.length); if (ev) firstEvent[sport] = { id: ev.id, key: PA.SPORT_KEYS[sport] }; } catch (err) { diff --git a/tests/unit/consensusRuler.test.js b/tests/unit/consensusRuler.test.js index a508920..3990a0f 100644 --- a/tests/unit/consensusRuler.test.js +++ b/tests/unit/consensusRuler.test.js @@ -114,16 +114,26 @@ describe('consensusRuler — labelling and the median', () => { }); describe('consensusRuler — the incumbent it is challenging', () => { - it('incumbent is literally first-row-wins, and that row can be a DFS book', () => { - const qs = [q('prizepicks', 1.5, -119, -119), q('novig', 1.5, -104, -104)]; - const inc = incumbentFairProb(qs, 1.5, 'over'); - expect(inc.book).toBe('prizepicks'); + const LIVE_ALLOWED = new Set(['draftkings', 'fanduel', 'betmgm', 'betrivers', 'pinnacle']); + + it('incumbent is first-row-wins AMONG ADMITTED BOOKS — the allow-list runs first', () => { + // normalizeProps applies ALLOWED_BOOKS before dedupeProps, so a DFS book + // sitting first in the raw feed is NOT the incumbent. Getting this wrong + // overstates the incumbent's badness, which is its own dishonesty. + const qs = [q('prizepicks', 1.5, -119, -119), q('betmgm', 1.5, -115, -105), q('draftkings', 1.5, -110, -110)]; + const inc = incumbentFairProb(qs, 1.5, 'over', LIVE_ALLOWED); + expect(inc.book).toBe('betmgm'); expect(inc.ruler_version).toBe(roles.RULER_V1); }); + it('a prop with NO admitted book has no incumbent at all — it is never graded', () => { + const qs = [q('prizepicks', 1.5, -119, -119), q('novig', 1.5, -104, -104)]; + expect(incumbentFairProb(qs, 1.5, 'over', LIVE_ALLOWED).fair_prob).toBeNull(); + }); + it('compareRulers returns a signed delta in probability points', () => { const qs = [q('draftkings', 1.5, -140, 120), q('novig', 1.5, -104, -104), q('kalshi', 1.5, -103, -105)]; - const c = compareRulers(qs, 1.5, 'over'); + const c = compareRulers(qs, 1.5, 'over', { allowedBooks: LIVE_ALLOWED }); expect(c.incumbent.book).toBe('draftkings'); expect(c.consensus.source).toBe('consensus'); expect(c.delta_pts).toBeLessThan(0); // dk's favourite priced over above the exchanges @@ -131,6 +141,6 @@ describe('consensusRuler — the incumbent it is challenging', () => { }); it('delta is null when either side is unavailable — never 0', () => { - expect(compareRulers([q('prizepicks', 1.5, -119, -119)], 1.5, 'over').delta_pts).toBeNull(); + expect(compareRulers([q('prizepicks', 1.5, -119, -119)], 1.5, 'over', { allowedBooks: LIVE_ALLOWED }).delta_pts).toBeNull(); }); });