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