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
+193
View File
@@ -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 },
};