141 lines
4.9 KiB
JavaScript
141 lines
4.9 KiB
JavaScript
/**
|
|
* 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 },
|
|
};
|