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.' });