Session 23: All-day intelligence layer — schedule, game lines, streaks, hot lists, stat filtering, ParlayAPI dead (1567 tests)

This commit is contained in:
Kev
2026-06-12 11:16:58 -04:00
parent 6ab49d4c37
commit 0538205fab
32 changed files with 2276 additions and 2 deletions
+140
View File
@@ -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 },
};