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:
Kev
2026-07-27 00:45:01 -04:00
parent 914a057611
commit e81c9b8c51
9 changed files with 704 additions and 52 deletions
+34 -10
View File
@@ -15,6 +15,7 @@
const express = require('express');
const bookComparison = require('../services/bookComparisonService');
const { cacheGet } = require('../utils/redis');
const { nameKey } = require('../utils/playerName');
const { createRateLimit } = require('../middleware/rateLimit');
const router = express.Router();
@@ -25,15 +26,31 @@ const MISSION_HEADER = { 'X-VYNDR-Mission': 'Never leave money on the table' };
const SUPPORTED = new Set(['nba', 'wnba', 'mlb', 'soccer', 'nfl', 'nhl']);
// Read cached grouped props for a sport without triggering a fetch.
// oddsService caches `odds:{sport}:{utcDate}` = { updated_at, props, spreads }.
async function readCachedProps(sport) {
// Read GROUPED per-prop book rows for a sport without triggering a fetch.
//
// PRIMARY: the snapshot-locked, display-only `bookprices:{sport}` store (Phase 1)
// — its entries are already grouped `{ player, stat_type, books:[...] }`, which
// is exactly what compareProp reads. It's normalized-name-keyed and lives at
// SNAP_TTL, so it survives the gap between cron runs (the 1h odds cache would
// blank). FALLBACK: group the transient odds cache flat rows the same way, so
// the route still works before the first snapshot writes the store.
async function readGroupedProps(sport) {
const store = await cacheGet(`bookprices:${sport}`);
if (store && Array.isArray(store.props) && store.props.length) return store.props;
const utcDate = new Date().toISOString().split('T')[0];
const cache =
(await cacheGet(`odds:${sport}:${utcDate}`)) ??
(await cacheGet(`odds:${sport}`));
if (!cache) return [];
return Array.isArray(cache.props) ? cache.props : [];
const flat = cache && Array.isArray(cache.props) ? cache.props : [];
const by = new Map();
for (const p of flat) {
if (!p || !p.player || !p.stat_type || p.line == null || !p.book) continue;
const k = `${p.player}|${p.stat_type}`;
if (!by.has(k)) by.set(k, { player: p.player, stat_type: p.stat_type, books: [] });
by.get(k).books.push({ book: p.book, line: p.line, over_odds: p.over_odds, under_odds: p.under_odds });
}
return [...by.values()];
}
router.get('/:sport', async (req, res) => {
@@ -44,9 +61,12 @@ router.get('/:sport', async (req, res) => {
const side = req.query.side === 'under' ? 'under' : 'over';
const limit = req.query.limit ? Math.max(0, parseInt(req.query.limit, 10) || 0) : 20;
try {
const props = await readCachedProps(sport);
const hasStore = !!(await cacheGet(`bookprices:${sport}`));
const props = await readGroupedProps(sport);
const lines = bookComparison.bestLines(props, { side, limit });
return res.set(MISSION_HEADER).json({ sport, side, bestLines: lines, source: 'odds-cache' });
// `source` is the deploy fingerprint: 'bookprices' proves the new snapshot-
// locked store is serving; 'odds-cache' = falling back pre-first-snapshot.
return res.set(MISSION_HEADER).json({ sport, side, bestLines: lines, source: hasStore ? 'bookprices' : 'odds-cache' });
} catch (err) {
console.error(`[books/${sport}]`, err.message);
return res.set(MISSION_HEADER).json({ sport, side, bestLines: [], source: 'odds-cache' });
@@ -59,10 +79,14 @@ router.get('/:sport/:player/:stat', async (req, res) => {
const stat = req.params.stat;
const side = req.query.side === 'under' ? 'under' : 'over';
try {
const props = await readCachedProps(sport);
const props = await readGroupedProps(sport);
// Match on the normalized name key so "A.J. Ewing" / "AJ Ewing" resolve to
// the same player (the store is already normalized-keyed).
const wantKey = nameKey(player);
const wantStat = stat.toLowerCase();
const prop = props.find(
(p) => (p.player || '').toLowerCase() === player.toLowerCase() &&
(p.stat_type || p.stat || '').toLowerCase() === stat.toLowerCase(),
(p) => nameKey(p.player || '') === wantKey &&
(p.stat_type || p.stat || '').toLowerCase() === wantStat,
);
if (!prop) {
return res.status(404).set(MISSION_HEADER).json({ error: 'Prop not found in current slate.' });
+70 -14
View File
@@ -30,22 +30,73 @@ function oddsForSide(line, side) {
return raw == null ? null : Number(raw);
}
// Book Comparison order — the crown is PRE-REGISTERED and threshold-gated. It
// stays OFF until Phase 2's spread measurement (scripts/measure-book-spread.js)
// confirms the median same-line spread clears the bar (≥8¢ OR ≥2 implied-prob
// pts). A crown over a flat market is a claim of edge that does not exist.
function crownEnabled(opts) {
if (opts && opts.crownEnabled != null) return !!opts.crownEnabled;
return process.env.BOOK_CROWN_ENABLED === '1';
}
// Render every book row honestly, with NO crown. Used for single-book props,
// props with no shared line, and whenever the crown flag is off.
function honestGrid(prop, side, books) {
const rows = books.map((b) => ({
book: b.book,
line: b.line ?? null,
over_odds: b.over_odds ?? null,
under_odds: b.under_odds ?? null,
isBest: false,
}));
return {
player: prop.player,
stat: prop.stat_type || prop.stat,
line: prop.line ?? (books[0] && books[0].line) ?? null,
side,
books: rows,
bestBook: null,
bestOdds: null,
bookCount: new Set(books.map((b) => b.book)).size,
savings: 0,
crowned: false,
};
}
/**
* Compare one grouped prop across its books for a given side.
* Returns null when there are no usable book lines.
* Returns null when there are no usable book rows at all.
*
* HONEST-ABSENT: a single-book prop renders that one book — no crown, no implied
* second price, no savings claim. The crown fires ONLY among ≥2 books posting
* the SAME line with DIFFERING prices, and ONLY when the flag is enabled (Phase 2
* gate). Comparing prices across different lines is meaningless (Data Semantics).
*/
function compareProp(prop, side = 'over') {
function compareProp(prop, side = 'over', opts = {}) {
const books = (prop?.lines || prop?.books || []).filter((b) => b && b.book);
const priced = books.filter((b) => Number.isFinite(oddsForSide(b, side)));
if (priced.length === 0) return null;
if (books.length === 0) return null;
let best = priced[0];
for (const b of priced) {
const priced = books.filter((b) => Number.isFinite(oddsForSide(b, side)));
// Single priced book, or crown disabled → honest grid, no crown.
if (priced.length < 2 || !crownEnabled(opts)) return honestGrid(prop, side, books);
// Crown only among books at the SAME line (the shared-line comparison set).
const byLine = {};
for (const b of priced) { const L = String(b.line); (byLine[L] = byLine[L] || []).push(b); }
let shared = [];
for (const rows of Object.values(byLine)) { if (rows.length > shared.length) shared = rows; }
const distinctBooks = new Set(shared.map((b) => b.book));
const distinctPrices = new Set(shared.map((b) => oddsForSide(b, side)));
// Fewer than 2 books at one line, or all books post the identical price →
// nothing to crown. Render honestly.
if (distinctBooks.size < 2 || distinctPrices.size < 2) return honestGrid(prop, side, books);
let best = shared[0];
for (const b of shared) {
if (americanToDecimal(oddsForSide(b, side)) > americanToDecimal(oddsForSide(best, side))) best = b;
}
const bestDecimal = americanToDecimal(oddsForSide(best, side));
const avgDecimal = average(priced.map((b) => americanToDecimal(oddsForSide(b, side))));
const avgDecimal = average(shared.map((b) => americanToDecimal(oddsForSide(b, side))));
const savings = Math.round((bestDecimal - avgDecimal) * 100 * 100) / 100; // per $100, 2dp
return {
@@ -53,17 +104,19 @@ function compareProp(prop, side = 'over') {
stat: prop.stat_type || prop.stat,
line: best.line ?? prop.line ?? null,
side,
books: priced.map((b) => ({
// Show all books; crown only the best AT THE SHARED LINE.
books: books.map((b) => ({
book: b.book,
line: b.line ?? null,
over_odds: b.over_odds ?? null,
under_odds: b.under_odds ?? null,
isBest: b.book === best.book,
isBest: b.book === best.book && Number(b.line) === Number(best.line),
})),
bestBook: best.book,
bestOdds: oddsForSide(best, side),
bookCount: priced.length,
bookCount: distinctBooks.size,
savings,
crowned: true,
};
}
@@ -72,12 +125,15 @@ function compareProp(prop, side = 'over') {
* shopping matters most). Drops props with a single book (nothing to
* compare). `limit` caps the result.
*/
function bestLines(props, { side = 'over', limit = 20 } = {}) {
function bestLines(props, { side = 'over', limit = 20, crownEnabled: ce } = {}) {
if (!Array.isArray(props)) return [];
// "Best lines tonight" is inherently a crown claim — when the crown is gated
// off (Phase 2 not yet cleared) it makes no such claim.
if (!crownEnabled({ crownEnabled: ce })) return [];
const out = [];
for (const prop of props) {
const cmp = compareProp(prop, side);
if (cmp && cmp.bookCount >= 2) out.push(cmp);
const cmp = compareProp(prop, side, { crownEnabled: ce });
if (cmp && cmp.crowned && cmp.bookCount >= 2) out.push(cmp);
}
out.sort((a, b) => b.savings - a.savings);
return limit > 0 ? out.slice(0, limit) : out;
+92
View File
@@ -0,0 +1,92 @@
'use strict';
/**
* bookPriceStore — DISPLAY-ONLY per-book price capture (Book Comparison order,
* Phase 1).
*
* WHY THIS EXISTS: `normalizeProps` emits one row per book, but every
* persistence point on the GRADE path collapses to a single book
* (gradeSlateService.dedupeProps keeps the first book per line;
* snapshotService.indexOdds keeps one row per player|stat; the grades cache,
* snapshot, and ledger each lock one price). The only place per-book prices
* survived was the transient odds cache (`odds:{sport}:{utcDate}`, ~1h TTL,
* raw player names, grade-path INPUT). This module captures them at snapshot
* time into a snapshot-locked, normalized-key, display-only store.
*
* STRUCTURAL FENCE (not a comment — an enforced property):
* 1. This module ONLY reads a props array and RETURNS a new object. It never
* mutates the props it is handed (a bookPriceStore.test.js case deep-freezes
* the input and asserts capture succeeds).
* 2. Its output is written to its OWN Redis key (`bookprices:{sport}`), read by
* NOTHING on the grade path — not gradeSlateService, snapshotService's dedup,
* indexOdds, any challenger, the ledger, edge_pct/ev_pct, or the selector.
* A test greps the grade-path files and asserts zero `bookprices` references.
* 3. In runSnapshot the capture is a leaf `cacheSet` whose result is assigned to
* no variable used downstream — the grade-byte-identical test proves the
* graded slate is unchanged whether or not the capture runs.
*
* So no value this file produces can ever reach a grade, a locked line, a
* challenger, the ledger, or the hero. It is a pure display leaf.
*/
const { nameKey, normalizeName } = require('../utils/playerName');
/** Canonical lookup key for a graded prop's book rows. */
function bookKey(player, statType) {
return `${nameKey(player)}|${String(statType || '').toLowerCase()}`;
}
/**
* Group a flat multi-book props array (oddsNormalizer shape) into one entry per
* normalized player+stat, each carrying every real book row. Rows with no price
* on EITHER side are dropped (nothing to compare — honest-absent, never a
* fabricated price). Never mutates `props`.
*
* @returns {{ updated_at, props: Array, count }}
*/
function captureBookPrices(props, opts = {}) {
const now = opts.now || (() => new Date().toISOString());
const ts = now();
const byKey = new Map();
for (const p of props || []) {
if (!p || !p.player || !p.stat_type || p.line == null || !p.book) continue;
// A row with neither price is not a comparable book row.
if (p.over_odds == null && p.under_odds == null) continue;
const stat = String(p.stat_type).toLowerCase();
const key = bookKey(p.player, stat);
if (!byKey.has(key)) {
byKey.set(key, {
key,
player: normalizeName(p.player).display || p.player,
stat_type: stat,
books: [],
});
}
const entry = byKey.get(key);
// A book appears once per line. Keep the FIRST row for a given book+line
// (mirrors dedupeProps' first-wins, applied here only for display de-dup —
// it does NOT feed grading).
const bl = `${p.book}|${p.line}`;
if (entry.books.some((b) => `${b.book}|${b.line}` === bl)) continue;
entry.books.push({
book: p.book,
line: Number(p.line),
over_odds: p.over_odds ?? null,
under_odds: p.under_odds ?? null,
});
}
const out = [...byKey.values()];
return { updated_at: ts, props: out, count: out.length };
}
/** Books posting a given line for a prop entry (shared-line comparison set). */
function booksAtLine(entry, line) {
if (!entry || !Array.isArray(entry.books) || line == null) return [];
const L = Number(line);
return entry.books.filter((b) => Number(b.line) === L);
}
module.exports = { captureBookPrices, bookKey, booksAtLine };
+19
View File
@@ -284,6 +284,10 @@ async function runSnapshot(sport, opts = {}) {
// suite that doesn't know about this dep can never make a live ESPN call.
// Session 64 — model-snapshot retention. Injectable; null disables it.
retention: opts.retention !== undefined ? opts.retention : require('./retentionService'),
// Book Comparison order (Phase 1) — DISPLAY-ONLY per-book price capture.
// Fenced: written to its own `bookprices:{sport}` key, read by nothing on
// the grade path. Injectable; a failure never touches grading.
captureBookPrices: opts.captureBookPrices || require('./bookPriceStore').captureBookPrices,
refreshTeamStats: opts.refreshTeamStats
|| (process.env.NODE_ENV === 'test'
? async () => null
@@ -335,6 +339,21 @@ async function runSnapshot(sport, opts = {}) {
console.warn(`[snapshot] game binding failed for ${sp} (rows without a real game time will be skipped):`, e.message);
}
// Book Comparison order (Phase 1) — CAPTURE PER-BOOK PRICES BEFORE DEDUP.
// `props` still carries one row per book here (dedupeProps runs downstream
// inside gradeAndCacheSlate). The capture only READS props and writes its own
// `bookprices:{sport}` key at SNAP_TTL — long enough to outlive the cron gap
// (the 1h odds cache would blank between runs). Best-effort and structurally
// fenced: nothing on the grade path reads this key, and the graded slate is
// byte-identical whether or not this runs (bookPriceStore.test.js locks it).
try {
const bookStore = deps.captureBookPrices(props, { now: deps.now });
await deps.cacheSet(`bookprices:${sp}`, bookStore, SNAP_TTL);
console.log(`[snapshot] book prices ${sp}: ${bookStore.count} props with book rows captured`);
} catch (e) {
console.warn(`[snapshot] book-price capture failed for ${sp} (display-only, grading continues):`, e.message);
}
// Session 63 — REFRESH TEAM STATS BEFORE GRADING.
// `refreshTeamStats` is the ONLY writer of `team_stats:{sport}:{abbr}`, which
// is the ONLY source of `opp_rank_stat` — and it had zero production callers,