ROIBot / src /matchups.js
Codex
Render pitcher by-pitch location plots
a32588e
import { Pool } from 'pg';
import { asyncBufferFromUrl, parquetReadObjects } from 'hyparquet/src/node.js';
const DEFAULT_MATCHUP_LIMIT = 10;
const DEFAULT_RECENT_WINDOW = 'season';
const DEFAULT_SPLIT_KEY = 'overall';
const DEFAULT_WEIGHTED_MODE = 'weighted';
const DEFAULT_FALLBACK_DAYS = 7;
const DEFAULT_ROTOWIRE_URL = 'https://www.rotowire.com/baseball/daily-lineups.php';
const COCKROACH_RETRY_CODES = new Set(['40001']);
const PROFILE_CANDIDATE_LIMIT = 5;
const ROTOWIRE_LINEUP_STATUS = new Map([
['Confirmed Lineup', 'confirmed'],
['Expected Lineup', 'expected'],
['Unknown Lineup', 'unknown'],
]);
const ROTOWIRE_POSITION_TOKENS = new Set(['C', '1B', '2B', '3B', 'SS', 'LF', 'CF', 'RF', 'DH', 'P']);
const ROTOWIRE_STOP_TOKENS = new Set(['Home Run Odds', 'Starting Pitcher Intel']);
const ROTOWIRE_TIME_RE = /^\d{1,2}:\d{2}\s(?:AM|PM)\sET$/;
const ROTOWIRE_PRICE_RE = /^\$\d[\d,]*$/;
const MLB_TEAM_ALIASES = new Map([
['ARI', ['ari', 'arizona', 'diamondbacks', 'arizona diamondbacks']],
['ATL', ['atl', 'atlanta', 'braves', 'atlanta braves']],
['BAL', ['bal', 'baltimore', 'orioles', 'baltimore orioles']],
['BOS', ['bos', 'boston', 'red sox', 'boston red sox']],
['CHC', ['chc', 'chicago cubs', 'cubs']],
['CWS', ['cws', 'chw', 'chicago white sox', 'white sox']],
['CIN', ['cin', 'cincinnati', 'reds', 'cincinnati reds']],
['CLE', ['cle', 'cleveland', 'guardians', 'cleveland guardians']],
['COL', ['col', 'colorado', 'rockies', 'colorado rockies']],
['DET', ['det', 'detroit', 'tigers', 'detroit tigers']],
['HOU', ['hou', 'houston', 'astros', 'houston astros']],
['KC', ['kc', 'kcr', 'kansas city', 'royals', 'kansas city royals']],
['LAA', ['laa', 'los angeles angels', 'angels', 'anaheim angels']],
['LAD', ['lad', 'los angeles dodgers', 'dodgers']],
['MIA', ['mia', 'miami', 'marlins', 'miami marlins', 'florida marlins']],
['MIL', ['mil', 'milwaukee', 'brewers', 'milwaukee brewers']],
['MIN', ['min', 'minnesota', 'twins', 'minnesota twins']],
['NYM', ['nym', 'mets', 'new york mets']],
['NYY', ['nyy', 'yankees', 'new york yankees']],
['ATH', ['ath', 'athletics', 'a\'s', 'as', 'oakland', 'oakland athletics']],
['PHI', ['phi', 'philadelphia', 'phillies', 'philadelphia phillies']],
['PIT', ['pit', 'pittsburgh', 'pirates', 'pittsburgh pirates']],
['SD', ['sd', 'sdp', 'san diego', 'padres', 'san diego padres']],
['SEA', ['sea', 'seattle', 'mariners', 'seattle mariners']],
['SF', ['sf', 'sfg', 'san francisco', 'giants', 'san francisco giants']],
['STL', ['stl', 'cardinals', 'st louis', 'st. louis', 'st louis cardinals', 'st. louis cardinals']],
['TB', ['tb', 'tbr', 'tampa bay', 'rays', 'tampa bay rays']],
['TEX', ['tex', 'texas', 'rangers', 'texas rangers']],
['TOR', ['tor', 'toronto', 'blue jays', 'toronto blue jays']],
['WSH', ['wsh', 'was', 'washington', 'nationals', 'washington nationals']],
]);
const HITTER_COLUMNS = [
'game_pk',
'team',
'opponent_team',
'opposing_pitcher_name',
'opposing_pitcher_hand',
'batter',
'player_id',
'hitter_name',
'stand',
'split_key',
'recent_window',
'weighted_mode',
'matchup_score',
'ceiling_score',
'zone_fit_score',
'likely_starter_score',
'xwoba',
'swstr_pct',
'barrel_bbe_pct',
'fb_pct',
'pulled_barrel_pct',
'sweet_spot_pct',
'barrel_bip_pct',
'hard_hit_pct',
'avg_launch_angle',
];
const PITCHER_COLUMNS = [
'game_pk',
'team',
'opponent_team',
'pitcher_id',
'player_id',
'pitcher_name',
'p_throws',
'split_key',
'recent_window',
'weighted_mode',
'pitcher_score',
'strikeout_score',
'raw_pitcher_score',
'raw_strikeout_score',
'pitcher_matchup_adjustment',
'strikeout_matchup_adjustment',
'opponent_lineup_quality',
'opponent_contact_threat',
'opponent_whiff_tendency',
'opponent_family_fit_allowed',
'lineup_source',
'lineup_hitter_count',
'xwoba',
'csw_pct',
'swstr_pct',
'putaway_pct',
'ball_pct',
'siera',
'gb_pct',
'gb_fb_ratio',
'barrel_bip_pct',
'hard_hit_pct',
];
const SLATE_COLUMNS = [
'game_pk',
'away_team',
'home_team',
'away_probable_pitcher_id',
'home_probable_pitcher_id',
'away_probable_pitcher',
'home_probable_pitcher',
'away_probable_hand',
'home_probable_hand',
];
const ROSTER_COLUMNS = ['team', 'player_id', 'player_name'];
const EXCLUSION_COLUMNS = ['player_id', 'exclude_from_hitter_tables'];
const REUSABLE_HITTER_COLUMNS = [
'team',
'batter',
'player_id',
'hitter_name',
'split_key',
'recent_window',
'weighted_mode',
'matchup_score',
'ceiling_score',
'zone_fit_score',
'likely_starter_score',
'xwoba',
'fb_pct',
'pulled_barrel_pct',
'sweet_spot_pct',
'barrel_bip_pct',
'hard_hit_pct',
'avg_launch_angle',
];
const REUSABLE_PITCHER_COLUMNS = [
'team',
'pitcher_id',
'player_id',
'pitcher_name',
'p_throws',
'split_key',
'recent_window',
'weighted_mode',
'pitcher_score',
'strikeout_score',
'raw_pitcher_score',
'raw_strikeout_score',
'pitcher_matchup_adjustment',
'strikeout_matchup_adjustment',
'xwoba',
'csw_pct',
'swstr_pct',
'putaway_pct',
'ball_pct',
'siera',
'gb_pct',
'gb_fb_ratio',
'barrel_bip_pct',
'hard_hit_pct',
];
const HITTER_ROLLING_COLUMNS = [
'batter',
'player_id',
'hitter_name',
'window_label',
'window_size',
'xwoba',
'hard_hit_pct',
'barrel_bip_pct',
'sweet_spot_pct',
];
const PITCHER_ROLLING_COLUMNS = [
'pitcher_id',
'player_id',
'pitcher_name',
'window_label',
'window_size',
'xwoba',
'csw_pct',
'swstr_pct',
'putaway_pct',
'ball_pct',
];
const BATTER_ZONE_COLUMNS = [
'batter_id',
'pitcher_hand_key',
'batter',
'player_id',
'hitter_name',
'pitch_type',
'zone_label',
'zone',
'hit_rate',
'hr_rate',
'xwoba',
'hard_hit_pct',
'barrel_bip_pct',
'sample_size',
'bip',
];
const PITCHER_ZONE_COLUMNS = [
'pitcher_id',
'player_id',
'pitcher_name',
'batter_side_key',
'pitch_type',
'zone_label',
'zone',
'usage_rate',
'xwoba_allowed',
'hard_hit_pct_allowed',
'barrel_bip_pct_allowed',
'sample_size',
'bip',
];
const ARSENAL_COLUMNS = [
'pitcher_id',
'player_id',
'pitcher_name',
'pitch_type',
'usage_pct',
'avg_velocity',
'avg_spin_rate',
'whiff_rate',
'called_strike_rate',
'putaway_rate',
];
const COUNT_USAGE_COLUMNS = [
'pitcher_id',
'player_id',
'pitcher_name',
'count_bucket',
'batter_side_key',
'pitch_type',
'usage_pct',
];
function addDays(value, delta) {
const next = new Date(`${value}T12:00:00Z`);
next.setUTCDate(next.getUTCDate() + delta);
return next.toISOString().slice(0, 10);
}
function parseDateOrToday(value) {
if (!value) {
return new Date().toISOString().slice(0, 10);
}
if (/^\d{4}-\d{2}-\d{2}$/.test(value)) {
return value;
}
throw new Error('Dates must use YYYY-MM-DD format.');
}
function limitOrDefault(value, max = 15) {
const numeric = Number(value ?? DEFAULT_MATCHUP_LIMIT);
if (!Number.isFinite(numeric) || numeric <= 0) {
return DEFAULT_MATCHUP_LIMIT;
}
return Math.min(max, Math.max(1, Math.floor(numeric)));
}
function normalizeText(value) {
return String(value ?? '').trim().toLowerCase();
}
function normalizeName(value) {
return String(value ?? '')
.normalize('NFKD')
.replace(/[\u0300-\u036f]/g, '')
.toLowerCase()
.replace(/[^a-z0-9]+/g, ' ')
.trim()
.replace(/\s+/g, ' ');
}
function normalizeTeam(value) {
return String(value ?? '').trim().toUpperCase();
}
function resolveTeamAliases(value) {
const normalized = normalizeText(value);
if (!normalized) {
return [];
}
const matches = [];
for (const [canonical, aliases] of MLB_TEAM_ALIASES.entries()) {
if (aliases.includes(normalized) || canonical.toLowerCase() === normalized) {
matches.push(canonical);
}
}
if (matches.length > 0) {
return matches;
}
return [normalizeTeam(value)];
}
function rowMatchesTeamFilter(rowTeam, teamFilter) {
if (!teamFilter) {
return true;
}
const candidates = resolveTeamAliases(teamFilter);
const normalizedRowTeam = normalizeTeam(rowTeam);
return candidates.includes(normalizedRowTeam);
}
function resolveCanonicalTeamFilter(value) {
if (!value) {
return null;
}
const candidates = resolveTeamAliases(value);
if (candidates.length === 1) {
return candidates[0];
}
return normalizeTeam(value);
}
function numberOrNull(value) {
const numeric = Number(value);
return Number.isFinite(numeric) ? numeric : null;
}
function compareNullableDescending(left, right) {
const leftValue = numberOrNull(left);
const rightValue = numberOrNull(right);
if (leftValue === null && rightValue === null) {
return 0;
}
if (leftValue === null) {
return 1;
}
if (rightValue === null) {
return -1;
}
return rightValue - leftValue;
}
function sortHittersLiveApp(rows) {
return [...rows].sort((left, right) =>
compareNullableDescending(left.matchup_score, right.matchup_score)
|| compareNullableDescending(left.xwoba, right.xwoba)
|| String(left.hitter_name ?? '').localeCompare(String(right.hitter_name ?? ''))
);
}
function sortHitters(rows) {
return [...rows].sort((left, right) =>
compareNullableDescending(left.matchup_score, right.matchup_score)
|| compareNullableDescending(left.ceiling_score, right.ceiling_score)
|| compareNullableDescending(left.likely_starter_score, right.likely_starter_score)
|| compareNullableDescending(left.xwoba, right.xwoba)
|| String(left.hitter_name ?? '').localeCompare(String(right.hitter_name ?? ''))
);
}
function sortPitchers(rows) {
return [...rows].sort((left, right) =>
compareNullableDescending(left.pitcher_score, right.pitcher_score)
|| compareNullableDescending(left.strikeout_score, right.strikeout_score)
|| compareNullableDescending(left.pitcher_matchup_adjustment, right.pitcher_matchup_adjustment)
|| compareNullableDescending(left.xwoba, right.xwoba)
|| String(left.pitcher_name ?? '').localeCompare(String(right.pitcher_name ?? ''))
);
}
function normalizeSeries(values, inverse = false) {
const numerics = values.map((value) => numberOrNull(value));
const finite = numerics.filter((value) => value !== null);
if (!finite.length) {
return values.map(() => 0.5);
}
const min = Math.min(...finite);
const max = Math.max(...finite);
const normalized = numerics.map((value) => {
if (value === null || Math.abs(max - min) < 1e-9) {
return 0.5;
}
return (value - min) / (max - min);
});
return normalized.map((value) => inverse ? 1 - value : value);
}
function launchAngleBandScore(value, low = 20, ideal = 27.5, high = 35) {
const numeric = numberOrNull(value);
if (numeric === null) {
return 0.5;
}
if (numeric >= low && numeric <= high) {
if (numeric <= ideal) {
return 0.8 + 0.2 * ((numeric - low) / Math.max(ideal - low, 1e-9));
}
return 0.8 + 0.2 * ((high - numeric) / Math.max(high - ideal, 1e-9));
}
if (numeric < low) {
return Math.max(0, 0.8 - ((low - numeric) / Math.max(low, 1e-9)) * 0.8);
}
return Math.max(0, 0.8 - ((numeric - high) / Math.max(high, 1e-9)) * 0.8);
}
function weightedAverage(pairs) {
const valid = pairs.filter(({ value, weight }) => value !== null && weight !== null && weight > 0);
if (!valid.length) {
return null;
}
const totalWeight = valid.reduce((sum, item) => sum + item.weight, 0);
if (totalWeight <= 0) {
return null;
}
return valid.reduce((sum, item) => sum + (item.value * item.weight), 0) / totalWeight;
}
function aggregateBatterZoneMap(rows) {
if (!rows.length) {
return [];
}
const grouped = new Map();
for (const row of rows) {
const zone = numberOrNull(row.zone);
if (zone === null) {
continue;
}
const key = String(Math.trunc(zone));
const bucket = grouped.get(key) ?? [];
bucket.push(row);
grouped.set(key, bucket);
}
const output = [];
for (const [key, group] of grouped.entries()) {
const zone = Number(key);
const zoneValue = (
(weightedAverage(group.map((row) => ({ value: numberOrNull(row.hit_rate), weight: numberOrNull(row.sample_size) }))) ?? 0) * 0.6
+ (weightedAverage(group.map((row) => ({ value: numberOrNull(row.hr_rate), weight: numberOrNull(row.sample_size) }))) ?? 0) * 0.4
);
output.push({
zone,
sample_size: group.reduce((sum, row) => sum + (numberOrNull(row.sample_size) ?? 0), 0),
zone_value: zoneValue,
});
}
return output;
}
function aggregatePitcherZoneMap(rows) {
if (!rows.length) {
return [];
}
const grouped = new Map();
for (const row of rows) {
const zone = numberOrNull(row.zone);
if (zone === null) {
continue;
}
const key = String(Math.trunc(zone));
const bucket = grouped.get(key) ?? [];
bucket.push(row);
grouped.set(key, bucket);
}
const output = [];
for (const [key, group] of grouped.entries()) {
const zone = Number(key);
output.push({
zone,
sample_size: group.reduce((sum, row) => sum + (numberOrNull(row.sample_size) ?? 0), 0),
zone_value: group.reduce((sum, row) => sum + (numberOrNull(row.usage_rate) ?? 0), 0),
});
}
return output;
}
function buildZoneOverlayMap(batterMap, pitcherMap) {
if (!batterMap.length || !pitcherMap.length) {
return [];
}
const pitcherByZone = new Map(pitcherMap.map((row) => [row.zone, row]));
const merged = batterMap
.filter((row) => pitcherByZone.has(row.zone))
.map((row) => ({
zone: row.zone,
sample_size: Math.min(row.sample_size ?? 0, pitcherByZone.get(row.zone)?.sample_size ?? 0),
batter_zone_value: row.zone_value,
pitcher_zone_value: pitcherByZone.get(row.zone)?.zone_value ?? null,
}));
if (!merged.length) {
return [];
}
const batterScale = normalizeSeries(merged.map((row) => row.batter_zone_value));
const pitcherScale = normalizeSeries(merged.map((row) => row.pitcher_zone_value));
return merged.map((row, index) => ({
zone: row.zone,
sample_size: row.sample_size,
zone_value: batterScale[index] * pitcherScale[index],
}));
}
function overlayZoneFitScore(batterMap, pitcherMap) {
const overlay = buildZoneOverlayMap(batterMap, pitcherMap);
if (!overlay.length) {
return 0.5;
}
const sampleSum = overlay.reduce((sum, row) => sum + (numberOrNull(row.sample_size) ?? 0), 0);
if (sampleSum < 15) {
return 0.5;
}
const score = weightedAverage(overlay.map((row) => ({ value: numberOrNull(row.zone_value), weight: numberOrNull(row.sample_size) })));
if (score === null) {
return 0.5;
}
return Math.max(0, Math.min(1, score));
}
function selectBatterZoneRows(rows, batterId, opposingPitcherHand) {
const hitterRows = rows.filter((row) => String(row.batter_id ?? row.batter ?? '') === String(batterId));
if (!hitterRows.length) {
return [];
}
const handKey = opposingPitcherHand === 'R' ? 'vs_rhp' : opposingPitcherHand === 'L' ? 'vs_lhp' : 'overall';
const specific = hitterRows.filter((row) => String(row.pitcher_hand_key ?? '') === handKey);
if (specific.length) {
return specific;
}
const overall = hitterRows.filter((row) => String(row.pitcher_hand_key ?? '') === 'overall');
return overall.length ? overall : hitterRows;
}
function selectPitcherZoneRows(rows, pitcherId, hitterSide) {
const pitcherRows = rows.filter((row) => String(row.pitcher_id ?? '') === String(pitcherId));
if (!pitcherRows.length) {
return [];
}
const sideKey = hitterSide === 'L' ? 'vs_lhh' : hitterSide === 'R' ? 'vs_rhh' : 'overall';
const specific = pitcherRows.filter((row) => String(row.batter_side_key ?? '') === sideKey);
if (specific.length) {
return specific;
}
const overall = pitcherRows.filter((row) => String(row.batter_side_key ?? '') === 'overall');
return overall.length ? overall : pitcherRows;
}
function buildZoneFitScores(rows, batterZoneRows, pitcherZoneRows) {
const pitcherCache = new Map();
const batterCache = new Map();
return rows.map((row) => {
const batterId = String(row.batter ?? row.player_id ?? '');
const pitcherId = String(row.opposing_pitcher_id ?? '');
if (!batterId || !pitcherId) {
return 0.5;
}
const batterKey = `${batterId}|${row.opposing_pitcher_hand ?? 'overall'}`;
const pitcherKey = `${pitcherId}|${row.stand ?? 'overall'}`;
if (!batterCache.has(batterKey)) {
batterCache.set(batterKey, aggregateBatterZoneMap(selectBatterZoneRows(batterZoneRows, batterId, row.opposing_pitcher_hand)));
}
if (!pitcherCache.has(pitcherKey)) {
pitcherCache.set(pitcherKey, aggregatePitcherZoneMap(selectPitcherZoneRows(pitcherZoneRows, pitcherId, row.stand)));
}
return overlayZoneFitScore(batterCache.get(batterKey) ?? [], pitcherCache.get(pitcherKey) ?? []);
});
}
function addHitterMatchupScore(rows, batterZoneRows, pitcherZoneRows) {
if (!rows.length) {
return [];
}
const zoneFitScores = buildZoneFitScores(rows, batterZoneRows, pitcherZoneRows);
const swstrScores = normalizeSeries(rows.map((row) => row.swstr_pct), true);
const barrelScores = normalizeSeries(rows.map((row) => row.barrel_bbe_pct));
const sweetSpotScores = normalizeSeries(rows.map((row) => row.sweet_spot_pct));
const barrelSupportScores = normalizeSeries(rows.map((row) => row.barrel_bbe_pct));
const shapeScores = rows.map((row, index) => (
(launchAngleBandScore(row.avg_launch_angle, 12, 22, 32) * 0.55)
+ (sweetSpotScores[index] * 0.35)
+ (barrelSupportScores[index] * 0.10)
));
const pulledBarrelScales = normalizeSeries(rows.map((row) => row.pulled_barrel_pct));
const matchupScored = rows.map((row, index) => {
const baseScore = ((swstrScores[index] * 0.35) + (barrelScores[index] * 0.30) + (shapeScores[index] * 0.20) + (zoneFitScores[index] * 0.15)) * 100;
const pulledBarrelBonus = Math.max(0, (pulledBarrelScales[index] - 0.5) / 0.5) * 0.08;
const matchupScore = Math.max(0, Math.min(100, baseScore * (1 + pulledBarrelBonus)));
return {
...row,
zone_fit_score: zoneFitScores[index],
matchup_score: matchupScore,
_shape_score: shapeScores[index],
_pulled_barrel_scale: pulledBarrelScales[index],
};
});
const matchupNorm = normalizeSeries(matchupScored.map((row) => row.matchup_score));
const barrelBipNorm = normalizeSeries(matchupScored.map((row) => row.barrel_bip_pct));
const hhNorm = normalizeSeries(matchupScored.map((row) => row.hard_hit_pct));
return sortHitters(matchupScored.map((row, index) => ({
...row,
ceiling_score: Math.max(0, Math.min(100,
(
(matchupNorm[index] * 0.35)
+ ((row._pulled_barrel_scale ?? 0.5) * 0.30)
+ (barrelBipNorm[index] * 0.20)
+ ((row._shape_score ?? 0.5) * 0.10)
+ (hhNorm[index] * 0.05)
) * 100
)),
}))).map((row) => {
const { _shape_score, _pulled_barrel_scale, ...clean } = row;
return clean;
});
}
function buildBestMatchupBoardRows(rows) {
if (!rows?.length) {
return [];
}
const rowsWithGame = rows.filter((row) => row.game_pk !== null && row.game_pk !== undefined && row.game_pk !== '');
if (!rowsWithGame.length) {
return sortHitters(rows);
}
const grouped = new Map();
for (const row of rowsWithGame) {
const key = String(row.game_pk);
const existing = grouped.get(key) ?? [];
existing.push(row);
grouped.set(key, existing);
}
const boardRows = [];
for (const gameRows of grouped.values()) {
boardRows.push(...sortHitters(gameRows).slice(0, 3));
}
return sortHitters(boardRows);
}
function buildBestMatchupBoardRowsLiveApp(rows) {
if (!rows?.length) {
return [];
}
const rowsWithGame = rows.filter((row) => row.game_pk !== null && row.game_pk !== undefined && row.game_pk !== '');
if (!rowsWithGame.length) {
return sortHittersLiveApp(rows);
}
const grouped = new Map();
for (const row of rowsWithGame) {
const key = String(row.game_pk);
const bucket = grouped.get(key) ?? [];
bucket.push(row);
grouped.set(key, bucket);
}
const boardRows = [];
for (const gameRows of grouped.values()) {
boardRows.push(...sortHittersLiveApp(gameRows).slice(0, 3));
}
return sortHittersLiveApp(boardRows);
}
function keepRowForDefaults(row) {
const splitKey = String(row.split_key ?? DEFAULT_SPLIT_KEY);
const recentWindow = String(row.recent_window ?? DEFAULT_RECENT_WINDOW);
const weightedMode = String(row.weighted_mode ?? DEFAULT_WEIGHTED_MODE);
return splitKey === DEFAULT_SPLIT_KEY
&& recentWindow === DEFAULT_RECENT_WINDOW
&& weightedMode === DEFAULT_WEIGHTED_MODE;
}
function mapSlateTeams(slateRows) {
const lookup = new Map();
for (const row of slateRows) {
const awayTeam = String(row.away_team ?? '').trim();
const homeTeam = String(row.home_team ?? '').trim();
if (!awayTeam || !homeTeam) {
continue;
}
const awayEntry = {
gamePk: row.game_pk ?? null,
opponentTeam: homeTeam,
opposingPitcherId: row.home_probable_pitcher_id ?? null,
opposingPitcherName: row.home_probable_pitcher ?? null,
opposingPitcherHand: row.home_probable_hand ?? null,
};
const homeEntry = {
gamePk: row.game_pk ?? null,
opponentTeam: awayTeam,
opposingPitcherId: row.away_probable_pitcher_id ?? null,
opposingPitcherName: row.away_probable_pitcher ?? null,
opposingPitcherHand: row.away_probable_hand ?? null,
};
lookup.set(awayTeam, awayEntry);
lookup.set(homeTeam, homeEntry);
const normalizedAwayTeam = normalizeTeam(awayTeam);
const normalizedHomeTeam = normalizeTeam(homeTeam);
if (normalizedAwayTeam) {
lookup.set(normalizedAwayTeam, awayEntry);
}
if (normalizedHomeTeam) {
lookup.set(normalizedHomeTeam, homeEntry);
}
}
return lookup;
}
function buildExclusionSet(rows) {
const values = new Set();
for (const row of rows) {
const playerId = row.player_id;
if (playerId === null || playerId === undefined || playerId === '') {
continue;
}
const shouldExclude = row.exclude_from_hitter_tables === true
|| String(row.exclude_from_hitter_tables ?? '').toLowerCase() === 'true'
|| String(row.exclude_from_hitter_tables ?? '') === '1';
if (shouldExclude) {
values.add(String(playerId));
}
}
return values;
}
function buildRosterLookup(rows) {
const lookup = new Map();
for (const row of rows) {
const playerId = String(row.player_id ?? '').trim();
if (!playerId) {
continue;
}
lookup.set(playerId, row.player_name ?? null);
}
return lookup;
}
function buildRosterIdsByTeam(rows) {
const lookup = new Map();
for (const row of rows) {
const team = normalizeTeam(row.team);
const playerId = String(row.player_id ?? '').trim();
if (!team || !playerId) {
continue;
}
const bucket = lookup.get(team) ?? new Set();
bucket.add(playerId);
lookup.set(team, bucket);
}
return lookup;
}
function buildRosterNameLookupByTeam(rows) {
const lookup = new Map();
for (const row of rows) {
const team = normalizeTeam(row.team);
const playerName = String(row.player_name ?? '').trim();
const playerId = String(row.player_id ?? '').trim();
if (!team || !playerName || !playerId) {
continue;
}
let teamLookup = lookup.get(team);
if (!teamLookup) {
teamLookup = {
exact: new Map(),
normalized: new Map(),
};
lookup.set(team, teamLookup);
}
teamLookup.exact.set(playerName.toLowerCase(), playerId);
teamLookup.normalized.set(normalizeName(playerName), playerId);
}
return lookup;
}
function resolveHostedPlayerName(row, rosterLookup, type = 'hitter') {
const rawName = type === 'pitcher'
? String(row.pitcher_name ?? row.player_name ?? '').trim()
: String(row.hitter_name ?? row.player_name ?? '').trim();
const playerId = String(
row.player_id
?? row.batter
?? row.pitcher_id
?? ''
).trim();
const rosterName = rosterLookup.get(playerId) ?? null;
const looksLikeId = /^\d+$/.test(rawName);
if (rosterName && (!rawName || looksLikeId)) {
return rosterName;
}
return rawName || rosterName || null;
}
function firstNonBlankValue(...values) {
for (const value of values) {
if (value === null || value === undefined) {
continue;
}
if (typeof value === 'string') {
const trimmed = value.trim();
if (trimmed) {
return trimmed;
}
continue;
}
if (value !== '') {
return value;
}
}
return null;
}
function withDefaults(row, slateLookup, options = {}) {
const team = String(row.team ?? '').trim();
const slate = slateLookup.get(team) ?? slateLookup.get(normalizeTeam(team)) ?? {};
const rosterLookup = options.rosterLookup ?? new Map();
const playerType = options.playerType ?? 'hitter';
return {
...row,
team,
hitter_name: playerType === 'hitter'
? resolveHostedPlayerName(row, rosterLookup, 'hitter')
: row.hitter_name,
pitcher_name: playerType === 'pitcher'
? resolveHostedPlayerName(row, rosterLookup, 'pitcher')
: row.pitcher_name,
game_pk: firstNonBlankValue(row.game_pk, slate.gamePk),
opponent_team: firstNonBlankValue(row.opponent_team, slate.opponentTeam),
opposing_pitcher_id: firstNonBlankValue(row.opposing_pitcher_id, slate.opposingPitcherId),
opposing_pitcher_name: firstNonBlankValue(row.opposing_pitcher_name, slate.opposingPitcherName),
opposing_pitcher_hand: firstNonBlankValue(row.opposing_pitcher_hand, slate.opposingPitcherHand),
};
}
function decodeHtmlEntities(value) {
return String(value ?? '')
.replace(/&nbsp;/gi, ' ')
.replace(/&amp;/gi, '&')
.replace(/&quot;/gi, '"')
.replace(/&#39;/gi, '\'')
.replace(/&apos;/gi, '\'')
.replace(/&lt;/gi, '<')
.replace(/&gt;/gi, '>');
}
function extractRotowireTokens(html) {
return decodeHtmlEntities(
String(html ?? '')
.replace(/<script[\s\S]*?<\/script>/gi, ' ')
.replace(/<style[\s\S]*?<\/style>/gi, ' ')
.replace(/<[^>]+>/g, '\n')
)
.split(/\r?\n/)
.map((token) => token.trim())
.filter(Boolean);
}
function nextNonPriceToken(tokens, startIndex) {
let index = startIndex;
while (index < tokens.length) {
if (!ROTOWIRE_PRICE_RE.test(tokens[index])) {
return { token: tokens[index], index };
}
index += 1;
}
return { token: null, index };
}
function parseRotowireLineupSide(tokens, startIndex) {
const rawStatus = tokens[startIndex];
const status = ROTOWIRE_LINEUP_STATUS.get(rawStatus) ?? 'unknown';
let index = startIndex + 1;
const players = [];
if (status === 'unknown') {
while (index < tokens.length) {
const token = tokens[index];
if (
ROTOWIRE_LINEUP_STATUS.has(token)
|| ROTOWIRE_STOP_TOKENS.has(token)
|| token.startsWith('Umpire:')
|| ROTOWIRE_TIME_RE.test(token)
) {
break;
}
index += 1;
}
return { lineup: { status, players }, nextIndex: index };
}
let slot = 1;
while (index < tokens.length) {
const token = tokens[index];
if (
ROTOWIRE_LINEUP_STATUS.has(token)
|| ROTOWIRE_STOP_TOKENS.has(token)
|| token.startsWith('Umpire:')
|| ROTOWIRE_TIME_RE.test(token)
) {
break;
}
if (ROTOWIRE_POSITION_TOKENS.has(token)) {
const next = nextNonPriceToken(tokens, index + 1);
if (
next.token
&& !ROTOWIRE_POSITION_TOKENS.has(next.token)
&& !ROTOWIRE_STOP_TOKENS.has(next.token)
&& !ROTOWIRE_LINEUP_STATUS.has(next.token)
) {
players.push({
slot,
player_name: next.token,
position: token,
});
slot += 1;
index = next.index + 1;
continue;
}
}
index += 1;
}
return { lineup: { status, players }, nextIndex: index };
}
function parseRotowireLineups(html, validTeams) {
const tokens = extractRotowireTokens(html);
const validTeamSet = new Set(validTeams.map((team) => normalizeTeam(team)));
const lineups = {};
let index = 0;
while (index < tokens.length) {
if (!ROTOWIRE_TIME_RE.test(tokens[index])) {
index += 1;
continue;
}
const teamTokens = [];
let scanIndex = index + 1;
while (scanIndex < tokens.length && scanIndex < index + 25 && teamTokens.length < 2) {
const token = normalizeTeam(tokens[scanIndex]);
if (validTeamSet.has(token) && !teamTokens.includes(token)) {
teamTokens.push(token);
}
scanIndex += 1;
}
if (teamTokens.length !== 2) {
index += 1;
continue;
}
const [awayTeam, homeTeam] = teamTokens;
let firstStatusIndex = null;
for (let candidate = scanIndex; candidate < Math.min(tokens.length, scanIndex + 80); candidate += 1) {
if (ROTOWIRE_LINEUP_STATUS.has(tokens[candidate])) {
firstStatusIndex = candidate;
break;
}
}
if (firstStatusIndex === null) {
index += 1;
continue;
}
const awayResult = parseRotowireLineupSide(tokens, firstStatusIndex);
let secondStatusIndex = null;
for (let candidate = awayResult.nextIndex; candidate < Math.min(tokens.length, awayResult.nextIndex + 80); candidate += 1) {
if (ROTOWIRE_LINEUP_STATUS.has(tokens[candidate])) {
secondStatusIndex = candidate;
break;
}
}
if (secondStatusIndex === null) {
index = awayResult.nextIndex;
continue;
}
const homeResult = parseRotowireLineupSide(tokens, secondStatusIndex);
lineups[awayTeam] = awayResult.lineup;
lineups[homeTeam] = homeResult.lineup;
index = homeResult.nextIndex;
}
return lineups;
}
function resolveRotowireLineups(lineups, rosterRows) {
if (!lineups || !Object.keys(lineups).length || !rosterRows?.length) {
return lineups ?? {};
}
const rosterLookupByTeam = buildRosterNameLookupByTeam(rosterRows);
const resolved = {};
for (const [team, payload] of Object.entries(lineups)) {
const teamLookup = rosterLookupByTeam.get(normalizeTeam(team)) ?? { exact: new Map(), normalized: new Map() };
const players = (payload?.players ?? []).map((player) => {
const playerName = String(player.player_name ?? '');
const exactPlayerId = teamLookup.exact.get(playerName.toLowerCase());
const normalizedPlayerId = teamLookup.normalized.get(normalizeName(playerName));
return {
...player,
player_id: exactPlayerId ?? normalizedPlayerId ?? null,
};
});
resolved[normalizeTeam(team)] = {
status: payload?.status ?? 'unknown',
players,
};
}
return resolved;
}
function applyProjectedLineup(rows, team, rotowireLineups) {
if (!rows.length || !rotowireLineups) {
return rows;
}
const payload = rotowireLineups[normalizeTeam(team)];
if (!payload || !['confirmed', 'expected'].includes(String(payload.status ?? ''))) {
return rows;
}
const slotByPlayerId = new Map();
for (const player of payload.players ?? []) {
const playerId = String(player.player_id ?? '').trim();
if (!playerId) {
continue;
}
slotByPlayerId.set(playerId, Number(player.slot ?? null));
}
if (slotByPlayerId.size < 7) {
return rows;
}
return rows
.filter((row) => slotByPlayerId.has(String(row.batter ?? row.player_id ?? '').trim()))
.map((row) => ({
...row,
projected_lineup_slot: slotByPlayerId.get(String(row.batter ?? row.player_id ?? '').trim()) ?? null,
}))
.sort((left, right) => {
const leftSlot = numberOrNull(left.projected_lineup_slot);
const rightSlot = numberOrNull(right.projected_lineup_slot);
if (leftSlot === null && rightSlot === null) {
return 0;
}
if (leftSlot === null) {
return 1;
}
if (rightSlot === null) {
return -1;
}
return leftSlot - rightSlot;
});
}
function pickMetrics(row, metricKeys) {
return metricKeys
.map(([label, key]) => ({ label, key, value: numberOrNull(row[key]) }))
.filter((item) => item.value !== null);
}
function buildRollingSummary(rows, metricKey, valueKey) {
return rows
.map((row) => ({
label: row[metricKey] ?? (row.window_size ? `Rolling ${row.window_size}` : null),
value: numberOrNull(row[valueKey]),
}))
.filter((row) => row.label && row.value !== null)
.slice(0, 3);
}
function buildZoneSummary(rows, nameKey, valueKeys) {
return [...rows]
.map((row) => {
const label = row.zone_label ?? row.zone ?? null;
const metric = valueKeys
.map((key) => ({ key, value: numberOrNull(row[key]) }))
.find((item) => item.value !== null);
return {
label,
metricKey: metric?.key ?? null,
value: metric?.value ?? null,
sample: numberOrNull(row.sample_size ?? row.bip),
playerName: row[nameKey] ?? null,
};
})
.filter((row) => row.label && row.value !== null)
.sort((left, right) => compareNullableDescending(left.value, right.value))
.slice(0, 3);
}
function buildArsenalSummary(rows) {
return [...rows]
.map((row) => ({
pitchType: row.pitch_type ?? null,
usagePct: numberOrNull(row.usage_pct),
velocity: numberOrNull(row.avg_velocity),
whiffRate: numberOrNull(row.whiff_rate),
calledStrikeRate: numberOrNull(row.called_strike_rate),
}))
.filter((row) => row.pitchType && row.usagePct !== null)
.sort((left, right) => compareNullableDescending(left.usagePct, right.usagePct))
.slice(0, 4);
}
function buildCountUsageSummary(rows) {
return [...rows]
.map((row) => ({
countBucket: row.count_bucket ?? null,
batterSide: row.batter_side_key ?? 'all',
pitchType: row.pitch_type ?? null,
usagePct: numberOrNull(row.usage_pct),
}))
.filter((row) => row.countBucket && row.pitchType && row.usagePct !== null)
.sort((left, right) => compareNullableDescending(left.usagePct, right.usagePct))
.slice(0, 4);
}
function uniqueRowsByKey(rows, keyBuilder) {
const lookup = new Map();
for (const row of rows ?? []) {
const key = keyBuilder(row);
if (key && !lookup.has(key)) {
lookup.set(key, row);
}
}
return [...lookup.values()];
}
function formatDateLabel(value) {
return String(value ?? '').slice(5);
}
function normalizeToRadarScore(value, { min = 0, max = 100, scalePercent = false, inverse = false } = {}) {
const numeric = numberOrNull(value);
if (numeric === null) {
return 0;
}
const raw = scalePercent && Math.abs(numeric) <= 1 ? numeric * 100 : numeric;
const normalized = max === min ? 0 : ((raw - min) / (max - min)) * 100;
const clamped = Math.max(0, Math.min(100, normalized));
return inverse ? 100 - clamped : clamped;
}
function averageOrNull(values) {
const valid = values.map(numberOrNull).filter((value) => value !== null);
if (!valid.length) {
return null;
}
return valid.reduce((sum, value) => sum + value, 0) / valid.length;
}
function impliedProbabilityFromAmerican(odds) {
const numeric = numberOrNull(odds);
if (numeric === null || numeric === 0) {
return null;
}
if (numeric > 0) {
return 100 / (numeric + 100);
}
return Math.abs(numeric) / (Math.abs(numeric) + 100);
}
function opponentLabelFromPitcherRow(row) {
return row?.opponent_team ?? null;
}
function buildZoneOverlayInsights(cells) {
if (!cells?.length) {
return {
bestOverlay: 'No qualifying zone overlap',
shapeSummary: 'Zone inputs were not available.',
};
}
const sorted = [...cells].sort((left, right) => compareNullableDescending(left.overlayValue, right.overlayValue));
const best = sorted[0] ?? null;
const topTwoAverage = averageOrNull(sorted.slice(0, 2).map((cell) => cell.overlayValue));
return {
bestOverlay: best ? `Zone ${best.zone} at ${((numberOrNull(best.overlayValue) ?? 0) * 100).toFixed(0)}% fit` : 'No qualifying zone overlap',
shapeSummary: topTwoAverage !== null
? `Top-zone overlap averages ${(topTwoAverage * 100).toFixed(0)}% across the strongest damage pockets.`
: 'Zone inputs were not available.',
};
}
function formatMetricValue(value, type = 'number') {
const numeric = numberOrNull(value);
if (numeric === null) {
return 'N/A';
}
if (type === 'pct') {
const scaled = Math.abs(numeric) <= 1 ? numeric * 100 : numeric;
return `${scaled.toFixed(1)}%`;
}
if (Math.abs(numeric) < 1) {
return numeric.toFixed(3);
}
return numeric.toFixed(1);
}
function getPitcherWindowPointLimit(window) {
switch (String(window ?? 'last_5')) {
case 'last_10':
return 10;
case 'season_2026':
return 15;
case 'career':
return 6;
default:
return 5;
}
}
function windowLabel(window) {
switch (String(window ?? 'last_5')) {
case 'last_10':
return 'last 10';
case 'season_2026':
return 'season 2026';
case 'career':
return 'career';
default:
return 'last 5';
}
}
function splitLabel(split) {
switch (String(split ?? 'overall')) {
case 'vs_lhb':
return 'vs LHB';
case 'vs_rhb':
return 'vs RHB';
default:
return 'overall';
}
}
function countBucketLabel(bucket) {
switch (String(bucket ?? 'all')) {
case 'first_pitch':
return 'first pitch';
case 'ahead':
return 'ahead';
case 'behind':
return 'behind';
case 'putaway':
return 'putaway';
case 'two_strike':
return 'two strike';
default:
return 'all counts';
}
}
function labelForCompareTarget(compareTo) {
switch (String(compareTo ?? 'career')) {
case 'season_2026':
return 'Season 2026';
case 'prior_5':
return 'Prior 5';
case 'prior_10':
return 'Prior 10';
default:
return 'Career';
}
}
function pitcherTrendTitle(view) {
switch (view) {
case 'velo':
return 'Velocity Trend';
case 'spin':
return 'Spin Trend';
case 'release':
return 'Release Trend';
default:
return 'Pitcher Trend';
}
}
function pitcherTrendPrimaryLabel(view) {
switch (view) {
case 'velo':
return 'Release Speed';
case 'spin':
return 'Spin Rate';
case 'release':
return 'Extension';
default:
return 'Pitcher Trend';
}
}
function pitcherTrendOverlayLabel(view, index) {
if (view === 'velo') {
return index === 0 ? 'Effective Speed' : 'Move Z';
}
if (view === 'spin') {
return index === 0 ? 'Spin Efficiency Proxy' : 'Spin Axis';
}
return index === 0 ? 'Release X' : 'Release Z';
}
function formatTrendOverlayValue(view, index, value) {
const numeric = numberOrNull(value) ?? 0;
if (view === 'spin' && index === 0) {
return numeric * 100;
}
return numeric;
}
function pitcherTrendRead(view, points, pitchType) {
const latest = points[points.length - 1];
if (view === 'velo') {
return `${pitchType ? `${pitchType} ` : ''}velocity most recently checked in at ${formatMetricValue(latest?.value)}.`;
}
if (view === 'spin') {
return `${pitchType ? `${pitchType} ` : ''}spin most recently checked in at ${formatMetricValue(latest?.value)}.`;
}
return `${pitchType ? `${pitchType} ` : ''}release metrics track extension with position overlays across the selected window.`;
}
function pitcherArsenalTitle(view) {
switch (view) {
case 'shape':
return 'Pitch Shape Card';
case 'movement':
return 'Movement Cluster';
case 'usage':
return 'Pitch Mix Usage';
case 'evolution':
return 'Arsenal Evolution';
case 'outcomes':
return 'Pitch Outcomes';
case 'platoon':
return 'Platoon Arsenal';
default:
return 'Pitcher Arsenal';
}
}
function pitcherLocationTitle(view) {
switch (view) {
case 'bypitch':
return 'Location by Pitch';
case 'twostrike':
return 'Two Strike Location';
case 'chase':
return 'Chase Attack Map';
case 'damage':
return 'Damage Allowed Map';
case 'miss':
return 'Miss Location Map';
default:
return 'Location Heatmap';
}
}
function pitcherLocationRead(view) {
switch (view) {
case 'bypitch':
return 'Each dot is a pitch. Colors separate pitch types so you can see where each offering lives.';
case 'damage':
return 'Hotter cells flag where contact quality has done the most damage.';
case 'miss':
return 'Hotter cells flag where swings and misses show up most often.';
case 'chase':
return 'Hotter cells flag where the pitcher attacks for chase or edge pressure.';
default:
return 'Hotter cells show where the pitcher lives most often in the selected view.';
}
}
function pitcherLocationMetricConfig(view) {
switch (view) {
case 'damage':
return {
headline: 'Color = contact damage',
primaryLabel: 'xwOBA',
secondaryLabel: 'Usage',
suffix: '',
};
case 'miss':
return {
headline: 'Color = miss rate',
primaryLabel: 'Miss',
secondaryLabel: 'Usage',
suffix: '%',
};
case 'chase':
return {
headline: 'Color = chase pressure',
primaryLabel: 'Chase',
secondaryLabel: 'Usage',
suffix: '%',
};
default:
return {
headline: 'Color = usage rate',
primaryLabel: 'Usage',
secondaryLabel: 'Miss',
suffix: '%',
};
}
}
function pitcherApproachTitle(view) {
switch (view) {
case 'count_whiff':
return 'Whiff by Count';
case 'ahead_behind':
return 'Ahead vs Behind';
case 'first_pitch':
return 'First Pitch Usage';
case 'putaway':
return 'Putaway Distribution';
default:
return 'Pitch Usage by Count';
}
}
function pitcherCompareTitle(view) {
switch (view) {
case 'recent_vs_baseline':
return 'Recent vs Baseline';
case 'year_over_year':
return 'Year Over Year';
default:
return 'Current vs Career';
}
}
function buildPitchTypeSqlFilter(pitchTypeColumn, pitchNameColumn, parameterIndex) {
const pitchTypeExpr = pitchTypeColumn === 'NULL'
? 'NULL'
: `UPPER(COALESCE(NULLIF(${pitchTypeColumn}, ''), NULLIF(${pitchNameColumn}, '')))`;
const pitchNameExpr = `UPPER(COALESCE(NULLIF(${pitchNameColumn}, ''), NULLIF(${pitchTypeColumn}, '')))`;
return `AND ($${parameterIndex}::text IS NULL OR ${pitchTypeExpr} = UPPER($${parameterIndex}) OR ${pitchNameExpr} = UPPER($${parameterIndex}))`;
}
function buildSplitSqlFilter(columnExpression, parameterIndex) {
return `AND ($${parameterIndex}::text IS NULL OR $${parameterIndex}::text = 'overall' OR LOWER(${columnExpression}) = CASE WHEN $${parameterIndex}::text = 'vs_lhb' THEN 'l' WHEN $${parameterIndex}::text = 'vs_rhb' THEN 'r' ELSE LOWER(${columnExpression}) END)`;
}
function buildPitcherIdentitySql(nameColumn, idColumn, nameParameterIndex, idParameterIndex) {
return `(($${idParameterIndex}::text IS NOT NULL AND ${idColumn}::text = $${idParameterIndex}) OR ($${idParameterIndex}::text IS NULL AND LOWER(${nameColumn}) = LOWER($${nameParameterIndex})))`;
}
function deriveCountBucket(row) {
const balls = numberOrNull(row.balls) ?? 0;
const strikes = numberOrNull(row.strikes) ?? 0;
if (balls === 0 && strikes === 0) {
return 'first_pitch';
}
if (strikes >= 2) {
return 'putaway';
}
if (strikes > balls) {
return 'ahead';
}
if (balls > strikes) {
return 'behind';
}
return 'even';
}
function matchesCountBucket(row, countBucket) {
if (!countBucket || countBucket === 'all') {
return true;
}
if (countBucket === 'two_strike') {
return (numberOrNull(row.strikes) ?? 0) >= 2;
}
return deriveCountBucket(row) === countBucket;
}
function bucketPlateCell(plateX, plateZ) {
const x = numberOrNull(plateX) ?? 0;
const z = numberOrNull(plateZ) ?? 2.8;
const col = x < -0.28 ? 0 : x > 0.28 ? 2 : 1;
const row = z > 3.3 ? 0 : z < 2.5 ? 2 : 1;
return row * 3 + col + 1;
}
function isWhiffDescription(description) {
const normalized = normalizeText(description);
return ['swinging strike', 'swinging strike blocked', 'foul tip'].some((token) => normalized.includes(token));
}
function isChaseLikeLocation(plateX, plateZ) {
const x = Math.abs(numberOrNull(plateX) ?? 0);
const z = numberOrNull(plateZ) ?? 2.8;
return x > 0.85 || z < 1.7 || z > 3.9;
}
function buildPitcherLocationCells(rows, view) {
const buckets = new Map();
const totalRows = rows.length || 1;
for (const row of rows) {
const zone = bucketPlateCell(row.plate_x, row.plate_z);
const entry = buckets.get(zone) ?? { zone, pitches: 0, whiffs: 0, xwobaTotal: 0, xwobaCount: 0, chasePressure: 0 };
entry.pitches += 1;
if (isWhiffDescription(row.description)) {
entry.whiffs += 1;
}
const xwoba = numberOrNull(row.estimated_woba_using_speedangle);
if (xwoba !== null) {
entry.xwobaTotal += xwoba;
entry.xwobaCount += 1;
}
if (isChaseLikeLocation(row.plate_x, row.plate_z)) {
entry.chasePressure += 1;
}
buckets.set(zone, entry);
}
return [1, 2, 3, 4, 5, 6, 7, 8, 9].map((zone) => {
const entry = buckets.get(zone) ?? { zone, pitches: 0, whiffs: 0, xwobaTotal: 0, xwobaCount: 0, chasePressure: 0 };
const usage = entry.pitches / totalRows;
const whiffRate = entry.pitches > 0 ? entry.whiffs / entry.pitches : 0;
const damage = entry.xwobaCount > 0 ? entry.xwobaTotal / entry.xwobaCount : 0;
const chaseRate = entry.pitches > 0 ? entry.chasePressure / entry.pitches : 0;
let overlayValue = usage * 100;
if (view === 'miss') {
overlayValue = whiffRate * 100;
} else if (view === 'damage') {
overlayValue = damage * 100;
} else if (view === 'chase') {
overlayValue = chaseRate * 100;
}
return {
zone,
batterValue: whiffRate,
pitcherValue: usage,
overlayValue,
};
});
}
function buildPitcherLocationSummary(cells, view) {
const ranked = [...cells].sort((left, right) => compareNullableDescending(left.overlayValue, right.overlayValue)).slice(0, 3);
const zones = ranked.map((cell) => `Z${cell.zone}`).join(', ');
if (!zones) {
return 'No location summary available.';
}
if (view === 'damage') {
return `Most dangerous contact pockets sit in ${zones}.`;
}
if (view === 'miss') {
return `Best miss pockets sit in ${zones}.`;
}
return `Most frequent attack lanes sit in ${zones}.`;
}
function locationMetricSuffix(view) {
if (view === 'damage') {
return 'xwOBA';
}
return '%';
}
function buildPitchLocationPlot(rows) {
const counts = new Map();
const points = rows
.filter((row) => numberOrNull(row.plate_x) !== null && numberOrNull(row.plate_z) !== null)
.slice(0, 600)
.map((row) => {
const pitchName = firstNonBlankValue(row.pitch_name, row.pitch_type, 'Unknown');
counts.set(pitchName, (counts.get(pitchName) ?? 0) + 1);
return {
x: numberOrNull(row.plate_x) ?? 0,
y: numberOrNull(row.plate_z) ?? 0,
pitchName,
};
});
const breakdown = [...counts.entries()]
.sort((left, right) => right[1] - left[1])
.slice(0, 5)
.map(([pitchName, count]) => ({
pitchName,
count,
pct: points.length ? (count / points.length) * 100 : 0,
}));
return {
points,
breakdown,
};
}
function buildPitcherApproachDatasets(rows, view) {
const buckets = view === 'ahead_behind'
? ['ahead', 'even', 'behind']
: view === 'first_pitch'
? ['first_pitch']
: view === 'putaway'
? ['putaway']
: ['first_pitch', 'ahead', 'even', 'behind', 'putaway'];
const pitchUsage = new Map();
const pitchWhiffs = new Map();
const topCounts = new Map();
for (const row of rows) {
const bucket = deriveCountBucket(row);
const pitch = firstNonBlankValue(row.pitch_name, row.pitch_type, 'Unknown');
const key = `${pitch}|${bucket}`;
pitchUsage.set(key, (pitchUsage.get(key) ?? 0) + 1);
if (isWhiffDescription(row.description)) {
pitchWhiffs.set(key, (pitchWhiffs.get(key) ?? 0) + 1);
}
topCounts.set(pitch, (topCounts.get(pitch) ?? 0) + 1);
}
const topPitches = [...topCounts.entries()]
.sort((left, right) => right[1] - left[1])
.slice(0, 4)
.map(([pitch]) => pitch);
const datasets = topPitches.map((pitch) => ({
label: pitch,
values: buckets.map((bucket) => {
const key = `${pitch}|${bucket}`;
const usage = pitchUsage.get(key) ?? 0;
if (view === 'count_whiff') {
return usage > 0 ? ((pitchWhiffs.get(key) ?? 0) / usage) * 100 : 0;
}
const bucketTotal = [...topPitches].reduce((sum, name) => sum + (pitchUsage.get(`${name}|${bucket}`) ?? 0), 0);
return bucketTotal > 0 ? (usage / bucketTotal) * 100 : 0;
}),
})).filter((dataset) => dataset.values.some((value) => value > 0));
return {
labels: buckets.map((bucket) => countBucketLabel(bucket)),
datasets,
read: view === 'count_whiff'
? 'Higher bars show where each pitch shape generates the most swing-and-miss pressure.'
: 'Higher bars show which pitch shapes own the selected count states most often.',
};
}
function findBestPlayerMatch(rows, key, playerName) {
const normalizedNeedle = normalizeText(playerName);
const exact = rows.find((row) => normalizeText(row[key]) === normalizedNeedle);
if (exact) {
return exact;
}
return rows.find((row) => normalizeText(row[key]).includes(normalizedNeedle)) ?? null;
}
async function sleep(ms) {
await new Promise((resolve) => setTimeout(resolve, ms));
}
export class HostedArtifactSource {
constructor(config = {}, options = {}) {
this.baseUrl = String(config.baseUrl ?? '').trim().replace(/\/$/, '');
this.cacheTtlMs = Number(config.cacheTtlMs ?? 5 * 60 * 1000);
this.fallbackDays = Number(config.fallbackDays ?? DEFAULT_FALLBACK_DAYS);
this.logger = options.logger ?? console;
this.readParquetImpl = options.readParquetImpl ?? readParquetFromUrl;
this.fetchTextImpl = options.fetchTextImpl ?? defaultFetchText;
this.rotowireUrl = String(config.rotowireUrl ?? DEFAULT_ROTOWIRE_URL);
this.cache = new Map();
}
async getCachedValue(cacheKey, producer) {
const now = Date.now();
const cached = this.cache.get(cacheKey);
if (cached && cached.expiresAt > now) {
return cached.rows ?? cached.value;
}
const pending = Promise.resolve().then(producer);
this.cache.set(cacheKey, {
expiresAt: now + this.cacheTtlMs,
value: pending,
});
try {
const rows = await pending;
this.cache.set(cacheKey, {
expiresAt: now + this.cacheTtlMs,
rows,
});
return rows;
} catch (error) {
const current = this.cache.get(cacheKey);
if (current?.value === pending) {
this.cache.delete(cacheKey);
}
throw error;
}
}
isConfigured() {
return Boolean(this.baseUrl);
}
async getLatestAvailableDate(targetDate) {
if (!this.isConfigured()) {
return null;
}
const safeTargetDate = parseDateOrToday(targetDate);
return this.getCachedValue(`latest-date|${safeTargetDate}`, async () => {
for (let offset = 0; offset <= this.fallbackDays; offset += 1) {
const candidate = addDays(safeTargetDate, -offset);
try {
await this.readDailyFile(candidate, 'slate.parquet', SLATE_COLUMNS);
return candidate;
} catch (error) {
this.logger?.debug?.('Hosted slate check failed', { candidate, error: error.message });
}
}
return null;
});
}
async readDailyFile(targetDate, filename, columns) {
const safeDate = parseDateOrToday(targetDate);
const url = `${this.baseUrl}/daily/${safeDate}/${filename}`;
return this.readCached(url, columns);
}
async readReusableFile(filename, columns) {
const url = `${this.baseUrl}/reusable/${filename}`;
return this.readCached(url, columns);
}
async readCached(url, columns) {
const cacheKey = `${url}|${(columns ?? []).join(',')}`;
const now = Date.now();
const cached = this.cache.get(cacheKey);
if (cached && cached.expiresAt > now) {
return cached.rows;
}
const rows = await this.readParquetImpl(url, columns);
this.cache.set(cacheKey, {
expiresAt: now + this.cacheTtlMs,
rows,
});
return rows;
}
async fetchRotowireLineups(targetDate, validTeams, rosterRows = []) {
if (!this.fetchTextImpl || !validTeams?.length) {
return {};
}
const cacheKey = `rotowire|${parseDateOrToday(targetDate)}|${[...validTeams].sort().join(',')}`;
try {
return await this.getCachedValue(cacheKey, async () => {
const html = await this.fetchTextImpl(this.rotowireUrl, parseDateOrToday(targetDate));
const parsed = parseRotowireLineups(html, validTeams);
return resolveRotowireLineups(parsed, rosterRows);
});
} catch (error) {
this.logger?.debug?.('Rotowire lineup fetch failed', {
targetDate: parseDateOrToday(targetDate),
error: error.message,
});
return {};
}
}
getHostedHitterParityCacheKey(options = {}) {
return [
'hosted-hitter-parity',
parseDateOrToday(options.date),
String(options.split ?? DEFAULT_SPLIT_KEY),
String(options.recentWindow ?? DEFAULT_RECENT_WINDOW),
String(options.weightedMode ?? DEFAULT_WEIGHTED_MODE),
options.likelyOnly === true ? 'likely' : 'all',
].join('|');
}
applyHostedTeamFilter(result, team) {
return {
...result,
rows: result.rows.filter((row) => rowMatchesTeamFilter(row.team, team)),
boardRows: result.boardRows.filter((row) => rowMatchesTeamFilter(row.team, team)),
};
}
async buildHostedHitterParityBase(options = {}) {
return this.getCachedValue(this.getHostedHitterParityCacheKey(options), async () => {
const targetDate = parseDateOrToday(options.date);
const resolvedDate = await this.getLatestAvailableDate(targetDate);
if (!resolvedDate) {
throw new Error('No hosted matchup slate was available in the fallback window.');
}
const [slateRows, hitterRows, pitcherRows, exclusionRows, rosterRows, batterZoneRows, pitcherZoneRows] = await Promise.all([
this.readDailyFile(resolvedDate, 'slate.parquet', SLATE_COLUMNS),
this.readDailyFile(resolvedDate, 'daily_hitter_metrics.parquet', HITTER_COLUMNS),
this.readDailyFile(resolvedDate, 'daily_pitcher_metrics.parquet', PITCHER_COLUMNS),
this.readDailyFile(resolvedDate, 'hitter_pitcher_exclusions.parquet', EXCLUSION_COLUMNS).catch(() => []),
this.readDailyFile(resolvedDate, 'rosters.parquet', ROSTER_COLUMNS).catch(() => []),
this.readDailyFile(resolvedDate, 'daily_batter_zone_profiles.parquet', BATTER_ZONE_COLUMNS).catch(() => []),
this.readDailyFile(resolvedDate, 'daily_pitcher_zone_profiles.parquet', PITCHER_ZONE_COLUMNS).catch(() => []),
]);
const split = String(options.split ?? DEFAULT_SPLIT_KEY);
const recentWindow = String(options.recentWindow ?? DEFAULT_RECENT_WINDOW);
const weightedMode = String(options.weightedMode ?? DEFAULT_WEIGHTED_MODE);
const likelyOnly = options.likelyOnly === true;
const slateLookup = mapSlateTeams(slateRows);
const exclusionSet = buildExclusionSet(exclusionRows);
const rosterLookup = buildRosterLookup(rosterRows);
const rosterIdsByTeam = buildRosterIdsByTeam(rosterRows);
const validTeams = [...new Set(slateRows.flatMap((row) => [normalizeTeam(row.away_team), normalizeTeam(row.home_team)]).filter(Boolean))];
const rotowireLineups = await this.fetchRotowireLineups(resolvedDate, validTeams, rosterRows);
const filteredPitchers = pitcherRows.filter((row) =>
String(row.split_key ?? DEFAULT_SPLIT_KEY) === split
&& String(row.recent_window ?? DEFAULT_RECENT_WINDOW) === recentWindow
&& String(row.weighted_mode ?? DEFAULT_WEIGHTED_MODE) === weightedMode
);
const pitchersById = new Map();
for (const row of filteredPitchers) {
pitchersById.set(String(row.pitcher_id ?? row.player_id ?? ''), row);
}
const allRows = [];
const boardRows = [];
for (const slateRow of slateRows) {
const awayTeam = normalizeTeam(slateRow.away_team);
const homeTeam = normalizeTeam(slateRow.home_team);
const awayPitcher = pitchersById.get(String(slateRow.away_probable_pitcher_id ?? '')) ?? null;
const homePitcher = pitchersById.get(String(slateRow.home_probable_pitcher_id ?? '')) ?? null;
const awayHand = String(homePitcher?.p_throws ?? slateRow.home_probable_hand ?? '').trim().toUpperCase() || null;
const homeHand = String(awayPitcher?.p_throws ?? slateRow.away_probable_hand ?? '').trim().toUpperCase() || null;
const awayRows = this.prepareHostedTeamHitters({
team: awayTeam,
slateLookup,
rosterLookup,
rosterIdsByTeam,
exclusionSet,
rotowireLineups,
hitterRows,
opposingPitcherHand: awayHand,
split,
recentWindow,
weightedMode,
likelyOnly,
});
const homeRows = this.prepareHostedTeamHitters({
team: homeTeam,
slateLookup,
rosterLookup,
rosterIdsByTeam,
exclusionSet,
rotowireLineups,
hitterRows,
opposingPitcherHand: homeHand,
split,
recentWindow,
weightedMode,
likelyOnly,
});
const combinedGameRows = sortHittersLiveApp([
...addHitterMatchupScore(awayRows, batterZoneRows, pitcherZoneRows),
...addHitterMatchupScore(homeRows, batterZoneRows, pitcherZoneRows),
]);
allRows.push(...combinedGameRows);
boardRows.push(...combinedGameRows.slice(0, 3));
}
return {
source: 'hosted',
resolvedDate,
parityRanked: true,
rows: sortHittersLiveApp(allRows),
boardRows: sortHittersLiveApp(boardRows),
};
});
}
async buildHostedHitterParityResult(options = {}) {
const result = await this.buildHostedHitterParityBase(options);
return this.applyHostedTeamFilter(result, options.team);
}
prepareHostedTeamHitters({
team,
slateLookup,
rosterLookup,
rosterIdsByTeam,
exclusionSet,
rotowireLineups,
hitterRows,
opposingPitcherHand,
split,
recentWindow,
weightedMode,
likelyOnly,
}) {
const normalizedTeam = normalizeTeam(team);
const effectiveSplit = split === DEFAULT_SPLIT_KEY && opposingPitcherHand === 'R'
? 'vs_rhp'
: split === DEFAULT_SPLIT_KEY && opposingPitcherHand === 'L'
? 'vs_lhp'
: split;
const rosterPlayerIds = rosterIdsByTeam.get(normalizedTeam) ?? null;
const preparedRows = hitterRows
.filter((row) => normalizeTeam(row.team) === normalizedTeam)
.filter((row) => String(row.split_key ?? DEFAULT_SPLIT_KEY) === effectiveSplit)
.filter((row) => String(row.recent_window ?? DEFAULT_RECENT_WINDOW) === recentWindow)
.filter((row) => String(row.weighted_mode ?? DEFAULT_WEIGHTED_MODE) === weightedMode)
.filter((row) => !rosterPlayerIds || rosterPlayerIds.has(String(row.batter ?? row.player_id ?? '').trim()))
.filter((row) => !exclusionSet.has(String(row.batter ?? row.player_id ?? '').trim()))
.map((row) => withDefaults(row, slateLookup, { rosterLookup, playerType: 'hitter' }))
.filter((row) => !likelyOnly || (numberOrNull(row.likely_starter_score) ?? 0) > 0);
return applyProjectedLineup(preparedRows, normalizedTeam, rotowireLineups);
}
async getTopHitters(options = {}) {
const result = await this.buildHostedHitterParityResult(options);
return {
source: result.source,
resolvedDate: result.resolvedDate,
parityRanked: true,
rows: result.rows.slice(0, limitOrDefault(options.limit)),
};
}
async getTopPitchers(options = {}) {
const targetDate = parseDateOrToday(options.date);
const resolvedDate = await this.getLatestAvailableDate(targetDate);
if (!resolvedDate) {
throw new Error('No hosted pitcher slate was available in the fallback window.');
}
const [slateRows, pitcherRows, rosterRows] = await Promise.all([
this.readDailyFile(resolvedDate, 'slate.parquet', SLATE_COLUMNS),
this.readDailyFile(resolvedDate, 'daily_pitcher_metrics.parquet', PITCHER_COLUMNS),
this.readDailyFile(resolvedDate, 'rosters.parquet', ROSTER_COLUMNS).catch(() => []),
]);
const slateLookup = mapSlateTeams(slateRows);
const rosterLookup = buildRosterLookup(rosterRows);
const filteredRows = pitcherRows
.filter(keepRowForDefaults)
.map((row) => withDefaults(row, slateLookup, { rosterLookup, playerType: 'pitcher' }))
.filter((row) => rowMatchesTeamFilter(row.team, options.team));
return {
source: 'hosted',
resolvedDate,
rows: sortPitchers(filteredRows).slice(0, limitOrDefault(options.limit)),
};
}
async getBestMatchups(options = {}) {
const result = await this.buildHostedHitterParityResult(options);
return {
source: result.source,
resolvedDate: result.resolvedDate,
parityRanked: true,
rows: result.boardRows.slice(0, limitOrDefault(options.limit ?? (options.team ? 3 : 12), 12)),
};
}
async getTeamBestMatchups(options = {}) {
if (!options.team) {
throw new Error('Team Best Matchups requires a team filter.');
}
const result = await this.buildHostedHitterParityResult(options);
return {
source: result.source,
resolvedDate: result.resolvedDate,
parityRanked: true,
rows: result.rows.slice(0, limitOrDefault(options.limit ?? 3, 12)),
};
}
async getPlayerContext(options = {}) {
const targetDate = parseDateOrToday(options.date);
const resolvedDate = await this.getLatestAvailableDate(targetDate);
if (!resolvedDate) {
throw new Error('No hosted player context was available in the fallback window.');
}
const [slateRows, dailyHitterRows, dailyPitcherRows, reusableHitters, reusablePitchers, hitterRollingRows, pitcherRollingRows, batterZoneRows, pitcherZoneRows, arsenalRows, countUsageRows, rosterRows] = await Promise.all([
this.readDailyFile(resolvedDate, 'slate.parquet', SLATE_COLUMNS),
this.readDailyFile(resolvedDate, 'daily_hitter_metrics.parquet', HITTER_COLUMNS),
this.readDailyFile(resolvedDate, 'daily_pitcher_metrics.parquet', PITCHER_COLUMNS),
this.readReusableFile('hitter_metrics.parquet', REUSABLE_HITTER_COLUMNS),
this.readReusableFile('pitcher_metrics.parquet', REUSABLE_PITCHER_COLUMNS),
this.readReusableFile('hitter_rolling.parquet', HITTER_ROLLING_COLUMNS).catch(() => []),
this.readReusableFile('pitcher_rolling.parquet', PITCHER_ROLLING_COLUMNS).catch(() => []),
this.readReusableFile('batter_zone_profiles.parquet', BATTER_ZONE_COLUMNS).catch(() => []),
this.readReusableFile('pitcher_zone_profiles.parquet', PITCHER_ZONE_COLUMNS).catch(() => []),
this.readReusableFile('pitcher_arsenal.parquet', ARSENAL_COLUMNS).catch(() => []),
this.readReusableFile('pitcher_usage_by_count.parquet', COUNT_USAGE_COLUMNS).catch(() => []),
this.readDailyFile(resolvedDate, 'rosters.parquet', ROSTER_COLUMNS).catch(() => []),
]);
const slateLookup = mapSlateTeams(slateRows);
const rosterLookup = buildRosterLookup(rosterRows);
const normalizedType = normalizeText(options.playerType || 'auto');
const dailyHitters = dailyHitterRows
.filter(keepRowForDefaults)
.map((row) => withDefaults(row, slateLookup, { rosterLookup, playerType: 'hitter' }));
const dailyPitchers = dailyPitcherRows
.filter(keepRowForDefaults)
.map((row) => withDefaults(row, slateLookup, { rosterLookup, playerType: 'pitcher' }));
const baseHitters = reusableHitters
.filter(keepRowForDefaults)
.map((row) => ({ ...row, hitter_name: resolveHostedPlayerName(row, rosterLookup, 'hitter') }));
const basePitchers = reusablePitchers
.filter(keepRowForDefaults)
.map((row) => ({ ...row, pitcher_name: resolveHostedPlayerName(row, rosterLookup, 'pitcher') }));
const hitterMatch = normalizedType === 'pitcher' ? null : findBestPlayerMatch(dailyHitters, 'hitter_name', options.player)
?? findBestPlayerMatch(baseHitters, 'hitter_name', options.player);
const pitcherMatch = normalizedType === 'hitter' ? null : findBestPlayerMatch(dailyPitchers, 'pitcher_name', options.player)
?? findBestPlayerMatch(basePitchers, 'pitcher_name', options.player);
if (hitterMatch) {
const playerId = String(hitterMatch.batter ?? hitterMatch.player_id ?? '');
return {
source: 'hosted',
resolvedDate,
playerType: 'hitter',
name: hitterMatch.hitter_name,
team: hitterMatch.team ?? null,
opponentTeam: hitterMatch.opponent_team ?? null,
opposingPitcherName: hitterMatch.opposing_pitcher_name ?? null,
hand: hitterMatch.opposing_pitcher_hand ?? null,
overview: hitterMatch,
metrics: pickMetrics(hitterMatch, [
['Matchup', 'matchup_score'],
['Ceiling', 'ceiling_score'],
['Zone Fit', 'zone_fit_score'],
['Likely', 'likely_starter_score'],
['xwOBA', 'xwoba'],
['HH%', 'hard_hit_pct'],
['Brl/BIP%', 'barrel_bip_pct'],
]),
rolling: buildRollingSummary(
hitterRollingRows.filter((row) => String(row.batter ?? row.player_id ?? '') === playerId),
'window_label',
'xwoba'
),
zones: buildZoneSummary(
batterZoneRows.filter((row) => String(row.batter ?? row.player_id ?? '') === playerId),
'hitter_name',
['xwoba', 'hard_hit_pct', 'barrel_bip_pct']
),
arsenal: [],
countUsage: [],
};
}
if (pitcherMatch) {
const playerId = String(pitcherMatch.pitcher_id ?? pitcherMatch.player_id ?? '');
return {
source: 'hosted',
resolvedDate,
playerType: 'pitcher',
name: pitcherMatch.pitcher_name,
team: pitcherMatch.team ?? null,
opponentTeam: pitcherMatch.opponent_team ?? null,
hand: pitcherMatch.p_throws ?? null,
overview: pitcherMatch,
metrics: pickMetrics(pitcherMatch, [
['Pitch Score', 'pitcher_score'],
['Strikeout', 'strikeout_score'],
['Matchup Adj', 'pitcher_matchup_adjustment'],
['K Adj', 'strikeout_matchup_adjustment'],
['xwOBA', 'xwoba'],
['CSW%', 'csw_pct'],
['SwStr%', 'swstr_pct'],
]),
rolling: buildRollingSummary(
pitcherRollingRows.filter((row) => String(row.pitcher_id ?? row.player_id ?? '') === playerId),
'window_label',
'xwoba'
),
zones: buildZoneSummary(
pitcherZoneRows.filter((row) => String(row.pitcher_id ?? row.player_id ?? '') === playerId),
'pitcher_name',
['xwoba_allowed', 'hard_hit_pct_allowed', 'barrel_bip_pct_allowed']
),
arsenal: buildArsenalSummary(
arsenalRows.filter((row) => String(row.pitcher_id ?? row.player_id ?? '') === playerId)
),
countUsage: buildCountUsageSummary(
countUsageRows.filter((row) => String(row.pitcher_id ?? row.player_id ?? '') === playerId)
),
};
}
throw new Error(`No hosted matchup profile matched "${options.player}".`);
}
async getHealth(options = {}) {
const targetDate = parseDateOrToday(options.date);
const latestDate = await this.getLatestAvailableDate(targetDate);
return {
configured: this.isConfigured(),
baseUrl: this.baseUrl || null,
latestDate,
cacheEntries: this.cache.size,
cacheTtlMs: this.cacheTtlMs,
};
}
}
export class CockroachMatchupSource {
constructor(databaseUrl, options = {}) {
const usesSsl = /sslmode=(require|verify-ca|verify-full)/i.test(databaseUrl);
this.pool = options.pool ?? new Pool({
connectionString: databaseUrl,
ssl: usesSsl ? { rejectUnauthorized: false } : undefined,
});
this.logger = options.logger ?? console;
this.retryLimit = Number(options.retryLimit ?? 3);
}
async close() {
await this.pool.end();
}
async query(text, values = [], attempt = 1) {
try {
return await this.pool.query(text, values);
} catch (error) {
if (COCKROACH_RETRY_CODES.has(error?.code) && attempt < this.retryLimit) {
await sleep(50 * attempt);
return this.query(text, values, attempt + 1);
}
throw error;
}
}
async getLatestSnapshotDate() {
const { rows } = await this.query(
`
SELECT GREATEST(
COALESCE((SELECT MAX(slate_date)::date FROM public.hitter_model_snapshots), DATE '1970-01-01'),
COALESCE((SELECT MAX(slate_date)::date FROM public.pitcher_model_snapshots), DATE '1970-01-01')
) AS latest_slate_date
`
);
return rows[0]?.latest_slate_date ?? null;
}
async getTopHitters(options = {}) {
const limit = limitOrDefault(options.limit);
const values = [options.date ?? null, resolveCanonicalTeamFilter(options.team), limit];
const { rows } = await this.query(
`
WITH target_date AS (
SELECT COALESCE($1::date, (SELECT MAX(slate_date)::date FROM public.hitter_model_snapshots)) AS slate_date
)
SELECT
slate_date::date AS slate_date,
team,
hitter_name,
matchup_score,
ceiling_score,
zone_fit_score,
likely_starter_score,
xwoba
FROM public.hitter_model_snapshots
WHERE slate_date::date = (SELECT slate_date FROM target_date)
AND split_key = $4
AND recent_window = $5
AND weighted_mode = $6
AND ($2::text IS NULL OR team = $2::text)
ORDER BY matchup_score DESC NULLS LAST, ceiling_score DESC NULLS LAST, likely_starter_score DESC NULLS LAST, xwoba DESC NULLS LAST
LIMIT $3
`,
[...values, DEFAULT_SPLIT_KEY, DEFAULT_RECENT_WINDOW, DEFAULT_WEIGHTED_MODE]
);
return {
source: 'cockroach',
resolvedDate: rows[0]?.slate_date ?? null,
rows,
};
}
async getTopPitchers(options = {}) {
const limit = limitOrDefault(options.limit);
const values = [options.date ?? null, resolveCanonicalTeamFilter(options.team), limit];
const { rows } = await this.query(
`
WITH target_date AS (
SELECT COALESCE($1::date, (SELECT MAX(slate_date)::date FROM public.pitcher_model_snapshots)) AS slate_date
)
SELECT
slate_date::date AS slate_date,
team,
pitcher_name,
p_throws,
pitcher_score,
strikeout_score,
raw_pitcher_score,
raw_strikeout_score,
pitcher_matchup_adjustment,
strikeout_matchup_adjustment,
opponent_lineup_quality,
opponent_contact_threat,
opponent_whiff_tendency,
lineup_source,
lineup_hitter_count,
xwoba,
csw_pct,
swstr_pct,
putaway_pct,
ball_pct,
siera,
gb_pct,
gb_fb_ratio,
barrel_bip_pct,
hard_hit_pct
FROM public.pitcher_model_snapshots
WHERE slate_date::date = (SELECT slate_date FROM target_date)
AND split_key = $4
AND recent_window = $5
AND weighted_mode = $6
AND ($2::text IS NULL OR team = $2::text)
ORDER BY pitcher_score DESC NULLS LAST, strikeout_score DESC NULLS LAST, pitcher_matchup_adjustment DESC NULLS LAST, xwoba ASC NULLS LAST
LIMIT $3
`,
[...values, DEFAULT_SPLIT_KEY, DEFAULT_RECENT_WINDOW, DEFAULT_WEIGHTED_MODE]
);
return {
source: 'cockroach',
resolvedDate: rows[0]?.slate_date ?? null,
rows,
};
}
async getBestMatchups(options = {}) {
return this.getTopHitters({
...options,
limit: limitOrDefault(options.limit, 12),
});
}
async getTeamBestMatchups(options = {}) {
if (!options.team) {
throw new Error('Team Best Matchups requires a team filter.');
}
return this.getTopHitters({
...options,
limit: limitOrDefault(options.limit ?? 3, 12),
});
}
async getHitterSnapshotRows(options = {}) {
const values = [options.date ?? null, resolveCanonicalTeamFilter(options.team)];
const { rows } = await this.query(
`
WITH target_date AS (
SELECT COALESCE($1::date, (SELECT MAX(slate_date)::date FROM public.hitter_model_snapshots)) AS slate_date
)
SELECT
slate_date::date AS slate_date,
team,
hitter_name,
matchup_score,
ceiling_score,
zone_fit_score,
likely_starter_score,
xwoba,
hard_hit_pct,
barrel_bip_pct
FROM public.hitter_model_snapshots
WHERE slate_date::date = (SELECT slate_date FROM target_date)
AND split_key = $3
AND recent_window = $4
AND weighted_mode = $5
AND ($2::text IS NULL OR team = $2::text)
`,
[...values, DEFAULT_SPLIT_KEY, DEFAULT_RECENT_WINDOW, DEFAULT_WEIGHTED_MODE]
);
return rows;
}
async getPlayerContext(options = {}) {
const hitterResult = normalizeText(options.playerType) === 'pitcher'
? { rows: [] }
: await this.query(
`
SELECT
slate_date::date AS slate_date,
team,
hitter_name,
matchup_score,
ceiling_score,
zone_fit_score,
likely_starter_score,
xwoba,
hard_hit_pct,
barrel_bip_pct
FROM public.hitter_model_snapshots
WHERE slate_date::date = COALESCE($2::date, (SELECT MAX(slate_date)::date FROM public.hitter_model_snapshots))
AND split_key = $3
AND recent_window = $4
AND weighted_mode = $5
AND LOWER(hitter_name) LIKE LOWER($1)
ORDER BY CASE WHEN LOWER(hitter_name) = LOWER($6) THEN 0 ELSE 1 END, matchup_score DESC NULLS LAST
LIMIT $7
`,
[`%${options.player}%`, options.date ?? null, DEFAULT_SPLIT_KEY, DEFAULT_RECENT_WINDOW, DEFAULT_WEIGHTED_MODE, options.player, PROFILE_CANDIDATE_LIMIT]
);
const pitcherResult = normalizeText(options.playerType) === 'hitter'
? { rows: [] }
: await this.query(
`
SELECT
slate_date::date AS slate_date,
team,
pitcher_name,
p_throws,
pitcher_score,
strikeout_score,
pitcher_matchup_adjustment,
strikeout_matchup_adjustment,
xwoba,
csw_pct,
swstr_pct
FROM public.pitcher_model_snapshots
WHERE slate_date::date = COALESCE($2::date, (SELECT MAX(slate_date)::date FROM public.pitcher_model_snapshots))
AND split_key = $3
AND recent_window = $4
AND weighted_mode = $5
AND LOWER(pitcher_name) LIKE LOWER($1)
ORDER BY CASE WHEN LOWER(pitcher_name) = LOWER($6) THEN 0 ELSE 1 END, pitcher_score DESC NULLS LAST
LIMIT $7
`,
[`%${options.player}%`, options.date ?? null, DEFAULT_SPLIT_KEY, DEFAULT_RECENT_WINDOW, DEFAULT_WEIGHTED_MODE, options.player, PROFILE_CANDIDATE_LIMIT]
);
const hitter = hitterResult.rows[0] ?? null;
const pitcher = pitcherResult.rows[0] ?? null;
if (hitter) {
return {
source: 'cockroach',
resolvedDate: hitter.slate_date ?? null,
playerType: 'hitter',
name: hitter.hitter_name,
team: hitter.team ?? null,
opponentTeam: null,
opposingPitcherName: null,
hand: null,
overview: hitter,
metrics: pickMetrics(hitter, [
['Matchup', 'matchup_score'],
['Ceiling', 'ceiling_score'],
['Zone Fit', 'zone_fit_score'],
['Likely', 'likely_starter_score'],
['xwOBA', 'xwoba'],
['HH%', 'hard_hit_pct'],
['Brl/BIP%', 'barrel_bip_pct'],
]),
rolling: [],
zones: [],
arsenal: [],
countUsage: [],
};
}
if (pitcher) {
return {
source: 'cockroach',
resolvedDate: pitcher.slate_date ?? null,
playerType: 'pitcher',
name: pitcher.pitcher_name,
team: pitcher.team ?? null,
opponentTeam: null,
hand: pitcher.p_throws ?? null,
overview: pitcher,
metrics: pickMetrics(pitcher, [
['Pitch Score', 'pitcher_score'],
['Strikeout', 'strikeout_score'],
['Matchup Adj', 'pitcher_matchup_adjustment'],
['K Adj', 'strikeout_matchup_adjustment'],
['xwOBA', 'xwoba'],
['CSW%', 'csw_pct'],
['SwStr%', 'swstr_pct'],
]),
rolling: [],
zones: [],
arsenal: [],
countUsage: [],
};
}
throw new Error(`No Cockroach matchup profile matched "${options.player}".`);
}
async getHealth(options = {}) {
const latestDate = await this.getLatestSnapshotDate();
return {
configured: true,
latestDate: latestDate ?? null,
requestedDate: options.date ?? null,
};
}
}
export class MatchupService {
constructor(config = {}, options = {}) {
this.logger = options.logger ?? console;
this.hosted = options.hosted ?? new HostedArtifactSource(config.hosted ?? {}, { logger: this.logger });
this.fallback = options.fallback ?? new CockroachMatchupSource(config.databaseUrl, { logger: this.logger });
this.oddsProvider = options.oddsProvider ?? null;
this.chartCache = new Map();
this.chartCacheTtlMs = Number(config.chartCacheTtlMs ?? config.hosted?.cacheTtlMs ?? 5 * 60 * 1000);
}
async close() {
await this.fallback?.close?.();
}
setOddsProvider(provider) {
this.oddsProvider = provider ?? null;
}
async getCachedChartValue(cacheKey, producer) {
const now = Date.now();
const cached = this.chartCache.get(cacheKey);
if (cached && cached.expiresAt > now) {
return cached.value;
}
const value = await producer();
this.chartCache.set(cacheKey, {
value,
expiresAt: now + this.chartCacheTtlMs,
});
return value;
}
buildChartCacheKey(prefix, options = {}) {
const stable = Object.entries(options)
.filter(([, value]) => value !== undefined && value !== null && value !== '')
.sort(([left], [right]) => left.localeCompare(right))
.map(([key, value]) => `${key}:${String(value)}`);
return [prefix, ...stable].join('|');
}
async runHostedFirst(methodName, options = {}) {
let hostedError = null;
if (this.hosted?.isConfigured?.()) {
try {
const hostedResult = await this.hosted[methodName](options);
if (methodName === 'getTopHitters' && !hostedResult?.parityRanked) {
return await this.enrichHostedHitters(hostedResult, options, methodName);
}
return hostedResult;
} catch (error) {
hostedError = error;
this.logger?.warn?.(`Hosted matchup source failed for ${methodName}`, {
error: error.message,
options,
});
}
}
const result = await this.fallback[methodName](options);
if (hostedError) {
result.warning = hostedError.message;
}
return result;
}
async getTopHitters(options = {}) {
return this.runHostedFirst('getTopHitters', options);
}
async getTopPitchers(options = {}) {
return this.runHostedFirst('getTopPitchers', options);
}
async getBestMatchups(options = {}) {
return this.runHostedFirst('getBestMatchups', options);
}
async getTeamBestMatchups(options = {}) {
return this.runHostedFirst('getTeamBestMatchups', options);
}
async getPlayerContext(options = {}) {
return this.runHostedFirst('getPlayerContext', options);
}
async getHealth(options = {}) {
const [hosted, fallback] = await Promise.all([
this.hosted.getHealth(options).catch((error) => ({
configured: this.hosted?.isConfigured?.() ?? false,
latestDate: null,
error: error.message,
})),
this.fallback.getHealth(options).catch((error) => ({
configured: true,
latestDate: null,
error: error.message,
})),
]);
return {
hosted,
fallback,
};
}
async getHrBoardChartData(options = {}) {
return this.getCachedChartValue(this.buildChartCacheKey('hr-board', options), async () => {
const result = await this.getTopHitters({
...options,
limit: limitOrDefault(options.limit ?? 10, 15),
});
return {
source: result.source,
resolvedDate: result.resolvedDate,
rows: result.rows.map((row) => ({
...row,
label: `${row.hitter_name ?? 'Unknown'}${row.team ? ` (${row.team})` : ''}`,
matchup: row.matchup_score,
ceiling: row.ceiling_score,
})),
};
});
}
async getHrProfileData(options = {}) {
return this.getCachedChartValue(this.buildChartCacheKey('hr-profile', options), async () => {
const result = await this.getFastHitterChartContext(options.player, options);
const overview = result.overview ?? {};
return {
...result,
playerName: result.name,
labels: ['Matchup', 'Ceiling', 'Zone Fit', 'Barrel', 'Pulled Brl', 'SwStr', 'Sweet Spot', 'xwOBA'],
values: [
normalizeToRadarScore(overview.matchup_score),
normalizeToRadarScore(overview.ceiling_score),
normalizeToRadarScore(numberOrNull(overview.zone_fit_score) === null ? null : numberOrNull(overview.zone_fit_score) * 100, { min: 0, max: 100 }),
normalizeToRadarScore(overview.barrel_bip_pct, { min: 0, max: 25, scalePercent: true }),
normalizeToRadarScore(overview.pulled_barrel_pct, { min: 0, max: 20, scalePercent: true }),
normalizeToRadarScore(overview.swstr_pct, { min: 5, max: 18, scalePercent: true, inverse: true }),
normalizeToRadarScore(overview.sweet_spot_pct, { min: 20, max: 50, scalePercent: true }),
normalizeToRadarScore(overview.xwoba, { min: 0.25, max: 0.45 }),
],
zone_fit_score: overview.zone_fit_score,
matchup_score: overview.matchup_score,
ceiling_score: overview.ceiling_score,
team: result.team,
opposingPitcherName: result.opposingPitcherName,
opposingPitcherHand: result.hand,
};
});
}
async getKProfileData(options = {}) {
return this.getCachedChartValue(this.buildChartCacheKey('k-profile', options), async () => {
const result = await this.getFastPitcherChartContext(options.pitcher, options);
const overview = result.overview ?? {};
return {
...result,
pitcherName: result.name,
labels: ['Pitch Score', 'K Score', 'Pitch Adj', 'K Adj', 'CSW', 'SwStr', 'PutAway', 'Opp Whiff'],
values: [
normalizeToRadarScore(overview.pitcher_score),
normalizeToRadarScore(overview.strikeout_score),
normalizeToRadarScore(overview.pitcher_matchup_adjustment, { min: -10, max: 15 }),
normalizeToRadarScore(overview.strikeout_matchup_adjustment, { min: -10, max: 15 }),
normalizeToRadarScore(overview.csw_pct, { min: 20, max: 38, scalePercent: true }),
normalizeToRadarScore(overview.swstr_pct, { min: 8, max: 20, scalePercent: true }),
normalizeToRadarScore(overview.putaway_pct, { min: 10, max: 35, scalePercent: true }),
normalizeToRadarScore(overview.opponent_whiff_tendency, { min: 18, max: 32 }),
],
pitcher_score: overview.pitcher_score,
strikeout_score: overview.strikeout_score,
strikeout_matchup_adjustment: overview.strikeout_matchup_adjustment,
pitcherName: result.name,
opponentTeam: result.opponentTeam,
};
});
}
async getHrTrendData(options = {}) {
return this.getCachedChartValue(this.buildChartCacheKey('hr-trend', options), async () => {
const base = await this.getHrProfileData(options);
const points = await this.buildHostedTrendPoints({
date: options.date,
window: options.window,
kind: 'hitter',
playerName: options.player,
});
return {
...base,
points: points.map((point) => ({ label: point.label, value: point.matchup_score })),
primaryLabel: 'Matchup',
overlays: [
{ label: 'Barrel%', values: points.map((point) => point.barrel_pct), color: '#f97316' },
{ label: 'xwOBA', values: points.map((point) => point.xwoba), color: '#fca5a5' },
],
};
});
}
async getKTrendData(options = {}) {
return this.getCachedChartValue(this.buildChartCacheKey('k-trend', options), async () => {
const base = await this.getKProfileData(options);
const points = await this.buildHostedTrendPoints({
date: options.date,
window: options.window,
kind: 'pitcher',
playerName: options.pitcher,
});
return {
...base,
points: points.map((point) => ({ label: point.label, value: point.strikeout_score })),
primaryLabel: 'Strikeout Score',
overlays: [
{ label: 'CSW%', values: points.map((point) => point.csw_pct), color: '#93c5fd' },
{ label: 'SwStr%', values: points.map((point) => point.swstr_pct), color: '#c4b5fd' },
],
};
});
}
async getHrValueChartData(options = {}) {
return this.getCachedChartValue(this.buildChartCacheKey('hr-value', options), async () => {
if (!this.oddsProvider?.getPlayerHomeRunOdds) {
throw new Error('Home run value charts require the live odds provider.');
}
const board = await this.getHrBoardChartData({
...options,
limit: limitOrDefault(options.limit ?? 10, 15),
});
const oddsPayloads = await Promise.all(board.rows.map(async (row) => {
try {
const odds = await this.oddsProvider.getPlayerHomeRunOdds(row.hitter_name, { book: options.book });
const probabilities = odds.entries
.map((entry) => impliedProbabilityFromAmerican(entry.oddsInput))
.filter((value) => value !== null);
return {
row,
book: odds.bookFilter ?? options.book ?? null,
impliedProbability: averageOrNull(probabilities),
};
} catch {
return null;
}
}));
const points = oddsPayloads
.filter(Boolean)
.filter((item) => item.impliedProbability !== null)
.map((item) => ({
x: item.impliedProbability,
y: numberOrNull(item.row.matchup_score) ?? 0,
label: `${item.row.hitter_name} (${item.row.team})`,
highlight: (numberOrNull(item.row.ceiling_score) ?? 0) >= 75,
}));
if (!points.length) {
throw new Error('No home run odds were available for the current board.');
}
return {
source: board.source,
resolvedDate: board.resolvedDate,
points,
};
});
}
async getHrZoneData(options = {}) {
return this.getCachedChartValue(this.buildChartCacheKey('hr-zone', options), async () => {
if (!this.hosted?.isConfigured?.()) {
throw new Error('HR zone charts require hosted artifacts.');
}
const targetDate = parseDateOrToday(options.date);
const resolvedDate = await this.hosted.getLatestAvailableDate(targetDate);
if (!resolvedDate) {
throw new Error('No hosted matchup slate was available in the fallback window.');
}
const [slateRows, hitterRows, pitcherRows, rosterRows, batterZoneRows, pitcherZoneRows] = await Promise.all([
this.hosted.readDailyFile(resolvedDate, 'slate.parquet', SLATE_COLUMNS),
this.hosted.readDailyFile(resolvedDate, 'daily_hitter_metrics.parquet', HITTER_COLUMNS),
this.hosted.readDailyFile(resolvedDate, 'daily_pitcher_metrics.parquet', PITCHER_COLUMNS),
this.hosted.readDailyFile(resolvedDate, 'rosters.parquet', ROSTER_COLUMNS).catch(() => []),
this.hosted.readDailyFile(resolvedDate, 'daily_batter_zone_profiles.parquet', BATTER_ZONE_COLUMNS).catch(() => []),
this.hosted.readDailyFile(resolvedDate, 'daily_pitcher_zone_profiles.parquet', PITCHER_ZONE_COLUMNS).catch(() => []),
]);
const rosterLookup = buildRosterLookup(rosterRows);
const pitcherLookup = new Map(
pitcherRows
.filter((row) => keepRowForDefaults(row))
.map((row) => [String(row.pitcher_id ?? row.player_id ?? ''), row])
);
const slateLookup = mapSlateTeams(slateRows);
for (const [team, slate] of slateLookup.entries()) {
const pitcher = pitcherLookup.get(String(slate.opposingPitcherId ?? ''));
if (pitcher?.p_throws) {
slateLookup.set(team, { ...slate, opposingPitcherHand: pitcher.p_throws });
}
}
const preparedRows = hitterRows
.filter((row) => keepRowForDefaults(row))
.map((row) => withDefaults(row, slateLookup, { rosterLookup, playerType: 'hitter' }));
const hitterMatch = findBestPlayerMatch(preparedRows, 'hitter_name', options.player);
if (!hitterMatch) {
throw new Error(`No hitter matchup profile matched "${options.player}".`);
}
const hitterSlate = slateLookup.get(String(hitterMatch.team ?? '').trim())
?? slateLookup.get(normalizeTeam(hitterMatch.team))
?? {};
const opposingPitcherId = String(
firstNonBlankValue(hitterMatch.opposing_pitcher_id, hitterSlate.opposingPitcherId) ?? ''
).trim();
const opposingPitcherRow = pitcherLookup.get(opposingPitcherId) ?? null;
const opposingPitcherRowName = opposingPitcherRow
? resolveHostedPlayerName(opposingPitcherRow, rosterLookup, 'pitcher')
: null;
const opposingPitcherName = firstNonBlankValue(
hitterMatch.opposing_pitcher_name,
hitterSlate.opposingPitcherName,
opposingPitcherRowName,
);
const opposingPitcherHand = firstNonBlankValue(
hitterMatch.opposing_pitcher_hand,
hitterSlate.opposingPitcherHand,
);
const batterMap = aggregateBatterZoneMap(selectBatterZoneRows(
batterZoneRows,
String(hitterMatch.batter ?? hitterMatch.player_id ?? ''),
opposingPitcherHand
));
const pitcherMap = aggregatePitcherZoneMap(selectPitcherZoneRows(
pitcherZoneRows,
opposingPitcherId,
hitterMatch.stand
));
const overlay = buildZoneOverlayMap(batterMap, pitcherMap);
if (!overlay.length) {
throw new Error('Zone profile inputs were unavailable for that hitter.');
}
const batterByZone = new Map(batterMap.map((row) => [row.zone, row]));
const pitcherByZone = new Map(pitcherMap.map((row) => [row.zone, row]));
const overlayByZone = new Map(overlay.map((row) => [row.zone, row]));
const cells = [1, 2, 3, 4, 5, 6, 7, 8, 9].map((zone) => ({
zone,
batterValue: batterByZone.get(zone)?.zone_value ?? 0,
pitcherValue: pitcherByZone.get(zone)?.zone_value ?? 0,
overlayValue: overlayByZone.get(zone)?.zone_value ?? 0,
}));
const insights = buildZoneOverlayInsights(cells);
return {
source: 'hosted',
resolvedDate,
playerName: hitterMatch.hitter_name,
team: hitterMatch.team,
opposingPitcherName,
opposingPitcherHand,
zone_fit_score: hitterMatch.zone_fit_score ?? overlayZoneFitScore(batterMap, pitcherMap),
cells,
bestOverlay: insights.bestOverlay,
shapeSummary: insights.shapeSummary,
read: 'Green cells show the strongest overlap between batter damage and pitcher attack lanes.',
};
});
}
async getKLadderData(options = {}) {
return this.getCachedChartValue(this.buildChartCacheKey('k-ladder', options), async () => {
if (!this.oddsProvider?.getPlayerMarketOdds) {
throw new Error('Strikeout ladder charts require the live odds provider.');
}
const profile = await this.getKProfileData(options);
const booksToTry = options.book ? [options.book] : ['FanDuel', 'DraftKings', 'BetMGM', 'Caesars'];
let oddsResult = null;
for (const book of booksToTry) {
try {
oddsResult = await this.oddsProvider.getPlayerMarketOdds('pitcher_strikeouts', options.pitcher, { book });
if (oddsResult?.entries?.length) {
break;
}
} catch {
continue;
}
}
if (!oddsResult?.entries?.length) {
throw new Error(`No strikeout ladder odds were found for ${options.pitcher}.`);
}
const rows = oddsResult.entries.map((entry) => ({
label: entry.selectionDisplay ?? `${Math.floor(numberOrNull(entry.lineValue) ?? 0) + 1}+`,
probability: impliedProbabilityFromAmerican(entry.oddsInput) ?? 0,
price: entry.oddsInput,
}));
return {
...profile,
source: 'odds',
book: oddsResult.bookFilter ?? null,
rows,
bestLabel: rows[0]?.label ?? null,
bestPrice: rows[0]?.price ?? null,
};
});
}
async getKMatchupData(options = {}) {
return this.getCachedChartValue(this.buildChartCacheKey('k-matchup', options), async () => {
const profile = await this.getKProfileData(options);
const overview = profile.overview ?? {};
const pitcherMetrics = [
{ label: 'Strikeout Score', value: formatMetricValue(overview.strikeout_score), normalized: normalizeToRadarScore(overview.strikeout_score) / 100 },
{ label: 'CSW%', value: formatMetricValue(overview.csw_pct, 'pct'), normalized: normalizeToRadarScore(overview.csw_pct, { min: 20, max: 38, scalePercent: true }) / 100 },
{ label: 'SwStr%', value: formatMetricValue(overview.swstr_pct, 'pct'), normalized: normalizeToRadarScore(overview.swstr_pct, { min: 8, max: 20, scalePercent: true }) / 100 },
{ label: 'PutAway%', value: formatMetricValue(overview.putaway_pct, 'pct'), normalized: normalizeToRadarScore(overview.putaway_pct, { min: 10, max: 35, scalePercent: true }) / 100 },
];
const opponentMetrics = [
{ label: 'Opp Whiff', value: formatMetricValue(overview.opponent_whiff_tendency), normalized: normalizeToRadarScore(overview.opponent_whiff_tendency, { min: 18, max: 32 }) / 100 },
{ label: 'Lineup Quality', value: formatMetricValue(overview.opponent_lineup_quality), normalized: normalizeToRadarScore(overview.opponent_lineup_quality, { min: 60, max: 110, inverse: true }) / 100 },
{ label: 'Contact Threat', value: formatMetricValue(overview.opponent_contact_threat), normalized: normalizeToRadarScore(overview.opponent_contact_threat, { min: 60, max: 110, inverse: true }) / 100 },
{ label: 'K Adjustment', value: formatMetricValue(overview.strikeout_matchup_adjustment), normalized: normalizeToRadarScore(overview.strikeout_matchup_adjustment, { min: -10, max: 15 }) / 100 },
];
return {
...profile,
pitcherMetrics,
opponentMetrics,
read: `${profile.pitcherName} projects as a ${numberOrNull(overview.strikeout_score) !== null && overview.strikeout_score >= 70 ? 'high-upside' : 'moderate'} strikeout spot versus ${profile.opponentTeam ?? 'the current opponent'}.`,
};
});
}
async getKCountData(options = {}) {
return this.getCachedChartValue(this.buildChartCacheKey('k-count', options), async () => {
const profile = await this.getKProfileData(options);
if (!this.hosted?.isConfigured?.()) {
throw new Error('Count leverage charts require hosted artifacts.');
}
const targetDate = parseDateOrToday(options.date);
const resolvedDate = await this.hosted.getLatestAvailableDate(targetDate);
const [pitchers, countRows] = await Promise.all([
this.hosted.readDailyFile(resolvedDate, 'daily_pitcher_metrics.parquet', PITCHER_COLUMNS),
this.hosted.readReusableFile('pitcher_usage_by_count.parquet', COUNT_USAGE_COLUMNS).catch(() => []),
]);
const pitcherMatch = findBestPlayerMatch(
pitchers.filter((row) => keepRowForDefaults(row)).map((row) => ({ ...row, pitcher_name: row.pitcher_name })),
'pitcher_name',
options.pitcher
);
const pitcherId = String(pitcherMatch?.pitcher_id ?? pitcherMatch?.player_id ?? profile.overview?.pitcher_id ?? '');
const filtered = countRows.filter((row) => String(row.pitcher_id ?? row.player_id ?? '') === pitcherId);
if (!filtered.length) {
throw new Error('No count-usage rows were available for that pitcher.');
}
const buckets = [...new Set(filtered.map((row) => row.count_bucket).filter(Boolean))];
const pitchTypes = uniqueRowsByKey(
[...filtered].sort((left, right) => compareNullableDescending(left.usage_pct, right.usage_pct)),
(row) => row.pitch_type
)
.slice(0, 4)
.map((row) => row.pitch_type);
const datasets = pitchTypes.map((pitchType) => ({
label: pitchType,
values: buckets.map((bucket) => {
const bucketRows = filtered.filter((row) => row.count_bucket === bucket && row.pitch_type === pitchType);
const usage = averageOrNull(bucketRows.map((row) => row.usage_pct));
const numeric = numberOrNull(usage);
return numeric === null ? 0 : (Math.abs(numeric) <= 1 ? numeric * 100 : numeric);
}),
}));
return {
...profile,
labels: buckets,
datasets,
read: 'Higher bars show which pitch types carry the most leverage in each count bucket.',
};
});
}
async queryCockroachRows(text, values = []) {
if (typeof this.fallback?.query !== 'function') {
throw new Error('Cockroach query access is required for this pitcher chart.');
}
const result = await this.fallback.query(text, values);
return result?.rows ?? [];
}
async resolvePitcherSuiteContext(playerName, options = {}) {
return this.getCachedChartValue(this.buildChartCacheKey('pitcher-suite-context', {
pitcher: playerName,
date: options.date,
}), async () => {
const candidates = await this.queryCockroachRows(
`
WITH candidates AS (
SELECT
pitcher_name AS pitcher_name,
pitcher_id::text AS pitcher_id,
team,
opponent AS opponent_team,
p_throws AS pitcher_hand,
slate_date::date AS latest_date,
1 AS source_rank
FROM public.pitcher_model_snapshots
WHERE LOWER(pitcher_name) LIKE LOWER($1)
UNION ALL
SELECT
pitcher_name AS pitcher_name,
pitcher_id::text AS pitcher_id,
team,
NULL::text AS opponent_team,
NULL::text AS pitcher_hand,
slate_date::date AS latest_date,
2 AS source_rank
FROM public.pitcher_game_outcomes
WHERE LOWER(pitcher_name) LIKE LOWER($1)
UNION ALL
SELECT
pitcher_name AS pitcher_name,
pitcher::text AS pitcher_id,
NULL::text AS team,
NULL::text AS opponent_team,
p_throws AS pitcher_hand,
game_date::date AS latest_date,
3 AS source_rank
FROM public.live_pitch_mix_2026
WHERE LOWER(pitcher_name) LIKE LOWER($1)
UNION ALL
SELECT
pitcher_name AS pitcher_name,
pitcher::text AS pitcher_id,
NULL::text AS team,
NULL::text AS opponent_team,
COALESCE(pitcher_hand, p_throws) AS pitcher_hand,
game_date::date AS latest_date,
4 AS source_rank
FROM public.shared_pitcher_baseline_event_rows
WHERE LOWER(pitcher_name) LIKE LOWER($1)
)
SELECT pitcher_name, pitcher_id, team, opponent_team, pitcher_hand, latest_date
FROM candidates
ORDER BY
CASE WHEN LOWER(pitcher_name) = LOWER($2) THEN 0 ELSE 1 END,
source_rank,
latest_date DESC NULLS LAST
LIMIT 1
`,
[`%${playerName}%`, playerName]
);
const identity = candidates[0];
if (!identity) {
throw new Error(`No pitcher chart profile matched "${playerName}".`);
}
const overviewRows = await this.queryCockroachRows(
`
SELECT
slate_date::date AS slate_date,
team,
opponent AS opponent_team,
pitcher_name,
p_throws,
pitcher_score,
strikeout_score,
pitcher_matchup_adjustment,
strikeout_matchup_adjustment,
opponent_lineup_quality,
opponent_contact_threat,
opponent_whiff_tendency,
xwoba,
csw_pct,
swstr_pct,
putaway_pct,
ball_pct,
siera,
gb_pct,
gb_fb_ratio,
barrel_bip_pct,
hard_hit_pct
FROM public.pitcher_model_snapshots
WHERE LOWER(pitcher_name) = LOWER($1)
AND ($2::date IS NULL OR slate_date::date = $2::date)
AND split_key = $3
AND recent_window = $4
AND weighted_mode = $5
ORDER BY slate_date::date DESC
LIMIT 1
`,
[identity.pitcher_name, options.date ?? null, DEFAULT_SPLIT_KEY, DEFAULT_RECENT_WINDOW, DEFAULT_WEIGHTED_MODE]
);
const overview = overviewRows[0] ?? {};
return {
source: 'cockroach',
resolvedDate: identity.latest_date ?? overview.slate_date ?? null,
pitcherName: identity.pitcher_name,
pitcherId: String(identity.pitcher_id ?? '').trim() || null,
team: identity.team ?? overview.team ?? null,
opponentTeam: identity.opponent_team ?? overview.opponent_team ?? null,
hand: identity.pitcher_hand ?? overview.p_throws ?? null,
overview,
};
});
}
async getPitcherTrendChartData(options = {}) {
return this.getCachedChartValue(this.buildChartCacheKey('pitcher-trend-suite', options), async () => {
const context = await this.resolvePitcherSuiteContext(options.pitcher, options);
const view = String(options.view ?? 'velo');
const window = String(options.window ?? 'last_5');
const split = String(options.split ?? 'overall');
const pitchType = options.pitchType ?? options.pitch_type ?? null;
const pitcherId = context.pitcherId ?? null;
if (view === 'results') {
const rows = await this.queryCockroachRows(
`
SELECT slate_date::date AS point_date, strikeouts, walks, hits_allowed, home_runs_allowed, outs_recorded
FROM public.pitcher_game_outcomes
WHERE LOWER(pitcher_name) = LOWER($1)
ORDER BY slate_date::date DESC
LIMIT $2
`,
[context.pitcherName, getPitcherWindowPointLimit(window)]
);
if (!rows.length) {
throw new Error(`No game outcome trend was available for ${context.pitcherName}.`);
}
const ordered = [...rows].reverse();
return {
...context,
chartType: 'line',
view,
window,
title: `Pitcher Results - ${context.pitcherName}`,
subtitle: `${context.team ?? 'N/A'} | ${windowLabel(window)}`,
points: ordered.map((row) => ({ label: formatDateLabel(row.point_date), value: numberOrNull(row.strikeouts) ?? 0 })),
primaryLabel: 'Strikeouts',
overlays: [
{ label: 'Walks', values: ordered.map((row) => numberOrNull(row.walks) ?? 0), color: '#f59e0b' },
{ label: 'Hits Allowed', values: ordered.map((row) => numberOrNull(row.hits_allowed) ?? 0), color: '#f87171' },
],
read: `Recent results show ${context.pitcherName} averaging ${averageOrNull(rows.map((row) => numberOrNull(row.strikeouts)))?.toFixed(1) ?? 'N/A'} strikeouts across the selected window.`,
};
}
if (view === 'form') {
const rows = await this.queryCockroachRows(
`
SELECT *
FROM public.shared_pitcher_rolling_summary
WHERE LOWER(player_name) = LOWER($1)
LIMIT 1
`,
[context.pitcherName]
);
const row = rows[0];
if (!row) {
throw new Error(`No rolling form summary was available for ${context.pitcherName}.`);
}
return {
...context,
chartType: 'radar',
view,
window,
title: `Pitcher Form - ${context.pitcherName}`,
subtitle: `${context.team ?? 'N/A'} | rolling dashboard`,
labels: ['Velo 5G', 'Spin 5G', 'EV Allowed', 'HH Allowed', 'Barrel Allowed', 'HR Allowed'],
values: [
normalizeToRadarScore(row.pitcher_avg_release_speed_5g, { min: 88, max: 100 }),
normalizeToRadarScore(row.pitcher_avg_release_spin_rate_5g, { min: 1800, max: 2800 }),
normalizeToRadarScore(row.pitcher_ev_allowed_5g, { min: 80, max: 95, inverse: true }),
normalizeToRadarScore(row.pitcher_hard_hit_rate_allowed_5g, { min: 20, max: 50, inverse: true }),
normalizeToRadarScore(row.pitcher_barrel_rate_allowed_5g, { min: 2, max: 14, inverse: true }),
normalizeToRadarScore(row.pitcher_hr_allowed_rate_5g, { min: 0.005, max: 0.08, inverse: true }),
],
read: `Recent form confidence is ${formatMetricValue(row.pitcher_rolling_confidence)} with ${numberOrNull(row.pitcher_games_in_window_5g) ?? 0} games in the five-game window.`,
};
}
if (view === 'baseline') {
const currentRows = await this.queryCockroachRows(
`
SELECT
AVG(release_speed) AS avg_release_speed,
AVG(release_spin_rate) AS avg_release_spin_rate,
AVG(release_extension) AS avg_release_extension,
AVG(pfx_x) AS avg_pfx_x,
AVG(pfx_z) AS avg_pfx_z
FROM public.live_pitch_mix_2026
WHERE ${buildPitcherIdentitySql('pitcher_name', 'pitcher', 1, 2)}
${buildPitchTypeSqlFilter('pitch_type', 'pitch_name', 3)}
${buildSplitSqlFilter('stand', 4)}
`,
[context.pitcherName, pitcherId, pitchType, split]
);
const baselineRows = await this.queryCockroachRows(
`
SELECT
AVG(release_speed) AS avg_release_speed,
AVG(release_spin_rate) AS avg_release_spin_rate,
AVG(release_extension) AS avg_release_extension,
AVG(pfx_x) AS avg_pfx_x,
AVG(pfx_z) AS avg_pfx_z
FROM public.shared_pitcher_baseline_event_rows
WHERE ${buildPitcherIdentitySql('pitcher_name', 'pitcher', 1, 2)}
${buildPitchTypeSqlFilter('pitch_type', 'pitch_name', 3)}
${buildSplitSqlFilter('COALESCE(batter_stand, stand)', 4)}
`,
[context.pitcherName, pitcherId, pitchType, split]
);
const current = currentRows[0] ?? {};
const baseline = baselineRows[0] ?? {};
return {
...context,
chartType: 'bar',
view,
window,
title: `Pitcher Baseline - ${context.pitcherName}`,
subtitle: `${context.team ?? 'N/A'} | current vs baseline`,
labels: ['Velocity', 'Spin', 'Extension', 'Move X', 'Move Z'],
datasets: [
{
label: 'Season 2026',
values: [
numberOrNull(current.avg_release_speed) ?? 0,
(numberOrNull(current.avg_release_spin_rate) ?? 0) / 100,
(numberOrNull(current.avg_release_extension) ?? 0) * 10,
(numberOrNull(current.avg_pfx_x) ?? 0) * 10,
(numberOrNull(current.avg_pfx_z) ?? 0) * 10,
],
},
{
label: labelForCompareTarget(options.compareTo ?? 'career'),
values: [
numberOrNull(baseline.avg_release_speed) ?? 0,
(numberOrNull(baseline.avg_release_spin_rate) ?? 0) / 100,
(numberOrNull(baseline.avg_release_extension) ?? 0) * 10,
(numberOrNull(baseline.avg_pfx_x) ?? 0) * 10,
(numberOrNull(baseline.avg_pfx_z) ?? 0) * 10,
],
},
],
read: `This view compares the current-season pitch shape and release baseline against ${labelForCompareTarget(options.compareTo ?? 'career').toLowerCase()}.`,
};
}
const tableName = window === 'career' ? 'public.shared_pitcher_baseline_event_rows' : 'public.live_pitch_mix_2026';
const dateExpr = window === 'career' ? 'source_season::text' : 'game_date::date';
const splitExpr = window === 'career' ? 'COALESCE(batter_stand, stand)' : 'stand';
const primaryExpr = view === 'velo'
? 'AVG(release_speed)'
: view === 'spin'
? 'AVG(release_spin_rate)'
: 'AVG(release_extension)';
const overlayAExpr = view === 'velo'
? 'AVG(effective_speed)'
: view === 'spin'
? 'AVG(spin_efficiency_proxy)'
: 'AVG(release_pos_x)';
const overlayBExpr = view === 'velo'
? 'AVG(pfx_z)'
: view === 'spin'
? 'AVG(spin_axis)'
: 'AVG(release_pos_z)';
const rows = await this.queryCockroachRows(
`
WITH grouped AS (
SELECT
${dateExpr} AS point_key,
${primaryExpr} AS primary_value,
${overlayAExpr} AS overlay_a,
${overlayBExpr} AS overlay_b
FROM ${tableName}
WHERE ${buildPitcherIdentitySql('pitcher_name', 'pitcher', 1, 2)}
${buildPitchTypeSqlFilter('pitch_type', 'pitch_name', 3)}
${buildSplitSqlFilter(splitExpr, 4)}
GROUP BY point_key
)
SELECT point_key, primary_value, overlay_a, overlay_b
FROM grouped
ORDER BY point_key DESC
LIMIT $5
`,
[context.pitcherName, pitcherId, pitchType, split, getPitcherWindowPointLimit(window)]
);
if (!rows.length) {
throw new Error(`No ${view} trend points were available for ${context.pitcherName}.`);
}
const ordered = [...rows].reverse();
return {
...context,
chartType: 'line',
view,
window,
pitchType,
split,
title: `${pitcherTrendTitle(view)} - ${context.pitcherName}`,
subtitle: `${context.team ?? 'N/A'} | ${windowLabel(window)}${pitchType ? ` | ${pitchType}` : ''}${split !== 'overall' ? ` | ${splitLabel(split)}` : ''}`,
points: ordered.map((row) => ({ label: String(row.point_key), value: numberOrNull(row.primary_value) ?? 0 })),
primaryLabel: pitcherTrendPrimaryLabel(view),
overlays: [
{ label: pitcherTrendOverlayLabel(view, 0), values: ordered.map((row) => formatTrendOverlayValue(view, 0, row.overlay_a)), color: '#93c5fd' },
{ label: pitcherTrendOverlayLabel(view, 1), values: ordered.map((row) => formatTrendOverlayValue(view, 1, row.overlay_b)), color: '#c084fc' },
],
read: pitcherTrendRead(view, ordered, pitchType),
};
});
}
async getPitcherArsenalChartData(options = {}) {
return this.getCachedChartValue(this.buildChartCacheKey('pitcher-arsenal-suite', options), async () => {
const context = await this.resolvePitcherSuiteContext(options.pitcher, options);
const view = String(options.view ?? 'shape');
const window = String(options.window ?? 'last_5');
const split = String(options.split ?? 'overall');
const pitchType = options.pitchType ?? options.pitch_type ?? null;
if (['shape', 'movement'].includes(view)) {
const rows = await this.queryCockroachRows(
`
SELECT pitch_type, usage_rate, avg_velocity, avg_spin_rate, avg_extension, avg_pfx_x, avg_pfx_z, avg_spin_axis
FROM public.pitcher_arsenal_profiles
WHERE pitcher::text = $1
AND ($2::text IS NULL OR UPPER(pitch_type) = UPPER($2))
ORDER BY usage_rate DESC NULLS LAST, sample_size DESC NULLS LAST
LIMIT 6
`,
[context.pitcherId, pitchType]
);
if (!rows.length) {
throw new Error(`No arsenal shape profile was available for ${context.pitcherName}.`);
}
return {
...context,
view,
window,
pitchType,
title: `${pitcherArsenalTitle(view)} - ${context.pitcherName}`,
subtitle: `${context.team ?? 'N/A'} | ${windowLabel(window)}`,
columns: view === 'shape'
? [
{ key: 'usage_rate', label: 'Usage', type: 'pct' },
{ key: 'avg_velocity', label: 'Velo' },
{ key: 'avg_spin_rate', label: 'Spin' },
{ key: 'avg_pfx_z', label: 'Move Z' },
{ key: 'avg_pfx_x', label: 'Move X' },
]
: [
{ key: 'usage_rate', label: 'Usage', type: 'pct' },
{ key: 'avg_extension', label: 'Ext' },
{ key: 'avg_pfx_x', label: 'Move X' },
{ key: 'avg_pfx_z', label: 'Move Z' },
{ key: 'avg_spin_axis', label: 'Axis' },
],
rows: rows.map((row) => ({ label: row.pitch_type, ...row })),
read: `The arsenal card highlights ${rows[0]?.pitch_type ?? 'the primary pitch'} as the backbone of ${context.pitcherName}'s current shape profile.`,
};
}
const rows = await this.queryCockroachRows(
`
WITH latest_date AS (
SELECT MAX(slate_date)::date AS slate_date
FROM public.pitcher_arsenal_snapshots
WHERE LOWER(pitcher_name) = LOWER($1)
)
SELECT slate_date::date AS slate_date, pitch_name, batter_side_key, usage_pct, swstr_pct, hard_hit_pct, avg_release_speed, avg_spin_rate, xwoba_con
FROM public.pitcher_arsenal_snapshots
WHERE LOWER(pitcher_name) = LOWER($1)
AND slate_date::date = (SELECT slate_date FROM latest_date)
AND ($2::text IS NULL OR LOWER(batter_side_key) = LOWER($2))
AND ($3::text IS NULL OR UPPER(pitch_name) = UPPER($3))
ORDER BY usage_pct DESC NULLS LAST
`,
[context.pitcherName, split === 'overall' ? null : split, pitchType]
);
if (!rows.length) {
throw new Error(`No arsenal snapshot rows were available for ${context.pitcherName}.`);
}
if (view === 'evolution') {
const evolutionRows = await this.queryCockroachRows(
`
SELECT
pitch_name,
MIN(slate_date::date) AS first_date,
MAX(slate_date::date) AS last_date,
AVG(CASE WHEN slate_date::date = (SELECT MIN(slate_date::date) FROM public.pitcher_arsenal_snapshots WHERE LOWER(pitcher_name) = LOWER($1)) THEN usage_pct END) AS early_usage_pct,
AVG(CASE WHEN slate_date::date = (SELECT MAX(slate_date::date) FROM public.pitcher_arsenal_snapshots WHERE LOWER(pitcher_name) = LOWER($1)) THEN usage_pct END) AS latest_usage_pct,
AVG(CASE WHEN slate_date::date = (SELECT MAX(slate_date::date) FROM public.pitcher_arsenal_snapshots WHERE LOWER(pitcher_name) = LOWER($1)) THEN avg_spin_rate END) AS latest_spin_rate
FROM public.pitcher_arsenal_snapshots
WHERE LOWER(pitcher_name) = LOWER($1)
GROUP BY pitch_name
ORDER BY latest_usage_pct DESC NULLS LAST
LIMIT 6
`,
[context.pitcherName]
);
return {
...context,
view,
window,
title: `${pitcherArsenalTitle(view)} - ${context.pitcherName}`,
subtitle: `${context.team ?? 'N/A'} | season evolution`,
columns: [
{ key: 'early_usage_pct', label: 'Early', type: 'pct' },
{ key: 'latest_usage_pct', label: 'Latest', type: 'pct' },
{ key: 'usage_delta', label: 'Delta', type: 'pct_signed' },
{ key: 'latest_spin_rate', label: 'Spin' },
],
rows: evolutionRows.map((row) => ({
label: row.pitch_name,
...row,
usage_delta: (numberOrNull(row.latest_usage_pct) ?? 0) - (numberOrNull(row.early_usage_pct) ?? 0),
})),
read: `${context.pitcherName}'s pitch mix evolution shows where usage has moved most since the start of 2026.`,
};
}
if (view === 'platoon') {
const platoonRows = await this.queryCockroachRows(
`
SELECT
pitch_name,
AVG(CASE WHEN LOWER(batter_side_key) = 'vs_lhb' THEN usage_pct END) AS usage_vs_lhb,
AVG(CASE WHEN LOWER(batter_side_key) = 'vs_rhb' THEN usage_pct END) AS usage_vs_rhb,
AVG(CASE WHEN LOWER(batter_side_key) = 'vs_lhb' THEN swstr_pct END) AS swstr_vs_lhb,
AVG(CASE WHEN LOWER(batter_side_key) = 'vs_rhb' THEN swstr_pct END) AS swstr_vs_rhb
FROM public.pitcher_arsenal_snapshots
WHERE LOWER(pitcher_name) = LOWER($1)
GROUP BY pitch_name
ORDER BY GREATEST(COALESCE(AVG(usage_pct), 0), COALESCE(AVG(swstr_pct), 0)) DESC
LIMIT 6
`,
[context.pitcherName]
);
return {
...context,
view,
window,
title: `${pitcherArsenalTitle(view)} - ${context.pitcherName}`,
subtitle: `${context.team ?? 'N/A'} | platoon split`,
columns: [
{ key: 'usage_vs_lhb', label: 'Vs LHB', type: 'pct' },
{ key: 'usage_vs_rhb', label: 'Vs RHB', type: 'pct' },
{ key: 'swstr_vs_lhb', label: 'Whiff L', type: 'pct' },
{ key: 'swstr_vs_rhb', label: 'Whiff R', type: 'pct' },
],
rows: platoonRows.map((row) => ({ label: row.pitch_name, ...row })),
read: `The platoon view shows how ${context.pitcherName} changes usage and whiff shape by hitter handedness.`,
};
}
return {
...context,
view,
window,
split,
pitchType,
title: `${pitcherArsenalTitle(view)} - ${context.pitcherName}`,
subtitle: `${context.team ?? 'N/A'} | ${splitLabel(split)}${pitchType ? ` | ${pitchType}` : ''}`,
columns: view === 'usage'
? [
{ key: 'usage_pct', label: 'Usage', type: 'pct' },
{ key: 'avg_release_speed', label: 'Velo' },
{ key: 'swstr_pct', label: 'Whiff', type: 'pct' },
{ key: 'hard_hit_pct', label: 'HH', type: 'pct' },
]
: [
{ key: 'usage_pct', label: 'Usage', type: 'pct' },
{ key: 'swstr_pct', label: 'Whiff', type: 'pct' },
{ key: 'hard_hit_pct', label: 'HH', type: 'pct' },
{ key: 'xwoba_con', label: 'xwOBAcon', type: 'decimal' },
],
rows: rows.slice(0, 6).map((row) => ({ label: row.pitch_name, ...row })),
read: `${context.pitcherName}'s ${view === 'usage' ? 'usage tree' : 'pitch outcomes'} are led by ${rows[0]?.pitch_name ?? 'the primary offering'}.`,
};
});
}
async getPitcherLocationChartData(options = {}) {
return this.getCachedChartValue(this.buildChartCacheKey('pitcher-location-suite', options), async () => {
const context = await this.resolvePitcherSuiteContext(options.pitcher, options);
const view = String(options.view ?? 'heatmap');
const split = String(options.split ?? 'overall');
const pitchType = options.pitchType ?? options.pitch_type ?? null;
const countBucket = String(options.countBucket ?? options.count_bucket ?? 'all');
const pitcherId = context.pitcherId ?? null;
const rows = await this.queryCockroachRows(
`
SELECT plate_x, plate_z, zone, pitch_type, pitch_name, stand, balls, strikes, description, events, estimated_woba_using_speedangle
FROM public.live_pitch_mix_2026
WHERE ${buildPitcherIdentitySql('pitcher_name', 'pitcher', 1, 2)}
${buildPitchTypeSqlFilter('pitch_type', 'pitch_name', 3)}
${buildSplitSqlFilter('stand', 4)}
ORDER BY game_date::date DESC, pitch_number DESC
LIMIT 4000
`,
[context.pitcherName, pitcherId, pitchType, split]
);
if (!rows.length) {
throw new Error(`No location rows were available for ${context.pitcherName}.`);
}
const filtered = rows.filter((row) => matchesCountBucket(row, countBucket));
if (!filtered.length) {
throw new Error(`No location rows matched the ${countBucketLabel(countBucket).toLowerCase()} filter for ${context.pitcherName}.`);
}
if (view === 'bypitch') {
const plot = buildPitchLocationPlot(filtered);
if (!plot.points.length) {
throw new Error(`No pitch plot points were available for ${context.pitcherName}.`);
}
return {
...context,
view,
split,
pitchType,
countBucket,
title: `${pitcherLocationTitle(view)} - ${context.pitcherName}`,
subtitle: `${context.team ?? 'N/A'} | ${splitLabel(split)} | ${countBucketLabel(countBucket)}${pitchType ? ` | ${pitchType}` : ''}`,
sampleSize: filtered.length,
plotPoints: plot.points,
pitchBreakdown: plot.breakdown,
read: pitcherLocationRead(view),
};
}
const cells = buildPitcherLocationCells(filtered, view);
const bestCell = [...cells].sort((left, right) => compareNullableDescending(left.overlayValue, right.overlayValue))[0];
return {
...context,
view,
split,
pitchType,
countBucket,
title: `${pitcherLocationTitle(view)} - ${context.pitcherName}`,
subtitle: `${context.team ?? 'N/A'} | ${splitLabel(split)} | ${countBucketLabel(countBucket)}${pitchType ? ` | ${pitchType}` : ''}`,
cells,
sampleSize: filtered.length,
metricConfig: pitcherLocationMetricConfig(view),
bestOverlay: bestCell ? `Zone ${bestCell.zone} at ${(numberOrNull(bestCell.overlayValue) ?? 0).toFixed(0)} ${locationMetricSuffix(view)}` : 'No clear hot zone',
shapeSummary: buildPitcherLocationSummary(cells, view),
read: pitcherLocationRead(view),
};
});
}
async getPitcherApproachChartData(options = {}) {
return this.getCachedChartValue(this.buildChartCacheKey('pitcher-approach-suite', options), async () => {
const context = await this.resolvePitcherSuiteContext(options.pitcher, options);
const view = String(options.view ?? 'count_usage');
const split = String(options.split ?? 'overall');
const pitchType = options.pitchType ?? options.pitch_type ?? null;
const pitcherId = context.pitcherId ?? null;
const rows = await this.queryCockroachRows(
`
SELECT pitch_name, balls, strikes, description, stand
FROM public.live_pitch_mix_2026
WHERE ${buildPitcherIdentitySql('pitcher_name', 'pitcher', 1, 2)}
${buildPitchTypeSqlFilter('NULL', 'pitch_name', 3)}
${buildSplitSqlFilter('stand', 4)}
ORDER BY game_date::date DESC, pitch_number DESC
LIMIT 4000
`,
[context.pitcherName, pitcherId, pitchType, split]
);
if (!rows.length) {
throw new Error(`No approach rows were available for ${context.pitcherName}.`);
}
const grouped = buildPitcherApproachDatasets(rows, view);
if (!grouped.datasets.length) {
throw new Error(`No ${view.replaceAll('_', ' ')} rows were available for ${context.pitcherName}.`);
}
return {
...context,
view,
split,
pitchType,
title: `${pitcherApproachTitle(view)} - ${context.pitcherName}`,
subtitle: `${context.team ?? 'N/A'} | ${splitLabel(split)}${pitchType ? ` | ${pitchType}` : ''}`,
labels: grouped.labels,
datasets: grouped.datasets,
sampleSize: rows.length,
read: grouped.read,
};
});
}
async getPitcherCompareChartData(options = {}) {
return this.getCachedChartValue(this.buildChartCacheKey('pitcher-compare-suite', options), async () => {
const context = await this.resolvePitcherSuiteContext(options.pitcher, options);
const view = String(options.view ?? 'current_vs_career');
const window = String(options.window ?? 'last_5');
const pitcherId = context.pitcherId ?? null;
if (view === 'risk_reward') {
const rows = await this.queryCockroachRows(
`
SELECT slate_date::date AS point_date, strikeout_score, hard_hit_pct, xwoba, pitcher_score
FROM public.pitcher_model_snapshots
WHERE LOWER(pitcher_name) = LOWER($1)
ORDER BY slate_date::date DESC
LIMIT $2
`,
[context.pitcherName, getPitcherWindowPointLimit(window)]
);
if (!rows.length) {
throw new Error(`No risk/reward snapshot points were available for ${context.pitcherName}.`);
}
return {
...context,
chartType: 'scatter',
view,
window,
title: `Risk Reward - ${context.pitcherName}`,
subtitle: `${context.team ?? 'N/A'} | ${windowLabel(window)}`,
points: rows.map((row) => ({
x: numberOrNull(row.hard_hit_pct) ?? 0,
y: numberOrNull(row.strikeout_score) ?? 0,
label: formatDateLabel(row.point_date),
highlight: false,
})),
read: `Upper-left points pair stronger strikeout upside with lower hard-hit risk across recent snapshot dates.`,
};
}
const currentRows = await this.queryCockroachRows(
`
SELECT AVG(release_speed) AS avg_release_speed, AVG(release_spin_rate) AS avg_release_spin_rate, AVG(release_extension) AS avg_release_extension, AVG(pfx_x) AS avg_pfx_x, AVG(pfx_z) AS avg_pfx_z
FROM public.live_pitch_mix_2026
WHERE ${buildPitcherIdentitySql('pitcher_name', 'pitcher', 1, 2)}
`,
[context.pitcherName, pitcherId]
);
const baselineRows = await this.queryCockroachRows(
`
SELECT AVG(release_speed) AS avg_release_speed, AVG(release_spin_rate) AS avg_release_spin_rate, AVG(release_extension) AS avg_release_extension, AVG(pfx_x) AS avg_pfx_x, AVG(pfx_z) AS avg_pfx_z
FROM public.shared_pitcher_baseline_event_rows
WHERE ${buildPitcherIdentitySql('pitcher_name', 'pitcher', 1, 2)}
`,
[context.pitcherName, pitcherId]
);
const current = currentRows[0] ?? {};
const baseline = baselineRows[0] ?? {};
if (view === 'year_over_year') {
const seasonRows = await this.queryCockroachRows(
`
SELECT source_season::text AS season_label, AVG(release_speed) AS avg_release_speed, AVG(release_spin_rate) AS avg_release_spin_rate, AVG(release_extension) AS avg_release_extension
FROM public.shared_pitcher_baseline_event_rows
WHERE ${buildPitcherIdentitySql('pitcher_name', 'pitcher', 1, 2)}
GROUP BY source_season
ORDER BY source_season DESC
LIMIT 6
`,
[context.pitcherName, pitcherId]
);
return {
...context,
chartType: 'compare',
view,
window,
title: `Year Over Year - ${context.pitcherName}`,
subtitle: `${context.team ?? 'N/A'} | arsenal evolution`,
compareLabel: 'Season',
baselineLabel: 'Metrics',
rows: seasonRows.map((row) => ({
label: row.season_label,
currentValue: formatMetricValue(row.avg_release_speed),
baselineValue: `${formatMetricValue(row.avg_release_spin_rate)} spin | ${formatMetricValue(row.avg_release_extension)} ext`,
})),
read: `${context.pitcherName}'s year-over-year card shows how velocity, spin, and extension have changed season to season.`,
};
}
const baselineLabel = view === 'recent_vs_baseline' ? 'Baseline' : 'Career';
return {
...context,
chartType: 'compare',
view,
window,
title: `${pitcherCompareTitle(view)} - ${context.pitcherName}`,
subtitle: `${context.team ?? 'N/A'} | ${windowLabel(window)}`,
compareLabel: 'Current',
baselineLabel,
rows: [
{ label: 'Velocity', currentValue: formatMetricValue(current.avg_release_speed), baselineValue: formatMetricValue(baseline.avg_release_speed) },
{ label: 'Spin', currentValue: formatMetricValue(current.avg_release_spin_rate), baselineValue: formatMetricValue(baseline.avg_release_spin_rate) },
{ label: 'Extension', currentValue: formatMetricValue(current.avg_release_extension), baselineValue: formatMetricValue(baseline.avg_release_extension) },
{ label: 'Move X', currentValue: formatMetricValue(current.avg_pfx_x), baselineValue: formatMetricValue(baseline.avg_pfx_x) },
{ label: 'Move Z', currentValue: formatMetricValue(current.avg_pfx_z), baselineValue: formatMetricValue(baseline.avg_pfx_z) },
],
read: `This compare card shows where ${context.pitcherName}'s current pitch traits sit versus ${baselineLabel.toLowerCase()}.`,
};
});
}
async buildHostedTrendPoints({ date, window, kind, playerName }) {
const requestedWindow = Math.max(3, Math.min(14, Number(window ?? 7)));
if (!this.hosted?.isConfigured?.()) {
throw new Error('Trend charts require hosted artifacts.');
}
const targetDate = parseDateOrToday(date);
const points = [];
for (let offset = 0; offset < requestedWindow * 4 && points.length < requestedWindow; offset += 1) {
const candidate = addDays(targetDate, -offset);
try {
if (kind === 'hitter') {
const [slateRows, hitterRows, pitcherRows, rosterRows] = await Promise.all([
this.hosted.readDailyFile(candidate, 'slate.parquet', SLATE_COLUMNS),
this.hosted.readDailyFile(candidate, 'daily_hitter_metrics.parquet', HITTER_COLUMNS),
this.hosted.readDailyFile(candidate, 'daily_pitcher_metrics.parquet', PITCHER_COLUMNS),
this.hosted.readDailyFile(candidate, 'rosters.parquet', ROSTER_COLUMNS).catch(() => []),
]);
const rosterLookup = buildRosterLookup(rosterRows);
const pitcherLookup = new Map(
pitcherRows
.filter((row) => keepRowForDefaults(row))
.map((row) => [String(row.pitcher_id ?? row.player_id ?? ''), row])
);
const slateLookup = mapSlateTeams(slateRows);
for (const [team, slate] of slateLookup.entries()) {
const pitcher = pitcherLookup.get(String(slate.opposingPitcherId ?? ''));
if (pitcher?.p_throws) {
slateLookup.set(team, { ...slate, opposingPitcherHand: pitcher.p_throws });
}
}
const preparedRows = hitterRows
.filter((row) => keepRowForDefaults(row))
.map((row) => withDefaults(row, slateLookup, { rosterLookup, playerType: 'hitter' }));
const match = findBestPlayerMatch(preparedRows, 'hitter_name', playerName);
if (!match) {
continue;
}
points.push({
label: formatDateLabel(candidate),
matchup_score: numberOrNull(match.matchup_score) ?? 0,
xwoba: numberOrNull(match.xwoba) ?? 0,
barrel_pct: (Math.abs(numberOrNull(match.barrel_bip_pct) ?? 0) <= 1 ? (numberOrNull(match.barrel_bip_pct) ?? 0) * 100 : (numberOrNull(match.barrel_bip_pct) ?? 0)),
});
continue;
}
const pitcherRows = await this.hosted.readDailyFile(candidate, 'daily_pitcher_metrics.parquet', PITCHER_COLUMNS);
const match = findBestPlayerMatch(
pitcherRows.filter((row) => keepRowForDefaults(row)),
'pitcher_name',
playerName
);
if (!match) {
continue;
}
points.push({
label: formatDateLabel(candidate),
strikeout_score: numberOrNull(match.strikeout_score) ?? 0,
csw_pct: (Math.abs(numberOrNull(match.csw_pct) ?? 0) <= 1 ? (numberOrNull(match.csw_pct) ?? 0) * 100 : (numberOrNull(match.csw_pct) ?? 0)),
swstr_pct: (Math.abs(numberOrNull(match.swstr_pct) ?? 0) <= 1 ? (numberOrNull(match.swstr_pct) ?? 0) * 100 : (numberOrNull(match.swstr_pct) ?? 0)),
});
} catch {
continue;
}
}
if (!points.length) {
throw new Error(`No recent trend points were available for ${playerName}.`);
}
return points.reverse();
}
async getFastHitterChartContext(playerName, options = {}) {
if (!this.hosted?.isConfigured?.()) {
const fallback = await this.getPlayerContext({
...options,
player: playerName,
playerType: 'hitter',
});
if (fallback.playerType !== 'hitter') {
throw new Error(`No hitter matchup profile matched "${playerName}".`);
}
return fallback;
}
const targetDate = parseDateOrToday(options.date);
const resolvedDate = await this.hosted.getLatestAvailableDate(targetDate);
if (!resolvedDate) {
throw new Error('No hosted hitter slate was available in the fallback window.');
}
const cacheKey = this.buildChartCacheKey('hitter-chart-base', { date: resolvedDate, player: playerName });
const base = await this.getCachedChartValue(cacheKey, async () => {
const [slateRows, hitterRows, pitcherRows, exclusionRows, rosterRows, batterZoneRows, pitcherZoneRows] = await Promise.all([
this.hosted.readDailyFile(resolvedDate, 'slate.parquet', SLATE_COLUMNS),
this.hosted.readDailyFile(resolvedDate, 'daily_hitter_metrics.parquet', HITTER_COLUMNS),
this.hosted.readDailyFile(resolvedDate, 'daily_pitcher_metrics.parquet', PITCHER_COLUMNS),
this.hosted.readDailyFile(resolvedDate, 'hitter_pitcher_exclusions.parquet', EXCLUSION_COLUMNS).catch(() => []),
this.hosted.readDailyFile(resolvedDate, 'rosters.parquet', ROSTER_COLUMNS).catch(() => []),
this.hosted.readDailyFile(resolvedDate, 'daily_batter_zone_profiles.parquet', BATTER_ZONE_COLUMNS).catch(() => []),
this.hosted.readDailyFile(resolvedDate, 'daily_pitcher_zone_profiles.parquet', PITCHER_ZONE_COLUMNS).catch(() => []),
]);
const rosterLookup = buildRosterLookup(rosterRows);
const rosterIdsByTeam = buildRosterIdsByTeam(rosterRows);
const exclusionSet = buildExclusionSet(exclusionRows);
const pitcherLookup = new Map(
pitcherRows
.filter((row) => keepRowForDefaults(row))
.map((row) => [String(row.pitcher_id ?? row.player_id ?? ''), row])
);
const slateLookup = mapSlateTeams(slateRows);
for (const [team, slate] of slateLookup.entries()) {
const pitcher = pitcherLookup.get(String(slate.opposingPitcherId ?? ''));
if (pitcher?.p_throws) {
slateLookup.set(team, { ...slate, opposingPitcherHand: pitcher.p_throws });
}
}
const preparedRows = hitterRows
.filter((row) => keepRowForDefaults(row))
.map((row) => withDefaults(row, slateLookup, { rosterLookup, playerType: 'hitter' }));
const matchedPlayer = findBestPlayerMatch(preparedRows, 'hitter_name', playerName);
if (!matchedPlayer) {
return {
resolvedDate,
rows: [],
scoredRows: [],
batterZoneRows,
pitcherZoneRows,
};
}
const normalizedTeam = normalizeTeam(matchedPlayer.team);
const effectiveSplit = matchedPlayer.opposing_pitcher_hand === 'R'
? 'vs_rhp'
: matchedPlayer.opposing_pitcher_hand === 'L'
? 'vs_lhp'
: DEFAULT_SPLIT_KEY;
const teamRows = hitterRows
.filter((row) => normalizeTeam(row.team) === normalizedTeam)
.filter((row) => String(row.split_key ?? DEFAULT_SPLIT_KEY) === effectiveSplit)
.filter((row) => String(row.recent_window ?? DEFAULT_RECENT_WINDOW) === DEFAULT_RECENT_WINDOW)
.filter((row) => String(row.weighted_mode ?? DEFAULT_WEIGHTED_MODE) === DEFAULT_WEIGHTED_MODE)
.filter((row) => {
const rosterPlayerIds = rosterIdsByTeam.get(normalizedTeam) ?? null;
return !rosterPlayerIds || rosterPlayerIds.has(String(row.batter ?? row.player_id ?? '').trim());
})
.filter((row) => !exclusionSet.has(String(row.batter ?? row.player_id ?? '').trim()))
.map((row) => withDefaults(row, slateLookup, { rosterLookup, playerType: 'hitter' }));
return {
resolvedDate,
rows: preparedRows,
scoredRows: addHitterMatchupScore(teamRows, batterZoneRows, pitcherZoneRows),
};
});
const hitterMatch = findBestPlayerMatch(base.scoredRows ?? [], 'hitter_name', playerName)
?? findBestPlayerMatch(base.rows ?? [], 'hitter_name', playerName);
if (!hitterMatch) {
throw new Error(`No hitter matchup profile matched "${playerName}".`);
}
return {
source: 'hosted',
resolvedDate,
playerType: 'hitter',
name: hitterMatch.hitter_name,
team: hitterMatch.team ?? null,
opponentTeam: hitterMatch.opponent_team ?? null,
opposingPitcherName: hitterMatch.opposing_pitcher_name ?? null,
hand: hitterMatch.opposing_pitcher_hand ?? null,
overview: hitterMatch,
};
}
async getFastPitcherChartContext(playerName, options = {}) {
if (!this.hosted?.isConfigured?.()) {
const fallback = await this.getPlayerContext({
...options,
player: playerName,
playerType: 'pitcher',
});
if (fallback.playerType !== 'pitcher') {
throw new Error(`No pitcher matchup profile matched "${playerName}".`);
}
return fallback;
}
const targetDate = parseDateOrToday(options.date);
const resolvedDate = await this.hosted.getLatestAvailableDate(targetDate);
if (!resolvedDate) {
throw new Error('No hosted pitcher slate was available in the fallback window.');
}
const cacheKey = this.buildChartCacheKey('pitcher-chart-base', { date: resolvedDate });
const base = await this.getCachedChartValue(cacheKey, async () => {
const [slateRows, pitcherRows, rosterRows] = await Promise.all([
this.hosted.readDailyFile(resolvedDate, 'slate.parquet', SLATE_COLUMNS),
this.hosted.readDailyFile(resolvedDate, 'daily_pitcher_metrics.parquet', PITCHER_COLUMNS),
this.hosted.readDailyFile(resolvedDate, 'rosters.parquet', ROSTER_COLUMNS).catch(() => []),
]);
const slateLookup = mapSlateTeams(slateRows);
const rosterLookup = buildRosterLookup(rosterRows);
return pitcherRows
.filter(keepRowForDefaults)
.map((row) => withDefaults(row, slateLookup, { rosterLookup, playerType: 'pitcher' }));
});
const pitcherMatch = findBestPlayerMatch(base, 'pitcher_name', playerName);
if (pitcherMatch) {
return {
source: 'hosted',
resolvedDate,
playerType: 'pitcher',
name: pitcherMatch.pitcher_name,
team: pitcherMatch.team ?? null,
opponentTeam: pitcherMatch.opponent_team ?? null,
hand: pitcherMatch.p_throws ?? null,
overview: pitcherMatch,
};
}
const broaderContext = await this.getPlayerContext({
...options,
player: playerName,
playerType: 'pitcher',
});
if (broaderContext.playerType !== 'pitcher') {
throw new Error(`No pitcher matchup profile matched "${playerName}".`);
}
return broaderContext;
}
async enrichHostedHitters(result, options = {}, methodName = 'getTopHitters') {
if (!result?.rows?.length || !this.fallback?.getHitterSnapshotRows) {
return result;
}
try {
const snapshotRows = await this.fallback.getHitterSnapshotRows({
date: result.resolvedDate ?? options.date,
team: options.team ?? null,
});
const snapshotLookup = new Map(
snapshotRows.map((row) => [`${normalizeText(row.hitter_name)}|${normalizeTeam(row.team)}`, row])
);
const enrichedRows = result.rows.map((row) => {
const snapshot = snapshotLookup.get(`${normalizeText(row.hitter_name)}|${normalizeTeam(row.team)}`);
return {
...row,
matchup_score: row.matchup_score ?? snapshot?.matchup_score ?? null,
ceiling_score: row.ceiling_score ?? snapshot?.ceiling_score ?? null,
zone_fit_score: row.zone_fit_score ?? snapshot?.zone_fit_score ?? null,
likely_starter_score: row.likely_starter_score ?? snapshot?.likely_starter_score ?? null,
};
});
const sortedRows = sortHitters(enrichedRows);
const boardRows = methodName === 'getBestMatchups'
? buildBestMatchupBoardRows(sortedRows)
: sortedRows;
const limit = methodName === 'getBestMatchups'
? limitOrDefault(options.limit ?? (options.team ? 3 : 12), 12)
: limitOrDefault(options.limit);
return {
...result,
rows: boardRows.slice(0, limit),
};
} catch (error) {
this.logger?.warn?.('Hosted hitter enrichment failed', { error: error.message, options });
return result;
}
}
}
async function defaultFetchText(url, targetDate) {
const requestUrl = new URL(url);
requestUrl.searchParams.set('date', parseDateOrToday(targetDate));
try {
const response = await fetch(requestUrl, {
headers: {
'User-Agent': 'KasperMLB/1.0',
},
signal: AbortSignal.timeout(10_000),
});
if (!response.ok) {
throw new Error(`Rotowire request failed with status ${response.status}`);
}
return response.text();
} catch (error) {
throw new Error(`Rotowire request failed: ${error.message}`);
}
}
export async function readParquetFromUrl(url, columns) {
try {
const file = await asyncBufferFromUrl({ url });
return parquetReadObjects({
file,
columns,
});
} catch (error) {
throw new Error(`Hosted parquet fetch failed: ${error.message}`);
}
}