c38db1ad65
My first delta run modelled the incumbent as first-row-wins over the RAW feed and reported that an EXCLUDED book was "the market" on 69% of MLB prop-lines, with prizepicks alone at 47%. That is WRONG and I caught it before it went anywhere. normalizeProps applies ALLOWED_BOOKS BEFORE gradeSlateService.dedupeProps runs, so DFS books never reach the incumbent. The allow-list, for all the coverage it costs, does keep DFS out of the ruler. incumbentFairProb now takes the allow-list (defaulting to the live ALLOWED_BOOKS) and reproduces the real chain. Two tests lock it, including that a prop with no admitted book has NO incumbent -- it is never graded at all, which is the real loss and is already measured as invisible_props. Overstating the incumbent's badness would have been as dishonest as understating it, and more persuasive. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01QJs13VsyiSKYQP6rj3NNmc
174 lines
7.0 KiB
JavaScript
174 lines
7.0 KiB
JavaScript
'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 } };
|