f0543b57a4
IDENTITY (CLAUDE.md top + MASTER-PLAN header). VYNDR is a PREDICTIVE MODEL: it projects what a player will DO and picks accurately. Market edge is a BYPRODUCT of a good prediction, never the success criterion. Success = the forecast is honest about its own confidence AND still ranks -- calibration and resolution, both. No edge/CLV term belongs in a pass/fail gate; they are diagnostics we report, not thresholds a model must clear. A model tuned to beat a closing line has been fitted to the market instead of to the game. Per-sport doctrine (Phillips 2022, classify by what players DO not by position): each sport is its own model -- own variables, archetypes, conditions, calibration, honest ceiling. Shared across sports: ONLY the Bayesian inference math. Truth Law: no fabricated data; honest-absent over invented; label limitations in-band; provisional stays provisional until re-run; documented is not verified. PHASE 2 -- AGGREGATOR WIDENING (live). normalizeProps now emits every DISPLAY book instead of 5 of 18. Before this we discarded 13 books of our own accord and 64.8% of the MLB slate was invisible to users. Every prop carries book_role (both/takeable/reference/dfs/offshore) so the display layer can say WHAT a price is -- a fixed-payout DFS number and a two-way sportsbook price are not interchangeable objects. Unknown books are still dropped. PHASE 3 -- MODEL GATE (the model does not move). bookRoles splits MODEL_BOOKS (the legacy allow-list, character for character) from DISPLAY_BOOKS. Both model paths re-filter before they pick a line: gradeSlateService.dedupeProps (before first-row-wins AND before the limit) and intradayRefreshService.indexOddsProps (which RE-GRADES at the current line -- without the gate, widening would have silently moved locked lines onto books the model has never been calibrated against). A test asserts the graded set is byte-identical through the widening. CURRENT_RULER_VERSION stays v1_first_book. The gate lifts only when the MLB calibration is re-run on the consensus ruler and v2 is promoted. HONEST FRAMING, recorded in the plan: this is an AGGREGATOR win and it does NOT fix the model. WNBA still abstains -- a model problem, not a coverage problem; it is better covered than MLB. MLB isotonic still provisional. The consensus is MARKET, not SHARP: pinnacle, matchbook and polymarket are 0% on both sports, so no sharp anchor exists in our feed. Two superseded tests updated to stronger properties rather than deleted: roleOf now names the KIND of book, and the normalizer test asserts the display set widens WHILE the model set does not. Gates: 4,027 tests / 322 suites green; next build exit 0. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01QJs13VsyiSKYQP6rj3NNmc
182 lines
8.0 KiB
JavaScript
182 lines
8.0 KiB
JavaScript
'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);
|
|
// roleOf now names the KIND ('dfs'), which is stronger than 'excluded':
|
|
// the display layer needs to say WHAT a price is, not just that it can't
|
|
// price. Exclusion from pricing is asserted on the line above.
|
|
expect(roles.roleOf(dfs)).toBe('dfs');
|
|
expect(roles.isReferenceBook(dfs)).toBe(false);
|
|
expect(roles.isModelBook(dfs)).toBe(false);
|
|
expect(roles.isDisplayBook(dfs)).toBe(true); // shown for breadth, tagged
|
|
}
|
|
});
|
|
|
|
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', () => {
|
|
const LIVE_ALLOWED = new Set(['draftkings', 'fanduel', 'betmgm', 'betrivers', 'pinnacle']);
|
|
|
|
it('incumbent is first-row-wins AMONG ADMITTED BOOKS — the allow-list runs first', () => {
|
|
// normalizeProps applies ALLOWED_BOOKS before dedupeProps, so a DFS book
|
|
// sitting first in the raw feed is NOT the incumbent. Getting this wrong
|
|
// overstates the incumbent's badness, which is its own dishonesty.
|
|
const qs = [q('prizepicks', 1.5, -119, -119), q('betmgm', 1.5, -115, -105), q('draftkings', 1.5, -110, -110)];
|
|
const inc = incumbentFairProb(qs, 1.5, 'over', LIVE_ALLOWED);
|
|
expect(inc.book).toBe('betmgm');
|
|
expect(inc.ruler_version).toBe(roles.RULER_V1);
|
|
});
|
|
|
|
it('a prop with NO admitted book has no incumbent at all — it is never graded', () => {
|
|
const qs = [q('prizepicks', 1.5, -119, -119), q('novig', 1.5, -104, -104)];
|
|
expect(incumbentFairProb(qs, 1.5, 'over', LIVE_ALLOWED).fair_prob).toBeNull();
|
|
});
|
|
|
|
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', { allowedBooks: LIVE_ALLOWED });
|
|
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', { allowedBooks: LIVE_ALLOWED }).delta_pts).toBeNull();
|
|
});
|
|
});
|
|
|
|
describe('bookRoles — display vs model separation (Order Zero Phase 2/3)', () => {
|
|
it('MODEL_BOOKS is the legacy allow-list, unchanged — the model must not move', () => {
|
|
expect([...roles.MODEL_BOOKS].sort()).toEqual([
|
|
'bet365', 'betmgm', 'betrivers', 'caesars', 'draftkings', 'fanatics',
|
|
'fanduel', 'hardrockbet', 'pinnacle', 'pointsbet', 'thescore',
|
|
].sort());
|
|
});
|
|
|
|
it('DISPLAY_BOOKS is a strict superset of MODEL_BOOKS', () => {
|
|
for (const b of roles.MODEL_BOOKS) expect(roles.DISPLAY_BOOKS.has(b)).toBe(true);
|
|
expect(roles.DISPLAY_BOOKS.size).toBeGreaterThan(roles.MODEL_BOOKS.size);
|
|
});
|
|
|
|
it('NO DFS or offshore book may reach the model, however visible it is', () => {
|
|
for (const b of [...roles.DFS_PLATFORMS, ...roles.OFFSHORE_OR_INTL]) {
|
|
expect(roles.isDisplayBook(b)).toBe(true);
|
|
expect(roles.isModelBook(b)).toBe(false);
|
|
expect(roles.isReferenceBook(b)).toBe(false);
|
|
}
|
|
});
|
|
|
|
it('an exchange is visible and may rule, but is never TAKEABLE', () => {
|
|
for (const b of roles.EXCHANGES) {
|
|
expect(roles.isDisplayBook(b)).toBe(true);
|
|
expect(roles.isTakeableBook(b)).toBe(false);
|
|
}
|
|
});
|
|
});
|