/** * Hot lists (Session 23). * * Rolling recent-window leaders — but through VYNDR's lens. "Hot" does * NOT mean "highest raw number." It means performing ABOVE the player's * own baseline in the recent window. A 20-PPG player going 28/31/25 is * hot; a 30-PPG player who dropped 28 is not. * * Baseline preference: * 1. explicit `player.seasonAvg[stat]` if supplied * 2. else the player's own games OUTSIDE the recent window (recent vs rest) * * If neither baseline is available (a player with only window-length * history and no season avg) the player is excluded — we can't claim * "trending up" without something to trend against. * * Pure & deterministic. The route supplies cached logs; this does math. */ const { __internals } = require('./streaksService'); const { nba, mlb, soccer } = __internals; // category → accessor fn, per sport. Mirrors STAT_FILTERS categories. const HOT_STATS = { nba: { points: nba.points, rebounds: nba.rebounds, assists: nba.assists, threes: nba.threes, blocks: nba.blocks, steals: nba.steals, pra: nba.pra, }, wnba: { points: nba.points, rebounds: nba.rebounds, assists: nba.assists, threes: nba.threes, blocks: nba.blocks, steals: nba.steals, }, mlb: { hits: mlb.hits, home_runs: mlb.homeRuns, stolen_bases: mlb.stolenBases, rbis: mlb.rbi, total_bases: mlb.totalBases, strikeouts: mlb.strikeouts, on_base: mlb.onBase, }, soccer: { goals: soccer.goals, assists: soccer.assists, shots: soccer.shotsOnTarget, }, }; // Headline stat per sport when the caller asks for 'all'. const DEFAULT_STAT = { nba: 'points', wnba: 'points', mlb: 'hits', soccer: 'goals' }; const STAT_LABEL = { points: 'pts', rebounds: 'reb', assists: 'ast', threes: '3PM', blocks: 'blk', steals: 'stl', pra: 'PRA', hits: 'H', home_runs: 'HR', stolen_bases: 'SB', rbis: 'RBI', total_bases: 'TB', strikeouts: 'K', on_base: 'OB', goals: 'G', shots: 'SOT', }; function mean(rows, fn) { if (!rows.length) return 0; return rows.reduce((acc, r) => acc + fn(r), 0) / rows.length; } function round1(n) { return Math.round(n * 10) / 10; } function resolveStat(sport, stat) { const table = HOT_STATS[sport] || {}; if (!stat || stat === 'all') return DEFAULT_STAT[sport] || Object.keys(table)[0] || null; return table[stat] ? stat : null; } /** * Returns a ranked list of hot players for one stat. * players = [{ name, playerId, team, games, seasonAvg? }] * opts = { stat, window=7, limit, now, windowDays } * * When rows carry a `date` and `windowDays`+`now` are supplied, the recent * window is date-based; otherwise it's the last `window` games. */ function computeHotList(players, sport, opts = {}) { const key = String(sport || '').toLowerCase(); const stat = resolveStat(key, opts.stat); if (!stat || !Array.isArray(players)) return []; const fn = HOT_STATS[key][stat]; const window = opts.window && opts.window > 0 ? opts.window : 7; const rows = []; for (const p of players) { const games = Array.isArray(p?.games) ? p.games.slice() : []; if (games.length === 0) continue; if (opts.chronological) games.reverse(); let recent; let rest; if (opts.windowDays && opts.now && games[0]?.date) { const cutoff = opts.now - opts.windowDays * 86_400_000; recent = games.filter((g) => new Date(g.date).getTime() >= cutoff); rest = games.filter((g) => new Date(g.date).getTime() < cutoff); } else { recent = games.slice(0, window); rest = games.slice(window); } if (recent.length === 0) continue; const recentAvg = mean(recent, fn); // Baseline: explicit season avg, else the player's older games. let baseline = null; const sa = p.seasonAvg && p.seasonAvg[stat]; if (sa !== undefined && sa !== null && Number.isFinite(Number(sa))) { baseline = Number(sa); } else if (rest.length > 0) { baseline = mean(rest, fn); } if (baseline === null) continue; // nothing to trend against if (recentAvg <= baseline) continue; // not hot — at or below baseline const delta = recentAvg - baseline; rows.push({ sport: key, stat, name: p.name || p.player || null, playerId: p.playerId ?? p.id ?? null, team: p.team || null, recentAvg: round1(recentAvg), baseline: round1(baseline), delta: round1(delta), window: recent.length, statLine: `${round1(recentAvg)} ${STAT_LABEL[stat] || stat} over last ${recent.length}`, trendDescription: `+${round1(delta)} above ${round1(baseline)} avg`, }); } // Rank by how far above baseline (the "trending" signal), then by raw // recent average as the tie-breaker (secondary stat). rows.sort((a, b) => (b.delta - a.delta) || (b.recentAvg - a.recentAvg)); const limited = opts.limit && opts.limit > 0 ? rows.slice(0, opts.limit) : rows; return limited.map((r, i) => ({ rank: i + 1, ...r })); } module.exports = { computeHotList, resolveStat, __internals: { HOT_STATS, DEFAULT_STAT, mean }, };