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
+166
View File
@@ -0,0 +1,166 @@
'use strict';
/**
* measure-book-spread — Book Comparison order, Phase 2 (GATES THE CROWN).
*
* Reads the snapshot-locked `bookprices:{sport}` store (Phase 1) — falling back
* to the transient `odds:{sport}:{utcDate}` cache — and reports, PER SPORT, the
* best-vs-worst PRICE spread among books posting the SAME line for the same
* side:
* - median + distribution + tail, in American cents AND implied-prob points
* - how often the spread is exactly zero
* - book-count histogram per prop
* - pinnacle presence (captured, not built on — this order)
*
* PRE-REGISTERED CROWN THRESHOLD (do NOT lower it to make the crown appear):
* the crown ships for a sport ONLY if median same-line spread
* >= 8 American cents OR >= 2.0 implied-probability points.
*
* Never pools sports. Reports n + effective sample on every figure.
*
* Redis runs degraded locally (no live data) → this exits 0 cleanly rather than
* hanging on a reconnect timer (the verify-grade-range.js precedent). Run it
* post-deploy against prod Redis, after inducing a snapshot.
*
* node scripts/measure-book-spread.js [sport ...] (default: mlb wnba nba)
*/
const SPORTS = process.argv.slice(2).filter(Boolean);
const DEFAULT_SPORTS = ['mlb', 'wnba', 'nba', 'soccer'];
const CROWN_CENTS = 8;
const CROWN_PROB_PTS = 2.0;
/** American → implied probability (0..1). Includes the vig. */
function impliedProb(a) {
if (a == null || !Number.isFinite(Number(a)) || Number(a) === 0) return null;
const n = Number(a);
return n > 0 ? 100 / (n + 100) : Math.abs(n) / (Math.abs(n) + 100);
}
function median(xs) {
if (!xs.length) return null;
const s = [...xs].sort((a, b) => a - b);
const m = Math.floor(s.length / 2);
return s.length % 2 ? s[m] : (s[m - 1] + s[m]) / 2;
}
function pct(xs, p) {
if (!xs.length) return null;
const s = [...xs].sort((a, b) => a - b);
return s[Math.min(s.length - 1, Math.floor((p / 100) * s.length))];
}
function entriesFrom(store, oddsCache) {
// Phase-1 store shape: { props: [{ player, stat_type, books:[{book,line,over_odds,under_odds}] }] }
if (store && Array.isArray(store.props)) return store.props;
// Fallback: group the flat odds cache the same way.
const flat = oddsCache && Array.isArray(oddsCache.props) ? oddsCache.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()];
}
function measureSport(entries) {
const centsSpreads = [];
const probSpreads = [];
const bookCountHist = {};
let sharedLineProps = 0;
let zeroSpread = 0;
let pinnacleRows = 0;
let totalProps = 0;
for (const e of entries) {
totalProps += 1;
const books = e.books || [];
if (books.some((b) => b.book === 'pinnacle')) pinnacleRows += 1;
const nBooks = new Set(books.map((b) => b.book)).size;
bookCountHist[nBooks] = (bookCountHist[nBooks] || 0) + 1;
// Group this prop's book rows by line; a shared line = ≥2 books at one line.
const byLine = {};
for (const b of books) {
const L = String(b.line);
(byLine[L] = byLine[L] || []).push(b);
}
let contributed = false;
for (const rows of Object.values(byLine)) {
const distinctBooks = new Set(rows.map((r) => r.book));
if (distinctBooks.size < 2) continue;
for (const side of ['over_odds', 'under_odds']) {
const prices = rows.map((r) => r[side]).filter((v) => v != null && Number.isFinite(Number(v))).map(Number);
if (prices.length < 2) continue;
// Best price for a bettor = highest implied payout = LOWEST implied prob.
const probs = prices.map(impliedProb).filter((v) => v != null);
if (probs.length < 2) continue;
const probSpread = (Math.max(...probs) - Math.min(...probs)) * 100; // points
probSpreads.push(+probSpread.toFixed(3));
// American cents: meaningful when same-sign; use nominal max-min.
const centSpread = Math.max(...prices) - Math.min(...prices);
centsSpreads.push(Math.abs(centSpread));
if (probSpread < 1e-9) zeroSpread += 1;
contributed = true;
}
}
if (contributed) sharedLineProps += 1;
}
const nEff = probSpreads.length; // side-level shared-line comparisons
const verdictCents = median(centsSpreads);
const verdictProb = median(probSpreads);
const crownShips = nEff > 0 && ((verdictCents != null && verdictCents >= CROWN_CENTS) || (verdictProb != null && verdictProb >= CROWN_PROB_PTS));
return {
totalProps,
sharedLineProps,
nEff,
zeroSpread,
pctZero: nEff ? +(100 * zeroSpread / nEff).toFixed(1) : null,
bookCountHist,
pinnacleRows,
cents: { median: verdictCents, p75: pct(centsSpreads, 75), p90: pct(centsSpreads, 90), max: centsSpreads.length ? Math.max(...centsSpreads) : null },
prob: { median: verdictProb, p75: pct(probSpreads, 75), p90: pct(probSpreads, 90), max: probSpreads.length ? Math.max(...probSpreads) : null },
crownShips,
};
}
async function main() {
let cacheGet;
try {
({ cacheGet } = require('../src/utils/redis'));
} catch (e) {
console.log('[measure] redis util unavailable — nothing to measure.');
process.exit(0);
}
const sports = SPORTS.length ? SPORTS : DEFAULT_SPORTS;
const utcDate = new Date().toISOString().split('T')[0];
const report = {};
for (const sp of sports) {
let store = null; let oddsCache = null;
try { store = await cacheGet(`bookprices:${sp}`); } catch { /* degraded */ }
if (!store) { try { oddsCache = (await cacheGet(`odds:${sp}:${utcDate}`)) || (await cacheGet(`odds:${sp}`)); } catch { /* degraded */ } }
const entries = entriesFrom(store, oddsCache);
report[sp] = { source: store ? 'bookprices' : (oddsCache ? 'odds-cache' : 'none'), ...measureSport(entries) };
}
console.log('\n=== BOOK-PRICE SPREAD (Phase 2) — never pooled ===');
for (const sp of sports) {
const r = report[sp];
console.log(`\n--- ${sp.toUpperCase()} (source: ${r.source}) ---`);
if (r.source === 'none' || r.totalProps === 0) { console.log(' no captured data (run post-deploy after a snapshot)'); continue; }
console.log(` props: ${r.totalProps} | with ≥2 books at a shared line: ${r.sharedLineProps} | side-level comparisons n=${r.nEff}`);
console.log(` book-count histogram: ${JSON.stringify(r.bookCountHist)}`);
console.log(` pinnacle present on: ${r.pinnacleRows} props`);
console.log(` spread exactly zero: ${r.zeroSpread}/${r.nEff} (${r.pctZero}%)`);
console.log(` American cents — median ${r.cents.median} | p75 ${r.cents.p75} | p90 ${r.cents.p90} | max ${r.cents.max}`);
console.log(` implied-prob pt — median ${r.prob.median} | p75 ${r.prob.p75} | p90 ${r.prob.p90} | max ${r.prob.max}`);
console.log(` CROWN THRESHOLD (median ≥${CROWN_CENTS}c OR ≥${CROWN_PROB_PTS}pp): ${r.crownShips ? 'MET → crown MAY ship' : 'NOT met → crown does NOT ship'}`);
}
console.log('\n(JSON) ' + JSON.stringify(report));
process.exit(0);
}
main().catch((e) => { console.error('[measure] failed:', e.message); process.exit(0); });
+90 -2
View File
@@ -1,6 +1,49 @@
# VYNDR — STATE OF THE WORLD
### As of `4f3f433` (main, DEPLOYED + fingerprinted live), 2026-07-22. This file opens every future session. **Start with the CURRENT STATUS + OPEN ITEMS block below.**
---
# 🎯 CLV REDIRECT — investigated 2026-07-26. FINDING: it was ALREADY BUILT.
*The premise "CLV is dead, redirect it to closing_captures" is STALE. Nothing needed building.*
- **Redirect EXISTS + WIRED:** `closingCapture.buildCaptureRows``closing_captures` (append-only,
provenance: captured_at/book/line_type/both-prices/missed_reason) via `intradayRefreshService:221`
+ internal endpoint; `ledgerService.attachClosingProb``closing_prob` (de-vigs both raw sides,
write-once, honest-absent `market_unavailable_reason`) via `snapshotScheduler:310`.
- **Honest-absent is already implemented + is the invalidation marker:** 651,624 capture rows
(86% `missed`, no fabricated price); ledger has **59 genuine `closing_prob`, 870
`market_unavailable_reason`**. Of 841 old-`clv` rows, **791 are already marked unavailable**;
**745 (89%) of old `clv` = exactly 0/'flat'** (the fake "never captured" signal). Old broken
`clv`/`closing_line` left in place, unread by the new instrument (but STILL consumed by ledger
ROW_COLUMNS/UI — repointing display is a FUTURE order, not done here).
- **0.4 GATE:** captures **differ from locked** (10/10 comparable rows moved) → NOT a
captures==locked timing defect. BUT the capture **86%-misses** → CLV is STARVED: only **~10-50
usable rows**, and only **10 have both `closing_prob` + `fair_prob_lock` (all WNBA, 0 MLB)**.
- **LINE-LAG HYPOTHESIS → CANNOT DETERMINE.** The 10 WNBA comparable rows move ~1pp, roughly
symmetric (mean CLV gap 0.008 over / 0.011 under). **The +4.57% MLB-C and the over/under
asymmetry are NEITHER confirmed nor refuted by CLV** — there is essentially no MLB CLV data.
- **The real open defect (separate order):** WHY 86% of captures miss (props leave the feed before
the 45-min window? matching failure?). Until that's fixed the instrument stays starved.
- Docs corrected: CLAUDE.md (WNBA settles, not MLB-only), STATE.md grade_11 (model_snapshots only).
Orphan `next start -p 3111` (6-day-old, the "1 shell running") killed.
---
# 📐 CALIBRATION DIAGNOSIS — measured 2026-07-26 (read-only; nothing changed)
*Run on BOTH populations: unselected both-sides (model_snapshots, p_win per side) vs published selected (ledger). Never pooled across sport. Full detail in chat log of that order.*
**Feasibility caveats that govern every number below:**
- p_win window ≈ **last 10 games** (`probabilityEstimator`, last-5 double-weighted) → **SE ≈ 0.16 at p=0.5** (noisy estimator).
- Continuous p_win coverage is thin+recent (revived ~07-20): **~18 MLB games, ~5 WNBA games** of joinable calibration data. WNBA calibration is **effectively anecdotal (~5 games)**; MLB is suggestive not confident (~18 games).
- Effective sample generally: MLB 31 games / 190 players, **WNBA 12 games / 77 players** — prop outcomes cluster within a game; do not treat rows as independent.
**Findings (VERIFIED where measured; sample caveats attached):**
1. **Selection bias, measured directly** = both-sides→selected Brier: **MLB 0.212→0.233, WNBA 0.258→0.278 (~+0.02 each)**. Published grade is inflated ~1 sub-tier by the side-pick: mean idx **MLB C→C+**, **WNBA C+→B**. Honest A-or-better ≈ 0 → the "caps at ~B+/A-" story survives (optimistic if anything).
2. **MLB calibration** (both-sides): Reliability 0.010 (good), Resolution 0.047 (moderate), Uncertainty 0.249 → **discriminates + roughly calibrated**; middle deciles near-perfect. SELECTED side is **overconfident, growing with p** (+0.02 at p<.5 → **+0.19 at p≥.8**).
3. **WNBA calibration** (both-sides): Brier 0.258 **> uncertainty 0.25 = worse than always-predicting-0.5**; Resolution 0.019 (near-noise), Reliability 0.027 (poor). Realized ~flat vs predicted. **Champion does not discriminate on WNBA** — but ~5 games, so unproven.
4. **Null baselines (real prices):** **unders lose ~14% flat both sports; overs near break-even** (MLB 2.8%, WNBA 1.4%). **Ledger strongly supports "no unders by default."** MLB model beats always-over (MLB-C +4.6% vs 2.8%); **WNBA model LOSES to always-over** (5% vs 1.4%). Both within noise at 31/12 games.
5. **ev_pct = p_win×decimal1 uses the overconfident p_win** → inflated; hero ranks on it → picks the most-overconfident reads. No current metric reliably predicts ROI; price-aware EV requires recalibrated p_win first.
**BUILD FORK (declared before data; sample-tempered):** MLB → *resolution good / reliability poor-when-selected* → recalibration mapping + EV ranking — **but ~18 games can't validate a holdout yet (test half 15 games)**. WNBA → *resolution poor* → champion doesn't discriminate / price-aware replacement — **but ~5 games = accrue volume before concluding.** **CLV redirect ships next regardless.** Most robust actionable finding now: **kill default unders.**
---
# 🧭 CURRENT STATUS + OPEN ITEMS — orientation block (2026-07-20, ~03:20 UTC)
@@ -19,8 +62,12 @@ was verified live unless explicitly marked UNVERIFIED.*
| 6 | ESPN team-stats parser fixed (`buckets is not iterable`, 0/15 → 15/15) | live refresh |
### ⚠️ Two honest qualifiers on the above
- **A STILL DOES NOT EMIT IN PRODUCTION.** The 11-step grade is now *stored*
(`grade_11`) and A/D are *arithmetically* reachable + locked by tests, but the
- **A STILL DOES NOT EMIT IN PRODUCTION.** The 11-step grade is stored in
`model_snapshots.grade_11` ONLY (CORRECTED 2026-07-26: it is NOT in
`ledger_entries``_grade_11` is deleted at `gradeSlateService.js:97` before
the ledger write; sub-tier resolution on a SETTLED ledger row is recoverable
only by joining model_snapshots). A/D are *arithmetically* reachable + locked
by tests, but the
±1.0 opponent factor is still dead because `opp_rank_stat` is underivable from
ESPN (see open item 6). Live boards remain **B/C only**.
**→ The "A-RATED" marketing hold STANDS** (`AccuracyBadge` correctly falls
@@ -1858,6 +1905,47 @@ Dockerfile must copy every dir the runtime reads.
`web/src/app/terms/page.tsx` (6×) + `web/src/app/privacy/page.tsx` (5×).
A test keeps unverified entity names out until replaced.
## BOOK COMPARISON — data layer shipped (per the build order)
**What shipped (backend + tooling; UI routing is the gated next step):**
- **Phase 1 — fenced, snapshot-locked per-book store.** `src/services/
bookPriceStore.js` captures per-book prices from the multi-book `props` array
BEFORE `dedupeProps` runs, keyed by `nameKey|stat_type`, written to
`bookprices:{sport}` at SNAP_TTL (24h) inside `snapshotService.runSnapshot`.
STRUCTURAL FENCE: it only reads `props` and writes its own key; nothing on the
grade path reads it. Proven byte-identical — the graded slate is unchanged with
or without the capture (`tests/unit/bookPriceStore.test.js`, incl. a grep test
asserting no grade-path file references `bookprices`).
- **Phase 2 — the crown is threshold-gated.** `scripts/measure-book-spread.js`
reports best-vs-worst same-line price spread (American cents AND implied-prob
points), book-count histogram, %-zero, pinnacle presence — per sport, never
pooled. PRE-REGISTERED: crown ships only if median ≥8¢ OR ≥2 implied-prob pts.
Runs post-deploy against real captured data (Redis degraded locally → exits 0).
- **Phase 3 (backend) — honest-absent + gated crown.** `bookComparisonService.
compareProp` now renders single-book / flat-market props with NO crown; the
crown fires only among ≥2 books at the SAME line with DIFFERING prices, and only
when `BOOK_CROWN_ENABLED=1` (default OFF until Phase 2 clears). `/api/books/
:sport` (a crown claim) returns [] while gated off; `/api/books/:sport/:player/
:stat` returns the honest grid always. Route repointed to read `bookprices:*`
(fallback: odds cache), matched by `nameKey`. Response `source` field is the
deploy fingerprint (`bookprices` = new store serving).
**What it revealed:** the premise ("BookComparison.tsx is a dead component") was
incomplete — the ENTIRE feature (service + `/api/books` routes + Next proxy) was
built and mounted, but NON-FUNCTIONAL end-to-end: the route fed FLAT odds-cache
rows to a comparator that expects GROUPED `{lines|books}`, so it always returned
empty. Per-book prices were retained only transiently (odds cache, ~1h, raw
names, grade-path INPUT); every grade-path persistence point collapses to one
book. Fixed by the snapshot-locked store + route repoint.
**Gated next step (NOT this order-turn):** route `BookComparison.tsx` onto the
prop card fed by the store, and design the crown treatment — AFTER Phase 2's
measurement returns real spread numbers on prod (you can't honestly design a
crown before you know it ships). `BOOK_CROWN_ENABLED` stays 0 until then.
HELD unchanged: dedupeProps, snapshot dedup, the selector, the grade, champion,
challengers, ranking, edge_pct/ev_pct. No push-to-book, no movement strip, no
pinnacle edge test (data persisted only).
## HONEST OPEN ITEMS
- **Phase 4.5 — WNBA settlement via ESPN box scores. DUE ~Jul 24** (hard
+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.' });
+75 -19
View File
@@ -30,22 +30,73 @@ function oddsForSide(line, side) {
return raw == null ? null : Number(raw);
}
/**
* Compare one grouped prop across its books for a given side.
* Returns null when there are no usable book lines.
*/
function compareProp(prop, side = 'over') {
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;
let best = priced[0];
for (const b of priced) {
if (americanToDecimal(oddsForSide(b, side)) > americanToDecimal(oddsForSide(best, side))) best = b;
// 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 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', opts = {}) {
const books = (prop?.lines || prop?.books || []).filter((b) => b && b.book);
if (books.length === 0) return null;
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,
+43 -10
View File
@@ -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 () => {
+54 -15
View File
@@ -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);
});
});
test('single-book prop still compares (bookCount 1)', () => {
const c = compareProp({ player: 'X', stat_type: 'hits', lines: [{ book: 'dk', over_odds: -110 }] }, 'over');
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).toBe('dk');
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('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
});
+135
View File
@@ -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);
});
});