const { getOdds } = require('./oddsService'); const nbaStats = require('./nbaStatsClient'); const { evaluateKillConditions } = require('./killConditions'); const { computeGrade } = require('./grader'); const { deltaToSignal, directedDelta } = require('../utils/signals'); async function analyzeProp({ player, stat_type, line, direction, book }) { // Fetch all data in parallel const [oddsResult, seasonAvg, lastN, homeAwaySplit, restSplit] = await Promise.all([ getOdds('nba'), nbaStats.getSeasonAvg(player), nbaStats.getLastN(player, 10), nbaStats.getSplits(player, stat_type, 'home_away'), nbaStats.getSplits(player, stat_type, 'rest_days'), ]); // Determine opponent from odds data const playerProps = oddsResult.props.filter( (p) => p.player.toLowerCase().includes(player.toLowerCase()) && p.stat_type === stat_type ); let opponent = null; let isHome = null; if (playerProps.length > 0) { const prop = playerProps[0]; // We have home_team and away_team but don't know which the player belongs to // Use NBA stats team to determine const playerTeam = seasonAvg?.team; if (playerTeam) { if (playerTeam === prop.home_team) { isHome = true; opponent = prop.away_team; } else if (playerTeam === prop.away_team) { isHome = false; opponent = prop.home_team; } } } // Fetch vs-opponent split if we know the opponent let vsOpponentSplit = null; if (opponent) { try { vsOpponentSplit = await nbaStats.getSplits(player, stat_type, 'vs_team', opponent); } catch (_) { // No opponent data available } } // Find game spread let spread = null; if (oddsResult.spreads && oddsResult.spreads.length > 0) { const gameSpread = oddsResult.spreads.find((s) => { const playerTeam = seasonAvg?.team; return playerTeam && (s.home_team === playerTeam || s.away_team === playerTeam); }); if (gameSpread) { // home_spread is from the home team's perspective const playerTeam = seasonAvg?.team; if (playerTeam === gameSpread.home_team) { spread = gameSpread.home_spread; } else { spread = -gameSpread.home_spread; } } } const seasonStatVal = seasonAvg?.stats?.[stat_type]; const recentStatVal = lastN?.stats?.[stat_type]; // Step 1: Season average compare const seasonDelta = seasonStatVal != null ? directedDelta(seasonStatVal, line, direction) : 0; const seasonSignal = deltaToSignal(seasonDelta); // Step 2: Recent form (last 10) const recentDelta = recentStatVal != null ? directedDelta(recentStatVal, line, direction) : 0; const recentSignal = deltaToSignal(recentDelta); // Step 3: Situational factors const homeAwayData = homeAwaySplit?.splits; let situationalAvg = null; let homeAwaySignal = 'neutral'; let homeAwayContext = null; if (homeAwayData && isHome != null) { const relevantSplit = isHome ? homeAwayData.home : homeAwayData.away; if (relevantSplit) { situationalAvg = relevantSplit.avg; homeAwayContext = isHome ? 'home' : 'away'; homeAwaySignal = deltaToSignal(directedDelta(relevantSplit.avg, line, direction)); } } // Rest days / B2B const restData = restSplit?.splits; let restSignal = 'neutral'; let restContext = null; let restAvg = null; let isB2B = false; if (restData) { // Determine current rest status from last game date in lastN // For now, use overall rest data — B2B detection would need schedule info // Use the b2b split if games > 0 as an indicator if (restData.b2b && restData.b2b.games > 0) { restAvg = restData.b2b.avg; restContext = 'b2b'; // Check if current game is B2B (heuristic: if b2b games exist, flag it) // True B2B detection needs schedule — we'll flag when b2b avg is significantly different isB2B = false; // Conservative: only flag if we can confirm } if (restData['1_day_rest'] && restData['1_day_rest'].games > 0 && !restAvg) { restAvg = restData['1_day_rest'].avg; restContext = '1_day_rest'; } if (restAvg != null) { restSignal = deltaToSignal(directedDelta(restAvg, line, direction)); } } // Vs opponent let vsOpponentSignal = 'neutral'; let vsOpponentAvg = null; let vsOpponentGames = 0; if (vsOpponentSplit?.splits?.vs_opponent) { vsOpponentAvg = vsOpponentSplit.splits.vs_opponent.avg; vsOpponentGames = vsOpponentSplit.splits.vs_opponent.games; vsOpponentSignal = deltaToSignal(directedDelta(vsOpponentAvg, line, direction)); } // Step 4: Cross-book line comparison const allLines = playerProps.map((p) => ({ book: p.book, line: p.line })); // Also check grouped props from odds response (they may be grouped by player) let bestLine = null; let worstLine = null; let lineEdge = 0; if (allLines.length > 0) { if (direction === 'over') { // For over, lowest line is best bestLine = allLines.reduce((a, b) => (a.line < b.line ? a : b)); worstLine = allLines.reduce((a, b) => (a.line > b.line ? a : b)); } else { // For under, highest line is best bestLine = allLines.reduce((a, b) => (a.line > b.line ? a : b)); worstLine = allLines.reduce((a, b) => (a.line < b.line ? a : b)); } lineEdge = Math.abs(bestLine.line - worstLine.line); } const lineSignal = deltaToSignal(lineEdge); // Compute situational delta (weighted average of available splits) const sitDeltas = []; if (situationalAvg != null) sitDeltas.push(directedDelta(situationalAvg, line, direction)); if (restAvg != null) sitDeltas.push(directedDelta(restAvg, line, direction)); if (vsOpponentAvg != null) sitDeltas.push(directedDelta(vsOpponentAvg, line, direction)); const situationalDelta = sitDeltas.length > 0 ? sitDeltas.reduce((a, b) => a + b, 0) / sitDeltas.length : 0; // Step 5: Kill conditions const killConditions = evaluateKillConditions({ seasonStats: seasonAvg?.stats, recentStats: recentStatVal != null ? { value: recentStatVal } : null, homeAwaySplit: situationalAvg != null ? { avg: situationalAvg } : null, restSplit: { isB2B }, vsOpponentSplit: vsOpponentAvg != null ? { games: vsOpponentGames } : null, spread, }); // Step 6: Grade const seasonAndRecentAgree = (seasonDelta > 0 && recentDelta > 0) || (seasonDelta < 0 && recentDelta < 0); const { grade, confidence, composite } = computeGrade({ seasonDelta, recentDelta, situationalDelta, lineEdge, killConditions, gamesPlayed: seasonAvg?.stats?.games_played || 0, seasonAndRecentAgree: seasonDelta !== 0 && recentDelta !== 0 ? seasonAndRecentAgree : null, }); // Edge percentage const relevantAvg = recentStatVal || seasonStatVal || line; const edgePct = direction === 'over' ? Math.round(((relevantAvg - line) / line) * 1000) / 10 : Math.round(((line - relevantAvg) / line) * 1000) / 10; // Build reasoning summary const parts = []; if (seasonStatVal != null) parts.push(`${player} averages ${seasonStatVal} on the season`); if (recentStatVal != null && recentStatVal !== seasonStatVal) parts.push(`${recentStatVal} over his last 10`); if (homeAwayContext && situationalAvg != null) parts.push(`${situationalAvg} ${homeAwayContext === 'home' ? 'at home' : 'on the road'}`); if (vsOpponentAvg != null && opponent) parts.push(`${vsOpponentAvg} vs ${opponent} (${vsOpponentGames} games)`); if (killConditions.length > 0) parts.push(`Kill conditions: ${killConditions.map((k) => k.code).join(', ')}`); if (killConditions.length === 0) parts.push('No kill conditions'); return { player, stat_type, line, direction, book, grade, edge_pct: edgePct, confidence, kill_conditions_triggered: killConditions, reasoning: { summary: parts.join('. ') + '.', steps: { season_avg: { value: seasonStatVal ?? null, vs_line: seasonStatVal != null ? Math.round((seasonStatVal - line) * 10) / 10 : null, signal: seasonSignal, }, recent_form: { value: recentStatVal ?? null, vs_line: recentStatVal != null ? Math.round((recentStatVal - line) * 10) / 10 : null, signal: recentSignal, }, situational: { home_away: { value: situationalAvg, context: homeAwayContext, signal: homeAwaySignal, }, rest_days: { value: restAvg, context: restContext, signal: restSignal, }, vs_opponent: { value: vsOpponentAvg, games: vsOpponentGames, signal: vsOpponentSignal, }, }, line_comparison: { best_line: bestLine, worst_line: worstLine, edge_from_best: lineEdge, signal: lineSignal, }, kill_conditions: killConditions, final_grade: grade, }, }, }; } module.exports = { analyzeProp };