Session 28: Parlay builder, line movement tracker, book comparison — 3 features, zero credits (1623 tests)
This commit is contained in:
@@ -0,0 +1,193 @@
|
||||
'use strict';
|
||||
|
||||
/**
|
||||
* Parlay service (Session 28).
|
||||
*
|
||||
* Combines user-selected props (from the parlay builder) into a parlay:
|
||||
* - combined American/decimal odds (multiply decimal odds)
|
||||
* - combined grade (confidence-weighted average of leg grades)
|
||||
* - correlation detection (interaction matrix for same-game legs)
|
||||
* - kill-condition aggregation (any leg flagged → surface it)
|
||||
*
|
||||
* This is the lightweight BUILDER path — it operates on the simple leg
|
||||
* shape the frontend sends ({ player, team, gameId, stat, side, line,
|
||||
* odds, grade, confidence, killConditions }). It is distinct from the
|
||||
* full grading pipeline (`parlayGrader` / `correlationEngine`), which
|
||||
* consumes already-analyzed legs with full reasoning trees.
|
||||
*
|
||||
* Odds math reuses `payoutCalculator` where it can; the decimal/American
|
||||
* conversions live here because the builder needs both directions.
|
||||
*/
|
||||
|
||||
const { calculateParlayPayout } = require('./payoutCalculator');
|
||||
|
||||
// ---- odds conversions -------------------------------------------------
|
||||
function americanToDecimal(odds) {
|
||||
const n = Number(odds);
|
||||
if (!Number.isFinite(n) || n === 0) return 1;
|
||||
return n > 0 ? 1 + n / 100 : 1 + 100 / Math.abs(n);
|
||||
}
|
||||
|
||||
function decimalToAmerican(decimal) {
|
||||
const d = Number(decimal);
|
||||
if (!Number.isFinite(d) || d <= 1) return 0;
|
||||
return d >= 2
|
||||
? Math.round((d - 1) * 100)
|
||||
: Math.round(-100 / (d - 1));
|
||||
}
|
||||
|
||||
// ---- grade <-> numeric (A+ = 12 … F = 0) ------------------------------
|
||||
const GRADE_ORDER = ['F', 'D-', 'D', 'D+', 'C-', 'C', 'C+', 'B-', 'B', 'B+', 'A-', 'A', 'A+'];
|
||||
|
||||
function gradeToNumeric(grade) {
|
||||
const idx = GRADE_ORDER.indexOf(String(grade || 'C').toUpperCase());
|
||||
return idx === -1 ? GRADE_ORDER.indexOf('C') : idx;
|
||||
}
|
||||
|
||||
function numericToGrade(value) {
|
||||
const idx = Math.max(0, Math.min(GRADE_ORDER.length - 1, Math.round(value)));
|
||||
return GRADE_ORDER[idx];
|
||||
}
|
||||
|
||||
// ---- correlation interaction matrix -----------------------------------
|
||||
// Keyed by the two stat types sorted + joined. `sameTeam` / `crossTeam`
|
||||
// give the correlation sign. Stat names are normalized to lowercase.
|
||||
const INTERACTIONS = {
|
||||
'assists_points': { sameTeam: 'positive', crossTeam: 'neutral' },
|
||||
'assists_rebounds': { sameTeam: 'positive', crossTeam: 'neutral' },
|
||||
'points_points': { sameTeam: 'neutral', crossTeam: 'negative' },
|
||||
'rebounds_rebounds': { sameTeam: 'negative', crossTeam: 'negative' },
|
||||
'points_rebounds': { sameTeam: 'positive', crossTeam: 'neutral' },
|
||||
'assists_assists': { sameTeam: 'negative', crossTeam: 'neutral' },
|
||||
'pra_points': { sameTeam: 'positive', crossTeam: 'neutral' },
|
||||
// MLB
|
||||
'hits_strikeouts': { sameTeam: 'neutral', crossTeam: 'negative' },
|
||||
'hits_hits': { sameTeam: 'positive', crossTeam: 'neutral' },
|
||||
'strikeouts_strikeouts':{ sameTeam: 'neutral', crossTeam: 'positive' },
|
||||
'home_runs_strikeouts': { sameTeam: 'neutral', crossTeam: 'negative' },
|
||||
};
|
||||
|
||||
function normStat(s) {
|
||||
return String(s || '').toLowerCase().replace(/\s+/g, '_');
|
||||
}
|
||||
|
||||
function describeCorrelation(legA, legB, sign) {
|
||||
const a = `${legA.player} ${normStat(legA.stat).replace(/_/g, ' ')}`;
|
||||
const b = `${legB.player} ${normStat(legB.stat).replace(/_/g, ' ')}`;
|
||||
if (sign === 'negative') return `${a} and ${b} compete — these legs fight each other`;
|
||||
if (sign === 'positive') return `${a} and ${b} feed each other — correlated outcome`;
|
||||
return `${a} and ${b} are weakly related`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Correlation between two builder legs. Independent across different
|
||||
* games; otherwise looked up in the interaction matrix.
|
||||
*/
|
||||
function detectCorrelation(legA, legB) {
|
||||
if (!legA || !legB) return { correlated: false, type: 'independent' };
|
||||
|
||||
// Same player, opposite directions on the same stat → direct conflict.
|
||||
if (legA.player && legB.player && legA.player.toLowerCase() === legB.player.toLowerCase()) {
|
||||
if (normStat(legA.stat) === normStat(legB.stat) && legA.side && legB.side && legA.side !== legB.side) {
|
||||
return { correlated: true, type: 'negative', description: `${legA.player}: ${legA.side} vs ${legB.side} on the same prop — direct conflict` };
|
||||
}
|
||||
}
|
||||
|
||||
if (!legA.gameId || !legB.gameId || legA.gameId !== legB.gameId) {
|
||||
return { correlated: false, type: 'independent' };
|
||||
}
|
||||
|
||||
const key = [normStat(legA.stat), normStat(legB.stat)].sort().join('_');
|
||||
const interaction = INTERACTIONS[key];
|
||||
if (!interaction) return { correlated: false, type: 'unknown' };
|
||||
|
||||
const sameTeam = legA.team != null && legA.team === legB.team;
|
||||
const sign = sameTeam ? interaction.sameTeam : interaction.crossTeam;
|
||||
if (sign === 'neutral') return { correlated: false, type: 'neutral' };
|
||||
|
||||
return { correlated: true, type: sign, description: describeCorrelation(legA, legB, sign) };
|
||||
}
|
||||
|
||||
/**
|
||||
* Combine legs into a parlay analysis. Throws on empty/invalid input so
|
||||
* the route can 400 cleanly.
|
||||
*/
|
||||
function calculateParlay(legs) {
|
||||
if (!Array.isArray(legs) || legs.length === 0) {
|
||||
const err = new Error('A parlay needs at least one leg.');
|
||||
err.statusCode = 400;
|
||||
throw err;
|
||||
}
|
||||
|
||||
const decimalOdds = legs.map((l) => americanToDecimal(l.odds));
|
||||
const combinedDecimal = decimalOdds.reduce((a, b) => a * b, 1);
|
||||
const combinedAmerican = decimalToAmerican(combinedDecimal);
|
||||
|
||||
// Confidence-weighted grade.
|
||||
const totalConfidence = legs.reduce((sum, l) => sum + (Number(l.confidence) || 50), 0);
|
||||
const weightedGrade = legs.reduce((sum, l) => {
|
||||
const weight = (Number(l.confidence) || 50) / totalConfidence;
|
||||
return sum + gradeToNumeric(l.grade) * weight;
|
||||
}, 0);
|
||||
|
||||
// All pairwise correlations.
|
||||
const correlations = [];
|
||||
for (let i = 0; i < legs.length; i += 1) {
|
||||
for (let j = i + 1; j < legs.length; j += 1) {
|
||||
const c = detectCorrelation(legs[i], legs[j]);
|
||||
if (c.correlated) correlations.push({ legA: i, legB: j, type: c.type, description: c.description });
|
||||
}
|
||||
}
|
||||
|
||||
const killConditions = legs
|
||||
.map((l, i) => ({ leg: i, player: l.player, conditions: l.killConditions || [] }))
|
||||
.filter((k) => Array.isArray(k.conditions) && k.conditions.length > 0);
|
||||
|
||||
return {
|
||||
legCount: legs.length,
|
||||
combinedOdds: combinedAmerican,
|
||||
combinedDecimal: Math.round(combinedDecimal * 10000) / 10000,
|
||||
combinedGrade: numericToGrade(weightedGrade),
|
||||
payoutPer10: Math.round(calculateParlayPayout(10, legs.map((l) => Number(l.odds) || 0)) * 100) / 100,
|
||||
correlations,
|
||||
hasNegativeCorrelation: correlations.some((c) => c.type === 'negative'),
|
||||
hasPositiveCorrelation: correlations.some((c) => c.type === 'positive'),
|
||||
killConditions,
|
||||
hasKillCondition: killConditions.length > 0,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Suggest up to `max` parlays from a pool of graded props. Greedy: take
|
||||
* the best-graded props, avoid negative correlations within a suggestion.
|
||||
* Pure — the route supplies the prop pool (no API calls here).
|
||||
*/
|
||||
function suggestParlays(props, { legs = 3, max = 3 } = {}) {
|
||||
if (!Array.isArray(props) || props.length < legs) return [];
|
||||
const sorted = [...props].sort((a, b) => gradeToNumeric(b.grade) - gradeToNumeric(a.grade));
|
||||
|
||||
const suggestions = [];
|
||||
const used = new Set();
|
||||
for (let start = 0; start < sorted.length && suggestions.length < max; start += 1) {
|
||||
if (used.has(start)) continue;
|
||||
const combo = [start];
|
||||
for (let k = 0; k < sorted.length && combo.length < legs; k += 1) {
|
||||
if (k === start || used.has(k) || combo.includes(k)) continue;
|
||||
const conflicts = combo.some((idx) => detectCorrelation(sorted[idx], sorted[k]).type === 'negative');
|
||||
if (!conflicts) combo.push(k);
|
||||
}
|
||||
if (combo.length === legs) {
|
||||
combo.forEach((idx) => used.add(idx));
|
||||
const legObjs = combo.map((idx) => sorted[idx]);
|
||||
suggestions.push({ legs: legObjs, ...calculateParlay(legObjs) });
|
||||
}
|
||||
}
|
||||
return suggestions;
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
calculateParlay,
|
||||
detectCorrelation,
|
||||
suggestParlays,
|
||||
__internals: { americanToDecimal, decimalToAmerican, gradeToNumeric, numericToGrade, INTERACTIONS },
|
||||
};
|
||||
Reference in New Issue
Block a user