From a55dd2a6a0722870d11ef2a52de2186e76a075d3 Mon Sep 17 00:00:00 2001 From: Kev Date: Fri, 31 Jul 2026 23:44:37 -0400 Subject: [PATCH] Order Zero Phase 2: three-way book split + challenger consensus ruler CHALLENGER-FIRST. The live ruler is byte-identical: CURRENT_RULER_VERSION is still v1_first_book, nothing here writes a cache, a grade or a ledger row, and no live code path calls consensusRuler yet. bookRoles.js splits one allow-list into three, because it was answering two different questions -- "can we show this?" and "can we price against this?" -- with the same list, which is what bent the ruler. TAKEABLE the user can actually bet here (drives best price / shopping) REFERENCE may price the fair-prob ruler; never surfaced as a place to bet EXCLUDED DFS pick'em + offshore, permanently barred from all pricing Two deliberate calls, both evidence-based: - The six PropLine-phantom books (caesars/fanatics/bet365/hardrockbet/ pointsbet/thescore) are KEPT despite the order saying remove. They returned zero PropLine quotes, but PropLine is not our only provider and the odds-api backup path may carry them. A book that never appears is never matched, which costs nothing; deleting them risks silently dropping real books on the backup with no upside. Recorded in PHANTOM_ON_PROPLINE rather than enacted as a deletion. - REFERENCE = exchanges + pinnacle + bovada + the four US majors, chosen off the measured coverage curve rather than theory. exchange_only is cleanest (order-book, ~zero vig) but covers 14.3% of MLB and 5.6% of WNBA; adding the US majors gives 28.1% / 46.3%. pinnacle, matchbook and polymarket measured 0% on both sports and add nothing. The honest limitation is recorded in the config: this is a MARKET consensus, not a SHARP one. consensusRuler.js: median de-vigged fair_prob across >=2 reference books posting BOTH sides at the SAME line. Median so one stale exchange cannot drag it. Different lines are never averaged, one-sided quotes never rule, and n<2 falls back to single-book LABELLED as such with the v1 stamp -- never silently mixed, because a column holding both is two rulers wearing one name. The challenger delta runs over the live feed and reports incumbent_book_ roles, which is the real headline: the incumbent is literally first-row- wins, so it reports what KIND of book has been acting as "the market". DFS pick'em has the highest coverage in the feed, so a DFS book can be it. 18 ruler tests + 37 total in the two new suites. Full suite 4021 passed. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01QJs13VsyiSKYQP6rj3NNmc --- src/config/bookRoles.js | 118 ++++++++++++++++++++++ src/services/consensusRuler.js | 161 ++++++++++++++++++++++++++++++ src/services/proplineVerify.js | 88 +++++++++++++++- tests/unit/consensusRuler.test.js | 136 +++++++++++++++++++++++++ 4 files changed, 502 insertions(+), 1 deletion(-) create mode 100644 src/config/bookRoles.js create mode 100644 src/services/consensusRuler.js create mode 100644 tests/unit/consensusRuler.test.js diff --git a/src/config/bookRoles.js b/src/config/bookRoles.js new file mode 100644 index 0000000..9999c27 --- /dev/null +++ b/src/config/bookRoles.js @@ -0,0 +1,118 @@ +'use strict'; + +/** + * bookRoles — the three-way split of the book universe (Order Zero, Phase 2). + * + * ONE allow-list used to answer two different questions: "can we show this?" + * and "can we price against this?". Conflating them is what bent the ruler. + * They are now separate sets with separate rules. + * + * Measured on the live prod feed 2026-07-31 (MLB 3,138 props / 20 events, + * WNBA 160 props / 4 events — never pooled): + * - PropLine sends 18 book keys; the old ALLOWED_BOOKS admitted 5 of them. + * - MLB: 3.61 books/prop in the feed -> 0.57 after the allow-list, and + * 64.8% of props were INVISIBLE (zero admitted books). + * - WNBA: 4.21 books/prop in the feed -> 1.20 after. WNBA is NOT thin at the + * feed; it was allow-list-starved, same as MLB. + */ + +/** + * TAKEABLE — a user can actually place this bet. Drives best-price, line + * shopping and the `takeable` flag. NOT automatically a pricing reference. + * + * NOTE ON THE SIX "PHANTOM" ENTRIES (caesars, fanatics, bet365, hardrockbet, + * pointsbet, thescore): they returned ZERO quotes on PropLine in the live + * measurement. They are deliberately KEPT. PropLine is not our only provider — + * the odds-api backup path may carry them, and a book that never appears is + * simply never matched, which costs nothing. Deleting them risks silently + * dropping real books on the backup provider with no upside. Their absence is + * recorded in PHANTOM_ON_PROPLINE instead of being enacted as a deletion. + */ +const TAKEABLE_BOOKS = Object.freeze(new Set([ + 'draftkings', 'fanduel', 'betmgm', 'betrivers', + 'caesars', 'fanatics', 'bet365', 'hardrockbet', 'pointsbet', 'thescore', +])); + +/** Measured absent from PropLine — kept in TAKEABLE for the odds-api path. */ +const PHANTOM_ON_PROPLINE = Object.freeze(['caesars', 'fanatics', 'bet365', 'hardrockbet', 'pointsbet', 'thescore']); + +/** + * REFERENCE — may price the fair-probability ruler. Never surfaced as a place + * to bet (pinnacle and the exchanges are not US-retail takeable). + * + * The set is chosen from the measured coverage curve, not from theory: + * + * policy MLB n>=2 WNBA n>=2 + * exchange_only 14.3% 5.6% + * exchange_plus_sharp 14.3% 5.6% (pinnacle 0%, matchbook 0%, + * polymarket 0% -> add nothing) + * exchange_plus_us 28.1% 46.3% <- selected + * takeable_only 12.6% 30.6% + * + * A pure-exchange ruler is the theoretically cleanest (order-book, ~zero vig) + * but covers only 14% of MLB and 6% of WNBA. Adding the US majors roughly + * DOUBLES MLB coverage and takes WNBA from 6% to 46%. + * + * HONEST LIMITATION, to be stated wherever this ruler is used: including soft + * US books makes this a MARKET consensus, not a SHARP consensus. It is a large + * improvement on one arbitrary book; it is not the sharp closing line the + * category's leaders are measured against. + */ +const EXCHANGES = Object.freeze(['novig', 'smarkets', 'kalshi', 'matchbook', 'polymarket']); +const SHARP = Object.freeze(['pinnacle', 'bovada']); +const US_MAJORS = Object.freeze(['draftkings', 'fanduel', 'betmgm', 'betrivers']); + +const REFERENCE_BOOKS = Object.freeze(new Set([...EXCHANGES, ...SHARP, ...US_MAJORS])); + +/** + * EXCLUDED FROM ALL PRICING — permanently. + * + * DFS pick'em (prizepicks/underdog/sleeper/dabble) is fixed-payout with + * deliberately shaded lines. It is not a market price. It covers 82% of MLB + * props and 68% of WNBA props — the highest coverage in the feed — and that is + * precisely the trap: admitting it "for breadth" would bend the ruler in a new + * direction while looking like an improvement. + * + * onexbet / unibet / tab_au are offshore or non-US and are excluded from + * PRICING; they may still be counted for market-breadth display. + */ +const DFS_PLATFORMS = Object.freeze(new Set(['prizepicks', 'underdog', 'sleeper', 'dabble'])); +const OFFSHORE_OR_INTL = Object.freeze(new Set(['onexbet', 'unibet', 'tab_au'])); +const EXCLUDED_FROM_PRICING = Object.freeze(new Set([...DFS_PLATFORMS, ...OFFSHORE_OR_INTL])); + +/** + * RULER VERSION — stamped on every row so pre- and post-change measurements are + * never pooled. Changing the denominator means an edge or CLV number computed + * before the change is NOT the same measurement as one computed after it. This + * is arithmetically forced, not a policy preference. + * + * v1_first_book — the incumbent. `gradeSlateService.dedupeProps` keeps the + * FIRST prop row per player+stat+line in feed order and + * discards every other book. Whichever book PropLine happened + * to list first became the entire "market". + * v2_consensus — median de-vigged fair_prob across >=2 REFERENCE books + * posting BOTH sides at the SAME line. NOT LIVE. + */ +const RULER_V1 = 'v1_first_book'; +const RULER_V2 = 'v2_consensus'; +const CURRENT_RULER_VERSION = RULER_V1; // challenger-first: v2 is not live + +/** Minimum reference books for a real consensus. Below this we label, not fake. */ +const MIN_CONSENSUS_BOOKS = 2; + +const roleOf = (book) => { + const b = String(book || '').toLowerCase(); + if (EXCLUDED_FROM_PRICING.has(b)) return 'excluded'; + if (REFERENCE_BOOKS.has(b) && TAKEABLE_BOOKS.has(b)) return 'both'; + if (REFERENCE_BOOKS.has(b)) return 'reference'; + if (TAKEABLE_BOOKS.has(b)) return 'takeable'; + return 'unknown'; +}; + +module.exports = { + TAKEABLE_BOOKS, REFERENCE_BOOKS, EXCLUDED_FROM_PRICING, + DFS_PLATFORMS, OFFSHORE_OR_INTL, PHANTOM_ON_PROPLINE, + EXCHANGES, SHARP, US_MAJORS, + RULER_V1, RULER_V2, CURRENT_RULER_VERSION, MIN_CONSENSUS_BOOKS, + roleOf, +}; diff --git a/src/services/consensusRuler.js b/src/services/consensusRuler.js new file mode 100644 index 0000000..ab13127 --- /dev/null +++ b/src/services/consensusRuler.js @@ -0,0 +1,161 @@ +'use strict'; + +/** + * consensusRuler — the fair-probability ruler, v2 (Order Zero, Phase 2). + * + * NOT LIVE. Challenger-first: this computes alongside the incumbent so the + * delta can be reviewed before anything is flipped. + * + * WHAT IT REPLACES. Today's fair_prob is the two-way de-vig of ONE book — and + * not even a chosen one: `gradeSlateService.dedupeProps` keeps the FIRST prop + * row per player+stat+line in feed order. Whichever book PropLine happened to + * list first became the entire market. That is the ruler the model has been + * judged against. + * + * THE RULE + * 1. Group quotes by the SAME LINE. Two books at different lines are not + * pricing the same thing and must never be averaged. + * 2. Keep only TWO-SIDED quotes from REFERENCE books. A one-sided price + * cannot be de-vigged, so it cannot rule. + * 3. MEDIAN of the per-book de-vigged fair_prob — median, not mean, so one + * stale exchange cannot drag the ruler. + * 4. Require MIN_CONSENSUS_BOOKS (2). Below that, fall back to the incumbent + * single-book de-vig and LABEL it. Never silently mix the two: a column + * holding both is two different rulers wearing one name. + * + * Absent beats invented, throughout. No reference quote at all -> fair_prob is + * null and `source: 'none'`, never a fabricated number. + */ + +const { devigTwoWay } = require('../utils/devig'); +const { REFERENCE_BOOKS, MIN_CONSENSUS_BOOKS, RULER_V1, RULER_V2 } = require('../config/bookRoles'); + +const round4 = (n) => (Number.isFinite(n) ? Math.round(n * 10000) / 10000 : null); + +function median(xs) { + if (!xs.length) return null; + const s = [...xs].sort((a, b) => a - b); + const m = Math.floor(s.length / 2); + return s.length % 2 ? s[m] : (s[m - 1] + s[m]) / 2; +} + +/** Strict numeric: null/''/NaN are ABSENT, never 0. (`Number(null) === 0`.) */ +function num(v) { + if (v == null || v === '') return null; + const n = Number(v); + return Number.isFinite(n) ? n : null; +} + +/** + * Per-book de-vigged fair probability for one side, at one line. + * + * @param {Array} quotes [{ book, line, over_odds, under_odds }] + * @param {number} line the line to price at (exact match required) + * @param {'over'|'under'} side + * @param {Set} [books] which books may price (defaults to REFERENCE_BOOKS) + */ +function referenceFairProbs(quotes, line, side, books) { + const allow = books || REFERENCE_BOOKS; + const want = String(side || 'over').toLowerCase() === 'under' ? 'under' : 'over'; + const target = num(line); + const seen = new Map(); // one quote per book — first wins, deterministic + + for (const q of quotes || []) { + if (!q || !q.book) continue; + const b = String(q.book).toLowerCase(); + if (!allow.has(b) || seen.has(b)) continue; + if (target == null || num(q.line) !== target) continue; + const over = num(q.over_odds); + const under = num(q.under_odds); + if (over == null || under == null) continue; // one-sided cannot be de-vigged + const dv = devigTwoWay(over, under); + if (!dv) continue; + const p = want === 'under' ? dv.under.fair_prob : dv.over.fair_prob; + if (p == null) continue; + seen.set(b, { book: b, fair_prob: p, overround: dv.overround }); + } + return [...seen.values()].sort((a, b) => (a.book < b.book ? -1 : 1)); +} + +/** + * The ruler. Returns a labelled result — the label is load-bearing, because a + * consensus value and a fallback value are not the same measurement. + * + * @returns {{ + * fair_prob: number|null, source: 'consensus'|'single_book'|'none', + * ruler_version: string, n: number, books: string[], + * spread: number|null, median_overround: number|null + * }} + */ +function consensusFairProb(quotes, line, side, opts = {}) { + const minBooks = Number.isFinite(opts.minBooks) ? opts.minBooks : MIN_CONSENSUS_BOOKS; + const refs = referenceFairProbs(quotes, line, side, opts.books); + + if (refs.length >= minBooks) { + const ps = refs.map((r) => r.fair_prob); + return { + fair_prob: round4(median(ps)), + source: 'consensus', + ruler_version: RULER_V2, + n: refs.length, + books: refs.map((r) => r.book), + spread: round4(Math.max(...ps) - Math.min(...ps)), + median_overround: round4(median(refs.map((r) => r.overround))), + }; + } + + // FALLBACK — the incumbent, explicitly labelled as such. A single reference + // book is better than nothing but it is NOT a consensus, and pooling it with + // consensus rows would recreate the bent ruler inside a column that claims to + // be fixed. + if (refs.length === 1) { + return { + fair_prob: round4(refs[0].fair_prob), + source: 'single_book', + ruler_version: RULER_V1, + n: 1, + books: [refs[0].book], + spread: null, + median_overround: round4(refs[0].overround), + }; + } + + return { fair_prob: null, source: 'none', ruler_version: RULER_V2, n: 0, books: [], spread: null, median_overround: null }; +} + +/** + * 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. + */ +function incumbentFairProb(quotes, line, side) { + const want = String(side || 'over').toLowerCase() === 'under' ? 'under' : 'over'; + const target = num(line); + for (const q of quotes || []) { + if (!q || !q.book) continue; + if (target != null && num(q.line) !== target) continue; + const over = num(q.over_odds); + const under = num(q.under_odds); + if (over == null || under == null) continue; + const dv = devigTwoWay(over, under); + if (!dv) continue; + return { + fair_prob: round4(want === 'under' ? dv.under.fair_prob : dv.over.fair_prob), + book: String(q.book).toLowerCase(), + ruler_version: RULER_V1, + }; + } + return { fair_prob: null, book: null, ruler_version: RULER_V1 }; +} + +/** Both rulers plus the signed delta, for a single prop. */ +function compareRulers(quotes, line, side, opts = {}) { + const incumbent = incumbentFairProb(quotes, line, side); + const consensus = consensusFairProb(quotes, line, side, opts); + const delta = (incumbent.fair_prob != null && consensus.fair_prob != null) + ? round4(consensus.fair_prob - incumbent.fair_prob) + : null; + return { incumbent, consensus, delta_prob: delta, delta_pts: delta == null ? null : round4(delta * 100) }; +} + +module.exports = { consensusFairProb, incumbentFairProb, compareRulers, referenceFairProbs, __internals: { median, num, round4 } }; diff --git a/src/services/proplineVerify.js b/src/services/proplineVerify.js index 425a73c..55b2011 100644 --- a/src/services/proplineVerify.js +++ b/src/services/proplineVerify.js @@ -250,6 +250,91 @@ const REFERENCE_POLICIES = Object.freeze({ takeable_only: ['draftkings', 'fanduel', 'betmgm', 'betrivers'], }); + +/** + * CHALLENGER DELTA (Order Zero, Phase 2 item 7). Computes BOTH rulers over the + * live feed and reports the difference. The live ruler is untouched — nothing + * here writes a cache, a grade or a ledger row. + * + * The headline is not the delta size. It is `incumbent_book_roles`: the + * 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) { + const { compareRulers } = require('./consensusRuler'); + const { roleOf } = require('../config/bookRoles'); + + const byPropLine = new Map(); // key -> [{book, line, over_odds, under_odds}] in FEED ORDER + for (const ev of raw || []) { + if (!ev || !Array.isArray(ev.bookmakers)) continue; + for (const bm of ev.bookmakers) { + if (!bm || !bm.key || !Array.isArray(bm.markets)) continue; + for (const mk of bm.markets) { + const pairs = new Map(); + for (const oc of (mk && mk.outcomes) || []) { + if (!oc || !oc.description || oc.point == null) continue; + const k = `${oc.description}::${oc.point}`; + if (!pairs.has(k)) pairs.set(k, { player: oc.description, point: oc.point }); + if (oc.name === 'Over') pairs.get(k).over = oc.price; + else if (oc.name === 'Under') pairs.get(k).under = oc.price; + } + for (const q of pairs.values()) { + const key = `${ev.id}|${q.player}|${mk.key}|${q.point}`; + if (!byPropLine.has(key)) byPropLine.set(key, []); + byPropLine.get(key).push({ book: bm.key, line: q.point, over_odds: q.over, under_odds: q.under }); + } + } + } + } + + const deltas = []; + const roleCounts = {}; + let consensusAvailable = 0; + let bothAvailable = 0; + let disagree2pts = 0; + let disagree5pts = 0; + + for (const [key, quotes] of byPropLine.entries()) { + const line = quotes[0].line; + const c = compareRulers(quotes, line, 'over'); + if (c.consensus.source === 'consensus') consensusAvailable += 1; + if (c.incumbent.book) { + const r = roleOf(c.incumbent.book); + roleCounts[r] = (roleCounts[r] || 0) + 1; + roleCounts[`book:${c.incumbent.book}`] = (roleCounts[`book:${c.incumbent.book}`] || 0) + 1; + } + if (c.delta_pts != null && c.consensus.source === 'consensus') { + bothAvailable += 1; + deltas.push(c.delta_pts); + if (Math.abs(c.delta_pts) >= 2) disagree2pts += 1; + if (Math.abs(c.delta_pts) >= 5) disagree5pts += 1; + } + } + + const sorted = [...deltas].sort((a, b) => a - b); + const at = (p) => (sorted.length ? round2(sorted[Math.min(sorted.length - 1, Math.floor((p / 100) * sorted.length))]) : null); + const abs = deltas.map(Math.abs).sort((a, b) => a - b); + + return { + prop_line_groups: byPropLine.size, + consensus_available: consensusAvailable, + consensus_available_pct: byPropLine.size ? round2((100 * consensusAvailable) / byPropLine.size) : null, + comparable: bothAvailable, + // WHICH KIND of book has been serving as "the market" under first-row-wins. + incumbent_book_roles: roleCounts, + delta_pts: sorted.length ? { + mean: round2(deltas.reduce((a, b) => a + b, 0) / deltas.length), + median: at(50), p10: at(10), p90: at(90), + abs_median: abs.length ? round2(abs[Math.floor(abs.length / 2)]) : null, + abs_p90: abs.length ? round2(abs[Math.floor(0.9 * abs.length)]) : null, + } : null, + disagree_ge_2pts: disagree2pts, + disagree_ge_2pts_pct: bothAvailable ? round2((100 * disagree2pts) / bothAvailable) : null, + disagree_ge_5pts: disagree5pts, + disagree_ge_5pts_pct: bothAvailable ? round2((100 * disagree5pts) / bothAvailable) : null, + }; +} + /** One read-only probe. Never throws; classifies works / partial / no. */ async function probe(path, params, note, httpGet) { const get = httpGet || axios.get; @@ -350,6 +435,7 @@ async function verify(opts = {}) { continue; } out.per_sport[sport] = analyseBreadth(raw, allowed); + out.per_sport[sport].ruler_delta = rulerDelta(raw); 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) { @@ -409,4 +495,4 @@ async function verify(opts = {}) { return out; } -module.exports = { verify, __internals: { analyseBreadth, scrubKeys, probe, summarise, detectRedaction, REFERENCE_CANDIDATES, REFERENCE_POLICIES, DFS_PLATFORMS, BASE } }; +module.exports = { verify, __internals: { analyseBreadth, scrubKeys, probe, summarise, detectRedaction, rulerDelta, REFERENCE_CANDIDATES, REFERENCE_POLICIES, DFS_PLATFORMS, BASE } }; diff --git a/tests/unit/consensusRuler.test.js b/tests/unit/consensusRuler.test.js new file mode 100644 index 0000000..a508920 --- /dev/null +++ b/tests/unit/consensusRuler.test.js @@ -0,0 +1,136 @@ +'use strict'; + +/** + * consensusRuler + bookRoles — Order Zero Phase 2. + * + * These tests lock the properties that make the ruler honest, not just the + * arithmetic: DFS can never price, different lines can never be averaged, + * one-sided quotes can never rule, and a fallback can never masquerade as a + * consensus. + */ + +const { consensusFairProb, incumbentFairProb, compareRulers, referenceFairProbs } = require('../../src/services/consensusRuler'); +const roles = require('../../src/config/bookRoles'); + +const q = (book, line, over, under) => ({ book, line, over_odds: over, under_odds: under }); + +describe('bookRoles — the three-way split', () => { + it('DFS platforms can never price: excluded, and absent from REFERENCE', () => { + for (const dfs of roles.DFS_PLATFORMS) { + expect(roles.REFERENCE_BOOKS.has(dfs)).toBe(false); + expect(roles.EXCLUDED_FROM_PRICING.has(dfs)).toBe(true); + expect(roles.roleOf(dfs)).toBe('excluded'); + } + }); + + it('keeps the six PropLine-phantom books in TAKEABLE for the odds-api path', () => { + for (const b of roles.PHANTOM_ON_PROPLINE) expect(roles.TAKEABLE_BOOKS.has(b)).toBe(true); + }); + + it('pinnacle and the exchanges are reference-only, never takeable', () => { + for (const b of ['pinnacle', ...roles.EXCHANGES]) { + expect(roles.REFERENCE_BOOKS.has(b)).toBe(true); + expect(roles.TAKEABLE_BOOKS.has(b)).toBe(false); + } + }); + + it('the live ruler is still v1 — v2 is a challenger, not deployed', () => { + expect(roles.CURRENT_RULER_VERSION).toBe(roles.RULER_V1); + }); +}); + +describe('consensusRuler — what may price', () => { + it('a DFS book is ignored even when it is the only book at the line', () => { + const r = consensusFairProb([q('prizepicks', 1.5, -119, -119)], 1.5, 'over'); + expect(r.source).toBe('none'); + expect(r.fair_prob).toBeNull(); + }); + + it('three DFS books do not make a consensus', () => { + const r = consensusFairProb([ + q('prizepicks', 1.5, -119, -119), q('underdog', 1.5, -118, -118), q('sleeper', 1.5, -120, -120), + ], 1.5, 'over'); + expect(r.n).toBe(0); + expect(r.source).toBe('none'); + }); + + it('never averages across DIFFERENT lines', () => { + const r = consensusFairProb([q('novig', 1.5, -104, -104), q('smarkets', 2.5, 200, -240)], 1.5, 'over'); + expect(r.n).toBe(1); + expect(r.source).toBe('single_book'); // the 2.5 quote is a different market + }); + + it('a one-sided quote cannot rule (it cannot be de-vigged)', () => { + const r = consensusFairProb([q('novig', 1.5, -104, -104), { book: 'kalshi', line: 1.5, over_odds: -103 }], 1.5, 'over'); + expect(r.n).toBe(1); + }); + + it('counts a book once even if it appears twice', () => { + const refs = referenceFairProbs([q('novig', 1.5, -104, -104), q('novig', 1.5, -150, 130)], 1.5, 'over'); + expect(refs).toHaveLength(1); + }); +}); + +describe('consensusRuler — labelling and the median', () => { + it('n>=2 is a labelled consensus stamped v2', () => { + const r = consensusFairProb([q('novig', 1.5, -104, -104), q('kalshi', 1.5, -106, -102)], 1.5, 'over'); + expect(r.source).toBe('consensus'); + expect(r.ruler_version).toBe(roles.RULER_V2); + expect(r.n).toBe(2); + }); + + it('n==1 falls back and is labelled single_book stamped v1 — never called consensus', () => { + const r = consensusFairProb([q('novig', 1.5, -104, -104)], 1.5, 'over'); + expect(r.source).toBe('single_book'); + expect(r.ruler_version).toBe(roles.RULER_V1); + expect(r.fair_prob).toBeGreaterThan(0); + }); + + it('uses the MEDIAN so one outlier book cannot drag the ruler', () => { + const tight = [q('novig', 1.5, -104, -104), q('kalshi', 1.5, -105, -103), q('smarkets', 1.5, -103, -105)]; + const withOutlier = [...tight, q('bovada', 1.5, -400, 300)]; + const a = consensusFairProb(tight, 1.5, 'over').fair_prob; + const b = consensusFairProb(withOutlier, 1.5, 'over').fair_prob; + expect(Math.abs(b - a)).toBeLessThan(0.06); // a mean would move far more + expect(consensusFairProb(withOutlier, 1.5, 'over').spread).toBeGreaterThan(0.2); + }); + + it('over and under are complementary at a symmetric price', () => { + const qs = [q('novig', 1.5, -104, -104), q('kalshi', 1.5, -104, -104)]; + const o = consensusFairProb(qs, 1.5, 'over').fair_prob; + const u = consensusFairProb(qs, 1.5, 'under').fair_prob; + expect(o + u).toBeCloseTo(1, 3); + }); + + it('no reference quote at all returns null, never a fabricated number', () => { + const r = consensusFairProb([q('onexbet', 1.5, -110, -110)], 1.5, 'over'); + expect(r.fair_prob).toBeNull(); + expect(r.source).toBe('none'); + }); + + it('a null line does not become 0 and match a 0-line quote', () => { + expect(consensusFairProb([q('novig', 0, -104, -104)], null, 'over').source).toBe('none'); + }); +}); + +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'); + expect(inc.ruler_version).toBe(roles.RULER_V1); + }); + + 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'); + 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 + expect(Math.round(c.delta_pts * 100) / 100).toBeCloseTo((c.consensus.fair_prob - c.incumbent.fair_prob) * 100, 2); + }); + + 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(); + }); +});