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; }