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:
@@ -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.' });
|
||||
|
||||
Reference in New Issue
Block a user