feat: Feature 2.1 — Parlay Scan with correlation detection + monetization

POST /api/scan/parlay — authenticated parlay analysis:
- Supabase JWT auth middleware (auth.getUser verification)
- 5 correlation types detected between legs (same_game, same_team,
  same_player_conflicting, positive_correlation, blowout_cascade)
- Overall parlay grading (A/B/C/D) with correlation penalty adjustments
- Free tier: 5 scans/month, atomic scan count increment
- Scan 5: full analysis + personalized upgrade pitch
- Scan 6+: 403 block with upgrade pitch
- Pitch personalization from scan history (top stats, grades, tier rec)
- DB writes: picks + scan_sessions per scan

30 new tests, 158 total (131 Node.js + 27 Python), all passing

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Kev
2026-03-21 12:45:15 -04:00
parent c8c0962e56
commit 411cb6f196
14 changed files with 1539 additions and 48 deletions
+102
View File
@@ -0,0 +1,102 @@
const { detectCorrelations } = require('../../src/services/correlationEngine');
function makeLeg(overrides = {}) {
return {
player: 'Nikola Jokic',
stat_type: 'points',
direction: 'over',
line: 26.5,
grade: 'B',
confidence: 70,
_team: 'DEN',
_gameTime: '2026-03-21T19:00:00Z',
reasoning: { steps: { season_avg: { value: 26.3 } } },
...overrides,
};
}
describe('correlationEngine', () => {
test('returns empty array when no correlations exist', () => {
const legs = [
makeLeg({ player: 'Nikola Jokic', _team: 'DEN', _gameTime: '2026-03-21T19:00:00Z' }),
makeLeg({ player: 'Jayson Tatum', _team: 'BOS', _gameTime: '2026-03-21T20:00:00Z' }),
];
const flags = detectCorrelations(legs, []);
expect(flags).toEqual([]);
});
test('detects same_game_opposing_players', () => {
const legs = [
makeLeg({ player: 'Nikola Jokic', stat_type: 'points', direction: 'over', _team: 'DEN', _gameTime: 'game1' }),
makeLeg({ player: 'LeBron James', stat_type: 'points', direction: 'over', _team: 'LAL', _gameTime: 'game1' }),
];
const flags = detectCorrelations(legs, []);
expect(flags).toHaveLength(1);
expect(flags[0].type).toBe('same_game_opposing_players');
expect(flags[0].impact).toBe('minor_negative');
});
test('detects same_game_same_team', () => {
const legs = [
makeLeg({ player: 'LeBron James', _team: 'LAL', _gameTime: 'game1' }),
makeLeg({ player: 'Anthony Davis', _team: 'LAL', _gameTime: 'game1' }),
];
const flags = detectCorrelations(legs, []);
expect(flags).toHaveLength(1);
expect(flags[0].type).toBe('same_game_same_team');
expect(flags[0].impact).toBe('minor_negative');
});
test('detects same_player_conflicting (opposite directions)', () => {
const legs = [
makeLeg({ player: 'Nikola Jokic', stat_type: 'points', direction: 'over' }),
makeLeg({ player: 'Nikola Jokic', stat_type: 'points', direction: 'under' }),
];
const flags = detectCorrelations(legs, []);
expect(flags).toHaveLength(1);
expect(flags[0].type).toBe('same_player_conflicting');
expect(flags[0].impact).toBe('major_negative');
});
test('detects positive_correlation (same player, complementary same direction)', () => {
const legs = [
makeLeg({ player: 'Nikola Jokic', stat_type: 'points', direction: 'over' }),
makeLeg({ player: 'Nikola Jokic', stat_type: 'rebounds', direction: 'over' }),
];
const flags = detectCorrelations(legs, []);
expect(flags).toHaveLength(1);
expect(flags[0].type).toBe('positive_correlation');
expect(flags[0].impact).toBe('positive');
});
test('detects blowout_cascade (2+ legs, high spread)', () => {
const legs = [
makeLeg({ player: 'Nikola Jokic', _team: 'DEN', _gameTime: 'game1' }),
makeLeg({ player: 'LeBron James', _team: 'LAL', _gameTime: 'game1', stat_type: 'rebounds' }),
];
const spreads = [
{ home_team: 'DEN', away_team: 'LAL', game_time: 'game1', home_spread: -12, book: 'draftkings' },
];
const flags = detectCorrelations(legs, spreads);
const cascade = flags.find((f) => f.type === 'blowout_cascade');
expect(cascade).toBeDefined();
expect(cascade.impact).toBe('major_negative');
});
test('handles single leg (no correlations possible)', () => {
const legs = [makeLeg()];
const flags = detectCorrelations(legs, []);
expect(flags).toEqual([]);
});
test('multiple correlations can fire simultaneously', () => {
const legs = [
makeLeg({ player: 'Nikola Jokic', stat_type: 'points', direction: 'over', _team: 'DEN', _gameTime: 'game1' }),
makeLeg({ player: 'LeBron James', stat_type: 'points', direction: 'over', _team: 'LAL', _gameTime: 'game1' }),
makeLeg({ player: 'Anthony Davis', stat_type: 'rebounds', direction: 'over', _team: 'LAL', _gameTime: 'game1' }),
];
const flags = detectCorrelations(legs, []);
// Jokic vs LeBron = same_game_opposing_players, LeBron vs AD = same_game_same_team
expect(flags.length).toBeGreaterThanOrEqual(2);
});
});
+74
View File
@@ -0,0 +1,74 @@
const { gradeParlayFromLegs } = require('../../src/services/parlayGrader');
function makeLeg(grade, confidence, composite) {
return { grade, confidence, _composite: composite };
}
describe('parlayGrader', () => {
test('grade A: high composite, no D legs, no major_negative', () => {
const legs = [makeLeg('A', 85, 3.5), makeLeg('A', 90, 3.0), makeLeg('B', 72, 2.0)];
const result = gradeParlayFromLegs(legs, []);
expect(result.grade).toBe('A');
expect(result.confidence).toBeGreaterThanOrEqual(80);
});
test('grade B: moderate composite, at most 1 D leg', () => {
const legs = [makeLeg('B', 70, 2.5), makeLeg('B', 65, 2.0), makeLeg('D', 35, 0.3)];
const result = gradeParlayFromLegs(legs, []);
expect(result.grade).toBe('B');
});
test('grade C: low positive composite', () => {
const legs = [makeLeg('C', 55, 0.8), makeLeg('C', 50, 0.6)];
const result = gradeParlayFromLegs(legs, []);
expect(result.grade).toBe('C');
});
test('grade D: negative composite', () => {
const legs = [makeLeg('D', 35, 0.1), makeLeg('D', 30, -0.5)];
const result = gradeParlayFromLegs(legs, []);
expect(result.grade).toBe('D');
});
test('2+ D legs forces grade D', () => {
const legs = [makeLeg('A', 90, 4.0), makeLeg('D', 35, 0.2), makeLeg('D', 30, 0.1)];
const result = gradeParlayFromLegs(legs, []);
expect(result.grade).toBe('D');
});
test('major_negative caps grade at B', () => {
const legs = [makeLeg('A', 90, 4.0), makeLeg('A', 85, 3.5)];
const flags = [{ type: 'same_player_conflicting', legs: [0, 1], impact: 'major_negative' }];
const result = gradeParlayFromLegs(legs, flags);
expect(['B', 'C', 'D']).toContain(result.grade);
});
test('minor_negative reduces composite by 0.3 each', () => {
const legs = [makeLeg('B', 70, 2.0), makeLeg('B', 68, 1.8)];
const withoutFlags = gradeParlayFromLegs(legs, []);
const withFlags = gradeParlayFromLegs(legs, [
{ type: 'same_game_same_team', legs: [0, 1], impact: 'minor_negative' },
]);
expect(withFlags.composite).toBeCloseTo(withoutFlags.composite - 0.3, 1);
});
test('confidence adjusted: -5 per minor, -15 per major', () => {
const legs = [makeLeg('B', 80, 2.0), makeLeg('B', 80, 2.0)];
const noFlags = gradeParlayFromLegs(legs, []);
const minorFlag = gradeParlayFromLegs(legs, [
{ type: 'test', impact: 'minor_negative' },
]);
const majorFlag = gradeParlayFromLegs(legs, [
{ type: 'test', impact: 'major_negative' },
]);
expect(minorFlag.confidence).toBe(noFlags.confidence - 5);
expect(majorFlag.confidence).toBe(noFlags.confidence - 15);
});
test('confidence clamped to 30-95', () => {
const legs = [makeLeg('D', 35, 0.1)];
const manyFlags = Array(5).fill({ type: 'test', impact: 'major_negative' });
const result = gradeParlayFromLegs(legs, manyFlags);
expect(result.confidence).toBe(30);
});
});
+88
View File
@@ -0,0 +1,88 @@
const { generateUpgradePitch } = require('../../src/services/upgradePitch');
function makeMockSupabase(sessions = [], picks = []) {
return {
from: (table) => ({
select: () => ({
eq: () => ({
order: () => ({
limit: () => Promise.resolve({ data: table === 'scan_sessions' ? sessions : picks }),
}),
single: () => Promise.resolve({ data: null }),
}),
}),
}),
};
}
describe('upgradePitch', () => {
test('generates pitch with correct scan count', async () => {
const sessions = [
{ id: '1', final_grade: 'A', legs: ['a', 'b'], created_at: '2026-03-20' },
{ id: '2', final_grade: 'B', legs: ['c', 'd'], created_at: '2026-03-19' },
{ id: '3', final_grade: 'D', legs: ['e'], created_at: '2026-03-18' },
{ id: '4', final_grade: 'C', legs: ['f', 'g'], created_at: '2026-03-17' },
];
const picks = [
{ stat_type: 'points', direction: 'over', grade: 'A', player: 'Jokic' },
{ stat_type: 'points', direction: 'over', grade: 'B', player: 'Jokic' },
{ stat_type: 'rebounds', direction: 'over', grade: 'C', player: 'LeBron' },
];
const supabase = makeMockSupabase(sessions, picks);
const result = await generateUpgradePitch(supabase, 'user-1', { grade: 'B', legs: [] });
expect(result.hook).toContain('5'); // 4 prior + 1 current
expect(result.insight).toContain('points');
expect(result.cta).toContain('founder rate');
expect(result.tier_recommended).toBeDefined();
expect(result.founder_price).toBeDefined();
});
test('recommends analyst for casual bettors (avg <= 4 legs)', async () => {
const sessions = [
{ id: '1', final_grade: 'B', legs: ['a', 'b'], created_at: '2026-03-20' },
{ id: '2', final_grade: 'B', legs: ['c', 'd'], created_at: '2026-03-19' },
];
const picks = [
{ stat_type: 'points', direction: 'over', grade: 'B', player: 'Jokic' },
];
const supabase = makeMockSupabase(sessions, picks);
const result = await generateUpgradePitch(supabase, 'user-1', { grade: 'A', legs: [{ stat_type: 'points' }] });
expect(result.tier_recommended).toBe('analyst');
expect(result.founder_price).toBe('$14.99/mo');
});
test('recommends desk for power users (avg > 4 legs)', async () => {
const sessions = [
{ id: '1', final_grade: 'B', legs: ['a', 'b', 'c', 'd', 'e'], created_at: '2026-03-20' },
{ id: '2', final_grade: 'A', legs: ['f', 'g', 'h', 'i', 'j', 'k'], created_at: '2026-03-19' },
];
const picks = [
{ stat_type: 'points', direction: 'over', grade: 'A', player: 'Jokic' },
{ stat_type: 'points', direction: 'over', grade: 'B', player: 'LeBron' },
{ stat_type: 'rebounds', direction: 'over', grade: 'A', player: 'Giannis' },
{ stat_type: 'assists', direction: 'over', grade: 'B', player: 'Trae' },
{ stat_type: 'points', direction: 'over', grade: 'B', player: 'Luka' },
];
const supabase = makeMockSupabase(sessions, picks);
const result = await generateUpgradePitch(supabase, 'user-1', {
grade: 'B',
legs: [{ stat_type: 'points' }, { stat_type: 'rebounds' }, { stat_type: 'assists' }, { stat_type: 'points' }, { stat_type: 'steals' }],
});
expect(result.tier_recommended).toBe('desk');
expect(result.founder_price).toBe('$34.99/mo');
});
test('handles no prior scans gracefully', async () => {
const supabase = makeMockSupabase([], []);
const result = await generateUpgradePitch(supabase, 'user-1', { grade: 'C', legs: [{ stat_type: 'points' }] });
expect(result.hook).toContain('1'); // just the current scan
expect(result.tier_recommended).toBeDefined();
});
});