Wave 0: NBA/WNBA grade unlock — free ESPN per-athlete gamelog source

The Python nba_api service (gameLogService) is offline in prod, so
featureCache's non-MLB branch produced no l5/l20 averages →
projectionFor returned null → the ENTIRE NBA/WNBA slate refused
(insufficient_data). Only MLB actually graded.

Fix (free, no-auth, verified live):
- espnStatsAdapter.getPlayerGameLog(name, sport) — resolves name→ESPN
  numeric athlete id via the v2 search (the v3 /search now returns
  count:0; the v2 uid carries a:<id>, defaultLeagueSlug disambiguates
  league), fetches the per-athlete gamelog, and parses per-game rows
  keyed by VYNDR stat names (points/rebounds/assists/threes/steals/
  blocks/turnovers + computed pra). Columns are indexed by the
  response's own names[] array (NBA and WNBA orders DIFFER), never
  positionally. Most-recent first, defensive (null on unrecognized
  shape, never throws), cached (espngamelog:{sport}:{id} 4h + memory).
- featureCache.gameLogFeatures — falls back to the ESPN gamelog for
  nba/wnba when the Python source returns null/empty, producing
  l5/l10/l20 + rest_days + minutes_per_game via a new local
  NBA_LOG_FIELD map + pure nbaGameLogFeatures (S11 three-map-split:
  separate from MLB_LOG_FIELD).

Grade gates already whitelist all 8 NBA/WNBA stat types in both Node
paths (analyze.js + scan.js); no gate change needed.

Tests (hermetic, no network): espnGameLog.test.js (parser/resolver/
adapter) + featureCacheNba.test.js (the UNLOCK proof — empty features
refuse, ESPN-derived features grade). 3098 tests green; next build
exit 0.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Kev
2026-07-13 18:44:57 -04:00
parent 153ff21c11
commit 287c1c047a
4 changed files with 540 additions and 1 deletions
+77
View File
@@ -93,6 +93,22 @@ function mlbStatValue(statObj, statType) {
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
@@ -140,6 +156,49 @@ function mlbGameLogFeatures(res, statType) {
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;
}
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
@@ -156,6 +215,22 @@ async function gameLogFeatures(playerName, sport, statType) {
}
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);
@@ -341,6 +416,8 @@ module.exports = {
statFromGameLog,
mlbGameLogFeatures,
mlbStatValue,
nbaGameLogFeatures,
NBA_LOG_FIELD,
avg,
stddev,
daysBetween,