Session 28: Parlay builder, line movement tracker, book comparison — 3 features, zero credits (1623 tests)

This commit is contained in:
Kev
2026-06-13 12:37:08 -04:00
parent 66fafd8429
commit c48aecd510
23 changed files with 1567 additions and 1 deletions
+90
View File
@@ -0,0 +1,90 @@
'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 },
};