'use strict'; /** * OPPONENT STRENGTH — `opp_rank_stat` (Session 64, Phase 3). * * ONE COLUMN, ONE MEANING, ACROSS SPORTS. This is the shared contract, taken * from WNBA's live behaviour, which is the reference implementation: * * scale : 0–1 * polarity : HIGH (>= 0.70) = WEAK opponent defence → favourable to the * hitter/scorer, lifts an OVER * LOW (<= 0.30) = TOUGH opponent defence → fades an OVER * * MLB previously had NO opp_rank_stat at all (ESPN's MLB team endpoint carries * no defensive metric), so engine1's ±1.0 opponent factor never fired for the * sport carrying most of our volume. This derives it from data we ALREADY * ingest, free and unauthenticated: statsapi's team pitching splits — one call * returns all 30 teams with avg (opponent batting average against), slg, ops, * homeRuns, strikeOuts, era, whip. * * POLARITY IS THE HIGHEST-RISK PART. A batting average AGAINST is a * "higher = worse pitching" stat, so it maps DIRECTLY to our scale: a team that * allows a high BAA is a weak opponent → high opp_rank_stat. Getting this * backwards would silently mis-adjust every MLB grade in the wrong direction, * which is far worse than having no value — so a test asserts MLB polarity * equals WNBA polarity, not merely that a number exists. * * HONEST NULLS: below the sample floor — either the opponent's own games or the * league baseline being too thin — this returns NULL. We are FIXING a silent * null here; we do not get to replace it with a confident guess off three games. */ // Which pitching field expresses "how easy is this opponent for THIS stat". // Each is a higher = weaker-opponent measure, matching the shared polarity. const MLB_STAT_FIELD = { hits: 'avg', // opponent batting average against total_bases: 'slg', // slugging against home_runs: 'homeRuns', // HR allowed runs: 'runs', rbi: 'runs', doubles: 'slg', triples: 'slg', walks: 'baseOnBalls', // Strikeouts are INVERTED: a staff that strikes out MORE batters is a TOUGHER // opponent for a batter's hits/TB props, but for a BATTER-strikeouts prop a // high-K staff makes the over EASIER. Handled by `invert` below. strikeouts: 'strikeOuts', }; // Fields where a HIGHER raw value means a TOUGHER opponent for the graded side, // so the percentile must be flipped to preserve "high = weak". const INVERTED_FOR_BATTER = new Set([]); // For a batter's own strikeout prop, a high-K staff HELPS the over — so it is // NOT inverted. Listed explicitly so the intent is readable rather than implied. const NOT_INVERTED = new Set(['strikeouts']); const MIN_OPPONENT_GAMES = Number(process.env.OPP_RANK_MIN_GAMES || 20); const MIN_LEAGUE_TEAMS = Number(process.env.OPP_RANK_MIN_TEAMS || 20); function num(v) { if (v == null || v === '') return null; const n = Number(v); return Number.isFinite(n) ? n : null; } /** * Percentile of `value` within `all` (0–1), where a HIGHER raw value yields a * HIGHER percentile. Ties share the midpoint. */ function percentile(value, all) { const xs = all.filter((v) => Number.isFinite(v)).sort((a, b) => a - b); if (xs.length < 2) return null; let below = 0; let equal = 0; for (const x of xs) { if (x < value) below += 1; else if (x === value) equal += 1; } return (below + equal / 2) / xs.length; } /** * Derive MLB opp_rank_stat. * * @param {Array} teams [{ teamId, stat: { avg, slg, gamesPlayed, ... } }] — every team * @param {string|number} opponentTeamId * @param {string} statType graded stat (hits, total_bases, ...) * @returns {{value:number|null, reason:string|null, field:string|null, raw:number|null}} */ function deriveMlbOppRank(teams, opponentTeamId, statType, opts = {}) { const field = MLB_STAT_FIELD[String(statType || '').toLowerCase()]; if (!field) return { value: null, reason: 'stat_not_mapped', field: null, raw: null }; const list = Array.isArray(teams) ? teams : []; // The league baseline must itself be real. Early season / partial feeds // produce a baseline that cannot rank anything honestly. if (list.length < (opts.minTeams ?? MIN_LEAGUE_TEAMS)) { return { value: null, reason: 'league_baseline_too_thin', field, raw: null }; } const opp = list.find((t) => String(t.teamId) === String(opponentTeamId)); if (!opp || !opp.stat) return { value: null, reason: 'opponent_not_found', field, raw: null }; const games = num(opp.stat.gamesPlayed); if (games != null && games < (opts.minGames ?? MIN_OPPONENT_GAMES)) { return { value: null, reason: 'opponent_sample_too_thin', field, raw: null }; } const raw = num(opp.stat[field]); if (raw == null) return { value: null, reason: 'field_absent', field, raw: null }; const all = list .filter((t) => { const g = num(t.stat && t.stat.gamesPlayed); return g == null || g >= (opts.minGames ?? MIN_OPPONENT_GAMES); }) .map((t) => num(t.stat && t.stat[field])) .filter((v) => v != null); if (all.length < (opts.minTeams ?? MIN_LEAGUE_TEAMS)) { return { value: null, reason: 'league_baseline_too_thin', field, raw }; } let p = percentile(raw, all); if (p == null) return { value: null, reason: 'percentile_undefined', field, raw }; // Preserve the shared polarity: HIGH = weak opponent. const invert = INVERTED_FOR_BATTER.has(String(statType).toLowerCase()) && !NOT_INVERTED.has(String(statType).toLowerCase()); if (invert) p = 1 - p; return { value: Math.round(p * 1000) / 1000, reason: null, field, raw }; } /** * Health check. We are fixing a SILENT null — so an empty source AND an * unexpected null for a sport we expect to derive both page. A derived metric * that quietly stops deriving is the failure mode this whole session has been * about. */ function opponentStrengthHealth({ sport, teamsLoaded = 0, derived = 0, attempted = 0 } = {}) { if (teamsLoaded === 0) { return { alarm: true, reason: `${String(sport).toUpperCase()} opponent-strength source returned NO teams — opp_rank_stat cannot be derived` }; } if (attempted > 0 && derived === 0) { return { alarm: true, reason: `${String(sport).toUpperCase()} opponent-strength derived NULL for all ${attempted} props despite ${teamsLoaded} teams loaded` }; } return { alarm: false, reason: null }; } module.exports = { deriveMlbOppRank, percentile, opponentStrengthHealth, MLB_STAT_FIELD, MIN_OPPONENT_GAMES, MIN_LEAGUE_TEAMS, };