Sessions 5-7a: 955 tests, deployment ready
This commit is contained in:
@@ -0,0 +1,268 @@
|
||||
/**
|
||||
* Feature cache — the central feature-vector builder for every prop.
|
||||
*
|
||||
* Philosophy: features are OMITTED when the underlying data source is
|
||||
* unavailable, never zeroed. Engine 2 handles variable-length feature
|
||||
* sets; a zero would lie to the model about what we actually know.
|
||||
*
|
||||
* Per-feature TTL categories (Redis):
|
||||
* game_log: 4h — game logs refresh once per night
|
||||
* team: 24h — opponent stats are daily
|
||||
* coach: 30d — coach profiles are rare to change
|
||||
* ref: 12h — assignments published morning of game day
|
||||
* injury: 2h — injuries change at shootaround
|
||||
* line: 2m — line state changes constantly during the day
|
||||
* context: none — computed on demand (home/away, rest days)
|
||||
*
|
||||
* Cache key: features:{sport}:{playerId}:{statType}:{gameId}
|
||||
* The full vector is cached for 2 minutes so repeated calls during the
|
||||
* same grading cycle don't recompute. After 2 minutes, individual
|
||||
* features get refreshed from their own caches.
|
||||
*/
|
||||
|
||||
const { cacheGet, cacheSet } = require('../../utils/redis');
|
||||
const { getTeamStats, getOpponentRank } = require('./teamStatsCache');
|
||||
const { getRefImpact } = require('./refSignals');
|
||||
const { getCoachImpact } = require('./coachSignals');
|
||||
const { roleValue } = require('./lineupSignals');
|
||||
const { getTeamInjuries } = require('./injuryParser');
|
||||
const { getLineMovement } = require('./lineMovement');
|
||||
const gameLogs = require('./gameLogService');
|
||||
|
||||
const VECTOR_TTL_SECONDS = 120;
|
||||
|
||||
function avg(values) {
|
||||
const clean = values.filter((v) => Number.isFinite(v));
|
||||
if (clean.length === 0) return null;
|
||||
return clean.reduce((a, b) => a + b, 0) / clean.length;
|
||||
}
|
||||
|
||||
function stddev(values) {
|
||||
const clean = values.filter((v) => Number.isFinite(v));
|
||||
if (clean.length < 2) return null;
|
||||
const mean = avg(clean);
|
||||
const sq = clean.reduce((sum, v) => sum + (v - mean) ** 2, 0);
|
||||
return Math.sqrt(sq / (clean.length - 1));
|
||||
}
|
||||
|
||||
// Extract a stat value from a single game-log entry by stat type. Game-log
|
||||
// rows out of the Python service are keyed by stat name (points,
|
||||
// rebounds, etc.) and combo stats need to be summed at read time.
|
||||
function statFromGameLog(row, statType) {
|
||||
if (!row) return null;
|
||||
switch (statType) {
|
||||
case 'pts_reb_ast': {
|
||||
const s = (Number(row.points) || 0) + (Number(row.rebounds) || 0) + (Number(row.assists) || 0);
|
||||
return s;
|
||||
}
|
||||
case 'pts_reb':
|
||||
return (Number(row.points) || 0) + (Number(row.rebounds) || 0);
|
||||
case 'pts_ast':
|
||||
return (Number(row.points) || 0) + (Number(row.assists) || 0);
|
||||
case 'reb_ast':
|
||||
return (Number(row.rebounds) || 0) + (Number(row.assists) || 0);
|
||||
case 'stl_blk':
|
||||
return (Number(row.steals) || 0) + (Number(row.blocks) || 0);
|
||||
default: {
|
||||
const v = Number(row[statType]);
|
||||
return Number.isFinite(v) ? v : null;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function daysBetween(aIso, bIso) {
|
||||
const ms = new Date(aIso).getTime() - new Date(bIso).getTime();
|
||||
if (!Number.isFinite(ms)) return null;
|
||||
return Math.floor(ms / (1000 * 60 * 60 * 24));
|
||||
}
|
||||
|
||||
async function gameLogFeatures(playerName, sport, statType) {
|
||||
const logs = await gameLogs.getGameLogs(playerName, sport, 20);
|
||||
if (!logs || logs.length === 0) return {};
|
||||
|
||||
const valuesAll = logs.map((row) => statFromGameLog(row, statType)).filter((v) => v != null);
|
||||
const l5 = valuesAll.slice(0, 5);
|
||||
const l20 = valuesAll;
|
||||
const l10 = valuesAll.slice(0, 10);
|
||||
|
||||
const out = {};
|
||||
const m5 = avg(l5);
|
||||
const m20 = avg(l20);
|
||||
const s10 = stddev(l10);
|
||||
if (m5 != null) out.l5_avg = m5;
|
||||
if (m20 != null) out.l20_avg = m20;
|
||||
if (s10 != null) out.l10_stddev = s10;
|
||||
|
||||
// Career playoff games is a separate endpoint.
|
||||
const cp = await gameLogs.getCareerPlayoffGames(playerName, sport);
|
||||
if (Number.isFinite(cp)) out.career_playoff_games = cp;
|
||||
return out;
|
||||
}
|
||||
|
||||
async function teamFeatures(sport, opponentAbbr, statType) {
|
||||
const out = {};
|
||||
if (!opponentAbbr) return out;
|
||||
const oppStats = await getTeamStats(sport, opponentAbbr);
|
||||
if (oppStats) {
|
||||
if (Number.isFinite(oppStats.pace)) out.pace_factor = oppStats.pace;
|
||||
if (Number.isFinite(oppStats.pace)) out.team_pace = oppStats.pace;
|
||||
}
|
||||
const rank = await getOpponentRank(sport, opponentAbbr, statType);
|
||||
if (rank != null) out.opp_rank_stat = rank;
|
||||
return out;
|
||||
}
|
||||
|
||||
function contextFeatures(gameContext = {}) {
|
||||
const out = {};
|
||||
if (gameContext.home_away === 'home') out.home_away = 1.0;
|
||||
else if (gameContext.home_away === 'away') out.home_away = 0.0;
|
||||
if (Number.isFinite(gameContext.rest_days)) out.rest_days = gameContext.rest_days;
|
||||
if (Number.isFinite(gameContext.game_count_in_7d)) out.game_count_in_7d = gameContext.game_count_in_7d;
|
||||
if (gameContext.season_type != null) out.season_type = gameContext.season_type;
|
||||
if (Number.isFinite(gameContext.game_in_series)) out.game_in_series = gameContext.game_in_series;
|
||||
if (Number.isFinite(gameContext.season_phase)) out.season_phase = gameContext.season_phase;
|
||||
return out;
|
||||
}
|
||||
|
||||
async function injuryFeatures(sport, teamId, knownStarterIds = []) {
|
||||
const out = {};
|
||||
if (!teamId) return out;
|
||||
const list = await getTeamInjuries(sport, teamId);
|
||||
if (!list || list.length === 0) {
|
||||
out.injury_severity_score = 0;
|
||||
return out;
|
||||
}
|
||||
const starterSet = new Set(knownStarterIds.map(String));
|
||||
const missingStarters = list.filter(
|
||||
(i) => starterSet.has(i.playerId) && (i.status === 'OUT' || i.status === 'DOUBTFUL')
|
||||
);
|
||||
out.injury_severity_score = Math.min(5, missingStarters.length);
|
||||
|
||||
// Teammate-absence bump: a league-average constant when we don't have
|
||||
// with/without splits for this player. Engine 2 can replace this with
|
||||
// a learned value over time.
|
||||
if (missingStarters.length > 0) out.teammate_absence_bump = 0.05 * missingStarters.length;
|
||||
return out;
|
||||
}
|
||||
|
||||
async function lineFeatures(gameId, playerName, statType) {
|
||||
const lm = await getLineMovement(gameId, playerName, statType);
|
||||
if (!lm) return {};
|
||||
return { line_delta: lm.movement };
|
||||
}
|
||||
|
||||
async function refFeatures(gameId) {
|
||||
const impact = await getRefImpact(gameId);
|
||||
if (!impact) return {};
|
||||
const out = {};
|
||||
if (Number.isFinite(impact.pace_impact)) out.ref_pace_adjustment = impact.pace_impact;
|
||||
if (Number.isFinite(impact.foul_adjustment)) out.ref_foul_adjustment = impact.foul_adjustment;
|
||||
return out;
|
||||
}
|
||||
|
||||
async function coachFeatures(sport, teamAbbr, gameContext = {}) {
|
||||
const impact = await getCoachImpact(sport, teamAbbr, gameContext);
|
||||
if (!impact) return {};
|
||||
const out = {};
|
||||
if (Number.isFinite(impact.adjusted_pace_delta)) out.coach_pace_delta = impact.adjusted_pace_delta;
|
||||
if (Number.isFinite(impact.without_primary_pace_shift)) {
|
||||
out.coach_player_interaction = impact.without_primary_pace_shift;
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
function lineupFeatures(role) {
|
||||
if (!role) return {};
|
||||
return { lineup_ball_handler_role: roleValue(role) };
|
||||
}
|
||||
|
||||
// Top-level: build the full vector. Each sub-call is independent so a
|
||||
// failure in one (e.g. ref assignments not yet published) just omits its
|
||||
// feature and the rest of the vector is still useful.
|
||||
async function getFeatures(input = {}) {
|
||||
const {
|
||||
playerId,
|
||||
playerName,
|
||||
statType,
|
||||
sport,
|
||||
teamAbbr,
|
||||
opponentAbbr,
|
||||
teamId,
|
||||
opponentTeamId,
|
||||
gameId,
|
||||
gameContext,
|
||||
role,
|
||||
knownStarterIds = [],
|
||||
} = input;
|
||||
|
||||
const cacheKey = `features:${sport}:${playerId}:${statType}:${gameId}`;
|
||||
const cached = await cacheGet(cacheKey);
|
||||
if (cached) return cached;
|
||||
|
||||
const [gl, team, ctx, injury, line, ref, coach, lineup] = await Promise.all([
|
||||
gameLogFeatures(playerName, sport, statType),
|
||||
teamFeatures(sport, opponentAbbr, statType),
|
||||
Promise.resolve(contextFeatures(gameContext)),
|
||||
injuryFeatures(sport, teamId, knownStarterIds),
|
||||
lineFeatures(gameId, playerName, statType),
|
||||
refFeatures(gameId),
|
||||
coachFeatures(sport, teamAbbr, gameContext),
|
||||
Promise.resolve(lineupFeatures(role)),
|
||||
]);
|
||||
|
||||
const features = { ...gl, ...team, ...ctx, ...injury, ...line, ...ref, ...coach, ...lineup };
|
||||
const FEATURE_NAMES = [
|
||||
'l5_avg', 'l20_avg', 'l10_stddev', 'career_playoff_games',
|
||||
'opp_rank_stat', 'pace_factor', 'team_pace',
|
||||
'home_away', 'rest_days', 'game_count_in_7d', 'season_type', 'game_in_series', 'season_phase',
|
||||
'teammate_absence_bump', 'primary_stat_suppression', 'injury_severity_score',
|
||||
'line_delta',
|
||||
'ref_pace_adjustment', 'ref_foul_adjustment',
|
||||
'coach_pace_delta', 'coach_player_interaction',
|
||||
'lineup_ball_handler_role',
|
||||
];
|
||||
const available = FEATURE_NAMES.filter((n) => features[n] != null);
|
||||
const missing = FEATURE_NAMES.filter((n) => features[n] == null);
|
||||
|
||||
const payload = {
|
||||
features,
|
||||
meta: {
|
||||
computed_at: new Date().toISOString(),
|
||||
features_available: available,
|
||||
features_missing: missing,
|
||||
},
|
||||
};
|
||||
await cacheSet(cacheKey, payload, VECTOR_TTL_SECONDS);
|
||||
return payload;
|
||||
}
|
||||
|
||||
async function clearCache(cacheKey) {
|
||||
// Hook for tests + manual invalidation.
|
||||
const { cacheDel } = require('../../utils/redis');
|
||||
return cacheDel(cacheKey);
|
||||
}
|
||||
|
||||
function getCacheStats() {
|
||||
return { ttlSeconds: VECTOR_TTL_SECONDS };
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
getFeatures,
|
||||
clearCache,
|
||||
getCacheStats,
|
||||
// Internal helpers exported for unit tests + Engine 2 reuse.
|
||||
__internals: {
|
||||
gameLogFeatures,
|
||||
teamFeatures,
|
||||
contextFeatures,
|
||||
injuryFeatures,
|
||||
lineFeatures,
|
||||
refFeatures,
|
||||
coachFeatures,
|
||||
lineupFeatures,
|
||||
statFromGameLog,
|
||||
avg,
|
||||
stddev,
|
||||
daysBetween,
|
||||
},
|
||||
};
|
||||
Reference in New Issue
Block a user