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(/ /gi, ' ') .replace(/&/gi, '&') .replace(/"/gi, '"') .replace(/'/gi, '\'') .replace(/'/gi, '\'') .replace(/</gi, '<') .replace(/>/gi, '>'); } function extractRotowireTokens(html) { return decodeHtmlEntities( String(html ?? '') .replace(//gi, ' ') .replace(//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}`); } }