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:
@@ -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.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)) 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 },
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user