'use strict'; /** * PLATOON SPLITS (Layer 3, Step 6) — the highest-value and highest-noise signal. * * The market often prices a hitter's BLENDED line while tonight he faces only * one handedness. That gap is the value. The danger is that platoon samples are * the thinnest in baseball — a hitter may see ~130 PA vs LHP in a season — so a * raw split is mostly noise. * * ── THE SPINE: REGRESS, OR THIS IS ACTIVELY HARMFUL ────────────────────── * Applying a raw split adjusts projections on noise, which is worse than not * building the adjuster at all. Every estimate here is shrunk toward a prior: * * regressed = (PA · observed + K · prior) / (PA + K) * * The PRIOR is the hitter's OWN overall rate, not the league's. The question a * platoon adjustment answers is "is he DIFFERENT against this hand than he * normally is" — so the null hypothesis is his own blended line, and a hitter * with no evidence of a split correctly gets no adjustment. * * K = 600 PA (env-tunable). Platoon skill is famously slow to stabilise; the * sabermetric literature puts the half-signal point for right-handed batters * near 1,000 PA vs a hand. 600 is deliberately conservative in the direction of * doing nothing: * 30 PA → 4.8% weight on the observed split (essentially the prior) * 130 PA → 18% * 400 PA → 40% * 1,000 PA → 63% * A 30-PA .310 hitter vs LHP therefore moves the projection almost not at all, * which is the correct answer, not a limitation. * * ── IT IS A MATCHUP SIGNAL, NOT AN ENVIRONMENT ─────────────────────────── * Park and weather compose into one environment coefficient. Platoon does NOT * join them: it is a property of this hitter against this pitcher's hand, and * entangling it with the stadium would make both harder to attribute when the * instrument scores them. */ /** Shrinkage constant, in PA vs the hand. Higher = more conservative. */ const K_PA = Number(process.env.PLATOON_K_PA) || 600; /** Below this the adjustment is reported as immaterial (it will already be * tiny from the regression; this is the honesty label, not the mechanism). */ const MATERIAL_THRESHOLD = Number(process.env.PLATOON_MATERIAL) || 0.02; /** Hard cap. Platoon leans; it never re-forecasts. */ const MAX_MULT = Number(process.env.PLATOON_MAX_MULT) || 0.15; /** Stats a platoon split speaks to. Absent elsewhere — we are not inventing a * platoon effect on a strikeout-allowed prop. */ const PLATOON_STATS = Object.freeze(new Set([ 'hits', 'home_runs', 'total_bases', 'rbi', 'runs', 'doubles', 'strikeouts', 'walks', ])); /** Which observed rate speaks to which stat. */ const STAT_RATE = Object.freeze({ hits: 'avg', doubles: 'avg', runs: 'ops', rbi: 'ops', home_runs: 'slg', total_bases: 'slg', strikeouts: 'k_rate', walks: 'bb_rate', }); /** Stats where a HIGHER rate means a LOWER prop outcome. */ const INVERTED = Object.freeze(new Set(['strikeouts'])); // MIGRATED to the shared guard (2026-08-02). `Number(null) === 0` has produced // at least SIX separate defects in this codebase, including one in a module // written the same week its author documented the trap — per-module vigilance // has demonstrably failed. Semantics are byte-identical to the local copy this // replaces, so no output changes; the point is that there is now ONE rule. const { knownNumber } = require('../utils/known'); const num = knownNumber; const clamp = (v, lo, hi) => (v < lo ? lo : v > hi ? hi : v); /** * regressSplit({ observedRate, observedPa, priorRate, k }) — PURE. * The whole safety mechanism in six lines. Returns the shrunk rate plus the * weight actually given to the observation, so callers can SEE how much of the * estimate is evidence and how much is prior. */ function regressSplit({ observedRate, observedPa, priorRate, k = K_PA } = {}) { const obs = num(observedRate); const pa = num(observedPa); const prior = num(priorRate); if (prior == null) return { rate: null, weight: 0, reason: 'no prior' }; if (obs == null || pa == null || pa <= 0) { return { rate: prior, weight: 0, reason: 'no observed split — prior stands' }; } const weight = pa / (pa + k); return { rate: obs * weight + prior * (1 - weight), weight: Math.round(weight * 1000) / 1000, observed_pa: pa, reason: null, }; } /** * platoonEstimate({ splits, batterHand, pitcherHand, statType }) — the signal. * * splits: { vsL: {pa, avg, ops, slg, k_rate, bb_rate}, vsR: {…}, overall: {…} } * Returns { multiplier, state, material, ... }. 1.0 is a true no-op and is * reached honestly and OFTEN — most hitters do not have enough evidence of a * split to move a projection, and saying so is the correct output. */ function platoonEstimate({ splits, batterHand, pitcherHand, statType } = {}) { const stat = String(statType || '').toLowerCase(); const neutral = (state, reason, extra = {}) => ({ multiplier: 1, state, reason, material: false, regressed_rate: null, prior_rate: null, observed_weight: 0, ...extra, }); if (!PLATOON_STATS.has(stat)) return neutral('not_applicable', 'platoon says nothing about this stat'); // GATE: without the hitter's handedness there is no platoon question to ask. // (statcast_aggregates.bats is 100% populated since Session 69, but a prop // for a player we never resolved still lands here.) if (!batterHand) return neutral('batter_hand_absent', 'no batter handedness'); const ph = String(pitcherHand || '').toUpperCase(); if (ph !== 'L' && ph !== 'R') return neutral('pitcher_hand_absent', "tonight's pitcher handedness unknown"); if (!splits || !splits.overall) return neutral('splits_absent', 'no split data for this hitter'); const side = ph === 'L' ? splits.vsL : splits.vsR; const rateKey = STAT_RATE[stat]; const prior = num(splits.overall[rateKey]); if (prior == null || prior === 0) return neutral('no_prior', 'hitter has no overall rate for this stat'); const reg = regressSplit({ observedRate: side ? num(side[rateKey]) : null, observedPa: side ? num(side.pa) : null, priorRate: prior, }); if (reg.rate == null) return neutral('no_prior', reg.reason); // The adjustment is the REGRESSED split relative to his own blended line. let ratio = reg.rate / prior; if (INVERTED.has(stat)) ratio = ratio === 0 ? 1 : 1 / ratio; const capped = clamp(ratio - 1, -MAX_MULT, MAX_MULT); const multiplier = Math.round((1 + capped) * 1000) / 1000; const material = Math.abs(multiplier - 1) >= MATERIAL_THRESHOLD; return { multiplier, state: 'present', reason: null, material, batter_hand: batterHand, pitcher_hand: ph, rate_key: rateKey, prior_rate: Math.round(prior * 10000) / 10000, observed_rate: side ? num(side[rateKey]) : null, observed_pa: reg.observed_pa ?? 0, observed_weight: reg.weight, // how much is evidence vs prior regressed_rate: Math.round(reg.rate * 10000) / 10000, capped: capped !== ratio - 1, }; } /** statsapi platoon splits for one hitter. `vl`/`vr` = vs left/right pitching. */ const SPLITS_URL = (playerId, season) => `https://statsapi.mlb.com/api/v1/people/${playerId}/stats` + `?stats=statSplits&sitCodes=vl,vr&group=hitting&season=${season}`; /** * parseSplits(payload, overall) — PURE. statsapi → the shape platoonEstimate * wants. A side with no rows is ABSENT, never zero-filled. */ function parseSplits(payload, overall = null) { const out = { vsL: null, vsR: null, overall: overall || null }; for (const st of (payload && payload.stats) || []) { for (const sp of st.splits || []) { const code = (sp.split && sp.split.code) || ''; const s = sp.stat || {}; const pa = num(s.plateAppearances); const row = { pa, avg: num(s.avg), ops: num(s.ops), slg: num(s.slg), obp: num(s.obp), k_rate: pa > 0 && num(s.strikeOuts) != null ? num(s.strikeOuts) / pa : null, bb_rate: pa > 0 && num(s.baseOnBalls) != null ? num(s.baseOnBalls) / pa : null, }; if (code === 'vl') out.vsL = row; if (code === 'vr') out.vsR = row; } } // Overall = the two sides combined, when a blended line was not supplied. if (!out.overall && (out.vsL || out.vsR)) { const parts = [out.vsL, out.vsR].filter((r) => r && r.pa); const totalPa = parts.reduce((a, r) => a + r.pa, 0); if (totalPa > 0) { const w = (key) => { const vals = parts.filter((r) => num(r[key]) != null); if (!vals.length) return null; const d = vals.reduce((a, r) => a + r.pa, 0); return d > 0 ? vals.reduce((a, r) => a + r[key] * r.pa, 0) / d : null; }; out.overall = { pa: totalPa, avg: w('avg'), ops: w('ops'), slg: w('slg'), obp: w('obp'), k_rate: w('k_rate'), bb_rate: w('bb_rate') }; } } return out; } module.exports = { platoonEstimate, regressSplit, parseSplits, SPLITS_URL, K_PA, MATERIAL_THRESHOLD, MAX_MULT, PLATOON_STATS, STAT_RATE, };