Files
vyndr/src/services/streaksService.js
T

254 lines
12 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* Streaks engine (Session 23).
*
* Computes player streaks from cached game-log data. Everything analyzed
* through VYNDR's lens — not "Wemby 31 PPG" but "Wemby on a 4-game 28+
* scoring streak." A streak is a CONSECUTIVE run of recent games meeting
* a threshold; we count from the most recent game backward and stop at
* the first miss.
*
* Pure & deterministic. `computePlayerStreaks` operates on one player's
* game array; `computeStreaks` fans out across a roster and returns a
* flat, sorted, optionally stat-filtered list. NO API calls live here —
* the route layer supplies cached logs.
*
* Game logs are expected MOST-RECENT-FIRST (index 0 = latest). Pass
* `{ chronological: true }` to reverse oldest-first input.
*/
// ---- defensive numeric field reader -------------------------------------
function num(row, ...keys) {
if (!row) return 0;
for (const k of keys) {
if (row[k] !== undefined && row[k] !== null && row[k] !== '') {
const n = Number(row[k]);
if (Number.isFinite(n)) return n;
}
}
return 0;
}
// NBA/WNBA stat accessors — tolerate the several field spellings the
// Python stats service and Tank01 use.
const nba = {
points: (r) => num(r, 'points', 'pts', 'PTS'),
rebounds: (r) => num(r, 'rebounds', 'reb', 'REB', 'totReb'),
assists: (r) => num(r, 'assists', 'ast', 'AST'),
threes: (r) => num(r, 'threes', 'threes_made', 'fg3m', 'tptfgm', 'threePointersMade'),
blocks: (r) => num(r, 'blocks', 'blk', 'BLK'),
steals: (r) => num(r, 'steals', 'stl', 'STL'),
fgPct: (r) => {
const pct = num(r, 'fg_pct', 'fgPct', 'fieldGoalPct');
if (pct > 0) return pct > 1 ? pct / 100 : pct; // accept 01 or 0100
const m = num(r, 'fgm', 'field_goals_made');
const a = num(r, 'fga', 'field_goals_attempted');
return a > 0 ? m / a : 0;
},
};
nba.pra = (r) => nba.points(r) + nba.rebounds(r) + nba.assists(r);
nba.doubleCount = (r) =>
[nba.points(r), nba.rebounds(r), nba.assists(r), nba.steals(r), nba.blocks(r)]
.filter((v) => v >= 10).length;
const mlb = {
hits: (r) => num(r, 'hits', 'H', 'h'),
homeRuns: (r) => num(r, 'homeRuns', 'home_runs', 'HR', 'hr'),
stolenBases: (r) => num(r, 'stolenBases', 'stolen_bases', 'SB', 'sb'),
rbi: (r) => num(r, 'rbi', 'RBI'),
walks: (r) => num(r, 'walks', 'baseOnBalls', 'BB', 'bb'),
hbp: (r) => num(r, 'hitByPitch', 'hbp', 'HBP'),
totalBases: (r) => num(r, 'totalBases', 'total_bases', 'TB'),
strikeouts: (r) => num(r, 'strikeOuts', 'strikeouts', 'pitcherK', 'K', 'so'),
inningsPitched: (r) => num(r, 'inningsPitched', 'ip', 'IP'),
earnedRuns: (r) => num(r, 'earnedRuns', 'er', 'ER'),
};
mlb.onBase = (r) => mlb.hits(r) + mlb.walks(r) + mlb.hbp(r);
mlb.isQualityStart = (r) => mlb.inningsPitched(r) >= 6 && mlb.earnedRuns(r) <= 3;
const nfl = {
passTd: (r) => num(r, 'passTD', 'passing_touchdowns', 'pass_td'),
rushTd: (r) => num(r, 'rushTD', 'rushing_touchdowns', 'rush_td'),
recTd: (r) => num(r, 'recTD', 'receiving_touchdowns', 'rec_td'),
rushYds:(r) => num(r, 'rushYds', 'rushing_yards', 'rush_yards'),
recYds: (r) => num(r, 'recYds', 'receiving_yards', 'rec_yards'),
ints: (r) => num(r, 'interceptions', 'int', 'passInt'),
};
nfl.anyTd = (r) => nfl.passTd(r) + nfl.rushTd(r) + nfl.recTd(r);
const soccer = {
goals: (r) => num(r, 'goals', 'G'),
assists: (r) => num(r, 'assists', 'A'),
shotsOnTarget: (r) => num(r, 'shotsOnTarget', 'shots_on_target', 'sot'),
goalsConceded: (r) => num(r, 'goalsConceded', 'goals_conceded', 'ga'),
minutes: (r) => num(r, 'minutes', 'min', 'MIN'),
};
// ---- streak specs -------------------------------------------------------
// Each spec: { key, category, threshold, label, value, mode }.
// value(row) → number; the game counts toward the streak when value >= threshold.
// mode 'consecutive' (default) counts the run from the latest game.
// mode 'rate' marks "hot" when the mean over the last `window` games >= threshold.
const SPECS = {
nba: [
{ key: 'points_25', category: 'points', collapse: 'points', threshold: 25, label: '25+ pts', value: nba.points },
{ key: 'points_20', category: 'points', collapse: 'points', threshold: 20, label: '20+ pts', value: nba.points },
{ key: 'assists_8', category: 'assists', collapse: 'assists', threshold: 8, label: '8+ ast', value: nba.assists },
{ key: 'assists_6', category: 'assists', collapse: 'assists', threshold: 6, label: '6+ ast', value: nba.assists },
{ key: 'rebounds_10',category: 'rebounds', collapse: 'rebounds', threshold: 10, label: '10+ reb', value: nba.rebounds },
{ key: 'rebounds_8', category: 'rebounds', collapse: 'rebounds', threshold: 8, label: '8+ reb', value: nba.rebounds },
{ key: 'threes_4', category: 'threes', collapse: 'threes', threshold: 4, label: '4+ threes', value: nba.threes },
{ key: 'threes_3', category: 'threes', collapse: 'threes', threshold: 3, label: '3+ threes', value: nba.threes },
{ key: 'blocks_2', category: 'blocks', threshold: 2, label: '2+ blk', value: nba.blocks },
{ key: 'steals_2', category: 'steals', threshold: 2, label: '2+ stl', value: nba.steals },
{ key: 'pra_40', category: 'pra', threshold: 40, label: '40+ PRA', value: nba.pra },
{ key: 'double_double', category: 'all', threshold: 2, label: 'double-double', value: nba.doubleCount, noun: 'double-double' },
{ key: 'triple_double', category: 'all', threshold: 3, label: 'triple-double', value: nba.doubleCount, noun: 'triple-double' },
{ key: 'hot_shooter', category: 'points', threshold: 0.5, label: 'hot shooter (FG% > 50%)', value: nba.fgPct, mode: 'rate', window: 5 },
],
// WNBA shares NBA's stat layout (no PRA/triple-double headline emphasis,
// but the specs are harmless if a player never hits them).
wnba: null, // filled below = nba minus the rate spec quirks
mlb: [
{ key: 'hit_streak', category: 'hits', collapse: 'hits', threshold: 1, label: 'hit', value: mlb.hits },
{ key: 'multi_hit', category: 'hits', collapse: 'hits', threshold: 2, label: 'multi-hit', value: mlb.hits },
{ key: 'hr_streak', category: 'home_runs', threshold: 1, label: 'HR', value: mlb.homeRuns },
{ key: 'sb_streak', category: 'stolen_bases', threshold: 1, label: 'SB', value: mlb.stolenBases },
{ key: 'rbi_streak', category: 'rbis', threshold: 1, label: 'RBI', value: mlb.rbi },
{ key: 'onbase_streak',category: 'on_base', threshold: 1, label: 'on-base', value: mlb.onBase },
{ key: 'tb_streak', category: 'total_bases', threshold: 2, label: '2+ total bases', value: mlb.totalBases },
{ key: 'k_streak', category: 'strikeouts', threshold: 7, label: '7+ K', value: mlb.strikeouts },
{ key: 'qs_streak', category: 'strikeouts', threshold: 1, label: 'quality start', value: (r) => (mlb.isQualityStart(r) ? 1 : 0) },
],
nfl: [
{ key: 'td_streak', category: 'touchdowns', threshold: 1, label: 'TD', value: nfl.anyTd },
{ key: 'multi_td', category: 'touchdowns', threshold: 2, label: 'multi-TD', value: nfl.anyTd },
{ key: 'rush_100', category: 'rushing_yards', threshold: 100, label: '100-yd rushing', value: nfl.rushYds },
{ key: 'rec_100', category: 'receiving_yards', threshold: 100, label: '100-yd receiving', value: nfl.recYds },
{ key: 'clean_qb', category: 'interceptions', threshold: 1, label: 'INT-free', value: (r) => (nfl.ints(r) === 0 ? 1 : 0) },
],
soccer: [
{ key: 'goal_streak', category: 'goals', threshold: 1, label: 'goal', value: soccer.goals },
{ key: 'assist_streak', category: 'assists', threshold: 1, label: 'assist', value: soccer.assists },
{ key: 'sot_streak', category: 'shots', threshold: 1, label: 'shot-on-target', value: soccer.shotsOnTarget },
{ key: 'clean_sheet', category: 'saves', threshold: 1, label: 'clean sheet',
value: (r) => (soccer.minutes(r) > 0 && soccer.goalsConceded(r) === 0 ? 1 : 0) },
],
};
SPECS.wnba = SPECS.nba;
function specsFor(sport) {
return SPECS[String(sport || '').toLowerCase()] || [];
}
// ---- core streak math ---------------------------------------------------
function consecutiveRun(games, valueFn, threshold) {
let run = 0;
for (const g of games) {
if (valueFn(g) >= threshold) run += 1;
else break;
}
return run;
}
function rateOverWindow(games, valueFn, window) {
const slice = games.slice(0, window);
if (slice.length < window) return { value: 0, count: slice.length };
const sum = slice.reduce((acc, g) => acc + valueFn(g), 0);
return { value: sum / slice.length, count: slice.length };
}
/**
* Minimum run length to surface a streak. A "1-game streak" is just a
* stat line, not a streak — require at least 2 to count as VYNDR signal,
* except double/triple-double which are notable at any length >= 2.
*/
const MIN_STREAK = 2;
function describe(spec, run) {
if (spec.noun) return `${run}-game ${spec.noun} streak`;
if (spec.mode === 'rate') return spec.label;
return `${run}-game ${spec.label} streak`;
}
/**
* All streaks for ONE player. Returns the strongest streak per stat
* CATEGORY (so a player with a 20+ and a 25+ points streak surfaces only
* the more impressive one) — keeps the feed signal-dense.
*/
function computePlayerStreaks(player, sport, opts = {}) {
const specs = specsFor(sport);
let games = Array.isArray(player?.games) ? player.games.slice() : [];
if (opts.chronological) games.reverse();
if (games.length === 0) return [];
const found = [];
for (const spec of specs) {
if (spec.mode === 'rate') {
const { value, count } = rateOverWindow(games, spec.value, spec.window);
if (count >= spec.window && value >= spec.threshold) {
found.push(makeStreak(player, sport, spec, spec.window, value));
}
continue;
}
const run = consecutiveRun(games, spec.value, spec.threshold);
if (run >= MIN_STREAK) found.push(makeStreak(player, sport, spec, run));
}
// Collapse tiered specs (e.g. 25+ and 20+ points) to one entry per
// collapse group — prefer the MORE IMPRESSIVE streak (higher threshold),
// tie-broken by the longer run. Non-tiered specs each have a unique
// collapse key, so they pass through untouched.
const best = new Map();
for (const s of found) {
const cur = best.get(s._collapse);
if (!cur ||
s.threshold > cur.threshold ||
(s.threshold === cur.threshold && s.currentStreak > cur.currentStreak)) {
best.set(s._collapse, s);
}
}
return Array.from(best.values()).map(({ _collapse, ...rest }) => rest);
}
function makeStreak(player, sport, spec, run, rateValue) {
return {
sport,
player: player.name || player.player || null,
playerId: player.playerId ?? player.id ?? null,
team: player.team || null,
type: spec.key,
category: spec.category,
threshold: spec.threshold,
currentStreak: run,
rate: rateValue ?? null,
description: describe(spec, run),
active: true,
_collapse: spec.collapse || spec.key, // internal — stripped before return
};
}
/**
* Fan out across a roster. `players` = [{ name, playerId, team, games }].
* Returns a flat list sorted by streak length desc, optionally narrowed
* to a single stat category and capped at `limit`.
*/
function computeStreaks(players, sport, opts = {}) {
if (!Array.isArray(players)) return [];
const stat = opts.stat && opts.stat !== 'all' ? String(opts.stat).toLowerCase() : null;
let all = [];
for (const p of players) {
all = all.concat(computePlayerStreaks(p, sport, opts));
}
if (stat) all = all.filter((s) => s.category === stat);
all.sort((a, b) => b.currentStreak - a.currentStreak);
if (opts.limit && opts.limit > 0) all = all.slice(0, opts.limit);
return all;
}
module.exports = {
computeStreaks,
computePlayerStreaks,
specsFor,
__internals: { consecutiveRun, rateOverWindow, nba, mlb, nfl, soccer, MIN_STREAK },
};