diff --git a/src/services/lineupContextService.js b/src/services/lineupContextService.js index c14aa42..79e603b 100644 --- a/src/services/lineupContextService.js +++ b/src/services/lineupContextService.js @@ -69,7 +69,7 @@ async function getJson(url, opts = {}) { */ async function fetchLineups(date, opts = {}) { const d = String(date || dateET()).slice(0, 10); - const url = `${BASE}/schedule?sportId=1&date=${d}&hydrate=lineups`; + const url = `${BASE}/schedule?sportId=1&date=${d}&hydrate=lineups,venue`; let data = null; try { data = await getJson(url, opts); } catch { return []; } const games = ((data && data.dates) || []).flatMap((day) => day.games || []); @@ -86,6 +86,9 @@ async function fetchLineups(date, opts = {}) { if (!p || !p.fullName) return; out.push({ game_pk: g.gamePk ?? null, + // The venue the game is ACTUALLY at — never inferred from the home + // team, which is wrong for neutral-site and international games. + venue_id: (g.venue && knownNumber(g.venue.id)) ?? null, game_date: String(g.gameDate || '').slice(0, 10) || d, team, side, @@ -144,6 +147,65 @@ async function fetchOpportunity(sourceId, season, opts = {}) { }; } +/** + * PLATOON SPLITS — a hitter's own vs-LHP / vs-RHP sample, from the same + * situational-splits endpoint the RISP opportunity uses. One call per hitter. + * + * Returns null when the splits are unavailable — never a symmetric guess, which + * would assert the hitter has no platoon split at all. + */ +async function fetchPlatoonSplits(sourceId, season, opts = {}) { + const id = knownNumber(sourceId); + if (id == null) return null; + const url = `${BASE}/people/${id}/stats?stats=statSplits&sitCodes=vl,vr&season=${season}&group=hitting`; + let data = null; + try { data = await getJson(url, opts); } catch { return null; } + const splits = ((data && data.stats) || []).flatMap((s) => s.splits || []); + if (splits.length === 0) return null; + const out = {}; + for (const sp of splits) { + const code = sp && sp.split && sp.split.code; + const st = (sp && sp.stat) || {}; + if (code !== 'vl' && code !== 'vr') continue; + out[`${code}_pa`] = knownNumber(st.plateAppearances); + out[`${code}_ab`] = knownNumber(st.atBats); + out[`${code}_hits`] = knownNumber(st.hits); + } + return Object.keys(out).length ? out : null; +} + +/** + * PARK DIMENSIONS — venue geometry from statsapi. Free, and the input a + * hits/hit-type park factor needs; parkFactors holds run coefficients, which + * cannot express a park that turns outs into hits without scoring. + */ +async function fetchParkDimensions(venueId, opts = {}) { + const id = knownNumber(venueId); + if (id == null) return null; + const url = `${BASE}/venues/${id}?hydrate=location,fieldInfo`; + let data = null; + try { data = await getJson(url, opts); } catch { return null; } + const v = ((data && data.venues) || [])[0]; + if (!v) return null; + const f = v.fieldInfo || {}; + const loc = v.location || {}; + const dims = { + venue_id: id, + venue_name: v.name || null, + left_line: knownNumber(f.leftLine), + left_center: knownNumber(f.leftCenter), + center: knownNumber(f.center), + right_center: knownNumber(f.rightCenter), + right_line: knownNumber(f.rightLine), + roof_type: f.roofType || null, + turf_type: f.turfType || null, + elevation: knownNumber(loc.elevation), + }; + // A venue with no geometry at all is ABSENT, not a park with zero dimensions. + const hasGeometry = ['left_line', 'center', 'right_line'].some((k) => dims[k] !== null); + return hasGeometry ? dims : null; +} + /** * Persist a slate's lineups. Best-effort like every other side-write: a context * failure must never break the pipeline it rides in. @@ -203,6 +265,36 @@ async function refreshContext(opts = {}) { }); } summary.opportunity_rows = rows.length; + + // PLATOON — same endpoint family, one call per hitter (season aggregate). + const platoonRows = []; + const getPlat = opts.fetchPlatoonSplits || fetchPlatoonSplits; + for (const p of ids.slice(0, opts.maxPlayers || 400)) { + const sp = await getPlat(p.source_id, season, opts); + if (!sp) continue; // absent, never a symmetric guess + platoonRows.push({ player_key: p.player_key, player_name: p.player_name, source_id: p.source_id, ...sp }); + } + summary.platoon_rows = platoonRows.length; + if (sb && platoonRows.length) { + const res = await persistPlatoon(sb, platoonRows, { sport, season, asOf: opts.asOfDate }); + summary.platoon = res.written; + if (res.error) summary.platoon_error = res.error; + } + + // PARK DIMENSIONS — one call per distinct venue on the slate. + const venueIds = [...new Set(lineups.map((l) => l.venue_id).filter((v) => v != null))]; + const dimRows = []; + const getDims = opts.fetchParkDimensions || fetchParkDimensions; + for (const vid of venueIds) { + const d = await getDims(vid, opts); + if (d) dimRows.push(d); + } + summary.park_dimension_rows = dimRows.length; + if (sb && dimRows.length) { + const res = await persistParkDimensions(sb, dimRows, { sport, asOf: opts.asOfDate }); + summary.park_dimensions = res.written; + if (res.error) summary.park_dimensions_error = res.error; + } if (sb && rows.length) { const res = await persistOpportunity(sb, rows, { sport, season, asOf: opts.asOfDate }); summary.opportunity = res.written; @@ -216,7 +308,26 @@ async function refreshContext(opts = {}) { return summary; } +async function persistPlatoon(sb, rows, { sport = 'mlb', season, asOf = null } = {}) { + if (!sb || !rows || rows.length === 0) return { written: 0 }; + const as_of_date = asOf || dateET(); + const { error } = await sb.from('platoon_splits') + .upsert(rows.map((r) => ({ ...r, sport, season, as_of_date })), + { onConflict: 'as_of_date,sport,season,player_key' }); + return error ? { written: 0, error: error.message } : { written: rows.length }; +} + +async function persistParkDimensions(sb, rows, { sport = 'mlb', asOf = null } = {}) { + if (!sb || !rows || rows.length === 0) return { written: 0 }; + const as_of_date = asOf || dateET(); + const { error } = await sb.from('park_dimensions') + .upsert(rows.map((r) => ({ ...r, sport, as_of_date })), + { onConflict: 'as_of_date,sport,venue_id' }); + return error ? { written: 0, error: error.message } : { written: rows.length }; +} + module.exports = { - fetchLineups, fetchOpportunity, persistLineups, persistOpportunity, + fetchLineups, fetchOpportunity, fetchPlatoonSplits, fetchParkDimensions, + persistLineups, persistOpportunity, persistPlatoon, persistParkDimensions, refreshContext, dateET, }; diff --git a/src/services/model/platoonSeverity.js b/src/services/model/platoonSeverity.js new file mode 100644 index 0000000..8575869 --- /dev/null +++ b/src/services/model/platoonSeverity.js @@ -0,0 +1,138 @@ +'use strict'; + +/** + * platoonSeverity — THE CAUSALLY-CORRECT PLATOON ATOM. + * + * The crude version is "left-handed hitter versus right-handed pitcher, add a + * boost." It failed the two-part gate for hits (Brier −0.0039, corrected + * interval spanning zero) for the same reason team-average defence did: it is + * not the unit the causal story runs through. + * + * The platoon advantage is only worth what THIS hitter's split is actually + * worth. Some left-handed hitters genuinely cannot hit left-handed pitching; + * others have essentially no split at all, and applying a flat league boost to + * both describes neither. Measured on a real hitter: .284 against left-handed + * pitching versus .221 against right-handed — a 63-point split, where the flat + * factor would have applied the same ±6% to a hitter with none. + * + * ── SAMPLE DISCIPLINE, WHICH IS MOST OF THE WORK ───────────────────────── + * A split measured over 40 plate appearances is noise wearing a decimal point. + * Two rules, and the second matters more: + * + * SHRINK the observed split toward the league split, weighted by sample. + * An established hitter keeps his own number; a thin one is pulled + * toward what hitters like him do. + * REFUSE below a floor there is no reading at all. `null`, not a shrunk + * guess — because a heavily-shrunk severity is indistinguishable + * from a measured league-average one, and those are different claims. + * + * Without the refusal the atom would quietly assert "this hitter has a + * league-typical split" about every September call-up in the league. + * + * ── SWITCH HITTERS ARE THE EASY CASE, NOT THE HARD ONE ─────────────────── + * A switch hitter bats opposite the pitcher by choice, so he has the platoon + * advantage in every plate appearance. What varies is how much that side of his + * swing is worth, which is a different question and one we do not have the + * per-side sample to answer — so he is UNREADABLE rather than credited with an + * automatic edge. + */ + +const { knownNumber, knownRate } = require('../../utils/known'); + +/** + * League platoon split in batting average — the shrinkage anchor. The advantage + * is real and modest; the point of this atom is that its SIZE varies by hitter. + */ +const LEAGUE_SPLIT = 0.020; +/** Plate appearances at which a hitter's own split is worth half the weight. */ +const STABILIZE_PA = 250; +/** Below this, on the smaller side, there is no reading. */ +const MIN_SIDE_PA = 60; +/** Bound on how far platoon may move a hit probability. */ +const MAX_EFFECT = 0.10; + +/** + * The platoon read for one hitter against tonight's pitcher. + * + * @param {object} splits { vl: {pa, hits, atBats}, vr: {pa, hits, atBats} } + * @param {string} bats 'R' | 'L' | 'S' + * @param {string} throws tonight's pitcher hand, 'R' | 'L' + * @returns {object|null} null when unreadable — never a fabricated severity. + */ +function platoonRead({ splits, bats, throws } = {}) { + const hand = String(bats || '').toUpperCase()[0]; + const arm = String(throws || '').toUpperCase()[0]; + if (!splits || (arm !== 'R' && arm !== 'L')) return null; + + // A switch hitter always bats opposite, so the DIRECTION is never in doubt — + // but the per-side value of his swing is a question this sample cannot answer. + if (hand === 'S') { + return { readable: false, reason: 'switch_hitter_side_value_unknown', multiplier: null }; + } + if (hand !== 'R' && hand !== 'L') return null; + + const vl = splits.vl || {}; + const vr = splits.vr || {}; + const rate = (s) => { + const h = knownRate(s.hits); const ab = knownRate(s.atBats); + return h !== null && ab !== null && ab > 0 ? h / ab : null; + }; + const rl = rate(vl); const rr = rate(vr); + const paL = knownRate(vl.pa) ?? 0; + const paR = knownRate(vr.pa) ?? 0; + if (rl === null || rr === null) return { readable: false, reason: 'missing_split', multiplier: null }; + + // THE REFUSAL. The smaller side governs — a 500/40 split is a 40-PA read. + const smaller = Math.min(paL, paR); + if (smaller < MIN_SIDE_PA) { + return { + readable: false, + reason: 'insufficient_split_sample', + smaller_side_pa: smaller, + multiplier: null, + }; + } + + // From THIS hitter's perspective tonight: opposite hand is the advantage. + const facingOpposite = hand !== arm; + const advantageRate = hand === 'R' ? rl : rr; // R hits L, L hits R + const disadvantageRate = hand === 'R' ? rr : rl; + const observedSplit = advantageRate - disadvantageRate; + + // SHRINK toward the league split by sample. + const w = smaller / (smaller + STABILIZE_PA); + const severity = w * observedSplit + (1 - w) * LEAGUE_SPLIT; + + // Applied in the direction tonight's matchup actually runs, and scaled by the + // hitter's own base rate so the multiplier is proportional rather than additive. + const baseRate = (advantageRate + disadvantageRate) / 2; + if (!(baseRate > 0)) return { readable: false, reason: 'no_base_rate', multiplier: null }; + const signed = facingOpposite ? severity / 2 : -severity / 2; + const effect = Math.max(-MAX_EFFECT, Math.min(MAX_EFFECT, signed / baseRate)); + + return { + readable: true, + multiplier: round3(1 + effect), + observed_split: round3(observedSplit), + shrunk_severity: round3(severity), + shrink_weight: round3(w), + smaller_side_pa: smaller, + facing_opposite_hand: facingOpposite, + }; +} + +/** A checkable sentence, or nothing. No fluent fallback for an absent read. */ +function explain(read, bats, throws) { + if (!read || !read.readable) return null; + const pts = Math.round(read.observed_split * 1000); + const dir = read.facing_opposite_hand ? 'has the platoon edge' : 'is on the wrong side of it'; + return `${bats}HB vs ${throws}HP — ${dir}; his measured split is ${pts} points of average ` + + `over ${read.smaller_side_pa} PA on the short side (shrunk ${read.shrink_weight} toward league)`; +} + +const round3 = (v) => (v == null || !Number.isFinite(v) ? null : Math.round(v * 1000) / 1000); + +module.exports = { + platoonRead, explain, + LEAGUE_SPLIT, STABILIZE_PA, MIN_SIDE_PA, MAX_EFFECT, +}; diff --git a/tests/unit/platoonSeverity.test.js b/tests/unit/platoonSeverity.test.js new file mode 100644 index 0000000..e0223a7 --- /dev/null +++ b/tests/unit/platoonSeverity.test.js @@ -0,0 +1,100 @@ +'use strict'; + +/** + * The causally-correct platoon atom. + * + * The flat version applies the same boost to a hitter with a 63-point split and + * one with none. These tests lock the thing that fixes that — and the refusal + * that stops a thin split becoming a fabricated severity. + */ + +const ps = require('../../src/services/model/platoonSeverity'); + +/** vl/vr with a given average over a given number of PA. */ +const side = (avg, pa) => ({ pa, atBats: Math.round(pa * 0.9), hits: Math.round(pa * 0.9 * avg) }); + +const bigSplit = { vl: side(0.284, 183), vr: side(0.221, 291) }; // real hitter +const noSplit = { vl: side(0.260, 200), vr: side(0.258, 300) }; + +describe('the severity is the hitter\'s own, not the league\'s', () => { + it('a hitter with a real split gets a real move; one without gets almost none', () => { + const strong = ps.platoonRead({ splits: bigSplit, bats: 'R', throws: 'L' }); + const flat = ps.platoonRead({ splits: noSplit, bats: 'R', throws: 'L' }); + expect(strong.readable).toBe(true); + expect(flat.readable).toBe(true); + // The whole point: the flat factor would have moved these identically. + expect(strong.multiplier).toBeGreaterThan(flat.multiplier); + expect(strong.observed_split).toBeGreaterThan(0.05); + expect(Math.abs(flat.observed_split)).toBeLessThan(0.01); + }); + + it('direction follows tonight\'s matchup, not a stored label', () => { + const edge = ps.platoonRead({ splits: bigSplit, bats: 'R', throws: 'L' }); + const wrongSide = ps.platoonRead({ splits: bigSplit, bats: 'R', throws: 'R' }); + expect(edge.facing_opposite_hand).toBe(true); + expect(wrongSide.facing_opposite_hand).toBe(false); + expect(edge.multiplier).toBeGreaterThan(1); + expect(wrongSide.multiplier).toBeLessThan(1); + }); +}); + +describe('sample discipline — shrink, then refuse', () => { + it('shrinks a thin split toward league and keeps an established one', () => { + const thin = ps.platoonRead({ splits: { vl: side(0.400, 70), vr: side(0.200, 300) }, bats: 'R', throws: 'L' }); + const deep = ps.platoonRead({ splits: { vl: side(0.400, 900), vr: side(0.200, 900) }, bats: 'R', throws: 'L' }); + // Same raw 200-point split; the thin one must not be believed as much. + expect(thin.shrunk_severity).toBeLessThan(deep.shrunk_severity); + expect(thin.shrink_weight).toBeLessThan(deep.shrink_weight); + }); + + it('REFUSES below the floor rather than shrinking to a league-average guess', () => { + // A heavily-shrunk severity is indistinguishable from a MEASURED + // league-average one, and those are different claims. + const r = ps.platoonRead({ splits: { vl: side(0.350, 25), vr: side(0.250, 400) }, bats: 'R', throws: 'L' }); + expect(r.readable).toBe(false); + expect(r.reason).toBe('insufficient_split_sample'); + expect(r.multiplier).toBeNull(); + expect(r.smaller_side_pa).toBe(25); + }); + + it('the SMALLER side governs — 500 against 40 is a 40-PA read', () => { + const r = ps.platoonRead({ splits: { vl: side(0.300, 40), vr: side(0.250, 500) }, bats: 'R', throws: 'L' }); + expect(r.readable).toBe(false); + expect(r.smaller_side_pa).toBe(40); + }); +}); + +describe('switch hitters are unreadable, not automatically credited', () => { + it('does not hand a switch hitter a free edge', () => { + // He always bats opposite, so the DIRECTION is never in doubt — but the + // per-side value of his swing is a question this sample cannot answer. + const r = ps.platoonRead({ splits: bigSplit, bats: 'S', throws: 'L' }); + expect(r.readable).toBe(false); + expect(r.reason).toBe('switch_hitter_side_value_unknown'); + expect(r.multiplier).toBeNull(); + }); +}); + +describe('honesty', () => { + it('missing a split side is unreadable, never assumed symmetric', () => { + const r = ps.platoonRead({ splits: { vl: side(0.300, 200) }, bats: 'R', throws: 'L' }); + expect(r.readable).toBe(false); + expect(r.reason).toBe('missing_split'); + }); + + it('no pitcher hand → no read at all', () => { + expect(ps.platoonRead({ splits: bigSplit, bats: 'R', throws: null })).toBeNull(); + expect(ps.platoonRead({ splits: null, bats: 'R', throws: 'L' })).toBeNull(); + }); + + it('the effect is bounded however extreme the split', () => { + const absurd = ps.platoonRead({ splits: { vl: side(0.900, 500), vr: side(0.050, 500) }, bats: 'R', throws: 'L' }); + expect(absurd.multiplier).toBeLessThanOrEqual(1 + ps.MAX_EFFECT + 1e-9); + }); + + it('reasoning is emitted only for a real read', () => { + const good = ps.platoonRead({ splits: bigSplit, bats: 'R', throws: 'L' }); + expect(ps.explain(good, 'R', 'L')).toMatch(/measured split is \d+ points/); + expect(ps.explain({ readable: false }, 'R', 'L')).toBeNull(); + }); +});