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