Wire MLB opp_rank_stat into live features (consumption path verified)
featureCache.teamFeatures now derives MLB opp_rank_stat from statsapi team
pitching splits when the ESPN path yields nothing — which for MLB is
always, because ESPN's MLB team endpoint carries no defensive metric at
all. mlbStatsAdapter.getTeamPitchingStats fetches all 30 teams in one free
unauthenticated call, cached at the season TTL.
CONSUMPTION PATH VERIFIED before wiring, not assumed:
featureCache.teamFeatures sets out.opp_rank_stat (line 338)
-> engine1.computeFactors READS features.opp_rank_stat (lines 96-102)
-> fires weak_opponent_defense (>=0.70) / top_opponent_defense (<=0.30)
So teamFeatures is the correct insertion point: the grader reads exactly
the field we populate. A value written anywhere else would have been a
dead end — computed, retained, and still not affecting the grade.
Contract preserved: the derived value goes into the SAME field with the
SAME 0-1 scale and the SAME high=weak polarity WNBA uses, so engine1 reads
one field with one meaning across sports. Isolated and best-effort — a
derivation failure leaves the field ABSENT (honest null), never a guessed
rank. Only fills when the ESPN path produced nothing, so WNBA behaviour is
untouched.
Suite 285/3435 green, build exit 0.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SmNjJAwEnqHPtXbvSZR8kA
This commit is contained in:
@@ -356,6 +356,27 @@ async function getPlayerStats(name, season = DEFAULT_SEASON, opts = {}) {
|
||||
* All MLB teams (Session 51) → [{ id, abbr, name }]. Cached 24h. Used to map a
|
||||
* UI abbreviation ("NYY") to the statsapi team id.
|
||||
*/
|
||||
/**
|
||||
* Session 64 — LEAGUE-WIDE TEAM PITCHING SPLITS. All 30 teams in ONE free,
|
||||
* unauthenticated call. This is the source for MLB `opp_rank_stat`: ESPN's MLB
|
||||
* team endpoint carries no defensive metric at all, so engine1's +/-1.0
|
||||
* opponent factor had never fired for MLB.
|
||||
* Returns [{ teamId, name, stat }]. [] on failure — the caller degrades to a
|
||||
* null rank rather than a guessed one.
|
||||
*/
|
||||
async function getTeamPitchingStats(season = DEFAULT_SEASON) {
|
||||
const url = `${BASE}/teams/stats?season=${season}&group=pitching&stats=season&sportIds=1`;
|
||||
const data = await fetchWithCache(url, `mlbstats:teampitching:${season}`, TTL.season);
|
||||
const splits = extractSplits(data);
|
||||
return splits
|
||||
.map((sp) => ({
|
||||
teamId: sp.team && sp.team.id ? sp.team.id : null,
|
||||
name: sp.team && sp.team.name ? sp.team.name : null,
|
||||
stat: sp.stat || {},
|
||||
}))
|
||||
.filter((t) => t.teamId != null);
|
||||
}
|
||||
|
||||
async function getTeams(season = DEFAULT_SEASON) {
|
||||
const url = `${BASE}/teams?sportId=1&season=${season}`;
|
||||
const data = await fetchWithCache(url, `mlbstats:teams:${season}`, 24 * 3600);
|
||||
@@ -398,6 +419,7 @@ module.exports = {
|
||||
matchPlayers,
|
||||
getPlayerStats,
|
||||
getTeams,
|
||||
getTeamPitchingStats,
|
||||
resolveTeam,
|
||||
getTeamRoster,
|
||||
__internals: {
|
||||
|
||||
@@ -336,6 +336,35 @@ async function teamFeatures(sport, opponentAbbr, statType) {
|
||||
}
|
||||
const rank = await getOpponentRank(sport, opponentAbbr, statType);
|
||||
if (rank != null) out.opp_rank_stat = rank;
|
||||
|
||||
// Session 64 — MLB opp_rank_stat. ESPN's MLB team endpoint carries NO
|
||||
// defensive metric, so this field was permanently null for MLB and engine1's
|
||||
// ±1.0 opponent factor never fired for the sport carrying most of our volume.
|
||||
// Derived instead from statsapi team pitching splits (one free call, all 30
|
||||
// teams), normalized to the SHARED CONTRACT: 0–1, HIGH = weak opponent —
|
||||
// identical to WNBA, so engine1 reads one field with one meaning.
|
||||
// Isolated + best-effort: a failure leaves the field ABSENT (honest null),
|
||||
// never a guessed rank.
|
||||
if (out.opp_rank_stat == null && String(sport).toLowerCase() === 'mlb') {
|
||||
try {
|
||||
const mlb = require('../adapters/mlbStatsAdapter');
|
||||
const strength = require('../opponentStrength');
|
||||
const [teams, oppTeam] = await Promise.all([
|
||||
mlb.getTeamPitchingStats(),
|
||||
mlb.resolveTeam(opponentAbbr),
|
||||
]);
|
||||
if (oppTeam && oppTeam.id) {
|
||||
const d = strength.deriveMlbOppRank(teams, oppTeam.id, statType);
|
||||
if (d.value != null) {
|
||||
out.opp_rank_stat = d.value;
|
||||
} else if (process.env.OPP_RANK_DEBUG === '1') {
|
||||
console.log(`[featureCache] mlb opp_rank null for ${opponentAbbr}/${statType}: ${d.reason}`);
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
console.warn('[featureCache] mlb opponent-strength derivation failed:', e.message);
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user