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
+136
View File
@@ -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();
});
});