/* ============================================================ VYNDR 2.0 — parlay correlation math (§12). Ported from the prototype's vyndr-parlay.jsx. Plain CommonJS so the Parlay Lab imports it (allowJs) AND Jest exercises it directly. NOTE: the BACKEND parlayService (Session 28) owns server-side combined odds + suggestions. This module is the FRONTEND correlation model that powers the Parlay Lab's live matrix + grade penalty. ============================================================ */ // Grade → representative American odds (the prototype's GRADE_ODDS). const GRADE_ODDS = { 'A+': -135, A: 110, 'A-': 115, 'B+': 125, B: 135, 'B-': 150, C: 175, D: 240 }; function amToDec(am) { return am > 0 ? am / 100 + 1 : 100 / -am + 1; } function decToAm(dec) { return dec >= 2 ? Math.round((dec - 1) * 100) : Math.round(-100 / (dec - 1)); } function fmtAmerican(am) { return (am > 0 ? '+' : '') + am; } /** * Pairwise correlation (§12): same player legs move together hardest, * then same team, then same league, then ~independent across sports. */ function getCorrelation(legA, legB) { if (!legA || !legB) return 0; if (legA.player && legA.player === legB.player) return 0.62; if (legA.team && legA.team === legB.team && legA.sport === legB.sport) return 0.34; if (legA.sport && legA.sport === legB.sport) return 0.06; return 0; } /** Highest pairwise correlation across the slip (the worst hidden drag). */ function maxCorrelation(legs) { let max = 0; for (let i = 0; i < legs.length; i++) { for (let j = i + 1; j < legs.length; j++) { max = Math.max(max, getCorrelation(legs[i], legs[j])); } } return max; } /** All correlated pairs as [i, j, correlation]. */ function correlationPairs(legs) { const pairs = []; for (let i = 0; i < legs.length; i++) { for (let j = i + 1; j < legs.length; j++) { pairs.push([i, j, getCorrelation(legs[i], legs[j])]); } } return pairs; } const GRADE_ORDER = ['A+', 'A', 'B+', 'B', 'C', 'D']; /** * Combined parlay grade. Averages the leg grades, then bumps the slip DOWN * one tier when any pair is strongly correlated (>0.4) — correlated stacks * are riskier than the books' independent pricing implies. */ function parlayGrade(legs) { if (!legs || !legs.length) return '—'; const avg = legs.reduce((s, l) => s + Math.max(0, GRADE_ORDER.indexOf(String(l.grade || 'B').replace('-', ''))), 0) / legs.length; const penalty = maxCorrelation(legs) > 0.4 ? 1 : 0; const idx = Math.min(GRADE_ORDER.length - 1, Math.round(avg + penalty)); return GRADE_ORDER[idx]; } /** Combined decimal odds (product of per-leg decimal odds). */ function combinedDecimal(legs) { return legs.reduce((d, l) => d * amToDec(GRADE_ODDS[l.grade] != null ? GRADE_ODDS[l.grade] : 110), 1); } /** Combined odds as an American string, or "—" for an empty slip. */ function combinedAmerican(legs) { if (!legs || !legs.length) return '—'; return fmtAmerican(decToAm(combinedDecimal(legs))); } module.exports = { GRADE_ODDS, GRADE_ORDER, amToDec, decToAm, fmtAmerican, getCorrelation, maxCorrelation, correlationPairs, parlayGrade, combinedDecimal, combinedAmerican, };