Session 38: Design system Phase G — living layer, i18n/odds, a11y, paywall, parlay math (1890 tests)
VYNDR 2.0 conversion, Phase G (the systems that make the design alive). All 5 wired. Frontend-only; zero backend changes. - lib/parlayMath.js: correlation model (0.62/0.34/0.06/0) + parlayGrade penalty + grade->odds + combined odds (frontend; backend parlayService unchanged). - lib/oddsFormat.js: fmtOdds across american/decimal/fractional/implied with the totals-pass-through rule (safer than the prototype's parseAm, which would mis-convert 228.5) + region presets. - lib/prefs.js: applyPrefs sets <html data-*> (the S33 a11y CSS layer) + load/save. - lib/liveTick.js: single tick engine (SSR/test-safe, no auto-start, fresh state). - lib/checkout.js: checkoutUrl(plan). - LiveLayer (useLive/LiveNumber/HeartbeatBar) under the Nav ticker; GlobalHosts in layout applies prefs + registers __prefs/__goPaywall/__checkout + hosts the Preferences and Paywall modals. Nav read-meter is now a paywall trigger. Gotchas: useEffect can't return a Set.delete unsub directly (boolean != cleanup); header grew to 124px so layout paddingTop + Slate sticky-top updated to match. 18 new tests. Backend 1872 -> 1890, 146 suites, zero regressions. Web build clean. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,96 @@
|
||||
/* ============================================================
|
||||
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,
|
||||
};
|
||||
Reference in New Issue
Block a user