Session 23: All-day intelligence layer — schedule, game lines, streaks, hot lists, stat filtering, ParlayAPI dead (1567 tests)
This commit is contained in:
@@ -0,0 +1,140 @@
|
||||
/**
|
||||
* 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 },
|
||||
};
|
||||
Reference in New Issue
Block a user