Files
vyndr/scripts/measure-book-spread.js
T
builtbykev e81c9b8c51 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
2026-07-27 00:45:01 -04:00

167 lines
7.2 KiB
JavaScript

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