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
This commit is contained in:
@@ -75,22 +75,55 @@ describe('GET /api/lines/:sport/movers', () => {
|
||||
describe('GET /api/books/:sport', () => {
|
||||
const app = () => mount('/api/books', '../../src/routes/bookComparison');
|
||||
|
||||
test('returns best lines from cached props', async () => {
|
||||
mockStore[`odds:nba:${new Date().toISOString().split('T')[0]}`] = {
|
||||
// Book Comparison order: /api/books/:sport is a crown claim ("best lines
|
||||
// tonight"), so it is gated OFF until Phase 2's spread measurement clears the
|
||||
// bar — even with data it returns []. `source` is the deploy fingerprint.
|
||||
test('reads the snapshot-locked bookprices store; makes no crown claim while gated off', async () => {
|
||||
mockStore['bookprices:nba'] = {
|
||||
props: [
|
||||
{
|
||||
player: 'Wemby', stat_type: 'points',
|
||||
lines: [
|
||||
{ book: 'dk', over_odds: -110 },
|
||||
{ book: 'fd', over_odds: -105 },
|
||||
],
|
||||
},
|
||||
{ player: 'Wemby', stat_type: 'points', books: [
|
||||
{ book: 'draftkings', line: 28.5, over_odds: -110 },
|
||||
{ book: 'fanduel', line: 28.5, over_odds: -105 },
|
||||
] },
|
||||
],
|
||||
};
|
||||
const res = await request(app()).get('/api/books/nba');
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body.source).toBe('bookprices'); // new store is serving
|
||||
expect(res.body.bestLines).toEqual([]); // crown gated off → no claim
|
||||
});
|
||||
|
||||
test('crown ENABLED → crowns the best price from the store', async () => {
|
||||
process.env.BOOK_CROWN_ENABLED = '1';
|
||||
mockStore['bookprices:nba'] = {
|
||||
props: [
|
||||
{ player: 'Wemby', stat_type: 'points', books: [
|
||||
{ book: 'draftkings', line: 28.5, over_odds: -110 },
|
||||
{ book: 'fanduel', line: 28.5, over_odds: -105 },
|
||||
] },
|
||||
],
|
||||
};
|
||||
const res = await request(app()).get('/api/books/nba');
|
||||
delete process.env.BOOK_CROWN_ENABLED;
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body.bestLines).toHaveLength(1);
|
||||
expect(res.body.bestLines[0].bestBook).toBe('fd');
|
||||
expect(res.body.bestLines[0].bestBook).toBe('fanduel');
|
||||
});
|
||||
|
||||
test('per-prop endpoint returns the honest grid (all books, no crown) even gated off', async () => {
|
||||
mockStore['bookprices:nba'] = {
|
||||
props: [
|
||||
{ player: 'Wemby', stat_type: 'points', books: [
|
||||
{ book: 'draftkings', line: 28.5, over_odds: -110 },
|
||||
{ book: 'fanduel', line: 28.5, over_odds: -105 },
|
||||
] },
|
||||
],
|
||||
};
|
||||
const res = await request(app()).get('/api/books/nba/Wemby/points');
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body.books).toHaveLength(2);
|
||||
expect(res.body.books.every((b) => b.isBest === false)).toBe(true);
|
||||
expect(res.body.bestBook).toBeNull();
|
||||
});
|
||||
|
||||
test('empty when no cached props', async () => {
|
||||
|
||||
@@ -1,4 +1,10 @@
|
||||
// Unit: book comparison service (Session 28). Pure functions.
|
||||
// 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');
|
||||
|
||||
@@ -12,9 +18,11 @@ const prop = {
|
||||
],
|
||||
};
|
||||
|
||||
describe('bookComparisonService — compareProp', () => {
|
||||
describe('compareProp — crown enabled (real spread)', () => {
|
||||
const on = { crownEnabled: true };
|
||||
test('identifies the best OVER line (highest payout)', () => {
|
||||
const c = compareProp(prop, 'over');
|
||||
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);
|
||||
@@ -22,42 +30,73 @@ describe('bookComparisonService — compareProp', () => {
|
||||
});
|
||||
|
||||
test('identifies the best UNDER line', () => {
|
||||
const c = compareProp(prop, 'under');
|
||||
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)', () => {
|
||||
const c = compareProp(prop, 'over');
|
||||
expect(c.savings).toBeGreaterThan(0);
|
||||
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('single-book prop still compares (bookCount 1)', () => {
|
||||
const c = compareProp({ player: 'X', stat_type: 'hits', lines: [{ book: 'dk', over_odds: -110 }] }, 'over');
|
||||
expect(c.bookCount).toBe(1);
|
||||
expect(c.bestBook).toBe('dk');
|
||||
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('bookComparisonService — bestLines', () => {
|
||||
test('drops single-book props and sorts by savings desc', () => {
|
||||
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', over_odds: -110 }] }, // 1 book → dropped
|
||||
{ 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', over_odds: -200 },
|
||||
{ book: 'fd', over_odds: +120 }, // huge spread → big savings
|
||||
{ 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' });
|
||||
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
|
||||
});
|
||||
|
||||
@@ -0,0 +1,135 @@
|
||||
// Book Comparison order (Phase 1) — display-only per-book price store.
|
||||
// Locks: capture correctness, the STRUCTURAL FENCE (no mutation, no grade-path
|
||||
// read), and grade-byte-identical proof through runSnapshot.
|
||||
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
const { captureBookPrices, bookKey, booksAtLine } = require('../../src/services/bookPriceStore');
|
||||
const snapshot = require('../../src/services/snapshotService');
|
||||
|
||||
// One prop (Judge TB 1.5) across 3 books at the shared line + a 1-book prop.
|
||||
const multiBookProps = [
|
||||
{ player: 'Aaron Judge', stat_type: 'total_bases', line: 1.5, over_odds: -115, under_odds: -105, book: 'draftkings' },
|
||||
{ player: 'Aaron Judge', stat_type: 'total_bases', line: 1.5, over_odds: -110, under_odds: -110, book: 'betmgm' },
|
||||
{ player: 'Aaron Judge', stat_type: 'total_bases', line: 1.5, over_odds: -120, under_odds: 100, book: 'pinnacle' },
|
||||
{ player: 'Mookie Betts', stat_type: 'hits', line: 1.5, over_odds: 120, under_odds: -150, book: 'draftkings' },
|
||||
];
|
||||
|
||||
describe('captureBookPrices — capture correctness', () => {
|
||||
it('groups one entry per normalized player+stat with every real book row', () => {
|
||||
const out = captureBookPrices(multiBookProps, { now: () => 'T' });
|
||||
expect(out.updated_at).toBe('T');
|
||||
expect(out.count).toBe(2);
|
||||
const judge = out.props.find((p) => p.key === bookKey('Aaron Judge', 'total_bases'));
|
||||
expect(judge.player).toBe('Aaron Judge');
|
||||
expect(judge.books).toHaveLength(3);
|
||||
expect(judge.books.map((b) => b.book).sort()).toEqual(['betmgm', 'draftkings', 'pinnacle']);
|
||||
const betts = out.props.find((p) => p.key === bookKey('Mookie Betts', 'hits'));
|
||||
expect(betts.books).toHaveLength(1); // single-book prop kept honestly
|
||||
});
|
||||
|
||||
it('drops rows with no price on either side (never a fabricated row)', () => {
|
||||
const out = captureBookPrices([
|
||||
{ player: 'X', stat_type: 'hits', line: 0.5, over_odds: null, under_odds: null, book: 'draftkings' },
|
||||
]);
|
||||
expect(out.count).toBe(0);
|
||||
});
|
||||
|
||||
it('de-dupes a repeated book+line (first row wins) and requires book/line', () => {
|
||||
const out = captureBookPrices([
|
||||
{ player: 'X', stat_type: 'hits', line: 0.5, over_odds: -110, under_odds: -110, book: 'draftkings' },
|
||||
{ player: 'X', stat_type: 'hits', line: 0.5, over_odds: 999, under_odds: 999, book: 'draftkings' },
|
||||
{ player: 'X', stat_type: 'hits', line: 0.5, over_odds: -110, book: null }, // no book → skip
|
||||
]);
|
||||
expect(out.props[0].books).toHaveLength(1);
|
||||
expect(out.props[0].books[0].over_odds).toBe(-110); // first-wins
|
||||
});
|
||||
|
||||
it('booksAtLine restricts to the shared line only', () => {
|
||||
const entry = { books: [{ book: 'a', line: 1.5 }, { book: 'b', line: 1.5 }, { book: 'c', line: 2.5 }] };
|
||||
expect(booksAtLine(entry, 1.5).map((b) => b.book)).toEqual(['a', 'b']);
|
||||
expect(booksAtLine(entry, 2.5)).toHaveLength(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe('STRUCTURAL FENCE', () => {
|
||||
it('never mutates the props it reads (deep-frozen input)', () => {
|
||||
const frozen = multiBookProps.map((p) => Object.freeze({ ...p }));
|
||||
Object.freeze(frozen);
|
||||
expect(() => captureBookPrices(frozen)).not.toThrow();
|
||||
});
|
||||
|
||||
it('no grade-path module references the bookprices store', () => {
|
||||
const gradePathFiles = [
|
||||
'src/services/gradeSlateService.js',
|
||||
'src/services/ledgerService.js',
|
||||
'src/services/challengerProjection.js',
|
||||
'src/services/contactChallenger.js',
|
||||
'src/services/projectionChallenger.js',
|
||||
'src/services/intelligence/analyzeViaEngine1.js',
|
||||
];
|
||||
for (const rel of gradePathFiles) {
|
||||
const src = fs.readFileSync(path.join(__dirname, '../../', rel), 'utf8');
|
||||
expect(src).not.toMatch(/bookprices/);
|
||||
expect(src).not.toMatch(/bookPriceStore/);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('GRADE BYTE-IDENTICAL through runSnapshot', () => {
|
||||
function memCache() {
|
||||
const store = {};
|
||||
return {
|
||||
store,
|
||||
cacheGet: async (k) => (k in store ? store[k] : null),
|
||||
cacheSet: async (k, v) => { store[k] = v; },
|
||||
};
|
||||
}
|
||||
const grades = [
|
||||
{ player: 'Aaron Judge', stat_type: 'total_bases', line: 1.5, direction: 'over', grade: 'A', confidence: 71, edge_pct: 4.2 },
|
||||
{ player: 'Mookie Betts', stat_type: 'hits', line: 1.5, direction: 'under', grade: 'B', confidence: 60, edge_pct: 1.1 },
|
||||
];
|
||||
const fakeGrade = () => async (_s, _p, opts) => {
|
||||
await opts.cacheSet('grades:x', { grades, updated_at: opts.now(), source: 'test' });
|
||||
return { written: true, count: grades.length };
|
||||
};
|
||||
const baseOpts = (cache, extra = {}) => ({
|
||||
getOdds: async () => ({ props: multiBookProps, provider: 'test' }),
|
||||
gradeAndCacheSlate: fakeGrade(),
|
||||
resolveStats: async () => ({ found: false }),
|
||||
classify: () => ({ primary: null }),
|
||||
cacheGet: cache.cacheGet,
|
||||
cacheSet: cache.cacheSet,
|
||||
now: () => '2026-07-27T00:00:00.000Z',
|
||||
nowMs: () => 1000,
|
||||
notify: async () => {},
|
||||
retention: null,
|
||||
ledger: { recordPipelineGrades: async () => ({ written: 0 }), captureClosing: async () => {}, __internals: require('../../src/services/ledgerService').__internals },
|
||||
refreshTeamStats: async () => null,
|
||||
buildEspnIndex: async () => ({}),
|
||||
gameBinder: { attachGameTimes: async () => ({ bound: 0, alreadyHad: 2, unresolved: 0, ambiguous: 0 }) },
|
||||
...extra,
|
||||
});
|
||||
|
||||
it('produces identical grades whether or not the capture runs, and writes the fenced key', async () => {
|
||||
// WITH capture (default dep).
|
||||
const c1 = memCache();
|
||||
await snapshot.runSnapshot('mlb', baseOpts(c1));
|
||||
// WITHOUT capture (inject a no-op that writes nothing).
|
||||
const c2 = memCache();
|
||||
await snapshot.runSnapshot('mlb', baseOpts(c2, { captureBookPrices: () => ({ updated_at: 'x', props: [], count: 0 }) }));
|
||||
|
||||
const strip = (snap) => JSON.stringify((snap.grades || []).map((g) => ({
|
||||
player: g.player, stat_type: g.stat_type, line: g.line, direction: g.direction,
|
||||
grade: g.grade, gradedAt: g.gradedAt,
|
||||
})));
|
||||
expect(strip(c1.store['snapshot:mlb:latest'])).toBe(strip(c2.store['snapshot:mlb:latest']));
|
||||
expect(JSON.stringify(c1.store['grades:mlb'].grades.map((g) => g.grade)))
|
||||
.toBe(JSON.stringify(c2.store['grades:mlb'].grades.map((g) => g.grade)));
|
||||
|
||||
// The fenced store IS written, with real book rows.
|
||||
const bp = c1.store['bookprices:mlb'];
|
||||
expect(bp.count).toBe(2);
|
||||
expect(bp.props.find((p) => p.stat_type === 'total_bases').books).toHaveLength(3);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user