/** * Feature cache — the central feature-vector builder for every prop. * * Philosophy: features are OMITTED when the underlying data source is * unavailable, never zeroed. Engine 2 handles variable-length feature * sets; a zero would lie to the model about what we actually know. * * Per-feature TTL categories (Redis): * game_log: 4h — game logs refresh once per night * team: 24h — opponent stats are daily * coach: 30d — coach profiles are rare to change * ref: 12h — assignments published morning of game day * injury: 2h — injuries change at shootaround * line: 2m — line state changes constantly during the day * context: none — computed on demand (home/away, rest days) * * Cache key: features:{sport}:{playerId}:{statType}:{gameId} * The full vector is cached for 2 minutes so repeated calls during the * same grading cycle don't recompute. After 2 minutes, individual * features get refreshed from their own caches. */ const { cacheGet, cacheSet } = require('../../utils/redis'); const { getTeamStats, getOpponentRank } = require('./teamStatsCache'); const { getRefImpact } = require('./refSignals'); const { getCoachImpact } = require('./coachSignals'); const { roleValue } = require('./lineupSignals'); const { getTeamInjuries } = require('./injuryParser'); const { getLineMovement } = require('./lineMovement'); const gameLogs = require('./gameLogService'); const VECTOR_TTL_SECONDS = 120; function avg(values) { const clean = values.filter((v) => Number.isFinite(v)); if (clean.length === 0) return null; return clean.reduce((a, b) => a + b, 0) / clean.length; } function stddev(values) { const clean = values.filter((v) => Number.isFinite(v)); if (clean.length < 2) return null; const mean = avg(clean); const sq = clean.reduce((sum, v) => sum + (v - mean) ** 2, 0); return Math.sqrt(sq / (clean.length - 1)); } // Extract a stat value from a single game-log entry by stat type. Game-log // rows out of the Python service are keyed by stat name (points, // rebounds, etc.) and combo stats need to be summed at read time. function statFromGameLog(row, statType) { if (!row) return null; switch (statType) { case 'pts_reb_ast': { const s = (Number(row.points) || 0) + (Number(row.rebounds) || 0) + (Number(row.assists) || 0); return s; } case 'pts_reb': return (Number(row.points) || 0) + (Number(row.rebounds) || 0); case 'pts_ast': return (Number(row.points) || 0) + (Number(row.assists) || 0); case 'reb_ast': return (Number(row.rebounds) || 0) + (Number(row.assists) || 0); case 'stl_blk': return (Number(row.steals) || 0) + (Number(row.blocks) || 0); default: { const v = Number(row[statType]); return Number.isFinite(v) ? v : null; } } } function daysBetween(aIso, bIso) { const ms = new Date(aIso).getTime() - new Date(bIso).getTime(); if (!Number.isFinite(ms)) return null; return Math.floor(ms / (1000 * 60 * 60 * 24)); } // MLB stat_type → the per-game field name in a statsapi.mlb.com game-log row. const MLB_LOG_FIELD = { total_bases: 'totalBases', home_runs: 'homeRuns', hits: 'hits', rbi: 'rbi', runs: 'runs', stolen_bases: 'stolenBases', walks: 'baseOnBalls', strikeouts: 'strikeOuts', earned_runs: 'earnedRuns', hits_allowed: 'hits', innings_pitched: 'inningsPitched', // Session 56 audit — real boxscore/game-log fields (Braves@Pirates verified). doubles: 'doubles', triples: 'triples', outs: 'outs', // OPPORTUNITY INPUT (2026-08-01) — at-bats is NOT a gradeable stat_type and no // market exists for it. It is mapped here only so `mlbStatValue` can read // per-game at-bats out of a log row for the opportunity-drift axis. // // DELIBERATELY NOT ADDED to the other two MLB maps (outcomeService's // MLB_LOG_FIELD, liveTrackingService's LIVE_BOX_FIELD). Those exist to SETTLE // and to TRACK graded props; nothing grades at-bats, so adding it there would // imply a settlement path for a market we do not carry. The three-map split // is intentional — see CLAUDE.md. at_bats: 'atBats', }; function mlbStatValue(statObj, statType) { const f = MLB_LOG_FIELD[statType]; if (!f || !statObj) return null; const n = parseFloat(statObj[f]); return Number.isFinite(n) ? n : null; } // Wave 0 — NBA/WNBA stat_type → the per-game field on an espnStatsAdapter // gamelog row's `stat` object. A SEPARATE local map from MLB_LOG_FIELD (the // S11 three-map-split rule — never merge). Combo stats route to statFromGameLog // (which sums points/rebounds/assists at read time); `pra`/`threes` are already // normalized fields on the row. A stat_type absent here does NOT grade via this // path (absent beats a fabricated projection). Add a new NBA/WNBA stat here to // unlock it. const NBA_LOG_FIELD = { points: 'points', rebounds: 'rebounds', assists: 'assists', threes: 'threes', steals: 'steals', blocks: 'blocks', turnovers: 'turnovers', pra: 'pra', // combos (statFromGameLog sums the raw components) pts_reb_ast: 'pts_reb_ast', pts_reb: 'pts_reb', pts_ast: 'pts_ast', reb_ast: 'reb_ast', stl_blk: 'stl_blk', }; /** * Session 46 — derive recent/season averages from a real MLB game log * (mlbStatsAdapter.getPlayerStats result). PURE so it's unit-testable without * the network. Produces l5_avg / l10_avg (recent form) + l20_avg (the season * per-game reference) — exactly the fields buildIntelFields consumes, which the * NBA/WNBA-only Python game-log path never populated for MLB. */ function mlbGameLogFeatures(res, statType) { if (!res || !res.found) return {}; const out = {}; // SAME WINDOW BUG AS THE BASE RATE. `l20_avg` is documented as "the season // per-game reference projectionFor needs" and is read by the consistency // pull — but built from last10 it was a TEN-game average wearing a season // label. fullLog is already in this same response, so widening is free. const logs = (Array.isArray(res.fullLog) && res.fullLog.length) ? res.fullLog : (Array.isArray(res.last10) ? res.last10 : []); const vals = logs.map((g) => mlbStatValue(g.stat, statType)).filter((v) => v != null); if (vals.length) { const m5 = avg(vals.slice(-5)); // game logs are chronological (recent last) const m10 = avg(vals.slice(-10)); const s10 = stddev(vals.slice(-10)); if (m5 != null) out.l5_avg = m5; if (m10 != null) out.l10_avg = m10; if (s10 != null) out.l10_stddev = s10; } const seasonTotal = mlbStatValue(res.season, statType); const games = parseFloat(res.season && (res.season.gamesPlayed ?? res.season.gamesStarted ?? res.season.gamesPitched)); if (seasonTotal != null && Number.isFinite(games) && games > 0) { out.l20_avg = seasonTotal / games; } else if (out.l10_avg != null) { out.l20_avg = out.l10_avg; // baseline so form has a reference } // Session 47 — complete VYNDR INTELLIGENCE for MLB: // - rest_days: days between the two most recent games (0 = back-to-back). // - ab_per_game: at-bats per game, the MLB "usage" equivalent. const dated = logs.filter((g) => g && g.date); if (dated.length >= 2) { const last = new Date(dated[dated.length - 1].date).getTime(); const prev = new Date(dated[dated.length - 2].date).getTime(); const gap = Math.round((last - prev) / 86_400_000); // rest_days = days OFF (0 = played the day before = B2B), matching the // NBA convention buildIntelFields uses. Consecutive calendar days → 0. if (Number.isFinite(gap) && gap >= 1 && gap <= 14) out.rest_days = gap - 1; } const ab = parseFloat(res.season && res.season.atBats); if (Number.isFinite(ab) && Number.isFinite(games) && games > 0) { out.ab_per_game = ab / games; } // OPPORTUNITY DRIFT (2026-08-01) — recent at-bats per game against the // player's OWN season baseline. // // opportunity_drift = mean(last 5 games' atBats) / (season atBats / games) // // WHY A RATIO AND NOT THE LEVEL: `l20_avg` is seasonTotal/games — the SAME // denominator as `ab_per_game` — so for a batter // hits/game ~= (hits/AB) x (AB/game), and the projection ALREADY embeds the // opportunity LEVEL multiplicatively. Adding that level as another input // double-counts it. A deviation from the player's own baseline is the part // the projection does not already contain. // // > 1 batting higher / playing more than his baseline // < 1 reduced role, platoon, lower slot // // HONEST ABSENCE: no at-bat rows, or no season baseline, leaves this // UNDEFINED. It is never 1.0-by-default and never 0 — `Number(null) === 0` // here would read as "no opportunity at all", the strongest possible signal, // from missing data. // // THIS IS A PROXY. The real driver of plate appearances is tonight's // confirmed batting order, which no wired source exposes (depthChartService // returns battingOrder: null for MLB; PropLine /context carries only a // lineup_confirmed boolean). Replace this with the real slot when a lineup // feed exists — do not present it as one. const abRows = logs.map((g) => mlbStatValue(g.stat, 'at_bats')).filter((v) => v != null); if (abRows.length >= 3) { const recentAb = avg(abRows.slice(-5)); if (recentAb != null) { out.recent_ab_per_game = recentAb; if (Number.isFinite(out.ab_per_game) && out.ab_per_game > 0) { out.opportunity_drift = recentAb / out.ab_per_game; } } } return out; } /** * Wave 0 — derive recent/season averages from an ESPN NBA/WNBA gamelog * (espnStatsAdapter.getPlayerGameLog result). PURE + unit-testable. The result's * last10 is MOST-RECENT-FIRST, so l5 = first 5, l20 = all available (the season * per-game reference projectionFor needs). Emits the SAME fields the Python * path did, plus rest_days + minutes_per_game (usage), so the grade card lights * up. Returns {} when the log is missing or the stat_type isn't mapped. */ function nbaGameLogFeatures(res, statType) { if (!res || !res.found) return {}; const field = NBA_LOG_FIELD[statType]; if (!field) return {}; const logs = Array.isArray(res.last10) ? res.last10 : []; const vals = logs.map((g) => statFromGameLog(g && g.stat, field)).filter((v) => v != null); const out = {}; if (vals.length) { const m5 = avg(vals.slice(0, 5)); // most-recent first const m10 = avg(vals.slice(0, 10)); const m20 = avg(vals.slice(0, 20)); const s10 = stddev(vals.slice(0, 10)); if (m5 != null) out.l5_avg = m5; if (m10 != null) out.l10_avg = m10; if (m20 != null) out.l20_avg = m20; if (s10 != null) out.l10_stddev = s10; } // rest_days from the two most-recent dated games (0 = back-to-back), mirroring // the MLB branch + the NBA convention buildIntelFields reads. const dated = logs.filter((g) => g && g.date); if (dated.length >= 2) { const last = new Date(dated[0].date).getTime(); // most recent const prev = new Date(dated[1].date).getTime(); const gap = Math.round((last - prev) / 86_400_000); if (Number.isFinite(gap) && gap >= 1 && gap <= 14) out.rest_days = gap - 1; } // minutes_per_game — the NBA "usage" equivalent buildIntelFields surfaces. const mins = logs.map((g) => g && g.stat && Number(g.stat.minutes)).filter((v) => Number.isFinite(v)); const mpg = avg(mins); if (mpg != null) out.minutes_per_game = mpg; return out; } /** * Session 63 — NORMALIZED PER-GAME STAT ROWS. * * The probability estimator (`probabilityEstimator.estimateProbability`) and the * consistency scorer both read a game-log row as `row[statType]`. The ONLY * producer wired to them was `gameLogService.getGameLogs`, which returns null for * MLB by construction and depends on the offline Python service for NBA/WNBA — * so `meta.gameLogs` was `[]` for every sport in production and every * probability-derived output (p_win, ev_pct, kelly, model_odds, value) was * silently skipped, along with the ±1.0 consistency factor. * * This is the S46 fix applied to the SECOND location: same adapters, same maps * (no new stat map — the three-map-split rule stands), emitting rows in the shape * those two consumers already expect: * * [{ date, [statType]: value }, ...] MOST-RECENT-FIRST * * Most-recent-first matters: the estimator treats `values.slice(0, 5)` as the * recency window. Returns [] (never null) when no real log exists — absent beats * a fabricated distribution. */ async function getStatRows(playerName, sport, statType) { const sp = String(sport || '').toLowerCase(); const rows = []; const push = (date, value) => { if (value == null || !Number.isFinite(Number(value))) return; rows.push({ date: date || null, [statType]: Number(value) }); }; try { if (sp === 'mlb') { const mlbStats = require('../adapters/mlbStatsAdapter'); const res = await mlbStats.getPlayerStats(playerName); // THE FULL SEASON LOG, NOT last10. // // This is the one line that made the champion worse than a frequency // table. `estimateProbability` computes its base rate as the frequency // over EVERY row it is given, so feeding it ten games meant the "season // rate" was a ten-game rate — and then 0.4 of the forecast was the last // five OF THOSE TEN. Measured point-in-time, a true season frequency // out-resolved the served champion on all four stats (hits 0.00774 vs // 0.00251, rbi 0.03133 vs 0.02481). // // `fullLog` is already fetched in the same adapter call that produced // last10, so this costs nothing: no extra request, no new dependency. const logs = (res && res.found) ? (Array.isArray(res.fullLog) && res.fullLog.length ? res.fullLog : (Array.isArray(res.last10) ? res.last10 : [])) : []; // MLB logs are chronological (most recent LAST) — reverse to match. for (const g of [...logs].reverse()) push(g && g.date, mlbStatValue(g && g.stat, statType)); return rows; } // NBA/WNBA — Python service first (it's the richer source when it's up), // then the FREE ESPN per-athlete gamelog. Same order as gameLogFeatures. const pyLogs = await gameLogs.getGameLogs(playerName, sp, 20); if (Array.isArray(pyLogs) && pyLogs.length) { // Python rows are already flat + most-recent-first. for (const r of pyLogs) push(r && r.date, statFromGameLog(r, statType)); return rows; } if (sp === 'nba' || sp === 'wnba') { const espnStats = require('../adapters/espnStatsAdapter'); const res = await espnStats.getPlayerGameLog(playerName, sp); const logs = (res && res.found && Array.isArray(res.last10)) ? res.last10 : []; const field = NBA_LOG_FIELD[statType]; if (!field) return rows; // unmapped stat → no rows, never a guess // ESPN last10 is most-recent-first already. for (const g of logs) push(g && g.date, statFromGameLog(g && g.stat, field)); } return rows; } catch (e) { console.warn('[featureCache] getStatRows failed:', e.message); return []; } } /** Games played in the trailing `days` window, from normalized rows. Powers the * `heavy_workload_7d` factor, whose feature nothing populated. */ function gameCountInWindow(statRows, days = 7, now = Date.now()) { if (!Array.isArray(statRows)) return null; const cutoff = now - days * 86_400_000; const dated = statRows.filter((r) => r && r.date && !Number.isNaN(new Date(r.date).getTime())); if (dated.length === 0) return null; return dated.filter((r) => new Date(r.date).getTime() >= cutoff).length; } async function gameLogFeatures(playerName, sport, statType) { // MLB game logs come from the FREE statsapi.mlb.com (Session 46) — the Python // gameLogService only covers NBA/WNBA, so MLB props had no recent/season // averages and the grade card's intel sections stayed empty. if (sport === 'mlb') { try { const mlbStats = require('../adapters/mlbStatsAdapter'); const res = await mlbStats.getPlayerStats(playerName); return mlbGameLogFeatures(res, statType); } catch (e) { console.warn('[featureCache] MLB game-log features failed:', e.message); return {}; } } const logs = await gameLogs.getGameLogs(playerName, sport, 20); // Wave 0 — NBA/WNBA grade unlock. The Python nba_api service (gameLogService) // is offline in prod, so `logs` is null and this branch used to return {} → // no l5/l20 → projectionFor null → the ENTIRE slate refused. Fall back to the // FREE ESPN per-athlete gamelog so NBA (off-season) + WNBA (in-season) grade. if ((!logs || logs.length === 0) && (sport === 'nba' || sport === 'wnba')) { try { const espnStats = require('../adapters/espnStatsAdapter'); const res = await espnStats.getPlayerGameLog(playerName, sport); return nbaGameLogFeatures(res, statType); } catch (e) { console.warn('[featureCache] ESPN game-log fallback failed:', e.message); return {}; } } if (!logs || logs.length === 0) return {}; const valuesAll = logs.map((row) => statFromGameLog(row, statType)).filter((v) => v != null); const l5 = valuesAll.slice(0, 5); const l20 = valuesAll; const l10 = valuesAll.slice(0, 10); const out = {}; const m5 = avg(l5); const m20 = avg(l20); const s10 = stddev(l10); if (m5 != null) out.l5_avg = m5; if (m20 != null) out.l20_avg = m20; if (s10 != null) out.l10_stddev = s10; // Career playoff games is a separate endpoint. const cp = await gameLogs.getCareerPlayoffGames(playerName, sport); if (Number.isFinite(cp)) out.career_playoff_games = cp; return out; } async function teamFeatures(sport, opponentAbbr, statType) { const out = {}; if (!opponentAbbr) return out; const oppStats = await getTeamStats(sport, opponentAbbr); if (oppStats) { if (Number.isFinite(oppStats.pace)) out.pace_factor = oppStats.pace; if (Number.isFinite(oppStats.pace)) out.team_pace = oppStats.pace; } const rank = await getOpponentRank(sport, opponentAbbr, statType); if (rank != null) out.opp_rank_stat = rank; // Session 64 — MLB opp_rank_stat. ESPN's MLB team endpoint carries NO // defensive metric, so this field was permanently null for MLB and engine1's // ±1.0 opponent factor never fired for the sport carrying most of our volume. // Derived instead from statsapi team pitching splits (one free call, all 30 // teams), normalized to the SHARED CONTRACT: 0–1, HIGH = weak opponent — // identical to WNBA, so engine1 reads one field with one meaning. // Isolated + best-effort: a failure leaves the field ABSENT (honest null), // never a guessed rank. if (out.opp_rank_stat == null && String(sport).toLowerCase() === 'mlb') { try { const mlb = require('../adapters/mlbStatsAdapter'); const strength = require('../opponentStrength'); const [teams, oppTeam] = await Promise.all([ mlb.getTeamPitchingStats(), mlb.resolveTeam(opponentAbbr), ]); if (oppTeam && oppTeam.id) { const d = strength.deriveMlbOppRank(teams, oppTeam.id, statType); if (d.value != null) { out.opp_rank_stat = d.value; } else if (process.env.OPP_RANK_DEBUG === '1') { console.log(`[featureCache] mlb opp_rank null for ${opponentAbbr}/${statType}: ${d.reason}`); } } } catch (e) { console.warn('[featureCache] mlb opponent-strength derivation failed:', e.message); } } return out; } function contextFeatures(gameContext = {}) { const out = {}; if (gameContext.home_away === 'home') out.home_away = 1.0; else if (gameContext.home_away === 'away') out.home_away = 0.0; if (Number.isFinite(gameContext.rest_days)) out.rest_days = gameContext.rest_days; if (Number.isFinite(gameContext.game_count_in_7d)) out.game_count_in_7d = gameContext.game_count_in_7d; if (gameContext.season_type != null) out.season_type = gameContext.season_type; if (Number.isFinite(gameContext.game_in_series)) out.game_in_series = gameContext.game_in_series; if (Number.isFinite(gameContext.season_phase)) out.season_phase = gameContext.season_phase; return out; } async function injuryFeatures(sport, teamId, knownStarterIds = []) { const out = {}; if (!teamId) return out; const list = await getTeamInjuries(sport, teamId); if (!list || list.length === 0) { out.injury_severity_score = 0; return out; } const starterSet = new Set(knownStarterIds.map(String)); const missingStarters = list.filter( (i) => starterSet.has(i.playerId) && (i.status === 'OUT' || i.status === 'DOUBTFUL') ); out.injury_severity_score = Math.min(5, missingStarters.length); // Teammate-absence bump: a league-average constant when we don't have // with/without splits for this player. Engine 2 can replace this with // a learned value over time. if (missingStarters.length > 0) out.teammate_absence_bump = 0.05 * missingStarters.length; return out; } async function lineFeatures(gameId, playerName, statType) { const lm = await getLineMovement(gameId, playerName, statType); if (!lm) return {}; return { line_delta: lm.movement }; } async function refFeatures(gameId) { const impact = await getRefImpact(gameId); if (!impact) return {}; const out = {}; if (Number.isFinite(impact.pace_impact)) out.ref_pace_adjustment = impact.pace_impact; if (Number.isFinite(impact.foul_adjustment)) out.ref_foul_adjustment = impact.foul_adjustment; return out; } async function coachFeatures(sport, teamAbbr, gameContext = {}) { const impact = await getCoachImpact(sport, teamAbbr, gameContext); if (!impact) return {}; const out = {}; if (Number.isFinite(impact.adjusted_pace_delta)) out.coach_pace_delta = impact.adjusted_pace_delta; if (Number.isFinite(impact.without_primary_pace_shift)) { out.coach_player_interaction = impact.without_primary_pace_shift; } return out; } function lineupFeatures(role) { if (!role) return {}; return { lineup_ball_handler_role: roleValue(role) }; } // Top-level: build the full vector. Each sub-call is independent so a // failure in one (e.g. ref assignments not yet published) just omits its // feature and the rest of the vector is still useful. async function getFeatures(input = {}) { const { playerId, playerName, statType, sport, teamAbbr, opponentAbbr, teamId, opponentTeamId, gameId, gameContext, role, knownStarterIds = [], } = input; const cacheKey = `features:${sport}:${playerId}:${statType}:${gameId}`; const cached = await cacheGet(cacheKey); if (cached) return cached; const [gl, team, ctx, injury, line, ref, coach, lineup] = await Promise.all([ gameLogFeatures(playerName, sport, statType), teamFeatures(sport, opponentAbbr, statType), Promise.resolve(contextFeatures(gameContext)), injuryFeatures(sport, teamId, knownStarterIds), lineFeatures(gameId, playerName, statType), refFeatures(gameId), coachFeatures(sport, teamAbbr, gameContext), Promise.resolve(lineupFeatures(role)), ]); const features = { ...gl, ...team, ...ctx, ...injury, ...line, ...ref, ...coach, ...lineup }; const FEATURE_NAMES = [ 'l5_avg', 'l20_avg', 'l10_stddev', 'career_playoff_games', 'opp_rank_stat', 'pace_factor', 'team_pace', 'home_away', 'rest_days', 'game_count_in_7d', 'season_type', 'game_in_series', 'season_phase', 'teammate_absence_bump', 'primary_stat_suppression', 'injury_severity_score', 'line_delta', 'ref_pace_adjustment', 'ref_foul_adjustment', 'coach_pace_delta', 'coach_player_interaction', 'lineup_ball_handler_role', ]; const available = FEATURE_NAMES.filter((n) => features[n] != null); const missing = FEATURE_NAMES.filter((n) => features[n] == null); const payload = { features, meta: { computed_at: new Date().toISOString(), features_available: available, features_missing: missing, }, }; await cacheSet(cacheKey, payload, VECTOR_TTL_SECONDS); return payload; } async function clearCache(cacheKey) { // Hook for tests + manual invalidation. const { cacheDel } = require('../../utils/redis'); return cacheDel(cacheKey); } function getCacheStats() { return { ttlSeconds: VECTOR_TTL_SECONDS }; } module.exports = { getFeatures, getStatRows, gameCountInWindow, clearCache, getCacheStats, // Internal helpers exported for unit tests + Engine 2 reuse. __internals: { gameLogFeatures, teamFeatures, contextFeatures, injuryFeatures, lineFeatures, refFeatures, coachFeatures, lineupFeatures, statFromGameLog, mlbGameLogFeatures, mlbStatValue, // Exported so the opportunity-input test can assert at_bats is mapped HERE // and deliberately NOT in the settlement map (the three-map split). MLB_LOG_FIELD, nbaGameLogFeatures, NBA_LOG_FIELD, avg, stddev, daysBetween, }, };