91 lines
2.8 KiB
JavaScript
91 lines
2.8 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);
|
|
}
|
|
|
|
/**
|
|
* Compare one grouped prop across its books for a given side.
|
|
* Returns null when there are no usable book lines.
|
|
*/
|
|
function compareProp(prop, side = 'over') {
|
|
const books = (prop?.lines || prop?.books || []).filter((b) => b && b.book);
|
|
const priced = books.filter((b) => Number.isFinite(oddsForSide(b, side)));
|
|
if (priced.length === 0) return null;
|
|
|
|
let best = priced[0];
|
|
for (const b of priced) {
|
|
if (americanToDecimal(oddsForSide(b, side)) > americanToDecimal(oddsForSide(best, side))) best = b;
|
|
}
|
|
|
|
const bestDecimal = americanToDecimal(oddsForSide(best, side));
|
|
const avgDecimal = average(priced.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,
|
|
books: priced.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,
|
|
})),
|
|
bestBook: best.book,
|
|
bestOdds: oddsForSide(best, side),
|
|
bookCount: priced.length,
|
|
savings,
|
|
};
|
|
}
|
|
|
|
/**
|
|
* 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 } = {}) {
|
|
if (!Array.isArray(props)) return [];
|
|
const out = [];
|
|
for (const prop of props) {
|
|
const cmp = compareProp(prop, side);
|
|
if (cmp && 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 },
|
|
};
|