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:
Kev
2026-07-31 23:44:37 -04:00
parent c3bcfaba94
commit a55dd2a6a0
4 changed files with 502 additions and 1 deletions
+87 -1
View File
@@ -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 } };