e81c9b8c51
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
147 lines
5.5 KiB
JavaScript
147 lines
5.5 KiB
JavaScript
'use strict';
|
|
|
|
/**
|
|
* Book comparison (Session 28).
|
|
*
|
|
* Surfaces the same prop across every available sportsbook with the best
|
|
* line highlighted. Data source is the grouped odds-api prop shape, which
|
|
* already carries book-by-book lines:
|
|
* { player, stat_type, lines: [{ book, line, over_odds, under_odds }] }
|
|
*
|
|
* "Best line" = highest decimal payout for the selected side. Savings is
|
|
* the per-$100 payout edge of the best book over the field average — the
|
|
* concrete dollars a user leaves on the table by not line-shopping.
|
|
*
|
|
* Zero credits: it only reads odds already fetched/cached.
|
|
*/
|
|
|
|
const { __internals } = require('./parlayService');
|
|
const { americanToDecimal } = __internals;
|
|
|
|
function average(nums) {
|
|
const valid = nums.filter((n) => Number.isFinite(n));
|
|
if (valid.length === 0) return 0;
|
|
return valid.reduce((a, b) => a + b, 0) / valid.length;
|
|
}
|
|
|
|
function oddsForSide(line, side) {
|
|
if (!line) return null;
|
|
const raw = side === 'under' ? line.under_odds : line.over_odds;
|
|
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 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(shared.map((b) => americanToDecimal(oddsForSide(b, side))));
|
|
const savings = Math.round((bestDecimal - avgDecimal) * 100 * 100) / 100; // per $100, 2dp
|
|
|
|
return {
|
|
player: prop.player,
|
|
stat: prop.stat_type || prop.stat,
|
|
line: best.line ?? prop.line ?? null,
|
|
side,
|
|
// 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 && Number(b.line) === Number(best.line),
|
|
})),
|
|
bestBook: best.book,
|
|
bestOdds: oddsForSide(best, side),
|
|
bookCount: distinctBooks.size,
|
|
savings,
|
|
crowned: true,
|
|
};
|
|
}
|
|
|
|
/**
|
|
* Best lines across a list of props, sorted by savings desc (where line-
|
|
* shopping matters most). Drops props with a single book (nothing to
|
|
* compare). `limit` caps the result.
|
|
*/
|
|
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, { 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;
|
|
}
|
|
|
|
module.exports = {
|
|
compareProp,
|
|
bestLines,
|
|
__internals: { average, oddsForSide },
|
|
};
|