diff --git a/src/services/adapters/statcastAdapter.js b/src/services/adapters/statcastAdapter.js index 0a61dda..c3d6bba 100644 --- a/src/services/adapters/statcastAdapter.js +++ b/src/services/adapters/statcastAdapter.js @@ -61,6 +61,19 @@ const FEEDS = Object.freeze({ // a five-pitch arsenal as a one-pitch one. Movement still supplies velo, // break and handedness; this supplies the mix. pitch_arsenal: (y) => `${BASE}/pitch-arsenal-stats?type=pitcher&pitchType=&year=${y}&min=1&csv=true`, + // WIDE velo: one row per pitcher, one column per pitch type + // (`{abbr}_avg_speed`). Session 69 — this is the velo FIX. The movement feed + // carries only each pitcher's primary pitch, which left velo at 53% coverage + // and unusable as an archetype axis. + pitch_velo: (y) => `${BASE}/pitch-arsenals?year=${y}&min=1&type=avg_speed&hand=&csv=true`, +}); + +/** statsapi — free, no key. Two calls that complete the Tier-A joins. + * HANDEDNESS: batSide + pitchHand + primaryPosition for every player. + * ROLE: gamesStarted/gamesPitched/saves/holds — real usage, not an IP proxy. */ +const STATSAPI = Object.freeze({ + handedness: (y) => `https://statsapi.mlb.com/api/v1/sports/1/players?season=${y}`, + pitcher_roles: (y) => `https://statsapi.mlb.com/api/v1/stats?stats=season&group=pitching&season=${y}&limit=2000&sportId=1&playerPool=ALL`, }); async function fetchText(url, opts = {}) { @@ -175,12 +188,30 @@ function indexPitchMix(rows) { return out; } +/** Wide velo CSV → Map(pitcherId → { FF: 96.7, SL: 89.4, ... }). */ +function indexVelo(rows) { + const out = new Map(); + for (const r of rows) { + const id = Number(r.pitcher ?? r.player_id); + if (!Number.isFinite(id) || id <= 0) continue; + const byType = {}; + for (const [k, v] of Object.entries(r)) { + const m = /^([a-z]{2})_avg_speed$/.exec(k); + if (!m) continue; + const n = numOrNull(v); + if (n != null) byType[m[1].toUpperCase()] = n; + } + if (Object.keys(byType).length) out.set(id, byType); + } + return out; +} + /** * Pitch ARSENAL — one row per (pitcher, pitch type). The real mix: usage%, * whiff%, K% and contact quality per pitch. Merged with the movement feed's * velo/break for the pitcher's primary pitch. */ -function indexArsenal(rows, movementIdx) { +function indexArsenal(rows, movementIdx, veloIdx = new Map()) { const out = new Map(); for (const r of rows) { const id = idOf(r); @@ -204,9 +235,16 @@ function indexArsenal(rows, movementIdx) { }); out.set(id, entry); } - // Fold in velo/break (primary pitch only — that is all the feed carries) and - // handedness, which exists on no other feed. + // Velo comes from the WIDE feed, matched BY PITCH TYPE — every pitch, not + // just the primary. Break + handedness still come from the movement feed + // (primary pitch only, which is all it carries). for (const [id, entry] of out) { + const velos = veloIdx.get(id); + if (velos) { + for (const p of entry.pitches) { + if (velos[p.type] != null) p.velo = velos[p.type]; + } + } const mv = movementIdx.get(id); if (!mv) continue; entry.throws = mv.throws || entry.throws; @@ -214,9 +252,9 @@ function indexArsenal(rows, movementIdx) { if (primary) { const match = entry.pitches.find((p) => p.type === primary.type); if (match) { - match.velo = primary.velo; match.break_z_induced = primary.break_z_induced; match.break_x = primary.break_x; + if (match.velo == null) match.velo = primary.velo; } } } @@ -247,11 +285,12 @@ async function fetchSeason(season = DEFAULT_SEASON, opts = {}) { } }; - const [bd, bbb, pd, pbb, mv, ars] = await Promise.all([ + const [bd, bbb, pd, pbb, mv, ars, velo] = await Promise.all([ get('batter_discipline'), get('batter_batted_ball'), get('pitcher_discipline'), get('pitcher_batted_ball'), - get('pitch_movement'), get('pitch_arsenal'), + get('pitch_movement'), get('pitch_arsenal'), get('pitch_velo'), ]); + const [hands, roles] = await Promise.all([fetchHandedness(season, opts), fetchRoles(season, opts)]); return { season, @@ -259,21 +298,70 @@ async function fetchSeason(season = DEFAULT_SEASON, opts = {}) { batterBattedBall: indexBy(bbb, BATTED_BALL), pitcherDiscipline: indexBy(pd, PITCHER_DISCIPLINE), pitcherBattedBall: indexBy(pbb, BATTED_BALL), - pitchMix: indexArsenal(ars, indexPitchMix(mv)), + pitchMix: indexArsenal(ars, indexPitchMix(mv), indexVelo(velo)), + handedness: hands, + roles, counts: { batter_discipline: bd.length, batter_batted_ball: bbb.length, pitcher_discipline: pd.length, pitcher_batted_ball: pbb.length, - pitch_movement: mv.length, pitch_arsenal: ars.length, + pitch_movement: mv.length, pitch_arsenal: ars.length, pitch_velo: velo.length, + handedness: hands.size, pitcher_roles: roles.size, }, }; } +/** statsapi handedness → Map(id → {bats, throws, position}). ONE call, and it + * carried 1,316/1,316 in the live probe. */ +async function fetchHandedness(season, opts = {}) { + const out = new Map(); + try { + const raw = opts.handednessJson || await fetchJson(STATSAPI.handedness(season), opts); + for (const p of (raw && raw.people) || []) { + const id = Number(p.id); + if (!Number.isFinite(id)) continue; + out.set(id, { + bats: (p.batSide && p.batSide.code) || null, + throws: (p.pitchHand && p.pitchHand.code) || null, + position: (p.primaryPosition && p.primaryPosition.abbreviation) || null, + }); + } + } catch (err) { console.warn('[statcast] handedness join failed:', err.message); } + return out; +} + +/** statsapi pitcher usage → Map(id → {gs, gp, gf, saves, holds}). `playerPool=ALL` + * is required: the default returns only the ~57 qualified pitchers. */ +async function fetchRoles(season, opts = {}) { + const out = new Map(); + try { + const raw = opts.rolesJson || await fetchJson(STATSAPI.pitcher_roles(season), opts); + const splits = ((raw && raw.stats) || [])[0] || {}; + for (const s of splits.splits || []) { + const id = Number(s.player && s.player.id); + if (!Number.isFinite(id)) continue; + const st = s.stat || {}; + out.set(id, { + gs: numOrNull(st.gamesStarted), gp: numOrNull(st.gamesPitched), + gf: numOrNull(st.gamesFinished), saves: numOrNull(st.saves), holds: numOrNull(st.holds), + }); + } + } catch (err) { console.warn('[statcast] roles join failed:', err.message); } + return out; +} + +async function fetchJson(url, opts = {}) { + if (opts.fetchJsonImpl) return opts.fetchJsonImpl(url); + const res = await axios.get(url, { timeout: HTTP_TIMEOUT_MS, headers: { Accept: 'application/json' } }); + return res.data; +} + module.exports = { fetchSeason, DEFAULT_SEASON, FEEDS, __internals: { - flipName, idOf, normalizeRow, indexBy, indexPitchMix, indexArsenal, + flipName, idOf, normalizeRow, indexBy, indexPitchMix, indexArsenal, indexVelo, + fetchHandedness, fetchRoles, STATSAPI, BATTER_DISCIPLINE, BATTED_BALL, PITCHER_DISCIPLINE, fetchText, }, }; diff --git a/src/services/statcastAggregateService.js b/src/services/statcastAggregateService.js index 391a33b..cd0003f 100644 --- a/src/services/statcastAggregateService.js +++ b/src/services/statcastAggregateService.js @@ -40,6 +40,21 @@ const num = (v) => { return Number.isFinite(n) ? n : null; }; +/** + * roleDetail(usage) — TRUE role from real usage, not the IP proxy (which drifts + * all season as innings accumulate). Order matters: a closer who also holds is + * a closer. Absent when statsapi carried no usage for him. + */ +function roleDetail(u) { + if (!u) return null; + const gs = u.gs ?? 0, gp = u.gp ?? 0, sv = u.saves ?? 0, hld = u.holds ?? 0; + if (gp <= 0) return null; + if (gs / gp >= 0.5) return 'starter'; + if (sv >= 5) return 'closer'; + if (hld >= 5) return 'setup'; + return 'reliever'; +} + /** * buildRows(season, feeds, opts) — PURE. Merges the five feed indexes into one * row per player per role. No I/O. @@ -56,6 +71,7 @@ function buildRows(season, feeds, opts = {}) { const b = feeds.batterBattedBall.get(id); const m = { ...(b ? b.metrics : {}), ...(d ? d.metrics : {}) }; const name = (d && d.name) || (b && b.name) || null; + const hand = feeds.handedness && feeds.handedness.get(id); const pa = num(m.pa); rows.push({ sport, @@ -64,8 +80,13 @@ function buildRows(season, feeds, opts = {}) { player_key: name ? nameKey(name) : null, player_name: name, role: 'batter', - throws: null, - bats: null, // roster join fills this; absent otherwise (never guessed) + // Tier-A join (Session 69): statsapi carries batSide/pitchHand for every + // player in ONE call. Absent only if the id is unknown to statsapi. + throws: (hand && hand.throws) || null, + bats: (hand && hand.bats) || null, + position: (hand && hand.position) || null, + role_detail: null, + games_started: null, games_pitched: null, saves: null, holds: null, sample_pa: pa, sample_ip: null, sample_bip: num(m.bip), @@ -104,6 +125,8 @@ function buildRows(season, feeds, opts = {}) { const mix = feeds.pitchMix.get(id); const m = { ...(b ? b.metrics : {}), ...(d ? d.metrics : {}) }; const name = (d && d.name) || (mix && mix.name) || (b && b.name) || null; + const hand = feeds.handedness && feeds.handedness.get(id); + const usage = feeds.roles && feeds.roles.get(id); const ip = num(m.ip); rows.push({ sport, @@ -114,8 +137,16 @@ function buildRows(season, feeds, opts = {}) { role: 'pitcher', // Handedness comes free on the movement feed — the ONLY feed that carries // it. Absent when a pitcher has thrown too few tracked pitches to appear. - throws: (mix && mix.throws) || null, - bats: null, + // statsapi is the authority for handedness; the movement feed is the + // fallback for pitchers statsapi does not carry. + throws: (hand && hand.throws) || (mix && mix.throws) || null, + bats: (hand && hand.bats) || null, + position: (hand && hand.position) || null, + role_detail: roleDetail(usage), + games_started: usage ? usage.gs : null, + games_pitched: usage ? usage.gp : null, + saves: usage ? usage.saves : null, + holds: usage ? usage.holds : null, sample_pa: null, sample_ip: ip, sample_bip: num(m.bip), @@ -180,7 +211,9 @@ async function refreshSeason(opts = {}) { thin: rows.filter((r) => !r._sufficient).length, joined: rows.filter((r) => r.player_key).length, unjoined: rows.filter((r) => !r.player_key).length, - with_handedness: rows.filter((r) => r.throws).length, + with_handedness: rows.filter((r) => r.throws || r.bats).length, + with_role: rows.filter((r) => r.role_detail).length, + by_role: rows.reduce((a, r) => { if (r.role_detail) a[r.role_detail] = (a[r.role_detail] || 0) + 1; return a; }, {}), written: 0, }; @@ -274,5 +307,5 @@ module.exports = { MIN_PA, MIN_IP, MAX_AGE_HOURS, - __internals: { toDbRow, num, getServiceClient }, + __internals: { toDbRow, num, getServiceClient, roleDetail }, };