Files
vyndr/src/services/intelligence/teamStatsCache.js
T

232 lines
8.1 KiB
JavaScript

/**
* 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 },
};