Files
vyndr/src/services/parlayService.js
T
builtbykev 8629021774 Session 54: Audit cleanup — name edges + polish (2255 tests)
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>
2026-06-19 15:45:07 -04:00

336 lines
14 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
'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.01.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 },
};