diff --git a/scripts/measure-book-spread.js b/scripts/measure-book-spread.js new file mode 100644 index 0000000..8dff67b --- /dev/null +++ b/scripts/measure-book-spread.js @@ -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); }); diff --git a/specs/STATE.md b/specs/STATE.md index 88e4dd9..59b2b3d 100644 --- a/specs/STATE.md +++ b/specs/STATE.md @@ -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×decimal−1 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 diff --git a/src/routes/bookComparison.js b/src/routes/bookComparison.js index 2b348f5..c8611b6 100644 --- a/src/routes/bookComparison.js +++ b/src/routes/bookComparison.js @@ -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.' }); diff --git a/src/services/bookComparisonService.js b/src/services/bookComparisonService.js index c6d5fdb..e282914 100644 --- a/src/services/bookComparisonService.js +++ b/src/services/bookComparisonService.js @@ -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; diff --git a/src/services/bookPriceStore.js b/src/services/bookPriceStore.js new file mode 100644 index 0000000..dff6a54 --- /dev/null +++ b/src/services/bookPriceStore.js @@ -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 }; diff --git a/src/services/snapshotService.js b/src/services/snapshotService.js index 9ddf904..f7da1ad 100644 --- a/src/services/snapshotService.js +++ b/src/services/snapshotService.js @@ -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, diff --git a/tests/integration/session28Routes.test.js b/tests/integration/session28Routes.test.js index f35cc15..2363110 100644 --- a/tests/integration/session28Routes.test.js +++ b/tests/integration/session28Routes.test.js @@ -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 () => { diff --git a/tests/unit/bookComparisonService.test.js b/tests/unit/bookComparisonService.test.js index fab7cb1..2fefc4d 100644 --- a/tests/unit/bookComparisonService.test.js +++ b/tests/unit/bookComparisonService.test.js @@ -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 }); diff --git a/tests/unit/bookPriceStore.test.js b/tests/unit/bookPriceStore.test.js new file mode 100644 index 0000000..1a05ff1 --- /dev/null +++ b/tests/unit/bookPriceStore.test.js @@ -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); + }); +});