411cb6f196
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>
127 lines
3.9 KiB
JavaScript
127 lines
3.9 KiB
JavaScript
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 };
|