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) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01QJs13VsyiSKYQP6rj3NNmc
This commit is contained in:
@@ -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 } };
|
||||
@@ -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 } };
|
||||
|
||||
Reference in New Issue
Block a user