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:
Kev
2026-07-20 22:30:12 -04:00
parent 27aabd078f
commit 1265c23305
2 changed files with 136 additions and 15 deletions
+97 -9
View File
@@ -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,
},
};
+39 -6
View File
@@ -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 },
};