Tier-A joins: handedness, true role, and the velo fix
Three Layer-2 prerequisites, each one free call. HANDEDNESS — statsapi /sports/1/players carries batSide, pitchHand AND primaryPosition for every player: 1,316/1,316 in the live probe. Batter handedness was 100% absent, which made every platoon or switch-flavour archetype unbuildable; it is now populated from the same call that gives pitchers theirs, with statsapi as the authority and the movement feed as the fallback. ROLE — statsapi season pitching with playerPool=ALL returns 751 rows (the default returns only the ~57 qualified). Real usage: gamesStarted, gamesPitched, gamesFinished, saves, holds. roleDetail derives starter/closer/setup/reliever from that instead of the season-IP proxy, which drifts all year as innings accumulate and left 32 pitchers in a 60-80 IP trough. VELO — recovered from 53% to 99% (721/729 pitchers). The movement feed carries only each pitcher's PRIMARY pitch, so matching by position could never do better than one pitch each. The wide pitch-arsenals feed has one column per pitch type, matched BY TYPE: Skubal now has velo on all 5 of his pitches. Velo archetypes are therefore buildable rather than shelved. Migration 032 applied. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01VCNgGSt5qvcLxaeQqa7Zpj
This commit is contained in:
@@ -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,
|
||||
},
|
||||
};
|
||||
|
||||
@@ -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 },
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user