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:
@@ -0,0 +1,85 @@
|
||||
// Session 50 — Parlay Lab correlation-score model.
|
||||
|
||||
const svc = require('../../src/services/parlayService');
|
||||
|
||||
const leg = (over) => ({ player: 'P', team: 'NYY', game: 'NYY@BOS', stat: 'hits', grade: 'A', ...over });
|
||||
|
||||
describe('correlationScore', () => {
|
||||
it('same player, same game → 0.7', () => {
|
||||
expect(svc.correlationScore(leg({ stat: 'hits' }), leg({ stat: 'total_bases' }))).toBe(0.7);
|
||||
});
|
||||
it('same team, same game, different players → 0.4', () => {
|
||||
expect(svc.correlationScore(leg({ player: 'A' }), leg({ player: 'B' }))).toBe(0.4);
|
||||
});
|
||||
it('same game, different teams → 0.2', () => {
|
||||
expect(svc.correlationScore(leg({ player: 'A', team: 'NYY' }), leg({ player: 'B', team: 'BOS' }))).toBe(0.2);
|
||||
});
|
||||
it('different games → 0.0', () => {
|
||||
expect(svc.correlationScore(leg({ game: 'NYY@BOS' }), leg({ game: 'LAD@SF', player: 'B', team: 'LAD' }))).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe('combinedGrade', () => {
|
||||
it('3 independent A legs → A (no penalty)', () => {
|
||||
const r = svc.combinedGrade([
|
||||
{ grade: 'A', game: 'g1', team: 't1', player: 'a' },
|
||||
{ grade: 'A', game: 'g2', team: 't2', player: 'b' },
|
||||
{ grade: 'A', game: 'g3', team: 't3', player: 'c' },
|
||||
]);
|
||||
expect(r.grade).toBe('A');
|
||||
expect(r.penalty).toBe(0);
|
||||
});
|
||||
it('3 same-team same-game A legs → penalized below A', () => {
|
||||
const r = svc.combinedGrade([
|
||||
{ grade: 'A', game: 'g1', team: 'NYY', player: 'a' },
|
||||
{ grade: 'A', game: 'g1', team: 'NYY', player: 'b' },
|
||||
{ grade: 'A', game: 'g1', team: 'NYY', player: 'c' },
|
||||
]);
|
||||
expect(r.avgCorrelation).toBe(0.4);
|
||||
expect(r.penalty).toBeGreaterThan(0);
|
||||
expect(['B+', 'B', 'B-', 'A-']).toContain(r.grade);
|
||||
expect(svc.gradeScore(r.grade)).toBeLessThan(svc.gradeScore('A'));
|
||||
});
|
||||
});
|
||||
|
||||
describe('estimatedPayout', () => {
|
||||
it('returns multiplier + discount for a 3-leg parlay', () => {
|
||||
const r = svc.estimatedPayout([{ grade: 'A' }, { grade: 'B+' }, { grade: 'A' }], 10);
|
||||
expect(r.fairMultiplier).toBeCloseTo(1.25 * 1.45 * 1.25, 2);
|
||||
expect(r.correlationDiscount).toBe(1); // independent
|
||||
expect(r.payout).toBeGreaterThan(10);
|
||||
});
|
||||
it('discounts correlated slips', () => {
|
||||
const corr = svc.estimatedPayout([{ grade: 'A', game: 'g', team: 'NYY', player: 'a' }, { grade: 'A', game: 'g', team: 'NYY', player: 'b' }], 10);
|
||||
expect(corr.correlationDiscount).toBeLessThan(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe('correlationWarning', () => {
|
||||
it('flags 2+ legs from the same team', () => {
|
||||
const w = svc.correlationWarning([
|
||||
{ game: 'g', team: 'PHI', player: 'a' }, { game: 'g', team: 'PHI', player: 'b' }, { game: 'g2', team: 'NYY', player: 'c' },
|
||||
]);
|
||||
expect(w).toBe('⚠ 2 legs from PHI — high correlation');
|
||||
});
|
||||
it('flags same-game legs on different teams', () => {
|
||||
expect(svc.correlationWarning([{ game: 'g', team: 'A', player: 'x' }, { game: 'g', team: 'B', player: 'y' }]))
|
||||
.toBe('⚠ 2 legs from the same game — correlated');
|
||||
});
|
||||
it('null when all legs are independent', () => {
|
||||
expect(svc.correlationWarning([{ game: 'g1', team: 'A', player: 'x' }, { game: 'g2', team: 'B', player: 'y' }])).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('gradeParlay (full analysis)', () => {
|
||||
it('returns combined + correlation + payout + legs', () => {
|
||||
const r = svc.gradeParlay([
|
||||
{ grade: 'A', game: 'g', team: 'NYY', player: 'a', stat: 'hits', line: 1.5 },
|
||||
{ grade: 'B+', game: 'g', team: 'NYY', player: 'b', stat: 'tb', line: 1.5 },
|
||||
], 10);
|
||||
expect(r.combined.grade).toBeTruthy();
|
||||
expect(r.correlation.warning).toContain('NYY');
|
||||
expect(r.payout.amount).toBeGreaterThan(0);
|
||||
expect(r.legs).toHaveLength(2);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,96 @@
|
||||
// Session 50 — Parlay Lab UI: context behaviors + components (source-asserted,
|
||||
// matching the repo's frontend test pattern) + legKey logic.
|
||||
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
const WEB = path.join(__dirname, '..', '..', 'web', 'src');
|
||||
const read = (rel) => fs.readFileSync(path.join(WEB, rel), 'utf8');
|
||||
|
||||
describe('ParlayContext (Session 50 extensions)', () => {
|
||||
const src = read('contexts/ParlayContext.tsx');
|
||||
it('legs carry team/game/archetype for the correlation model', () => {
|
||||
expect(src).toContain('team?: string;');
|
||||
expect(src).toContain('game?: string;');
|
||||
expect(src).toContain('archetype?: string;');
|
||||
});
|
||||
it('addLeg dedupes by player|stat|line|direction and caps at maxLegs', () => {
|
||||
expect(src).toContain('legKey');
|
||||
expect(src).toContain('prev.length >= maxLegsRef.current');
|
||||
});
|
||||
it('auto-grades via /api/parlay/grade when legs >= 2', () => {
|
||||
expect(src).toContain("fetch('/api/parlay/grade'");
|
||||
expect(src).toContain('if (legs.length < 2)');
|
||||
expect(src).toContain('setCombined');
|
||||
});
|
||||
it('exposes combined/correlation/payout + tier cap', () => {
|
||||
for (const k of ['combined', 'correlation', 'payout', 'maxLegs', 'setMaxLegs', 'atCap', 'hasLeg']) {
|
||||
expect(src).toContain(k);
|
||||
}
|
||||
});
|
||||
it('removeLeg + clear reset state', () => {
|
||||
expect(src).toContain('const removeLeg');
|
||||
expect(src).toContain('const clear = useCallback(() => setLegs([])');
|
||||
});
|
||||
});
|
||||
|
||||
describe('StatStrip "+" button', () => {
|
||||
const src = read('components/vyndr/StatStrip.tsx');
|
||||
it('renders an add/remove parlay button on graded props', () => {
|
||||
expect(src).toContain('onAddLeg');
|
||||
expect(src).toContain('isLegActive');
|
||||
expect(src).toContain('<ParlayBtn p={p} />');
|
||||
expect(src).toContain("active ? '✓' : '+'");
|
||||
});
|
||||
});
|
||||
|
||||
describe('GameCard wires the "+" to the parlay', () => {
|
||||
const src = read('components/vyndr/GameCard.tsx');
|
||||
it('uses useParlay and builds a leg with team + game', () => {
|
||||
expect(src).toContain('useParlay');
|
||||
expect(src).toContain('stripHandlers');
|
||||
expect(src).toContain('game: gameId');
|
||||
});
|
||||
it('toggles the leg (add if absent, remove if present)', () => {
|
||||
expect(src).toContain('if (existing) removeLeg(existing.id); else addLeg(leg);');
|
||||
});
|
||||
});
|
||||
|
||||
describe('GradeResultCard add-to-parlay (scan page)', () => {
|
||||
it('GradeResultCard has the Add to Parlay action', () => {
|
||||
expect(read('components/vyndr/GradeResultCard.tsx')).toContain('Add to Parlay');
|
||||
});
|
||||
it('scan page wires onAddToParlay → addLeg', () => {
|
||||
const src = read('app/scan/page.tsx');
|
||||
expect(src).toContain('onAddToParlay');
|
||||
expect(src).toContain('addLeg(');
|
||||
});
|
||||
});
|
||||
|
||||
describe('ParlayPanel', () => {
|
||||
const src = read('components/vyndr/ParlayPanel.tsx');
|
||||
it('renders legs (player + grade), combined grade, correlation warning, payout', () => {
|
||||
expect(src).toContain('PARLAY LAB');
|
||||
expect(src).toContain('COMBINED GRADE');
|
||||
expect(src).toContain('correlation?.warning');
|
||||
expect(src).toContain('EST. PAYOUT');
|
||||
});
|
||||
it('floating badge with leg count when closed', () => {
|
||||
expect(src).toContain('Open Parlay Lab');
|
||||
expect(src).toContain('{legs.length}');
|
||||
});
|
||||
it('tier-gates: free blurs payout + caps at 2, desk/analyst full', () => {
|
||||
expect(src).toContain('TIER_MAX = { free: 2, analyst: 4, desk: 6 }');
|
||||
expect(src).toContain("filter: 'blur(6px)'");
|
||||
expect(src).toContain('__goPaywall');
|
||||
expect(src).toContain('setMaxLegs(tierMaxLegs');
|
||||
});
|
||||
it('CLEAR ALL resets the slip', () => {
|
||||
expect(src).toContain('CLEAR ALL');
|
||||
expect(src).toContain('onClick={clear}');
|
||||
});
|
||||
it('is mounted globally in the layout', () => {
|
||||
const layout = read('app/layout.tsx');
|
||||
expect(layout).toContain('<ParlayPanel />');
|
||||
expect(layout).not.toContain('<ParlayTray />');
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user