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