8629021774
P1 name edge cases (BOTH playerName.js copies, kept identical): - normalizeName strips hyphens (display+key): "Jung-hoo Lee" === "Jung Hoo Lee". - nameKey strips single-letter MIDDLE tokens: "Josh H Smith" === "Josh Smith" (keeps first+last; real middle names + collapsed initials untouched). - richie -> richard added to NICKNAMES. P2 polish: - Team Hub names normalized at the source (teamService.getTeamHub) so "J.C. Escarra" renders as "JC Escarra" like the dashboard. - snapshotService dedup keeps the highest-confidence GRADE but the richest DISPLAY (accented "José" over "Jose") so prop rows match the pitcher line. - correlationWarning names the game: "2 legs from the same game (NYY @ BOS)". Backend 2246 -> 2255 tests (+9), 194 suites. Web build clean (exit 0). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
336 lines
14 KiB
JavaScript
336 lines
14 KiB
JavaScript
'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;
|
||
}
|
||
|
||
// ───────────────────────────────────────────────────────────────────
|
||
// Session 50 — Parlay Lab correlation-score model.
|
||
//
|
||
// A numeric, GAME-AWARE correlation model (0.0 independent … 1.0 perfectly
|
||
// correlated) layered on top of the S28 categorical matrix. Legs are the light
|
||
// UI shape { player, team, game, stat, grade }.
|
||
// ───────────────────────────────────────────────────────────────────
|
||
|
||
/** Grade → 0..1 score (A+ = 1.0 … F = 0.0), reusing the 13-step order. */
|
||
function gradeScore(grade) {
|
||
return gradeToNumeric(grade) / (GRADE_ORDER.length - 1);
|
||
}
|
||
/** 0..1 score → letter grade. */
|
||
function scoreToGrade(score) {
|
||
const s = Math.max(0, Math.min(1, Number(score) || 0));
|
||
return numericToGrade(s * (GRADE_ORDER.length - 1));
|
||
}
|
||
|
||
const sameVal = (a, b) => a != null && b != null && String(a).toLowerCase() === String(b).toLowerCase();
|
||
|
||
/**
|
||
* Pairwise correlation between two legs (0.0–1.0):
|
||
* 0.7 same player, same game (different stats move together)
|
||
* 0.4 same team, same game (team performance drives both)
|
||
* 0.2 same game, different teams (game pace, mostly independent)
|
||
* 0.0 different games (fully independent — what books price)
|
||
*/
|
||
function correlationScore(leg1, leg2) {
|
||
if (!leg1 || !leg2) return 0;
|
||
if (!sameVal(leg1.game, leg2.game)) return 0; // different (or unknown) game
|
||
if (sameVal(leg1.player, leg2.player)) return 0.7;
|
||
if (sameVal(leg1.team, leg2.team)) return 0.4;
|
||
return 0.2;
|
||
}
|
||
|
||
function pairwise(legs) {
|
||
const out = [];
|
||
for (let i = 0; i < legs.length; i += 1) {
|
||
for (let j = i + 1; j < legs.length; j += 1) out.push(correlationScore(legs[i], legs[j]));
|
||
}
|
||
return out;
|
||
}
|
||
|
||
/**
|
||
* Combined parlay grade — the leg-grade average PENALIZED by correlation.
|
||
* penalty = avgCorrelation * 0.5 (each 0.1 correlation ≈ 0.05 grade-score drop).
|
||
*/
|
||
function combinedGrade(legs) {
|
||
const list = Array.isArray(legs) ? legs : [];
|
||
if (list.length === 0) return { grade: '—', score: 0, penalty: 0, maxCorrelation: 0, avgCorrelation: 0 };
|
||
const rawAvg = list.reduce((s, l) => s + gradeScore(l.grade), 0) / list.length;
|
||
const pairs = pairwise(list);
|
||
const maxCorrelation = pairs.length ? Math.max(...pairs) : 0;
|
||
const avgCorrelation = pairs.length ? pairs.reduce((a, b) => a + b, 0) / pairs.length : 0;
|
||
const penalty = avgCorrelation * 0.5;
|
||
const score = Math.max(0, Math.min(1, rawAvg - penalty));
|
||
return {
|
||
grade: scoreToGrade(score),
|
||
score: Math.round(score * 1000) / 1000,
|
||
penalty: Math.round(penalty * 1000) / 1000,
|
||
maxCorrelation: Math.round(maxCorrelation * 100) / 100,
|
||
avgCorrelation: Math.round(avgCorrelation * 100) / 100,
|
||
};
|
||
}
|
||
|
||
// Fair decimal odds the model assigns each grade (lower grade = longer odds).
|
||
const FAIR_ODDS = {
|
||
'A+': 1.15, A: 1.25, 'A-': 1.35, 'B+': 1.45, B: 1.70, 'B-': 1.90,
|
||
'C+': 2.0, C: 2.10, 'C-': 2.5, 'D+': 2.8, D: 3.0, 'D-': 4.0, F: 5.0,
|
||
};
|
||
function fairOdds(grade) {
|
||
return FAIR_ODDS[String(grade || 'C').toUpperCase()] ?? 2.1;
|
||
}
|
||
|
||
/**
|
||
* Estimated payout. Books price parlays as independent (product of fair odds);
|
||
* VYNDR discounts that by the slip's average correlation.
|
||
*/
|
||
function estimatedPayout(legs, betAmount = 10) {
|
||
const list = Array.isArray(legs) ? legs : [];
|
||
const bet = Number(betAmount) || 10;
|
||
const fairMultiplier = list.reduce((m, l) => m * fairOdds(l.grade), 1);
|
||
const pairs = pairwise(list);
|
||
const avgCorrelation = pairs.length ? pairs.reduce((a, b) => a + b, 0) / pairs.length : 0;
|
||
const correlationDiscount = Math.max(0.5, Math.min(1, 1 - avgCorrelation));
|
||
const multiplier = fairMultiplier * correlationDiscount;
|
||
return {
|
||
payout: Math.round(bet * multiplier * 100) / 100,
|
||
multiplier: Math.round(multiplier * 100) / 100,
|
||
fairMultiplier: Math.round(fairMultiplier * 100) / 100,
|
||
correlationDiscount: Math.round(correlationDiscount * 100) / 100,
|
||
};
|
||
}
|
||
|
||
/** Human warning for the most-correlated cluster, or null when independent. */
|
||
function correlationWarning(legs) {
|
||
const list = Array.isArray(legs) ? legs : [];
|
||
if (list.length < 2) return null;
|
||
const byTeam = {};
|
||
for (const l of list) {
|
||
if (!l.team || !l.game) continue;
|
||
const k = `${String(l.team).toUpperCase()}|${l.game}`;
|
||
(byTeam[k] = byTeam[k] || []).push(l);
|
||
}
|
||
let worst = null;
|
||
for (const [k, group] of Object.entries(byTeam)) {
|
||
if (group.length >= 2 && (!worst || group.length > worst.count)) worst = { team: k.split('|')[0], count: group.length };
|
||
}
|
||
if (worst) return `⚠ ${worst.count} legs from ${worst.team} — high correlation`;
|
||
for (let i = 0; i < list.length; i += 1) {
|
||
for (let j = i + 1; j < list.length; j += 1) {
|
||
if (sameVal(list[i].game, list[j].game)) {
|
||
const g = list[i].game;
|
||
return g ? `⚠ 2 legs from the same game (${g}) — correlated` : '⚠ 2 legs from the same game — correlated';
|
||
}
|
||
}
|
||
}
|
||
return null;
|
||
}
|
||
|
||
/** The full Parlay-Lab analysis the /grade route returns. */
|
||
function gradeParlay(legs, betAmount = 10) {
|
||
const combined = combinedGrade(legs);
|
||
const payout = estimatedPayout(legs, betAmount);
|
||
return {
|
||
combined: { grade: combined.grade, score: combined.score, penalty: combined.penalty },
|
||
correlation: { max: combined.maxCorrelation, avg: combined.avgCorrelation, warning: correlationWarning(legs) },
|
||
payout: { amount: payout.payout, multiplier: payout.multiplier, fairMultiplier: payout.fairMultiplier, discount: payout.correlationDiscount },
|
||
legs: (Array.isArray(legs) ? legs : []).map((l) => ({ ...l, score: gradeScore(l.grade) })),
|
||
};
|
||
}
|
||
|
||
module.exports = {
|
||
calculateParlay,
|
||
detectCorrelation,
|
||
suggestParlays,
|
||
// Session 50 — Parlay Lab model
|
||
correlationScore,
|
||
combinedGrade,
|
||
estimatedPayout,
|
||
correlationWarning,
|
||
gradeParlay,
|
||
gradeToNumeric,
|
||
numericToGrade,
|
||
gradeScore,
|
||
scoreToGrade,
|
||
__internals: { americanToDecimal, decimalToAmerican, gradeToNumeric, numericToGrade, INTERACTIONS, FAIR_ODDS },
|
||
};
|