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,
|
// a five-pitch arsenal as a one-pitch one. Movement still supplies velo,
|
||||||
// break and handedness; this supplies the mix.
|
// break and handedness; this supplies the mix.
|
||||||
pitch_arsenal: (y) => `${BASE}/pitch-arsenal-stats?type=pitcher&pitchType=&year=${y}&min=1&csv=true`,
|
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 = {}) {
|
async function fetchText(url, opts = {}) {
|
||||||
@@ -175,12 +188,30 @@ function indexPitchMix(rows) {
|
|||||||
return out;
|
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%,
|
* 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
|
* whiff%, K% and contact quality per pitch. Merged with the movement feed's
|
||||||
* velo/break for the pitcher's primary pitch.
|
* velo/break for the pitcher's primary pitch.
|
||||||
*/
|
*/
|
||||||
function indexArsenal(rows, movementIdx) {
|
function indexArsenal(rows, movementIdx, veloIdx = new Map()) {
|
||||||
const out = new Map();
|
const out = new Map();
|
||||||
for (const r of rows) {
|
for (const r of rows) {
|
||||||
const id = idOf(r);
|
const id = idOf(r);
|
||||||
@@ -204,9 +235,16 @@ function indexArsenal(rows, movementIdx) {
|
|||||||
});
|
});
|
||||||
out.set(id, entry);
|
out.set(id, entry);
|
||||||
}
|
}
|
||||||
// Fold in velo/break (primary pitch only — that is all the feed carries) and
|
// Velo comes from the WIDE feed, matched BY PITCH TYPE — every pitch, not
|
||||||
// handedness, which exists on no other feed.
|
// 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) {
|
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);
|
const mv = movementIdx.get(id);
|
||||||
if (!mv) continue;
|
if (!mv) continue;
|
||||||
entry.throws = mv.throws || entry.throws;
|
entry.throws = mv.throws || entry.throws;
|
||||||
@@ -214,9 +252,9 @@ function indexArsenal(rows, movementIdx) {
|
|||||||
if (primary) {
|
if (primary) {
|
||||||
const match = entry.pitches.find((p) => p.type === primary.type);
|
const match = entry.pitches.find((p) => p.type === primary.type);
|
||||||
if (match) {
|
if (match) {
|
||||||
match.velo = primary.velo;
|
|
||||||
match.break_z_induced = primary.break_z_induced;
|
match.break_z_induced = primary.break_z_induced;
|
||||||
match.break_x = primary.break_x;
|
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('batter_discipline'), get('batter_batted_ball'),
|
||||||
get('pitcher_discipline'), get('pitcher_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 {
|
return {
|
||||||
season,
|
season,
|
||||||
@@ -259,21 +298,70 @@ async function fetchSeason(season = DEFAULT_SEASON, opts = {}) {
|
|||||||
batterBattedBall: indexBy(bbb, BATTED_BALL),
|
batterBattedBall: indexBy(bbb, BATTED_BALL),
|
||||||
pitcherDiscipline: indexBy(pd, PITCHER_DISCIPLINE),
|
pitcherDiscipline: indexBy(pd, PITCHER_DISCIPLINE),
|
||||||
pitcherBattedBall: indexBy(pbb, BATTED_BALL),
|
pitcherBattedBall: indexBy(pbb, BATTED_BALL),
|
||||||
pitchMix: indexArsenal(ars, indexPitchMix(mv)),
|
pitchMix: indexArsenal(ars, indexPitchMix(mv), indexVelo(velo)),
|
||||||
|
handedness: hands,
|
||||||
|
roles,
|
||||||
counts: {
|
counts: {
|
||||||
batter_discipline: bd.length, batter_batted_ball: bbb.length,
|
batter_discipline: bd.length, batter_batted_ball: bbb.length,
|
||||||
pitcher_discipline: pd.length, pitcher_batted_ball: pbb.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 = {
|
module.exports = {
|
||||||
fetchSeason,
|
fetchSeason,
|
||||||
DEFAULT_SEASON,
|
DEFAULT_SEASON,
|
||||||
FEEDS,
|
FEEDS,
|
||||||
__internals: {
|
__internals: {
|
||||||
flipName, idOf, normalizeRow, indexBy, indexPitchMix, indexArsenal,
|
flipName, idOf, normalizeRow, indexBy, indexPitchMix, indexArsenal, indexVelo,
|
||||||
|
fetchHandedness, fetchRoles, STATSAPI,
|
||||||
BATTER_DISCIPLINE, BATTED_BALL, PITCHER_DISCIPLINE, fetchText,
|
BATTER_DISCIPLINE, BATTED_BALL, PITCHER_DISCIPLINE, fetchText,
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -40,6 +40,21 @@ const num = (v) => {
|
|||||||
return Number.isFinite(n) ? n : null;
|
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
|
* buildRows(season, feeds, opts) — PURE. Merges the five feed indexes into one
|
||||||
* row per player per role. No I/O.
|
* row per player per role. No I/O.
|
||||||
@@ -56,6 +71,7 @@ function buildRows(season, feeds, opts = {}) {
|
|||||||
const b = feeds.batterBattedBall.get(id);
|
const b = feeds.batterBattedBall.get(id);
|
||||||
const m = { ...(b ? b.metrics : {}), ...(d ? d.metrics : {}) };
|
const m = { ...(b ? b.metrics : {}), ...(d ? d.metrics : {}) };
|
||||||
const name = (d && d.name) || (b && b.name) || null;
|
const name = (d && d.name) || (b && b.name) || null;
|
||||||
|
const hand = feeds.handedness && feeds.handedness.get(id);
|
||||||
const pa = num(m.pa);
|
const pa = num(m.pa);
|
||||||
rows.push({
|
rows.push({
|
||||||
sport,
|
sport,
|
||||||
@@ -64,8 +80,13 @@ function buildRows(season, feeds, opts = {}) {
|
|||||||
player_key: name ? nameKey(name) : null,
|
player_key: name ? nameKey(name) : null,
|
||||||
player_name: name,
|
player_name: name,
|
||||||
role: 'batter',
|
role: 'batter',
|
||||||
throws: null,
|
// Tier-A join (Session 69): statsapi carries batSide/pitchHand for every
|
||||||
bats: null, // roster join fills this; absent otherwise (never guessed)
|
// 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_pa: pa,
|
||||||
sample_ip: null,
|
sample_ip: null,
|
||||||
sample_bip: num(m.bip),
|
sample_bip: num(m.bip),
|
||||||
@@ -104,6 +125,8 @@ function buildRows(season, feeds, opts = {}) {
|
|||||||
const mix = feeds.pitchMix.get(id);
|
const mix = feeds.pitchMix.get(id);
|
||||||
const m = { ...(b ? b.metrics : {}), ...(d ? d.metrics : {}) };
|
const m = { ...(b ? b.metrics : {}), ...(d ? d.metrics : {}) };
|
||||||
const name = (d && d.name) || (mix && mix.name) || (b && b.name) || null;
|
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);
|
const ip = num(m.ip);
|
||||||
rows.push({
|
rows.push({
|
||||||
sport,
|
sport,
|
||||||
@@ -114,8 +137,16 @@ function buildRows(season, feeds, opts = {}) {
|
|||||||
role: 'pitcher',
|
role: 'pitcher',
|
||||||
// Handedness comes free on the movement feed — the ONLY feed that carries
|
// 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.
|
// it. Absent when a pitcher has thrown too few tracked pitches to appear.
|
||||||
throws: (mix && mix.throws) || null,
|
// statsapi is the authority for handedness; the movement feed is the
|
||||||
bats: null,
|
// 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_pa: null,
|
||||||
sample_ip: ip,
|
sample_ip: ip,
|
||||||
sample_bip: num(m.bip),
|
sample_bip: num(m.bip),
|
||||||
@@ -180,7 +211,9 @@ async function refreshSeason(opts = {}) {
|
|||||||
thin: rows.filter((r) => !r._sufficient).length,
|
thin: rows.filter((r) => !r._sufficient).length,
|
||||||
joined: rows.filter((r) => r.player_key).length,
|
joined: rows.filter((r) => r.player_key).length,
|
||||||
unjoined: 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,
|
written: 0,
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -274,5 +307,5 @@ module.exports = {
|
|||||||
MIN_PA,
|
MIN_PA,
|
||||||
MIN_IP,
|
MIN_IP,
|
||||||
MAX_AGE_HOURS,
|
MAX_AGE_HOURS,
|
||||||
__internals: { toDbRow, num, getServiceClient },
|
__internals: { toDbRow, num, getServiceClient, roleDetail },
|
||||||
};
|
};
|
||||||
|
|||||||
Reference in New Issue
Block a user