diff --git a/src/services/adapters/espnStatsAdapter.js b/src/services/adapters/espnStatsAdapter.js index 5b99a13..7b651a0 100644 --- a/src/services/adapters/espnStatsAdapter.js +++ b/src/services/adapters/espnStatsAdapter.js @@ -15,10 +15,16 @@ const axios = require('axios'); const { cacheGet, cacheSet } = require('../../utils/redis'); +const { nameKey } = require('../../utils/playerName'); const SEARCH = 'https://site.web.api.espn.com/apis/common/v3/search'; +// Wave 0 — the v3 /search endpoint above now returns count:0 for every query +// (verified live 2026-07-13); the v2 search below is the reliable resolver and +// carries the numeric athlete id inside its `uid` ("s:40~l:59~a:3149391"). +const SEARCH_V2 = 'https://site.api.espn.com/apis/search/v2'; const SPORT_PATH = { nba: 'basketball/nba', wnba: 'basketball/wnba' }; const TTL = 6 * 3600; +const GAMELOG_TTL = 4 * 3600; // per-game logs refresh once per night const TIMEOUT = 10_000; // ESPN stat label → our classifier-input key. Lowercased, punctuation-stripped. @@ -118,4 +124,178 @@ async function getSeasonAverages(name, sport, opts = {}) { } } -module.exports = { getSeasonAverages, parseAthleteStats, __internals: { STAT_MAP, keyify, SPORT_PATH } }; +// ───────────────────────────────────────────────────────────────────────── +// Wave 0 — FREE per-game logs for NBA/WNBA (the grade unlock). +// +// The Python nba_api service (gameLogService) is offline in prod, so +// featureCache's non-MLB branch produced no l5/l20 → projectionFor returned +// null → the ENTIRE NBA/WNBA slate refused. ESPN's per-athlete gamelog is free, +// no-auth, and returns per-game rows (VERIFIED LIVE: NBA id 1966 → 73 rows, +// WNBA id 3149391 → 23 rows). This mirrors mlbStatsAdapter.getPlayerGameLog's +// output contract ({ found, id, last10:[{ date, opponent, isHome, stat:{…} }] }) +// so it drops straight into the existing feature pipeline. +// +// The per-game `stats[]` array is aligned to the response's OWN `names[]` +// array — and that order DIFFERS between NBA and WNBA — so columns are indexed +// by name token, never by a hardcoded position. +// ───────────────────────────────────────────────────────────────────────── + +// ESPN `names[]` token → our per-game stat field. Compound cells ("9-23" = +// made-attempted) take the MADE portion. pra is COMPUTED (pts+reb+ast), never +// a raw column. +function toNum(v) { + const n = parseFloat(v); + return Number.isFinite(n) ? n : null; +} +function madeOf(v) { + if (v == null) return null; + const s = String(v); + const dash = s.indexOf('-'); + const n = parseInt(dash >= 0 ? s.slice(0, dash) : s, 10); + return Number.isFinite(n) ? n : null; +} + +function buildGameStat(statArr, idxOf) { + if (!Array.isArray(statArr)) return null; + const at = (token, fn = toNum) => { + const i = idxOf[token]; + return i == null ? null : fn(statArr[i]); + }; + const out = {}; + const points = at('points'); + const rebounds = at('totalRebounds'); + const assists = at('assists'); + const threes = at('threePointFieldGoalsMade-threePointFieldGoalsAttempted', madeOf); + const steals = at('steals'); + const blocks = at('blocks'); + const turnovers = at('turnovers'); + const minutes = at('minutes'); + // Absent beats zero — only attach a field ESPN actually reported. + if (points != null) out.points = points; + if (rebounds != null) out.rebounds = rebounds; + if (assists != null) out.assists = assists; + if (threes != null) out.threes = threes; + if (steals != null) out.steals = steals; + if (blocks != null) out.blocks = blocks; + if (turnovers != null) out.turnovers = turnovers; + if (minutes != null) out.minutes = minutes; + if (points != null && rebounds != null && assists != null) out.pra = points + rebounds + assists; + return Object.keys(out).length ? out : null; +} + +/** + * PURE: parse an ESPN athlete-gamelog payload into per-game rows, most-recent + * first. Returns an array or null (unrecognized shape). NEVER throws. + */ +function parseGameLog(payload) { + if (!payload || typeof payload !== 'object') return null; + const names = Array.isArray(payload.names) ? payload.names : null; + if (!names || names.length === 0) return null; + const idxOf = {}; + names.forEach((n, i) => { if (idxOf[String(n)] == null) idxOf[String(n)] = i; }); + const evMap = (payload.events && typeof payload.events === 'object') ? payload.events : {}; + const seen = new Set(); + const rows = []; + for (const st of Array.isArray(payload.seasonTypes) ? payload.seasonTypes : []) { + for (const cat of (st && Array.isArray(st.categories)) ? st.categories : []) { + for (const ev of (cat && Array.isArray(cat.events)) ? cat.events : []) { + const eid = ev && ev.eventId; + if (!eid || seen.has(eid)) continue; + const stat = buildGameStat(ev.stats, idxOf); + if (!stat) continue; + seen.add(eid); + const meta = evMap[eid] || {}; + const isHome = meta.atVs === 'vs' ? true : (meta.atVs === '@' ? false : null); + rows.push({ + date: meta.gameDate || null, + opponent: (meta.opponent && (meta.opponent.abbreviation || meta.opponent.displayName)) || null, + isHome, + stat, + }); + } + } + } + if (rows.length === 0) return null; + // Most-recent first. Undated rows sink to the end without reordering. + rows.sort((a, b) => { + const ta = a.date ? Date.parse(a.date) : NaN; + const tb = b.date ? Date.parse(b.date) : NaN; + if (Number.isNaN(ta) && Number.isNaN(tb)) return 0; + if (Number.isNaN(ta)) return 1; + if (Number.isNaN(tb)) return -1; + return tb - ta; + }); + return rows.slice(0, 20); +} + +async function fetchJsonG(url, opts = {}) { + if (typeof opts.fetchImpl === 'function') return opts.fetchImpl(url); + const client = opts.http || axios; + const res = await client.get(url, { timeout: TIMEOUT }); + return res && res.data; +} + +/** + * Resolve name → ESPN numeric athlete id for a basketball league via the v2 + * search. Disambiguates by `defaultLeagueSlug` (nba/wnba — an NCAA namesake is + * NOT returned for a WNBA query), then prefers an exact canonical-name match. + * Returns the numeric id string or null (a missing id beats the wrong player). + */ +async function resolveAthleteId(name, sport, opts = {}) { + const data = await fetchJsonG(`${SEARCH_V2}?query=${encodeURIComponent(name)}&limit=10`, opts); + const section = (data && Array.isArray(data.results) ? data.results : []).find((r) => r && r.type === 'player'); + const players = (section && Array.isArray(section.contents)) ? section.contents : []; + if (players.length === 0) return null; + const inLeague = players.filter((p) => String(p.defaultLeagueSlug || '').toLowerCase() === sport); + const pool = inLeague.length ? inLeague : players; + const target = nameKey(name); + const exact = pool.find((p) => nameKey(p.displayName || p.name || '') === target); + const chosen = exact || pool[0]; + if (!chosen) return null; + const m = /a:(\d+)/.exec(String(chosen.uid || '')); + if (m) return m[1]; + return /^\d+$/.test(String(chosen.id)) ? String(chosen.id) : null; +} + +const gameLogMem = new Map(); + +/** + * NBA/WNBA per-game logs from ESPN. Returns { found, id, last10 } (most-recent + * first) or { found:false }. Never throws. Cached (Redis `espngamelog:{sport}: + * {id}` 4h + in-memory mirror). opts.fetchImpl/opts.http injectable for tests. + */ +async function getPlayerGameLog(name, sport, opts = {}) { + const sp = String(sport || '').toLowerCase(); + const path = SPORT_PATH[sp]; + if (!path || !name) return { found: false }; + try { + const id = await resolveAthleteId(name, sp, opts); + if (!id) return { found: false }; + const ck = `espngamelog:${sp}:${id}`; + if (gameLogMem.has(ck)) return gameLogMem.get(ck); + try { + const cached = await cacheGet(ck); + if (cached) { gameLogMem.set(ck, cached); return cached; } + } catch { /* ignore cache read */ } + + const payload = await fetchJsonG(`https://site.web.api.espn.com/apis/common/v3/sports/${path}/athletes/${id}/gamelog`, opts); + const last10 = parseGameLog(payload); + if (!last10) return { found: false, id }; + const result = { found: true, id, last10 }; + gameLogMem.set(ck, result); + try { await cacheSet(ck, result, GAMELOG_TTL); } catch { /* ignore cache write */ } + return result; + } catch (err) { + console.warn('[espnStats] game log failed:', name, sp, err.message); + return { found: false }; + } +} + +module.exports = { + getSeasonAverages, + parseAthleteStats, + getPlayerGameLog, + parseGameLog, + resolveAthleteId, + __internals: { STAT_MAP, keyify, SPORT_PATH, buildGameStat, madeOf, toNum, gameLogMem }, +}; diff --git a/src/services/intelligence/featureCache.js b/src/services/intelligence/featureCache.js index 74fcd30..c32331b 100644 --- a/src/services/intelligence/featureCache.js +++ b/src/services/intelligence/featureCache.js @@ -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, diff --git a/tests/unit/espnGameLog.test.js b/tests/unit/espnGameLog.test.js new file mode 100644 index 0000000..ead45f8 --- /dev/null +++ b/tests/unit/espnGameLog.test.js @@ -0,0 +1,179 @@ +// Wave 0 — ESPN per-athlete gamelog parser + resolver (the NBA/WNBA grade +// unlock's free source). Hermetic: fetch is injected, no network. + +// Redis is a no-op here so the cache read/write paths don't touch a live server. +jest.mock('../../src/utils/redis', () => ({ + cacheGet: async () => null, + cacheSet: async () => {}, + cacheDel: async () => {}, +})); + +const espn = require('../../src/services/adapters/espnStatsAdapter'); + +// Real ESPN NBA gamelog shape (verified live 2026-07-13): a per-response +// `names[]` array + seasonTypes[].categories[].events[{eventId, stats[]}], with +// dates/opponents in a top-level `events` map keyed by eventId. Column order is +// indexed by name token — NBA and WNBA differ, so this is not positional. +const NBA_NAMES = [ + 'minutes', 'fieldGoalsMade-fieldGoalsAttempted', 'fieldGoalPct', + 'threePointFieldGoalsMade-threePointFieldGoalsAttempted', 'threePointPct', + 'freeThrowsMade-freeThrowsAttempted', 'freeThrowPct', 'totalRebounds', + 'assists', 'blocks', 'steals', 'fouls', 'turnovers', 'points', +]; +// min FG FG% 3PT 3P% FT FT% REB AST BLK STL PF TO PTS +const G_OLD = ['32', '9-19', '47.4', '1-4', '25.0', '4-4', '100', '8', '5', '0', '2', '3', '2', '23']; +const G_MID = ['36', '10-20', '50.0', '3-7', '42.9', '5-6', '83.3', '10', '7', '1', '1', '2', '4', '28']; +const G_NEW = ['40', '8-18', '44.4', '2-6', '33.3', '6-8', '75.0', '12', '3', '1', '0', '1', '4', '24']; + +const NBA_FIXTURE = { + names: NBA_NAMES, + labels: ['MIN', 'FG', 'FG%', '3PT', '3P%', 'FT', 'FT%', 'REB', 'AST', 'BLK', 'STL', 'PF', 'TO', 'PTS'], + seasonTypes: [ + { + displayName: '2025-26 Regular Season', + categories: [ + { + displayName: 'January', + events: [ + { eventId: 'E_OLD', stats: G_OLD }, + { eventId: 'E_MID', stats: G_MID }, + { eventId: 'E_NEW', stats: G_NEW }, + ], + }, + ], + }, + ], + events: { + E_OLD: { id: 'E_OLD', gameDate: '2026-01-01T00:30:00.000+00:00', atVs: '@', opponent: { abbreviation: 'BOS' } }, + E_MID: { id: 'E_MID', gameDate: '2026-01-03T00:30:00.000+00:00', atVs: 'vs', opponent: { abbreviation: 'GSW' } }, + E_NEW: { id: 'E_NEW', gameDate: '2026-01-05T00:30:00.000+00:00', atVs: 'vs', opponent: { abbreviation: 'HOU' } }, + }, +}; + +const V2_SEARCH = { + results: [ + { + type: 'player', + contents: [ + { uid: 's:40~l:46~a:1966', id: 'guid-abc', displayName: 'LeBron James', sport: 'basketball', defaultLeagueSlug: 'nba' }, + ], + }, + ], +}; + +function fetchImplFor(searchPayload, gamelogPayload) { + return async (url) => { + if (url.includes('/search/v2')) return searchPayload; + if (url.includes('/gamelog')) return gamelogPayload; + return null; + }; +} + +beforeEach(() => espn.__internals.gameLogMem.clear()); + +describe('parseGameLog (pure)', () => { + it('normalizes per-game rows keyed by VYNDR stat names, most-recent first', () => { + const rows = espn.parseGameLog(NBA_FIXTURE); + expect(Array.isArray(rows)).toBe(true); + expect(rows).toHaveLength(3); + // most-recent first → E_NEW leads + expect(rows[0].date).toBe('2026-01-05T00:30:00.000+00:00'); + expect(rows[0].opponent).toBe('HOU'); + expect(rows[0].isHome).toBe(true); + expect(rows[0].stat).toMatchObject({ + points: 24, rebounds: 12, assists: 3, threes: 2, steals: 0, blocks: 1, turnovers: 4, minutes: 40, + }); + // pra computed, not a raw column + expect(rows[0].stat.pra).toBe(24 + 12 + 3); + // oldest last, away game + expect(rows[2].date).toBe('2026-01-01T00:30:00.000+00:00'); + expect(rows[2].isHome).toBe(false); + }); + + it('indexes columns by the response names[] (WNBA order differs from NBA)', () => { + const wnbaNames = [ + 'minutes', 'points', 'totalRebounds', 'assists', 'steals', 'blocks', 'turnovers', + 'fieldGoalsMade-fieldGoalsAttempted', 'fieldGoalPct', + 'threePointFieldGoalsMade-threePointFieldGoalsAttempted', 'threePointPct', + 'freeThrowsMade-freeThrowsAttempted', 'freeThrowPct', 'fouls', + ]; + // min PTS REB AST STL BLK TO FG FG% 3PT 3P% FT FT% PF + const row = ['31', '20', '12', '2', '1', '2', '3', '9-23', '39.1', '2-5', '40.0', '2-2', '100.0', '2']; + const wnbaFixture = { + names: wnbaNames, + seasonTypes: [{ categories: [{ events: [{ eventId: 'W1', stats: row }] }] }], + events: { W1: { gameDate: '2026-07-13T01:00:00.000+00:00', atVs: 'vs', opponent: { abbreviation: 'IND' } } }, + }; + const rows = espn.parseGameLog(wnbaFixture); + expect(rows).toHaveLength(1); + expect(rows[0].stat).toMatchObject({ points: 20, rebounds: 12, assists: 2, steals: 1, blocks: 2, turnovers: 3, threes: 2 }); + }); + + it('omits a stat ESPN did not report (absent, never 0)', () => { + // Drop the points column entirely. + const noPtsNames = NBA_NAMES.slice(0, -1); // remove 'points' + const fixture = { + names: noPtsNames, + seasonTypes: [{ categories: [{ events: [{ eventId: 'X', stats: G_NEW.slice(0, -1) }] }] }], + events: { X: { gameDate: '2026-01-05T00:30:00.000+00:00' } }, + }; + const rows = espn.parseGameLog(fixture); + expect(rows[0].stat).not.toHaveProperty('points'); + expect(rows[0].stat).not.toHaveProperty('pra'); // pra needs all three → absent + expect(rows[0].stat.rebounds).toBe(12); + }); + + it('returns null on an unrecognized shape and never throws', () => { + expect(espn.parseGameLog(null)).toBeNull(); + expect(espn.parseGameLog({})).toBeNull(); + expect(espn.parseGameLog({ names: [], seasonTypes: [] })).toBeNull(); + expect(espn.parseGameLog({ names: NBA_NAMES, seasonTypes: [] })).toBeNull(); + expect(() => espn.parseGameLog({ names: NBA_NAMES, seasonTypes: 'bad', events: 5 })).not.toThrow(); + }); +}); + +describe('resolveAthleteId', () => { + it('extracts the numeric athlete id from the v2 uid', async () => { + const id = await espn.resolveAthleteId('LeBron James', 'nba', { fetchImpl: fetchImplFor(V2_SEARCH, NBA_FIXTURE) }); + expect(id).toBe('1966'); + }); + + it('disambiguates by league (a WNBA query never returns the NCAA namesake)', async () => { + const dual = { + results: [{ + type: 'player', + contents: [ + { uid: 's:40~l:59~a:3149391', displayName: "A'ja Wilson", defaultLeagueSlug: 'wnba' }, + { uid: 's:40~l:54~a:4412077', displayName: "A'Ja Wilson", defaultLeagueSlug: 'womens-college-basketball' }, + ], + }], + }; + const id = await espn.resolveAthleteId("A'ja Wilson", 'wnba', { fetchImpl: fetchImplFor(dual, NBA_FIXTURE) }); + expect(id).toBe('3149391'); + }); + + it('returns null when no player section matches', async () => { + const id = await espn.resolveAthleteId('Nobody', 'nba', { fetchImpl: fetchImplFor({ results: [] }, NBA_FIXTURE) }); + expect(id).toBeNull(); + }); +}); + +describe('getPlayerGameLog', () => { + it('resolves → fetches → normalizes into { found, id, last10 }', async () => { + const res = await espn.getPlayerGameLog('LeBron James', 'nba', { fetchImpl: fetchImplFor(V2_SEARCH, NBA_FIXTURE) }); + expect(res.found).toBe(true); + expect(res.id).toBe('1966'); + expect(res.last10).toHaveLength(3); + expect(res.last10[0].stat.points).toBe(24); + }); + + it('returns { found:false } for an unknown sport or empty name (no throw)', async () => { + await expect(espn.getPlayerGameLog('X', 'nfl')).resolves.toEqual({ found: false }); + await expect(espn.getPlayerGameLog('', 'nba')).resolves.toEqual({ found: false }); + }); + + it('returns { found:false } when the gamelog shape is unrecognized', async () => { + const res = await espn.getPlayerGameLog('LeBron James', 'nba', { fetchImpl: fetchImplFor(V2_SEARCH, { garbage: true }) }); + expect(res.found).toBe(false); + }); +}); diff --git a/tests/unit/featureCacheNba.test.js b/tests/unit/featureCacheNba.test.js new file mode 100644 index 0000000..d471dd2 --- /dev/null +++ b/tests/unit/featureCacheNba.test.js @@ -0,0 +1,103 @@ +// Wave 0 — THE PROOF: an NBA/WNBA prop now GRADES instead of refusing. +// +// Root cause (pre-fix): featureCache's non-MLB branch called gameLogService +// (Python nba_api, offline in prod) → null logs → no l5/l20 → projectionFor +// null → insufficientDataResult → the whole slate refused. This test injects +// the Python source as NULL (offline) and the ESPN gamelog as the free fallback, +// and asserts real l5_avg/l20_avg emerge → projectionFor returns a projection. + +// Python game-log service is OFFLINE (returns null) — the prod failure mode. +jest.mock('../../src/services/intelligence/gameLogService', () => ({ + getGameLogs: async () => null, + getCareerPlayoffGames: async () => null, + getWithWithoutStats: async () => null, +})); + +// ESPN gamelog fallback — a normalized result (as espnStatsAdapter would return). +const espnLog = { res: null }; +jest.mock('../../src/services/adapters/espnStatsAdapter', () => ({ + getPlayerGameLog: async () => espnLog.res, +})); + +const { __internals: fc } = require('../../src/services/intelligence/featureCache'); +const { __internals: eng } = require('../../src/services/intelligence/analyzeViaEngine1'); + +// Most-recent first, matching getPlayerGameLog's contract. +const lebronLog = { + found: true, + id: '1966', + last10: [ + { date: '2026-01-05T00:30:00Z', stat: { points: 24, rebounds: 12, assists: 3, threes: 2, minutes: 40, pra: 39 } }, + { date: '2026-01-03T00:30:00Z', stat: { points: 28, rebounds: 10, assists: 7, threes: 3, minutes: 36, pra: 45 } }, + { date: '2026-01-01T00:30:00Z', stat: { points: 23, rebounds: 8, assists: 5, threes: 1, minutes: 32, pra: 36 } }, + { date: '2025-12-29T00:30:00Z', stat: { points: 30, rebounds: 9, assists: 6, threes: 4, minutes: 38, pra: 45 } }, + { date: '2025-12-27T00:30:00Z', stat: { points: 21, rebounds: 11, assists: 8, threes: 2, minutes: 34, pra: 40 } }, + { date: '2025-12-25T00:30:00Z', stat: { points: 26, rebounds: 7, assists: 9, threes: 3, minutes: 37, pra: 42 } }, + ], +}; + +describe('nbaGameLogFeatures (pure)', () => { + it('derives l5/l10/l20 + rest + usage for an NBA points prop', () => { + const f = fc.nbaGameLogFeatures(lebronLog, 'points'); + // l5 = mean of the 5 most-recent points (24,28,23,30,21) = 25.2 + expect(f.l5_avg).toBeCloseTo((24 + 28 + 23 + 30 + 21) / 5, 5); + expect(f.l20_avg).toBeGreaterThan(0); + expect(f.minutes_per_game).toBeGreaterThan(0); + // consecutive games (2d apart) → 1d gap → 0 rest? dates are 2d apart → gap 2 → rest 1 + expect(f.rest_days).toBe(1); + }); + + it('handles combo (pra) + threes via the log-field map', () => { + expect(fc.nbaGameLogFeatures(lebronLog, 'pra').l5_avg).toBeGreaterThan(0); + expect(fc.nbaGameLogFeatures(lebronLog, 'threes').l5_avg).toBeGreaterThan(0); + // pts_reb_ast combo sums components → equals the precomputed pra + expect(fc.nbaGameLogFeatures(lebronLog, 'pts_reb_ast').l5_avg) + .toBeCloseTo(fc.nbaGameLogFeatures(lebronLog, 'pra').l5_avg, 5); + }); + + it('returns {} for an unfound player or unmapped stat', () => { + expect(fc.nbaGameLogFeatures({ found: false }, 'points')).toEqual({}); + expect(fc.nbaGameLogFeatures(lebronLog, 'not_a_stat')).toEqual({}); + }); +}); + +describe('gameLogFeatures — ESPN fallback when Python is offline', () => { + it('emits non-null l5_avg/l20_avg for nba (Python null → ESPN fallback)', async () => { + espnLog.res = lebronLog; + const f = await fc.gameLogFeatures('LeBron James', 'nba', 'points'); + expect(f.l5_avg).not.toBeNull(); + expect(f.l5_avg).toBeGreaterThan(0); + expect(f.l20_avg).toBeGreaterThan(0); + }); + + it('works for wnba too', async () => { + espnLog.res = lebronLog; // shape-identical + const f = await fc.gameLogFeatures("A'ja Wilson", 'wnba', 'rebounds'); + expect(f.l5_avg).toBeGreaterThan(0); + }); + + it('degrades to {} when ESPN also has nothing (still no fabrication)', async () => { + espnLog.res = { found: false }; + const f = await fc.gameLogFeatures('Ghost Player', 'nba', 'points'); + expect(f).toEqual({}); + }); +}); + +describe('THE UNLOCK — projectionFor now returns a grade, not insufficient_data', () => { + it('empty features (the old prod state) → projection null → REFUSE', () => { + expect(eng.projectionFor({}, { stat_type: 'points', line: 20 })).toBeNull(); + }); + + it('ESPN-derived features → finite projection → the prop GRADES', async () => { + espnLog.res = lebronLog; + const features = await fc.gameLogFeatures('LeBron James', 'nba', 'points'); + const projection = eng.projectionFor(features, { stat_type: 'points', line: 24.5 }); + expect(projection).not.toBeNull(); + expect(Number.isFinite(projection)).toBe(true); + // and the intel card lights up off the same vector + const intel = eng.buildIntelFields(features); + expect(intel.season_avg).toBeDefined(); + expect(intel.last10_avg).toBeDefined(); + expect(intel.form).toBeDefined(); + }); +});