de0077f6f9
Applying the method that worked for defence to the two factors the code flagged as still crude. PLATOON. The flat version is 'lefty versus righty, add a boost', and it failed the two-part gate for the same reason team-average defence did: it is not the unit the causal story runs through. The advantage is only worth what THIS hitter's split is actually worth -- measured on a real hitter, .284 against left-handed pitching versus .221 against right-handed, a 63-point split, where the flat factor applied the same six percent to him and to a hitter with none. Most of the work is sample discipline, and the second rule matters more than the first. Severity shrinks toward the league split weighted by the SMALLER side's plate appearances, because a 500-against-40 split is a 40-PA read. And below a floor it REFUSES outright rather than shrinking, 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 a league-typical split about every September call-up in the league. Switch hitters turn out to be the easy case misread as the hard one. He bats opposite by choice so the direction is never in doubt, but the per-side value of his swing is a different question and one this sample cannot answer, so he is unreadable rather than credited with an automatic edge. PARK DIMENSIONS. Free from statsapi's venue endpoint, which carries fence distances, roof, turf and elevation outright -- Wrigley returns 355 down the left line, 400 to centre, 353 to right, at 595 feet. parkFactors holds run COEFFICIENTS, which structurally cannot express a park that turns outs into hits without scoring, and that is why the crude park factor failed. The park join is by the venue the game is ACTUALLY at, carried from the schedule feed, never inferred from the home team -- neutral-site and international games break that assumption and they break it silently. A venue with no geometry at all is absent rather than a park with zero dimensions. Both tables dated in the primary key. Venue geometry changes rarely but it does change, and by now that is the default rather than a lesson. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01W1sivYNqY2TS5ftykmHBU9
334 lines
14 KiB
JavaScript
334 lines
14 KiB
JavaScript
'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,
|
|
};
|