'use strict'; /** * lineupContextService — THE INPUT RBI AND RUNS HAVE ALWAYS NEEDED. * * RBI is power TIMES opportunity. The same swing drives in one run or three * depending on who is standing on base, and a hitter batting with the bases * empty cannot drive anyone in however hard he hits it. Every context-free model * of RBI in this codebase has failed, and the failure kept being read as "skill * inputs don't work for RBI" when the truth was that we were modelling half the * stat. * * Both inputs are FREE from statsapi.mlb.com, which we already call for game * logs, schedules and probable pitchers. No new provider, no new key, no quota. * * ── RUNG 1 — BATTING ORDER ─────────────────────────────────────────────── * `schedule?hydrate=lineups` returns `homePlayers` / `awayPlayers` as ORDERED * arrays of nine. The order IS the batting order — index 0 is the leadoff * hitter. That single fact gives CATALYST its identity and supplies * lineup-position context for every context-dependent stat. * * ── RUNG 2 — RUNNERS IN SCORING POSITION ───────────────────────────────── * The cheapest-first rule expected this to need play-by-play reconstruction * across a season. It does not: statsapi serves situational splits directly, so * "how often does this hitter bat with runners to drive in" is ONE call per * player rather than one per game. Measured on a real hitter: 87 plate * appearances with RISP producing 25 RBI, against 302 with the bases empty * producing 17. That ratio is the opportunity half of the stat, and it is the * thing no amount of exit velocity can tell you. * * ── HONESTY ────────────────────────────────────────────────────────────── * Lineups are PRE-GAME facts that change by the hour — scratches, late swaps. * Everything here is DATED (`as_of_date` in the primary key) so what we knew at * grade time is never overwritten by what turned out to be true. An in-place * table would be worse here than anywhere else in the codebase. * * A missing lineup is ABSENT, never a guessed order; a hitter with no splits is * ABSENT, never a zero RISP share (which would assert he never bats with runners * on — a strong claim, and usually a false one). */ const axios = require('axios'); const { knownNumber } = require('../utils/known'); const { nameKey, normalizeName } = require('../utils/playerName'); const BASE = 'https://statsapi.mlb.com/api/v1'; const HTTP_TIMEOUT_MS = 15_000; /** ET calendar date — the day a slate belongs to. */ function dateET(d = new Date()) { return new Intl.DateTimeFormat('en-CA', { timeZone: 'America/New_York', year: 'numeric', month: '2-digit', day: '2-digit', }).format(d); } async function getJson(url, opts = {}) { const fetchImpl = opts.fetchImpl; if (fetchImpl) return fetchImpl(url); const res = await axios.get(url, { timeout: opts.timeout || HTTP_TIMEOUT_MS }); return res && res.data; } /** * RUNG 1 — confirmed/projected batting order for a slate. * * @returns {Array<{game_pk, game_date, team, side, player_name, player_key, source_id, batting_order}>} * Empty when the feed has no lineups yet — an empty slate is a valid answer, * and posting a guessed order would be worse than posting none. */ async function fetchLineups(date, opts = {}) { const d = String(date || dateET()).slice(0, 10); 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 || []); const out = []; for (const g of games) { const lineups = g.lineups; if (!lineups) continue; for (const [key, side] of [['awayPlayers', 'away'], ['homePlayers', 'home']]) { const players = lineups[key]; if (!Array.isArray(players) || players.length === 0) continue; const team = g.teams && g.teams[side] && g.teams[side].team ? g.teams[side].team.name : null; players.forEach((p, i) => { 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, player_name: normalizeName(p.fullName).display, player_key: nameKey(p.fullName), source_id: knownNumber(p.id), // ORDER IS THE ORDER — index 0 is the leadoff hitter. Nothing is // inferred; if the array is short we simply record fewer slots. batting_order: i + 1, }); }); } } return out; } /** * RUNG 2 — a hitter's runners-in-scoring-position opportunity. * * @returns {object|null} null when the splits are unavailable — never a zero * share, which would assert he never bats with runners on. */ async function fetchOpportunity(sourceId, season, opts = {}) { const id = knownNumber(sourceId); if (id == null) return null; const url = `${BASE}/people/${id}/stats?stats=statSplits&sitCodes=risp,r0&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; let rispPa = null; let rispRbi = null; let emptyPa = null; for (const sp of splits) { const code = sp && sp.split && sp.split.code; const st = (sp && sp.stat) || {}; if (code === 'risp') { rispPa = knownNumber(st.plateAppearances); rispRbi = knownNumber(st.rbi); } else if (code === 'r0') { emptyPa = knownNumber(st.plateAppearances); } } if (rispPa == null && emptyPa == null) return null; // total_pa is derived from the two splits we asked for. It UNDERCOUNTS the // runner-on-first-only case, which belongs to neither bucket — so the share is // "RISP as a fraction of the plate appearances we can classify", stated // exactly, rather than a fraction of a season total we did not fetch. const classified = (rispPa ?? 0) + (emptyPa ?? 0); return { risp_pa: rispPa, risp_rbi: rispRbi, bases_empty_pa: emptyPa, total_pa: classified > 0 ? classified : null, risp_share: classified > 0 && rispPa != null ? rispPa / classified : null, }; } /** * 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. */ async function persistLineups(sb, rows, { sport = 'mlb', asOf = null } = {}) { if (!sb || !rows || rows.length === 0) return { written: 0 }; const as_of_date = asOf || dateET(); const payload = rows.map((r) => ({ ...r, sport, as_of_date })); const { error } = await sb.from('lineup_context') .upsert(payload, { onConflict: 'as_of_date,sport,game_pk,player_key' }); if (error) return { written: 0, error: error.message }; return { written: payload.length, as_of_date }; } async function persistOpportunity(sb, rows, { sport = 'mlb', season, asOf = null } = {}) { if (!sb || !rows || rows.length === 0) return { written: 0 }; const as_of_date = asOf || dateET(); const payload = rows.map((r) => ({ ...r, sport, season, as_of_date })); const { error } = await sb.from('hitter_opportunity') .upsert(payload, { onConflict: 'as_of_date,sport,season,player_key' }); if (error) return { written: 0, error: error.message }; return { written: payload.length, as_of_date }; } /** * The whole ingest for one slate. Returns a summary and NEVER throws — the * caller rides it alongside a snapshot and must not be broken by a bad feed. */ async function refreshContext(opts = {}) { const sport = opts.sport || 'mlb'; const date = opts.date || dateET(); const season = opts.season || Number(String(date).slice(0, 4)); const sb = opts.supabase || null; const summary = { sport, date, season, lineups: 0, opportunity: 0 }; try { const lineups = await (opts.fetchLineups || fetchLineups)(date, opts); summary.lineup_rows = lineups.length; summary.games_with_lineups = new Set(lineups.map((l) => l.game_pk)).size; if (sb && lineups.length) { const res = await persistLineups(sb, lineups, { sport, asOf: opts.asOfDate }); summary.lineups = res.written; if (res.error) summary.lineup_error = res.error; } // Opportunity is a SEASON aggregate, so it is fetched once per hitter in the // slate rather than once per game — the reason Rung 2 turned out cheap. const ids = [...new Map(lineups.filter((l) => l.source_id != null) .map((l) => [l.source_id, l])).values()]; const rows = []; const getOpp = opts.fetchOpportunity || fetchOpportunity; for (const p of ids.slice(0, opts.maxPlayers || 400)) { const o = await getOpp(p.source_id, season, opts); if (!o) continue; // ABSENT, never a zero share rows.push({ player_key: p.player_key, player_name: p.player_name, source_id: p.source_id, ...o, }); } 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; if (res.error) summary.opportunity_error = res.error; } summary.ok = true; } catch (e) { summary.ok = false; summary.reason = e.message; } 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, fetchPlatoonSplits, fetchParkDimensions, persistLineups, persistOpportunity, persistPlatoon, persistParkDimensions, refreshContext, dateET, };