Files
vyndr/src/services/correlationEngine.js
T
builtbykev 411cb6f196 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>
2026-03-21 12:45:15 -04:00

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 };