Sessions 5-7a: 955 tests, deployment ready
This commit is contained in:
@@ -0,0 +1,231 @@
|
||||
/**
|
||||
* 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 },
|
||||
};
|
||||
Reference in New Issue
Block a user