Files
vyndr/tests/unit/bookComparisonService.test.js
T
builtbykev e81c9b8c51 Book Comparison Phase 1-3(backend): fenced per-book store + honest gated crown
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
2026-07-27 00:45:01 -04:00

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([]);
});
});