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