/** * Team stats cache — daily refresh, Redis-backed. * * Source priority for each sport: * nba / wnba / ncaab : ESPN team statistics endpoint * mlb : ESPN + MLB Stats API team totals * nfl / ncaafb : ESPN + CFBD talent composite (college) * nhl : ESPN team statistics endpoint * * The cache key is `team_stats:{sport}:{teamAbbr}` with a 24h TTL. The * refresh function (called from n8n or app startup) walks every team in * the sport and writes one cache entry per team. Rate-limited at 1 * request per 2 seconds to be respectful to ESPN. * * Per-team payload normalizes into a uniform shape; values not available * for a sport are simply omitted (mirrors the feature-cache philosophy). * * { * offensive_rating, defensive_rating, pace, opponent_ppg, * team_fg_pct, team_3pt_pct, team_ft_rate, * opponent_fg_pct, opponent_3pt_pct, * team_k_rate, // MLB only * defensive_rank, // 1-N (1 = best D) * by_stat: { points: { allowed: N, rank: 1-30 }, ... } * } */ const axios = require('axios'); const { cacheGet, cacheSet } = require('../../utils/redis'); const { createLimiter, createCircuitBreaker } = require('../../utils/rateLimiter'); const ESPN_BASE = 'https://site.api.espn.com/apis/site/v2/sports'; const CACHE_TTL_SECONDS = 24 * 60 * 60; const HTTP_TIMEOUT_MS = 10_000; const SPORT_PATH = Object.freeze({ nba: 'basketball/nba', wnba: 'basketball/wnba', mlb: 'baseball/mlb', nfl: 'football/nfl', nhl: 'hockey/nhl', ncaab: 'basketball/mens-college-basketball', ncaafb: 'football/college-football', }); const limiter = createLimiter({ tokensPerInterval: 30, interval: 60_000 }); // 1/2s const breaker = createCircuitBreaker({ failureThreshold: 3, resetTimeout: 60_000 }); function teamCacheKey(sport, teamAbbr) { return `team_stats:${sport}:${String(teamAbbr).toUpperCase()}`; } // Pull a numeric value out of ESPN's labeled statistics arrays. ESPN // returns categories with .stats[] of { name, value, displayValue, abbreviation }. function pickStat(categoryStats, name) { if (!Array.isArray(categoryStats)) return null; const match = categoryStats.find( (s) => (s?.name || '').toLowerCase() === name.toLowerCase() || (s?.abbreviation || '').toLowerCase() === name.toLowerCase() ); if (!match) return null; const v = Number(match.value); return Number.isFinite(v) ? v : null; } function flattenTeamStats(payload) { // ESPN returns: { team, season, splits: [...], stats: [...] } depending on // endpoint. Most commonly: payload.results.stats[]/categories[] for // /teams/{id}/statistics const buckets = payload?.results?.stats || payload?.stats || []; const all = []; for (const b of buckets) { if (Array.isArray(b?.stats)) all.push(...b.stats); if (Array.isArray(b?.splits)) { for (const split of b.splits) { if (Array.isArray(split?.stats)) all.push(...split.stats); } } } return all; } function normalizeBasketball(payload) { const all = flattenTeamStats(payload); return { offensive_rating: pickStat(all, 'offensiveRating') ?? pickStat(all, 'oRtg'), defensive_rating: pickStat(all, 'defensiveRating') ?? pickStat(all, 'dRtg'), pace: pickStat(all, 'pace'), opponent_ppg: pickStat(all, 'avgPointsAgainst') ?? pickStat(all, 'oppPPG'), team_fg_pct: pickStat(all, 'fieldGoalPct'), team_3pt_pct: pickStat(all, 'threePointFieldGoalPct') ?? pickStat(all, 'threePtPct'), team_ft_rate: pickStat(all, 'freeThrowAttemptRate'), opponent_fg_pct: pickStat(all, 'opponentFieldGoalPct'), opponent_3pt_pct: pickStat(all, 'opponentThreePointFieldGoalPct'), }; } function normalizeMlb(payload) { const all = flattenTeamStats(payload); return { team_k_rate: pickStat(all, 'strikeOutRate') ?? pickStat(all, 'strikeoutsPerNine'), opponent_ppg: pickStat(all, 'runsAgainst'), }; } function normalizeFootball(payload) { const all = flattenTeamStats(payload); return { offensive_rating: pickStat(all, 'totalPoints'), defensive_rating: pickStat(all, 'pointsAgainst'), opponent_ppg: pickStat(all, 'avgPointsAgainst'), }; } function normalize(sport, payload) { switch (sport) { case 'nba': case 'wnba': case 'ncaab': return normalizeBasketball(payload); case 'mlb': return normalizeMlb(payload); case 'nfl': case 'ncaafb': return normalizeFootball(payload); case 'nhl': default: return flattenTeamStats(payload).reduce((acc, s) => { if (s?.name && Number.isFinite(Number(s.value))) acc[s.name] = Number(s.value); return acc; }, {}); } } async function fetchTeamStatsRaw(sport, teamId) { const path = SPORT_PATH[sport]; if (!path) return null; await limiter.waitForToken(); return breaker.call(async () => { const res = await axios.get(`${ESPN_BASE}/${path}/teams/${teamId}/statistics`, { timeout: HTTP_TIMEOUT_MS, }); return res.data; }); } async function listTeams(sport) { const path = SPORT_PATH[sport]; if (!path) return []; await limiter.waitForToken(); const res = await axios.get(`${ESPN_BASE}/${path}/teams`, { timeout: HTTP_TIMEOUT_MS }); const groups = res.data?.sports?.[0]?.leagues?.[0]?.teams || []; return groups .map((t) => t?.team) .filter(Boolean) .map((t) => ({ id: String(t.id), abbr: t.abbreviation, name: t.displayName })); } async function refreshTeamStats(sport) { const teams = await listTeams(sport); // Two-pass: fetch every team's stats first, then rank across the league // so we can normalize opponent rank to 0..1. A raw defensive_rating // means different things across sports (NBA ~100-120, NHL ~2.5-3.5 // goals/game), so the cache stores both: raw + normalized. const fetched = []; let captured = 0; let errored = 0; for (const team of teams) { try { const raw = await fetchTeamStatsRaw(sport, team.id); if (!raw) { errored += 1; continue; } const stats = normalize(sport, raw); fetched.push({ team, stats }); captured += 1; } catch (err) { if (err?.code !== 'CIRCUIT_OPEN') { console.warn(`[teamStats] ${sport}/${team.abbr} failed: ${err?.message}`); } errored += 1; } } // Rank teams by defensive_rating ascending (lower allowed = better D). // Then map each team's rank to [0, 1] — 0 = best D (hardest matchup), // 1 = worst D (easiest matchup). The feature cache uses this directly. const withDef = fetched.filter((f) => Number.isFinite(Number(f.stats.defensive_rating))); withDef.sort((a, b) => Number(a.stats.defensive_rating) - Number(b.stats.defensive_rating)); const total = withDef.length; for (let i = 0; i < withDef.length; i += 1) { withDef[i].stats.defensive_rank_normalized = total > 1 ? i / (total - 1) : 0.5; } for (const { team, stats } of fetched) { await cacheSet( teamCacheKey(sport, team.abbr), { ...stats, team_id: team.id, team_name: team.name }, CACHE_TTL_SECONDS, ); } return { captured, errored, total: teams.length }; } async function getTeamStats(sport, teamAbbr) { return cacheGet(teamCacheKey(sport, teamAbbr)); } // Returns the opponent's normalized defensive rank on a 0..1 scale. // 0.0 = best defense in the league (hardest matchup) // 1.0 = worst defense (easiest matchup) // Comparable across sports — NBA, NHL, NFL all collapse to the same // scale even though their raw defensive_rating values differ by orders // of magnitude. Returns null when we have no cache entry yet. async function getOpponentRank(sport, teamAbbr, _statType) { const stats = await getTeamStats(sport, teamAbbr); if (!stats) return null; if (Number.isFinite(Number(stats.defensive_rank_normalized))) { return Number(stats.defensive_rank_normalized); } // Backward-compat: if the cache predates the normalization upgrade, we // can't normalize a single-team read in isolation — return null and // let the feature cache omit the feature rather than emit a raw value. return null; } module.exports = { refreshTeamStats, getTeamStats, getOpponentRank, __internals: { listTeams, normalize, teamCacheKey, limiter, breaker }, };