Files
vyndr/src/services/streaksService.js
T
builtbykev 2c9cbca7bd Session B (night2): streaks/hot-list engine — producer + form heat + THE LENS
RESURRECT verdict: the Session-23 engine existed, was pure, tested, and
mounted — it starved because every producer was external and unarmed
(tank01-prefetch via n8n, offline Python flow). The snapshot pipeline is
now the producer: each run merges slate players' real game logs (already
fetched for archetypes — zero extra calls) into rosterlogs:{sport}.

- computeFormHeat: hot hitters (7d AVG), hot sluggers (SLG), hot shooters
  (FG%) — correct sum/sum rate math, season baseline else prior stretch,
  min-sample refusals, never extrapolated.
- streakLens: no raw streak renders alone — built-vs opponents, tonight's
  matchup + opposing SP w/ ERA, step-up/step-down difficulty, one-line
  read. Absent context = say less, never invent.
- /api/streaks/:sport: heat merged into the feed, lens applied from cached
  schedule + pitchers (time-bounded, can never hang the route), snapshot
  grade letters joined.
- ACCEPTANCE (live statsapi, real 2026 logs): 32 rows found — Turang
  12-gm on-base, Reynolds 8-gm on-base, Pratt 7-gm on-base + 3-gm
  multi-hit, Meidroth 5-gm on-base, Cortes 5-gm on-base.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-11 00:39:18 -04:00

365 lines
17 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, undefined, games));
}
// 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, games) {
// Session 60 (night2/B) — the opponents the streak was BUILT against
// (unique, streak-window only). Lens fuel: "built vs OAK, LAA, SEA".
const opponents = Array.isArray(games)
? [...new Set(games.slice(0, run).map((g) => g && g.opponent).filter(Boolean))]
: [];
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),
opponents,
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;
}
// ---- Session 60 (night2/B): FORM HEAT — 7-day rate vs baseline ----------
// Hot hitters (AVG), hot sluggers (SLG/ISO), hot shooters (FG%). Rates are
// computed the correct way (Σmakes/Σattempts over the window, NOT a mean of
// per-game rates). Baseline = the player's season rate when the roster blob
// carries one, else the pre-window games — labeled accordingly, never faked.
const fmt3 = (n) => {
const s = n.toFixed(3);
return s.startsWith('0.') ? s.slice(1) : s; // .412, not 0.412
};
function windowSplit(games, now, windowDays = 7) {
const cutoff = (now || Date.now()) - windowDays * 86_400_000;
const dated = games.filter((g) => g && g.date && Number.isFinite(new Date(g.date).getTime()));
if (dated.length === 0) return { recent: games.slice(0, 5), rest: games.slice(5) };
return {
recent: dated.filter((g) => new Date(g.date).getTime() >= cutoff),
rest: dated.filter((g) => new Date(g.date).getTime() < cutoff),
};
}
const sumOf = (rows, ...keys) => rows.reduce((acc, r) => acc + num(r, ...keys), 0);
// Rate over a set of games: { made, att, rate|null }.
function ratio(rows, madeKeys, attKeys) {
const made = sumOf(rows, ...madeKeys);
const att = sumOf(rows, ...attKeys);
return { made, att, rate: att > 0 ? made / att : null };
}
const HEAT_SPECS = {
mlb: [
{
type: 'hot_hitter', category: 'hits', label: 'AVG',
made: ['hits', 'H', 'h'], att: ['atBats', 'ab', 'AB'],
seasonKey: 'avg', minAtt: 15, minDelta: 0.05, fmt: fmt3,
line: (r, b, src) => `hitting ${fmt3(r)} over the last 7 days (${src} ${fmt3(b)})`,
},
{
type: 'hot_slugger', category: 'total_bases', label: 'SLG',
made: ['totalBases', 'TB', 'total_bases'], att: ['atBats', 'ab', 'AB'],
seasonKey: 'slg', minAtt: 15, minDelta: 0.09, fmt: fmt3,
line: (r, b, src) => `slugging ${fmt3(r)} over the last 7 days (${src} ${fmt3(b)})`,
},
],
wnba: [
{
type: 'hot_shooter', category: 'points', label: 'FG%',
made: ['fgm', 'field_goals_made', 'fieldGoalsMade'], att: ['fga', 'field_goals_attempted', 'fieldGoalsAttempted'],
seasonKey: 'fgPct', minAtt: 20, minDelta: 0.05, fmt: (n) => `${Math.round(n * 100)}%`,
line: (r, b, src) => `shooting ${Math.round(r * 100)}% over the last 7 days (${src} ${Math.round(b * 100)}%)`,
},
],
};
HEAT_SPECS.nba = HEAT_SPECS.wnba;
/**
* Form-heat rows in the streak shape (they merge into the same feed).
* players = [{ name, playerId, team, games, seasonRaw? }] — games
* most-recent-first with date fields.
*/
function computeFormHeat(players, sport, opts = {}) {
const specs = HEAT_SPECS[String(sport || '').toLowerCase()] || [];
if (specs.length === 0 || !Array.isArray(players)) return [];
const out = [];
for (const p of players) {
const games = Array.isArray(p?.games) ? p.games.slice() : [];
if (opts.chronological) games.reverse();
if (games.length === 0) continue;
const { recent, rest } = windowSplit(games, opts.now, opts.windowDays || 7);
for (const spec of specs) {
const cur = ratio(recent, spec.made, spec.att);
if (cur.rate == null || cur.att < spec.minAtt) continue;
// Baseline: season rate from the blob when present, else prior games.
let baseline = null;
let baselineSrc = 'season';
const seasonVal = p.seasonRaw && Number(p.seasonRaw[spec.seasonKey]);
if (Number.isFinite(seasonVal) && seasonVal > 0) baseline = seasonVal;
else {
const prior = ratio(rest, spec.made, spec.att);
if (prior.rate != null && prior.att >= spec.minAtt) { baseline = prior.rate; baselineSrc = 'prior stretch'; }
}
if (baseline == null || cur.rate - baseline < spec.minDelta) continue;
out.push({
sport: String(sport).toLowerCase(),
player: p.name || p.player || null,
playerId: p.playerId ?? p.id ?? null,
team: p.team || null,
type: spec.type,
category: spec.category,
threshold: null,
currentStreak: recent.length,
rate: Math.round(cur.rate * 1000) / 1000,
baseline: Math.round(baseline * 1000) / 1000,
description: spec.line(cur.rate, baseline, baselineSrc),
opponents: [...new Set(recent.map((g) => g && g.opponent).filter(Boolean))],
active: true,
});
}
}
out.sort((a, b) => (b.rate - b.baseline) - (a.rate - a.baseline));
return opts.limit && opts.limit > 0 ? out.slice(0, opts.limit) : out;
}
module.exports = {
computeStreaks,
computePlayerStreaks,
computeFormHeat,
specsFor,
__internals: { consecutiveRun, rateOverWindow, windowSplit, ratio, nba, mlb, nfl, soccer, MIN_STREAK, HEAT_SPECS },
};