'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, 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, 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); 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, 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) : 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 } };