'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 }, };