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:
@@ -0,0 +1,126 @@
|
||||
function detectCorrelations(analyzedLegs, spreads) {
|
||||
const flags = [];
|
||||
|
||||
for (let i = 0; i < analyzedLegs.length; i++) {
|
||||
for (let j = i + 1; j < analyzedLegs.length; j++) {
|
||||
const a = analyzedLegs[i];
|
||||
const b = analyzedLegs[j];
|
||||
|
||||
const aGame = a.reasoning?.steps?.season_avg ? getGameKey(a) : null;
|
||||
const bGame = b.reasoning?.steps?.season_avg ? getGameKey(b) : null;
|
||||
const sameGame = aGame && bGame && aGame === bGame;
|
||||
|
||||
const aTeam = a._team;
|
||||
const bTeam = b._team;
|
||||
|
||||
// 1. same_player_conflicting
|
||||
if (a.player.toLowerCase() === b.player.toLowerCase()) {
|
||||
if (isConflicting(a, b)) {
|
||||
flags.push({
|
||||
type: 'same_player_conflicting',
|
||||
legs: [i, j],
|
||||
detail: `${a.player}: ${a.stat_type} ${a.direction} conflicts with ${b.stat_type} ${b.direction}`,
|
||||
impact: 'major_negative',
|
||||
});
|
||||
} else {
|
||||
// 4. positive_correlation (same player, complementary)
|
||||
flags.push({
|
||||
type: 'positive_correlation',
|
||||
legs: [i, j],
|
||||
detail: `${a.player}: ${a.stat_type} ${a.direction} + ${b.stat_type} ${b.direction} are correlated`,
|
||||
impact: 'positive',
|
||||
});
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
if (!sameGame) continue;
|
||||
|
||||
// 2. same_game_same_team
|
||||
if (aTeam && bTeam && aTeam === bTeam) {
|
||||
flags.push({
|
||||
type: 'same_game_same_team',
|
||||
legs: [i, j],
|
||||
detail: `${a.player} and ${b.player} are both ${aTeam} — usage overlap possible`,
|
||||
impact: 'minor_negative',
|
||||
});
|
||||
continue;
|
||||
}
|
||||
|
||||
// 3. same_game_opposing_players
|
||||
if (a.stat_type === b.stat_type && a.direction === 'over' && b.direction === 'over') {
|
||||
flags.push({
|
||||
type: 'same_game_opposing_players',
|
||||
legs: [i, j],
|
||||
detail: `${a.player} and ${b.player} in same game, both ${a.stat_type} overs`,
|
||||
impact: 'minor_negative',
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 5. blowout_cascade — 2+ legs from a high-spread game
|
||||
const gameLegs = groupByGame(analyzedLegs);
|
||||
for (const [gameKey, indices] of Object.entries(gameLegs)) {
|
||||
if (indices.length < 2) continue;
|
||||
const gameSpread = findSpreadForGame(analyzedLegs[indices[0]], spreads);
|
||||
if (gameSpread != null && Math.abs(gameSpread) > 8) {
|
||||
flags.push({
|
||||
type: 'blowout_cascade',
|
||||
legs: indices,
|
||||
detail: `${indices.length} legs from a game with ${gameSpread > 0 ? '+' : ''}${gameSpread} spread — blowout risk compounds`,
|
||||
impact: 'major_negative',
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return flags;
|
||||
}
|
||||
|
||||
function getGameKey(leg) {
|
||||
// Use home_team + away_team from reasoning to identify the game
|
||||
const sit = leg.reasoning?.steps?.situational;
|
||||
const lineCmp = leg.reasoning?.steps?.line_comparison;
|
||||
// Fallback: use the first line's game context
|
||||
// We'll use _gameTime attached by the scan service
|
||||
return leg._gameTime || null;
|
||||
}
|
||||
|
||||
function isConflicting(a, b) {
|
||||
// Same player, opposite directions on related stats
|
||||
if (a.direction !== b.direction) return true;
|
||||
|
||||
// Over points + under PRA (points is component of PRA)
|
||||
const complementary = [
|
||||
['points', 'pra'], ['rebounds', 'pra'], ['assists', 'pra'],
|
||||
];
|
||||
for (const [s1, s2] of complementary) {
|
||||
if ((a.stat_type === s1 && b.stat_type === s2) || (a.stat_type === s2 && b.stat_type === s1)) {
|
||||
if (a.direction !== b.direction) return true;
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
function groupByGame(legs) {
|
||||
const groups = {};
|
||||
for (let i = 0; i < legs.length; i++) {
|
||||
const key = legs[i]._gameTime;
|
||||
if (!key) continue;
|
||||
if (!groups[key]) groups[key] = [];
|
||||
groups[key].push(i);
|
||||
}
|
||||
return groups;
|
||||
}
|
||||
|
||||
function findSpreadForGame(leg, spreads) {
|
||||
if (!spreads || !leg._team) return null;
|
||||
const spread = spreads.find((s) =>
|
||||
s.home_team === leg._team || s.away_team === leg._team
|
||||
);
|
||||
if (!spread) return null;
|
||||
return spread.home_spread;
|
||||
}
|
||||
|
||||
module.exports = { detectCorrelations };
|
||||
Reference in New Issue
Block a user