e81c9b8c51
Per-book prices existed only transiently (odds cache, ~1h, raw names, grade-path
input); every grade-path persistence point collapses to one book. The
/api/books feature was built+mounted but non-functional (fed FLAT rows to a
GROUPED comparator -> always empty).
Phase 1: bookPriceStore captures per-book prices from `props` BEFORE dedupeProps,
keyed nameKey|stat, into bookprices:{sport} (SNAP_TTL) in snapshotService. Fenced:
reads props, writes its own key, read by nothing on the grade path. Grade proven
byte-identical (test + no-grade-path-reference grep test).
Phase 2: scripts/measure-book-spread.js reports same-line best-vs-worst spread
(cents + implied-prob pts), per sport, never pooled. Pre-registered crown
threshold: median >=8c OR >=2pp. Runs post-deploy on real data.
Phase 3 (backend): compareProp is honest-absent (single-book/flat -> no crown)
and the crown is gated (BOOK_CROWN_ENABLED, default OFF until Phase 2 clears).
/api/books repointed to the snapshot-locked store (fallback odds cache),
nameKey-matched; `source` field is the deploy fingerprint.
HELD unchanged: dedupeProps, snapshot dedup, selector, grade, champion,
challengers, ranking, edge_pct/ev_pct. UI routing of BookComparison + crown
treatment deferred to post-measurement (gated on Phase 2). Full suite 3834 green,
web build exit 0.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VsztNChZ7vEvSR61AuMhD1
108 lines
4.3 KiB
JavaScript
108 lines
4.3 KiB
JavaScript
// Unit: book comparison service. Pure functions.
|
|
// Book Comparison order (Phase 3) — the crown is now HONEST + threshold-gated:
|
|
// - single-book / flat-market props render with NO crown
|
|
// - the crown fires only among ≥2 books at the SAME line with DIFFERING prices
|
|
// - and only when enabled (BOOK_CROWN_ENABLED / opts.crownEnabled), OFF until
|
|
// Phase 2's spread measurement clears the bar.
|
|
// The crown-logic cases below pass { crownEnabled: true } explicitly.
|
|
|
|
const { compareProp, bestLines } = require('../../src/services/bookComparisonService');
|
|
|
|
const prop = {
|
|
player: 'Wembanyama',
|
|
stat_type: 'points',
|
|
lines: [
|
|
{ book: 'draftkings', line: 28.5, over_odds: -110, under_odds: -110 },
|
|
{ book: 'fanduel', line: 28.5, over_odds: -105, under_odds: -115 }, // best over
|
|
{ book: 'betmgm', line: 28.5, over_odds: -120, under_odds: -102 }, // best under
|
|
],
|
|
};
|
|
|
|
describe('compareProp — crown enabled (real spread)', () => {
|
|
const on = { crownEnabled: true };
|
|
test('identifies the best OVER line (highest payout)', () => {
|
|
const c = compareProp(prop, 'over', on);
|
|
expect(c.crowned).toBe(true);
|
|
expect(c.bestBook).toBe('fanduel'); // -105 pays more than -110/-120
|
|
expect(c.bestOdds).toBe(-105);
|
|
expect(c.books.find((b) => b.book === 'fanduel').isBest).toBe(true);
|
|
expect(c.bookCount).toBe(3);
|
|
});
|
|
|
|
test('identifies the best UNDER line', () => {
|
|
const c = compareProp(prop, 'under', on);
|
|
expect(c.bestBook).toBe('betmgm'); // -102 pays more than -110/-115
|
|
expect(c.bestOdds).toBe(-102);
|
|
});
|
|
|
|
test('savings is positive (best beats the field average)', () => {
|
|
expect(compareProp(prop, 'over', on).savings).toBeGreaterThan(0);
|
|
});
|
|
});
|
|
|
|
describe('compareProp — honest-absent', () => {
|
|
test('single-book prop renders the one book, NO crown', () => {
|
|
const c = compareProp({ player: 'X', stat_type: 'hits', lines: [{ book: 'dk', line: 0.5, over_odds: -110 }] }, 'over', { crownEnabled: true });
|
|
expect(c.bookCount).toBe(1);
|
|
expect(c.bestBook).toBeNull();
|
|
expect(c.crowned).toBe(false);
|
|
expect(c.books[0].isBest).toBe(false);
|
|
expect(c.savings).toBe(0);
|
|
});
|
|
|
|
test('crown DISABLED by default → multi-book prop shows all books, none crowned', () => {
|
|
const c = compareProp(prop, 'over'); // no opts → env default (off in tests)
|
|
expect(c.crowned).toBe(false);
|
|
expect(c.books.every((b) => b.isBest === false)).toBe(true);
|
|
expect(c.bestBook).toBeNull();
|
|
});
|
|
|
|
test('identical prices at the same line → no crown even when enabled', () => {
|
|
const flat = { player: 'X', stat_type: 'hits', lines: [
|
|
{ book: 'draftkings', line: 0.5, over_odds: -110, under_odds: -110 },
|
|
{ book: 'fanduel', line: 0.5, over_odds: -110, under_odds: -110 },
|
|
] };
|
|
expect(compareProp(flat, 'over', { crownEnabled: true }).crowned).toBe(false);
|
|
});
|
|
|
|
test('no usable lines → null, not crash', () => {
|
|
expect(compareProp({ player: 'X', stat_type: 'hits', lines: [] }, 'over')).toBeNull();
|
|
expect(compareProp({ player: 'X', stat_type: 'hits' }, 'over')).toBeNull();
|
|
});
|
|
|
|
test('reads a grouped `books` array (bookprices store shape)', () => {
|
|
const c = compareProp({ player: 'Y', stat_type: 'hits', books: [
|
|
{ book: 'draftkings', line: 1.5, over_odds: -115 },
|
|
{ book: 'betmgm', line: 1.5, over_odds: -105 },
|
|
] }, 'over', { crownEnabled: true });
|
|
expect(c.bestBook).toBe('betmgm');
|
|
});
|
|
});
|
|
|
|
describe('bestLines', () => {
|
|
test('makes no crown claim when gated off (returns [])', () => {
|
|
expect(bestLines([prop], { side: 'over', crownEnabled: false })).toEqual([]);
|
|
});
|
|
|
|
test('enabled: drops single-book props and sorts by savings desc', () => {
|
|
const props = [
|
|
prop,
|
|
{ player: 'Solo', stat_type: 'reb', lines: [{ book: 'dk', line: 5.5, over_odds: -110 }] }, // 1 book → dropped
|
|
{
|
|
player: 'BigEdge', stat_type: 'ast',
|
|
lines: [
|
|
{ book: 'dk', line: 5.5, over_odds: -200 },
|
|
{ book: 'fd', line: 5.5, over_odds: +120 }, // huge spread → big savings
|
|
],
|
|
},
|
|
];
|
|
const lines = bestLines(props, { side: 'over', crownEnabled: true });
|
|
expect(lines.map((l) => l.player)).not.toContain('Solo');
|
|
expect(lines[0].player).toBe('BigEdge'); // biggest savings first
|
|
});
|
|
|
|
test('non-array input → empty', () => {
|
|
expect(bestLines(null)).toEqual([]);
|
|
});
|
|
});
|