Session 50: Complete Parlay Lab (2215 tests)

Correlation-aware combined parlay grading — the Desk-tier differentiator.

- Correlation model (parlayService.js, added to S28 funcs): correlationScore
  (game-aware 0.7/0.4/0.2/0.0), combinedGrade (avg penalized by avgCorr*0.5),
  estimatedPayout (fair-odds product * (1-avgCorr) discount), correlationWarning,
  gradeParlay.
- POST /api/parlay/grade (public, 2-6 legs) -> {combined,correlation,payout,legs}.
  Fixed the Next proxy (was forwarding to /api/scan/parlay).
- ParlayContext: legs gained team/game/archetype; tier-aware maxLegs; auto-grades
  the slip (debounced) when legs>=2 -> live combined/correlation/payout; hasLeg/
  legKey/atCap.
- "+" button on every graded prop: StatStrip onAddLeg/isLegActive, wired by
  vyndr/GameCard via useParlay (builds leg w/ team + game). GradeResultCard feeds
  the same context from the scan page.
- ParlayPanel (replaces legacy ParlayTray): bottom slide-up w/ legs, combined
  grade, correlation warning, est payout, CLEAR ALL + floating leg-count badge.
  Tier-gated: free 2 legs (payout blurred -> Desk upsell), Analyst 4, Desk 6.

Backend 2185 -> 2215 tests (+30), 187 suites. Web build clean (exit 0).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Kev
2026-06-19 11:25:14 -04:00
parent 3b47b783dc
commit f1956dc953
14 changed files with 706 additions and 48 deletions
+140 -1
View File
@@ -185,9 +185,148 @@ function suggestParlays(props, { legs = 3, max = 3 } = {}) {
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)) return '⚠ 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,
__internals: { americanToDecimal, decimalToAmerican, gradeToNumeric, numericToGrade, INTERACTIONS },
// Session 50 — Parlay Lab model
correlationScore,
combinedGrade,
estimatedPayout,
correlationWarning,
gradeParlay,
gradeToNumeric,
numericToGrade,
gradeScore,
scoreToGrade,
__internals: { americanToDecimal, decimalToAmerican, gradeToNumeric, numericToGrade, INTERACTIONS, FAIR_ODDS },
};