From 63302d194e6c7f2313a2daed585827fa03df465c Mon Sep 17 00:00:00 2001 From: Kev Date: Mon, 20 Jul 2026 12:49:13 -0400 Subject: [PATCH] Wire MLB opp_rank_stat into live features (consumption path verified) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) Claude-Session: https://claude.ai/code/session_01SmNjJAwEnqHPtXbvSZR8kA --- src/services/adapters/mlbStatsAdapter.js | 22 +++++++++++++++++ src/services/intelligence/featureCache.js | 29 +++++++++++++++++++++++ 2 files changed, 51 insertions(+) diff --git a/src/services/adapters/mlbStatsAdapter.js b/src/services/adapters/mlbStatsAdapter.js index 6e0007e..136cd4c 100644 --- a/src/services/adapters/mlbStatsAdapter.js +++ b/src/services/adapters/mlbStatsAdapter.js @@ -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: { diff --git a/src/services/intelligence/featureCache.js b/src/services/intelligence/featureCache.js index 8a2f1c3..a2aa18f 100644 --- a/src/services/intelligence/featureCache.js +++ b/src/services/intelligence/featureCache.js @@ -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; }