Codex commited on
Commit
b24e6f2
·
1 Parent(s): e169425

Match hosted hitter commands to live app parity

Browse files
Files changed (2) hide show
  1. src/matchups.js +495 -70
  2. test/matchups.test.js +69 -5
src/matchups.js CHANGED
@@ -6,8 +6,18 @@ const DEFAULT_RECENT_WINDOW = 'season';
6
  const DEFAULT_SPLIT_KEY = 'overall';
7
  const DEFAULT_WEIGHTED_MODE = 'weighted';
8
  const DEFAULT_FALLBACK_DAYS = 7;
 
9
  const COCKROACH_RETRY_CODES = new Set(['40001']);
10
  const PROFILE_CANDIDATE_LIMIT = 5;
 
 
 
 
 
 
 
 
 
11
  const MLB_TEAM_ALIASES = new Map([
12
  ['ARI', ['ari', 'arizona', 'diamondbacks', 'arizona diamondbacks']],
13
  ['ATL', ['atl', 'atlanta', 'braves', 'atlanta braves']],
@@ -50,6 +60,7 @@ const HITTER_COLUMNS = [
50
  'batter',
51
  'player_id',
52
  'hitter_name',
 
53
  'split_key',
54
  'recent_window',
55
  'weighted_mode',
@@ -58,6 +69,8 @@ const HITTER_COLUMNS = [
58
  'zone_fit_score',
59
  'likely_starter_score',
60
  'xwoba',
 
 
61
  'fb_pct',
62
  'pulled_barrel_pct',
63
  'sweet_spot_pct',
@@ -276,6 +289,16 @@ function normalizeText(value) {
276
  return String(value ?? '').trim().toLowerCase();
277
  }
278
 
 
 
 
 
 
 
 
 
 
 
279
  function normalizeTeam(value) {
280
  return String(value ?? '').trim().toUpperCase();
281
  }
@@ -343,6 +366,14 @@ function compareNullableDescending(left, right) {
343
  return rightValue - leftValue;
344
  }
345
 
 
 
 
 
 
 
 
 
346
  function sortHitters(rows) {
347
  return [...rows].sort((left, right) =>
348
  compareNullableDescending(left.matchup_score, right.matchup_score)
@@ -649,6 +680,32 @@ function buildBestMatchupBoardRows(rows) {
649
  return sortHitters(boardRows);
650
  }
651
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
652
  function keepRowForDefaults(row) {
653
  const splitKey = String(row.split_key ?? DEFAULT_SPLIT_KEY);
654
  const recentWindow = String(row.recent_window ?? DEFAULT_RECENT_WINDOW);
@@ -714,6 +771,44 @@ function buildRosterLookup(rows) {
714
  return lookup;
715
  }
716
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
717
  function resolveHostedPlayerName(row, rosterLookup, type = 'hitter') {
718
  const rawName = type === 'pitcher'
719
  ? String(row.pitcher_name ?? row.player_name ?? '').trim()
@@ -756,6 +851,232 @@ function withDefaults(row, slateLookup, options = {}) {
756
  };
757
  }
758
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
759
  function pickMetrics(row, metricKeys) {
760
  return metricKeys
761
  .map(([label, key]) => ({ label, key, value: numberOrNull(row[key]) }))
@@ -839,6 +1160,8 @@ export class HostedArtifactSource {
839
  this.fallbackDays = Number(config.fallbackDays ?? DEFAULT_FALLBACK_DAYS);
840
  this.logger = options.logger ?? console;
841
  this.readParquetImpl = options.readParquetImpl ?? readParquetFromUrl;
 
 
842
  this.cache = new Map();
843
  }
844
 
@@ -892,37 +1215,177 @@ export class HostedArtifactSource {
892
  return rows;
893
  }
894
 
895
- async getTopHitters(options = {}) {
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
896
  const targetDate = parseDateOrToday(options.date);
897
  const resolvedDate = await this.getLatestAvailableDate(targetDate);
898
  if (!resolvedDate) {
899
  throw new Error('No hosted matchup slate was available in the fallback window.');
900
  }
901
 
902
- const [slateRows, hitterRows, exclusionRows, rosterRows] = await Promise.all([
903
  this.readDailyFile(resolvedDate, 'slate.parquet', SLATE_COLUMNS),
904
  this.readDailyFile(resolvedDate, 'daily_hitter_metrics.parquet', HITTER_COLUMNS),
 
905
  this.readDailyFile(resolvedDate, 'hitter_pitcher_exclusions.parquet', EXCLUSION_COLUMNS).catch(() => []),
906
  this.readDailyFile(resolvedDate, 'rosters.parquet', ROSTER_COLUMNS).catch(() => []),
 
 
907
  ]);
908
 
909
- const exclusionSet = buildExclusionSet(exclusionRows);
 
 
 
910
  const slateLookup = mapSlateTeams(slateRows);
 
911
  const rosterLookup = buildRosterLookup(rosterRows);
912
- const filteredRows = hitterRows
913
- .filter(keepRowForDefaults)
914
- .map((row) => withDefaults(row, slateLookup, { rosterLookup, playerType: 'hitter' }))
915
- .filter((row) => rowMatchesTeamFilter(row.team, options.team))
916
- .filter((row) => {
917
- const playerId = row.batter ?? row.player_id;
918
- return !exclusionSet.has(String(playerId ?? ''));
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
919
  });
920
 
 
 
 
 
 
 
 
 
 
 
 
 
921
  return {
922
  source: 'hosted',
923
  resolvedDate,
924
- rows: sortHitters(filteredRows).slice(0, limitOrDefault(options.limit)),
925
- };
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
926
  }
927
 
928
  async getTopPitchers(options = {}) {
@@ -953,64 +1416,12 @@ export class HostedArtifactSource {
953
  }
954
 
955
  async getBestMatchups(options = {}) {
956
- const targetDate = parseDateOrToday(options.date);
957
- const resolvedDate = await this.getLatestAvailableDate(targetDate);
958
- if (!resolvedDate) {
959
- throw new Error('No hosted matchup slate was available in the fallback window.');
960
- }
961
-
962
- const [slateRows, hitterRows, exclusionRows, rosterRows, batterZoneRows, pitcherZoneRows] = await Promise.all([
963
- this.readDailyFile(resolvedDate, 'slate.parquet', SLATE_COLUMNS),
964
- this.readDailyFile(resolvedDate, 'daily_hitter_metrics.parquet', HITTER_COLUMNS),
965
- this.readDailyFile(resolvedDate, 'hitter_pitcher_exclusions.parquet', EXCLUSION_COLUMNS).catch(() => []),
966
- this.readDailyFile(resolvedDate, 'rosters.parquet', ROSTER_COLUMNS).catch(() => []),
967
- this.readDailyFile(resolvedDate, 'daily_batter_zone_profiles.parquet', BATTER_ZONE_COLUMNS).catch(() => []),
968
- this.readDailyFile(resolvedDate, 'daily_pitcher_zone_profiles.parquet', PITCHER_ZONE_COLUMNS).catch(() => []),
969
- ]);
970
-
971
- const slateLookup = mapSlateTeams(slateRows);
972
- const exclusionSet = buildExclusionSet(exclusionRows);
973
- const rosterLookup = buildRosterLookup(rosterRows);
974
- const rosterIdsByTeam = new Map();
975
- for (const row of rosterRows) {
976
- const team = normalizeTeam(row.team);
977
- const playerId = String(row.player_id ?? '').trim();
978
- if (!team || !playerId) {
979
- continue;
980
- }
981
- const bucket = rosterIdsByTeam.get(team) ?? new Set();
982
- bucket.add(playerId);
983
- rosterIdsByTeam.set(team, bucket);
984
- }
985
-
986
- const scoredRows = [];
987
- for (const [team, slate] of slateLookup.entries()) {
988
- if (!rowMatchesTeamFilter(team, options.team)) {
989
- continue;
990
- }
991
-
992
- const splitKey = slate.opposingPitcherHand === 'R'
993
- ? 'vs_rhp'
994
- : slate.opposingPitcherHand === 'L'
995
- ? 'vs_lhp'
996
- : DEFAULT_SPLIT_KEY;
997
- const rosterPlayerIds = rosterIdsByTeam.get(normalizeTeam(team)) ?? null;
998
- const teamRows = hitterRows
999
- .filter((row) => normalizeTeam(row.team) === normalizeTeam(team))
1000
- .filter((row) => String(row.split_key ?? '') === splitKey)
1001
- .filter((row) => String(row.recent_window ?? '') === DEFAULT_RECENT_WINDOW)
1002
- .filter((row) => String(row.weighted_mode ?? '') === DEFAULT_WEIGHTED_MODE)
1003
- .filter((row) => !rosterPlayerIds || rosterPlayerIds.has(String(row.batter ?? row.player_id ?? '')))
1004
- .filter((row) => !exclusionSet.has(String(row.batter ?? row.player_id ?? '')))
1005
- .map((row) => withDefaults(row, slateLookup, { rosterLookup, playerType: 'hitter' }));
1006
-
1007
- scoredRows.push(...addHitterMatchupScore(teamRows, batterZoneRows, pitcherZoneRows));
1008
- }
1009
-
1010
  return {
1011
- source: 'hosted',
1012
- resolvedDate,
1013
- rows: buildBestMatchupBoardRows(scoredRows).slice(0, limitOrDefault(options.limit ?? (options.team ? 3 : 12), 12)),
 
1014
  };
1015
  }
1016
 
@@ -1456,7 +1867,7 @@ export class MatchupService {
1456
  if (this.hosted?.isConfigured?.()) {
1457
  try {
1458
  const hostedResult = await this.hosted[methodName](options);
1459
- if (methodName === 'getTopHitters') {
1460
  return await this.enrichHostedHitters(hostedResult, options, methodName);
1461
  }
1462
  return hostedResult;
@@ -1555,6 +1966,20 @@ export class MatchupService {
1555
  }
1556
  }
1557
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1558
  export async function readParquetFromUrl(url, columns) {
1559
  const file = await asyncBufferFromUrl({ url });
1560
  return parquetReadObjects({
 
6
  const DEFAULT_SPLIT_KEY = 'overall';
7
  const DEFAULT_WEIGHTED_MODE = 'weighted';
8
  const DEFAULT_FALLBACK_DAYS = 7;
9
+ const DEFAULT_ROTOWIRE_URL = 'https://www.rotowire.com/baseball/daily-lineups.php';
10
  const COCKROACH_RETRY_CODES = new Set(['40001']);
11
  const PROFILE_CANDIDATE_LIMIT = 5;
12
+ const ROTOWIRE_LINEUP_STATUS = new Map([
13
+ ['Confirmed Lineup', 'confirmed'],
14
+ ['Expected Lineup', 'expected'],
15
+ ['Unknown Lineup', 'unknown'],
16
+ ]);
17
+ const ROTOWIRE_POSITION_TOKENS = new Set(['C', '1B', '2B', '3B', 'SS', 'LF', 'CF', 'RF', 'DH', 'P']);
18
+ const ROTOWIRE_STOP_TOKENS = new Set(['Home Run Odds', 'Starting Pitcher Intel']);
19
+ const ROTOWIRE_TIME_RE = /^\d{1,2}:\d{2}\s(?:AM|PM)\sET$/;
20
+ const ROTOWIRE_PRICE_RE = /^\$\d[\d,]*$/;
21
  const MLB_TEAM_ALIASES = new Map([
22
  ['ARI', ['ari', 'arizona', 'diamondbacks', 'arizona diamondbacks']],
23
  ['ATL', ['atl', 'atlanta', 'braves', 'atlanta braves']],
 
60
  'batter',
61
  'player_id',
62
  'hitter_name',
63
+ 'stand',
64
  'split_key',
65
  'recent_window',
66
  'weighted_mode',
 
69
  'zone_fit_score',
70
  'likely_starter_score',
71
  'xwoba',
72
+ 'swstr_pct',
73
+ 'barrel_bbe_pct',
74
  'fb_pct',
75
  'pulled_barrel_pct',
76
  'sweet_spot_pct',
 
289
  return String(value ?? '').trim().toLowerCase();
290
  }
291
 
292
+ function normalizeName(value) {
293
+ return String(value ?? '')
294
+ .normalize('NFKD')
295
+ .replace(/[\u0300-\u036f]/g, '')
296
+ .toLowerCase()
297
+ .replace(/[^a-z0-9]+/g, ' ')
298
+ .trim()
299
+ .replace(/\s+/g, ' ');
300
+ }
301
+
302
  function normalizeTeam(value) {
303
  return String(value ?? '').trim().toUpperCase();
304
  }
 
366
  return rightValue - leftValue;
367
  }
368
 
369
+ function sortHittersLiveApp(rows) {
370
+ return [...rows].sort((left, right) =>
371
+ compareNullableDescending(left.matchup_score, right.matchup_score)
372
+ || compareNullableDescending(left.xwoba, right.xwoba)
373
+ || String(left.hitter_name ?? '').localeCompare(String(right.hitter_name ?? ''))
374
+ );
375
+ }
376
+
377
  function sortHitters(rows) {
378
  return [...rows].sort((left, right) =>
379
  compareNullableDescending(left.matchup_score, right.matchup_score)
 
680
  return sortHitters(boardRows);
681
  }
682
 
683
+ function buildBestMatchupBoardRowsLiveApp(rows) {
684
+ if (!rows?.length) {
685
+ return [];
686
+ }
687
+
688
+ const rowsWithGame = rows.filter((row) => row.game_pk !== null && row.game_pk !== undefined && row.game_pk !== '');
689
+ if (!rowsWithGame.length) {
690
+ return sortHittersLiveApp(rows);
691
+ }
692
+
693
+ const grouped = new Map();
694
+ for (const row of rowsWithGame) {
695
+ const key = String(row.game_pk);
696
+ const bucket = grouped.get(key) ?? [];
697
+ bucket.push(row);
698
+ grouped.set(key, bucket);
699
+ }
700
+
701
+ const boardRows = [];
702
+ for (const gameRows of grouped.values()) {
703
+ boardRows.push(...sortHittersLiveApp(gameRows).slice(0, 3));
704
+ }
705
+
706
+ return sortHittersLiveApp(boardRows);
707
+ }
708
+
709
  function keepRowForDefaults(row) {
710
  const splitKey = String(row.split_key ?? DEFAULT_SPLIT_KEY);
711
  const recentWindow = String(row.recent_window ?? DEFAULT_RECENT_WINDOW);
 
771
  return lookup;
772
  }
773
 
774
+ function buildRosterIdsByTeam(rows) {
775
+ const lookup = new Map();
776
+ for (const row of rows) {
777
+ const team = normalizeTeam(row.team);
778
+ const playerId = String(row.player_id ?? '').trim();
779
+ if (!team || !playerId) {
780
+ continue;
781
+ }
782
+ const bucket = lookup.get(team) ?? new Set();
783
+ bucket.add(playerId);
784
+ lookup.set(team, bucket);
785
+ }
786
+ return lookup;
787
+ }
788
+
789
+ function buildRosterNameLookupByTeam(rows) {
790
+ const lookup = new Map();
791
+ for (const row of rows) {
792
+ const team = normalizeTeam(row.team);
793
+ const playerName = String(row.player_name ?? '').trim();
794
+ const playerId = String(row.player_id ?? '').trim();
795
+ if (!team || !playerName || !playerId) {
796
+ continue;
797
+ }
798
+ let teamLookup = lookup.get(team);
799
+ if (!teamLookup) {
800
+ teamLookup = {
801
+ exact: new Map(),
802
+ normalized: new Map(),
803
+ };
804
+ lookup.set(team, teamLookup);
805
+ }
806
+ teamLookup.exact.set(playerName.toLowerCase(), playerId);
807
+ teamLookup.normalized.set(normalizeName(playerName), playerId);
808
+ }
809
+ return lookup;
810
+ }
811
+
812
  function resolveHostedPlayerName(row, rosterLookup, type = 'hitter') {
813
  const rawName = type === 'pitcher'
814
  ? String(row.pitcher_name ?? row.player_name ?? '').trim()
 
851
  };
852
  }
853
 
854
+ function decodeHtmlEntities(value) {
855
+ return String(value ?? '')
856
+ .replace(/ /gi, ' ')
857
+ .replace(/&/gi, '&')
858
+ .replace(/"/gi, '"')
859
+ .replace(/'/gi, '\'')
860
+ .replace(/'/gi, '\'')
861
+ .replace(/&lt;/gi, '<')
862
+ .replace(/&gt;/gi, '>');
863
+ }
864
+
865
+ function extractRotowireTokens(html) {
866
+ return decodeHtmlEntities(
867
+ String(html ?? '')
868
+ .replace(/<script[\s\S]*?<\/script>/gi, ' ')
869
+ .replace(/<style[\s\S]*?<\/style>/gi, ' ')
870
+ .replace(/<[^>]+>/g, '\n')
871
+ )
872
+ .split(/\r?\n/)
873
+ .map((token) => token.trim())
874
+ .filter(Boolean);
875
+ }
876
+
877
+ function nextNonPriceToken(tokens, startIndex) {
878
+ let index = startIndex;
879
+ while (index < tokens.length) {
880
+ if (!ROTOWIRE_PRICE_RE.test(tokens[index])) {
881
+ return { token: tokens[index], index };
882
+ }
883
+ index += 1;
884
+ }
885
+ return { token: null, index };
886
+ }
887
+
888
+ function parseRotowireLineupSide(tokens, startIndex) {
889
+ const rawStatus = tokens[startIndex];
890
+ const status = ROTOWIRE_LINEUP_STATUS.get(rawStatus) ?? 'unknown';
891
+ let index = startIndex + 1;
892
+ const players = [];
893
+
894
+ if (status === 'unknown') {
895
+ while (index < tokens.length) {
896
+ const token = tokens[index];
897
+ if (
898
+ ROTOWIRE_LINEUP_STATUS.has(token)
899
+ || ROTOWIRE_STOP_TOKENS.has(token)
900
+ || token.startsWith('Umpire:')
901
+ || ROTOWIRE_TIME_RE.test(token)
902
+ ) {
903
+ break;
904
+ }
905
+ index += 1;
906
+ }
907
+ return { lineup: { status, players }, nextIndex: index };
908
+ }
909
+
910
+ let slot = 1;
911
+ while (index < tokens.length) {
912
+ const token = tokens[index];
913
+ if (
914
+ ROTOWIRE_LINEUP_STATUS.has(token)
915
+ || ROTOWIRE_STOP_TOKENS.has(token)
916
+ || token.startsWith('Umpire:')
917
+ || ROTOWIRE_TIME_RE.test(token)
918
+ ) {
919
+ break;
920
+ }
921
+ if (ROTOWIRE_POSITION_TOKENS.has(token)) {
922
+ const next = nextNonPriceToken(tokens, index + 1);
923
+ if (
924
+ next.token
925
+ && !ROTOWIRE_POSITION_TOKENS.has(next.token)
926
+ && !ROTOWIRE_STOP_TOKENS.has(next.token)
927
+ && !ROTOWIRE_LINEUP_STATUS.has(next.token)
928
+ ) {
929
+ players.push({
930
+ slot,
931
+ player_name: next.token,
932
+ position: token,
933
+ });
934
+ slot += 1;
935
+ index = next.index + 1;
936
+ continue;
937
+ }
938
+ }
939
+ index += 1;
940
+ }
941
+
942
+ return { lineup: { status, players }, nextIndex: index };
943
+ }
944
+
945
+ function parseRotowireLineups(html, validTeams) {
946
+ const tokens = extractRotowireTokens(html);
947
+ const validTeamSet = new Set(validTeams.map((team) => normalizeTeam(team)));
948
+ const lineups = {};
949
+ let index = 0;
950
+
951
+ while (index < tokens.length) {
952
+ if (!ROTOWIRE_TIME_RE.test(tokens[index])) {
953
+ index += 1;
954
+ continue;
955
+ }
956
+
957
+ const teamTokens = [];
958
+ let scanIndex = index + 1;
959
+ while (scanIndex < tokens.length && scanIndex < index + 25 && teamTokens.length < 2) {
960
+ const token = normalizeTeam(tokens[scanIndex]);
961
+ if (validTeamSet.has(token) && !teamTokens.includes(token)) {
962
+ teamTokens.push(token);
963
+ }
964
+ scanIndex += 1;
965
+ }
966
+
967
+ if (teamTokens.length !== 2) {
968
+ index += 1;
969
+ continue;
970
+ }
971
+
972
+ const [awayTeam, homeTeam] = teamTokens;
973
+ let firstStatusIndex = null;
974
+ for (let candidate = scanIndex; candidate < Math.min(tokens.length, scanIndex + 80); candidate += 1) {
975
+ if (ROTOWIRE_LINEUP_STATUS.has(tokens[candidate])) {
976
+ firstStatusIndex = candidate;
977
+ break;
978
+ }
979
+ }
980
+ if (firstStatusIndex === null) {
981
+ index += 1;
982
+ continue;
983
+ }
984
+
985
+ const awayResult = parseRotowireLineupSide(tokens, firstStatusIndex);
986
+ let secondStatusIndex = null;
987
+ for (let candidate = awayResult.nextIndex; candidate < Math.min(tokens.length, awayResult.nextIndex + 80); candidate += 1) {
988
+ if (ROTOWIRE_LINEUP_STATUS.has(tokens[candidate])) {
989
+ secondStatusIndex = candidate;
990
+ break;
991
+ }
992
+ }
993
+ if (secondStatusIndex === null) {
994
+ index = awayResult.nextIndex;
995
+ continue;
996
+ }
997
+
998
+ const homeResult = parseRotowireLineupSide(tokens, secondStatusIndex);
999
+ lineups[awayTeam] = awayResult.lineup;
1000
+ lineups[homeTeam] = homeResult.lineup;
1001
+ index = homeResult.nextIndex;
1002
+ }
1003
+
1004
+ return lineups;
1005
+ }
1006
+
1007
+ function resolveRotowireLineups(lineups, rosterRows) {
1008
+ if (!lineups || !Object.keys(lineups).length || !rosterRows?.length) {
1009
+ return lineups ?? {};
1010
+ }
1011
+
1012
+ const rosterLookupByTeam = buildRosterNameLookupByTeam(rosterRows);
1013
+ const resolved = {};
1014
+
1015
+ for (const [team, payload] of Object.entries(lineups)) {
1016
+ const teamLookup = rosterLookupByTeam.get(normalizeTeam(team)) ?? { exact: new Map(), normalized: new Map() };
1017
+ const players = (payload?.players ?? []).map((player) => {
1018
+ const playerName = String(player.player_name ?? '');
1019
+ const exactPlayerId = teamLookup.exact.get(playerName.toLowerCase());
1020
+ const normalizedPlayerId = teamLookup.normalized.get(normalizeName(playerName));
1021
+ return {
1022
+ ...player,
1023
+ player_id: exactPlayerId ?? normalizedPlayerId ?? null,
1024
+ };
1025
+ });
1026
+ resolved[normalizeTeam(team)] = {
1027
+ status: payload?.status ?? 'unknown',
1028
+ players,
1029
+ };
1030
+ }
1031
+
1032
+ return resolved;
1033
+ }
1034
+
1035
+ function applyProjectedLineup(rows, team, rotowireLineups) {
1036
+ if (!rows.length || !rotowireLineups) {
1037
+ return rows;
1038
+ }
1039
+
1040
+ const payload = rotowireLineups[normalizeTeam(team)];
1041
+ if (!payload || !['confirmed', 'expected'].includes(String(payload.status ?? ''))) {
1042
+ return rows;
1043
+ }
1044
+
1045
+ const slotByPlayerId = new Map();
1046
+ for (const player of payload.players ?? []) {
1047
+ const playerId = String(player.player_id ?? '').trim();
1048
+ if (!playerId) {
1049
+ continue;
1050
+ }
1051
+ slotByPlayerId.set(playerId, Number(player.slot ?? null));
1052
+ }
1053
+
1054
+ if (slotByPlayerId.size < 7) {
1055
+ return rows;
1056
+ }
1057
+
1058
+ return rows
1059
+ .filter((row) => slotByPlayerId.has(String(row.batter ?? row.player_id ?? '').trim()))
1060
+ .map((row) => ({
1061
+ ...row,
1062
+ projected_lineup_slot: slotByPlayerId.get(String(row.batter ?? row.player_id ?? '').trim()) ?? null,
1063
+ }))
1064
+ .sort((left, right) => {
1065
+ const leftSlot = numberOrNull(left.projected_lineup_slot);
1066
+ const rightSlot = numberOrNull(right.projected_lineup_slot);
1067
+ if (leftSlot === null && rightSlot === null) {
1068
+ return 0;
1069
+ }
1070
+ if (leftSlot === null) {
1071
+ return 1;
1072
+ }
1073
+ if (rightSlot === null) {
1074
+ return -1;
1075
+ }
1076
+ return leftSlot - rightSlot;
1077
+ });
1078
+ }
1079
+
1080
  function pickMetrics(row, metricKeys) {
1081
  return metricKeys
1082
  .map(([label, key]) => ({ label, key, value: numberOrNull(row[key]) }))
 
1160
  this.fallbackDays = Number(config.fallbackDays ?? DEFAULT_FALLBACK_DAYS);
1161
  this.logger = options.logger ?? console;
1162
  this.readParquetImpl = options.readParquetImpl ?? readParquetFromUrl;
1163
+ this.fetchTextImpl = options.fetchTextImpl ?? defaultFetchText;
1164
+ this.rotowireUrl = String(config.rotowireUrl ?? DEFAULT_ROTOWIRE_URL);
1165
  this.cache = new Map();
1166
  }
1167
 
 
1215
  return rows;
1216
  }
1217
 
1218
+ async fetchRotowireLineups(targetDate, validTeams, rosterRows = []) {
1219
+ if (!this.fetchTextImpl || !validTeams?.length) {
1220
+ return {};
1221
+ }
1222
+
1223
+ const cacheKey = `rotowire|${parseDateOrToday(targetDate)}|${[...validTeams].sort().join(',')}`;
1224
+ const now = Date.now();
1225
+ const cached = this.cache.get(cacheKey);
1226
+ if (cached && cached.expiresAt > now) {
1227
+ return cached.rows;
1228
+ }
1229
+
1230
+ try {
1231
+ const html = await this.fetchTextImpl(this.rotowireUrl, parseDateOrToday(targetDate));
1232
+ const parsed = parseRotowireLineups(html, validTeams);
1233
+ const resolved = resolveRotowireLineups(parsed, rosterRows);
1234
+ this.cache.set(cacheKey, {
1235
+ expiresAt: now + this.cacheTtlMs,
1236
+ rows: resolved,
1237
+ });
1238
+ return resolved;
1239
+ } catch (error) {
1240
+ this.logger?.debug?.('Rotowire lineup fetch failed', {
1241
+ targetDate: parseDateOrToday(targetDate),
1242
+ error: error.message,
1243
+ });
1244
+ return {};
1245
+ }
1246
+ }
1247
+
1248
+ async buildHostedHitterParityResult(options = {}) {
1249
  const targetDate = parseDateOrToday(options.date);
1250
  const resolvedDate = await this.getLatestAvailableDate(targetDate);
1251
  if (!resolvedDate) {
1252
  throw new Error('No hosted matchup slate was available in the fallback window.');
1253
  }
1254
 
1255
+ const [slateRows, hitterRows, pitcherRows, exclusionRows, rosterRows, batterZoneRows, pitcherZoneRows] = await Promise.all([
1256
  this.readDailyFile(resolvedDate, 'slate.parquet', SLATE_COLUMNS),
1257
  this.readDailyFile(resolvedDate, 'daily_hitter_metrics.parquet', HITTER_COLUMNS),
1258
+ this.readDailyFile(resolvedDate, 'daily_pitcher_metrics.parquet', PITCHER_COLUMNS),
1259
  this.readDailyFile(resolvedDate, 'hitter_pitcher_exclusions.parquet', EXCLUSION_COLUMNS).catch(() => []),
1260
  this.readDailyFile(resolvedDate, 'rosters.parquet', ROSTER_COLUMNS).catch(() => []),
1261
+ this.readDailyFile(resolvedDate, 'daily_batter_zone_profiles.parquet', BATTER_ZONE_COLUMNS).catch(() => []),
1262
+ this.readDailyFile(resolvedDate, 'daily_pitcher_zone_profiles.parquet', PITCHER_ZONE_COLUMNS).catch(() => []),
1263
  ]);
1264
 
1265
+ const split = String(options.split ?? DEFAULT_SPLIT_KEY);
1266
+ const recentWindow = String(options.recentWindow ?? DEFAULT_RECENT_WINDOW);
1267
+ const weightedMode = String(options.weightedMode ?? DEFAULT_WEIGHTED_MODE);
1268
+ const likelyOnly = options.likelyOnly === true;
1269
  const slateLookup = mapSlateTeams(slateRows);
1270
+ const exclusionSet = buildExclusionSet(exclusionRows);
1271
  const rosterLookup = buildRosterLookup(rosterRows);
1272
+ const rosterIdsByTeam = buildRosterIdsByTeam(rosterRows);
1273
+ const validTeams = [...new Set(slateRows.flatMap((row) => [normalizeTeam(row.away_team), normalizeTeam(row.home_team)]).filter(Boolean))];
1274
+ const rotowireLineups = await this.fetchRotowireLineups(resolvedDate, validTeams, rosterRows);
1275
+ const filteredPitchers = pitcherRows.filter((row) =>
1276
+ String(row.split_key ?? DEFAULT_SPLIT_KEY) === split
1277
+ && String(row.recent_window ?? DEFAULT_RECENT_WINDOW) === recentWindow
1278
+ && String(row.weighted_mode ?? DEFAULT_WEIGHTED_MODE) === weightedMode
1279
+ );
1280
+ const pitchersById = new Map();
1281
+ for (const row of filteredPitchers) {
1282
+ pitchersById.set(String(row.pitcher_id ?? row.player_id ?? ''), row);
1283
+ }
1284
+
1285
+ const allRows = [];
1286
+ const boardRows = [];
1287
+ for (const slateRow of slateRows) {
1288
+ const gamePk = slateRow.game_pk ?? null;
1289
+ const awayTeam = normalizeTeam(slateRow.away_team);
1290
+ const homeTeam = normalizeTeam(slateRow.home_team);
1291
+ const awayPitcher = pitchersById.get(String(slateRow.away_probable_pitcher_id ?? '')) ?? null;
1292
+ const homePitcher = pitchersById.get(String(slateRow.home_probable_pitcher_id ?? '')) ?? null;
1293
+ const awayHand = String(homePitcher?.p_throws ?? slateRow.home_probable_hand ?? '').trim().toUpperCase() || null;
1294
+ const homeHand = String(awayPitcher?.p_throws ?? slateRow.away_probable_hand ?? '').trim().toUpperCase() || null;
1295
+
1296
+ const awayRows = this.prepareHostedTeamHitters({
1297
+ team: awayTeam,
1298
+ slateLookup,
1299
+ rosterLookup,
1300
+ rosterIdsByTeam,
1301
+ exclusionSet,
1302
+ rotowireLineups,
1303
+ hitterRows,
1304
+ opposingPitcherHand: awayHand,
1305
+ split,
1306
+ recentWindow,
1307
+ weightedMode,
1308
+ likelyOnly,
1309
+ });
1310
+ const homeRows = this.prepareHostedTeamHitters({
1311
+ team: homeTeam,
1312
+ slateLookup,
1313
+ rosterLookup,
1314
+ rosterIdsByTeam,
1315
+ exclusionSet,
1316
+ rotowireLineups,
1317
+ hitterRows,
1318
+ opposingPitcherHand: homeHand,
1319
+ split,
1320
+ recentWindow,
1321
+ weightedMode,
1322
+ likelyOnly,
1323
  });
1324
 
1325
+ const awayScored = addHitterMatchupScore(awayRows, batterZoneRows, pitcherZoneRows);
1326
+ const homeScored = addHitterMatchupScore(homeRows, batterZoneRows, pitcherZoneRows);
1327
+ const combinedGameRows = sortHittersLiveApp([...awayScored, ...homeScored]);
1328
+ allRows.push(...combinedGameRows);
1329
+ boardRows.push(...combinedGameRows.slice(0, 3));
1330
+ }
1331
+
1332
+ const sortedRows = sortHittersLiveApp(allRows);
1333
+ const sortedBoardRows = sortHittersLiveApp(boardRows);
1334
+ const filteredRows = sortedRows.filter((row) => rowMatchesTeamFilter(row.team, options.team));
1335
+ const filteredBoardRows = sortedBoardRows.filter((row) => rowMatchesTeamFilter(row.team, options.team));
1336
+
1337
  return {
1338
  source: 'hosted',
1339
  resolvedDate,
1340
+ parityRanked: true,
1341
+ rows: filteredRows,
1342
+ boardRows: filteredBoardRows,
1343
+ };
1344
+ }
1345
+
1346
+ prepareHostedTeamHitters({
1347
+ team,
1348
+ slateLookup,
1349
+ rosterLookup,
1350
+ rosterIdsByTeam,
1351
+ exclusionSet,
1352
+ rotowireLineups,
1353
+ hitterRows,
1354
+ opposingPitcherHand,
1355
+ split,
1356
+ recentWindow,
1357
+ weightedMode,
1358
+ likelyOnly,
1359
+ }) {
1360
+ const normalizedTeam = normalizeTeam(team);
1361
+ const effectiveSplit = split === DEFAULT_SPLIT_KEY && opposingPitcherHand === 'R'
1362
+ ? 'vs_rhp'
1363
+ : split === DEFAULT_SPLIT_KEY && opposingPitcherHand === 'L'
1364
+ ? 'vs_lhp'
1365
+ : split;
1366
+ const rosterPlayerIds = rosterIdsByTeam.get(normalizedTeam) ?? null;
1367
+
1368
+ const preparedRows = hitterRows
1369
+ .filter((row) => normalizeTeam(row.team) === normalizedTeam)
1370
+ .filter((row) => String(row.split_key ?? DEFAULT_SPLIT_KEY) === effectiveSplit)
1371
+ .filter((row) => String(row.recent_window ?? DEFAULT_RECENT_WINDOW) === recentWindow)
1372
+ .filter((row) => String(row.weighted_mode ?? DEFAULT_WEIGHTED_MODE) === weightedMode)
1373
+ .filter((row) => !rosterPlayerIds || rosterPlayerIds.has(String(row.batter ?? row.player_id ?? '').trim()))
1374
+ .filter((row) => !exclusionSet.has(String(row.batter ?? row.player_id ?? '').trim()))
1375
+ .map((row) => withDefaults(row, slateLookup, { rosterLookup, playerType: 'hitter' }))
1376
+ .filter((row) => !likelyOnly || (numberOrNull(row.likely_starter_score) ?? 0) > 0);
1377
+
1378
+ return applyProjectedLineup(preparedRows, normalizedTeam, rotowireLineups);
1379
+ }
1380
+
1381
+ async getTopHitters(options = {}) {
1382
+ const result = await this.buildHostedHitterParityResult(options);
1383
+ return {
1384
+ source: result.source,
1385
+ resolvedDate: result.resolvedDate,
1386
+ parityRanked: true,
1387
+ rows: result.rows.slice(0, limitOrDefault(options.limit)),
1388
+ };
1389
  }
1390
 
1391
  async getTopPitchers(options = {}) {
 
1416
  }
1417
 
1418
  async getBestMatchups(options = {}) {
1419
+ const result = await this.buildHostedHitterParityResult(options);
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1420
  return {
1421
+ source: result.source,
1422
+ resolvedDate: result.resolvedDate,
1423
+ parityRanked: true,
1424
+ rows: result.boardRows.slice(0, limitOrDefault(options.limit ?? (options.team ? 3 : 12), 12)),
1425
  };
1426
  }
1427
 
 
1867
  if (this.hosted?.isConfigured?.()) {
1868
  try {
1869
  const hostedResult = await this.hosted[methodName](options);
1870
+ if (methodName === 'getTopHitters' && !hostedResult?.parityRanked) {
1871
  return await this.enrichHostedHitters(hostedResult, options, methodName);
1872
  }
1873
  return hostedResult;
 
1966
  }
1967
  }
1968
 
1969
+ async function defaultFetchText(url, targetDate) {
1970
+ const requestUrl = new URL(url);
1971
+ requestUrl.searchParams.set('date', parseDateOrToday(targetDate));
1972
+ const response = await fetch(requestUrl, {
1973
+ headers: {
1974
+ 'User-Agent': 'KasperMLB/1.0',
1975
+ },
1976
+ });
1977
+ if (!response.ok) {
1978
+ throw new Error(`Rotowire request failed with status ${response.status}`);
1979
+ }
1980
+ return response.text();
1981
+ }
1982
+
1983
  export async function readParquetFromUrl(url, columns) {
1984
  const file = await asyncBufferFromUrl({ url });
1985
  return parquetReadObjects({
test/matchups.test.js CHANGED
@@ -11,6 +11,8 @@ test('hosted artifact source resolves latest available daily slate and caches pa
11
  game_pk: 10,
12
  away_team: 'CHC',
13
  home_team: 'MIL',
 
 
14
  away_probable_pitcher: 'Shota Imanaga',
15
  home_probable_pitcher: 'Freddy Peralta',
16
  away_probable_hand: 'L',
@@ -20,20 +22,48 @@ test('hosted artifact source resolves latest available daily slate and caches pa
20
  ['https://example.test/daily/2026-04-06/daily_hitter_metrics.parquet', [
21
  {
22
  team: 'CHC',
23
- hitter_name: 'Seiya Suzuki',
24
  batter: 100,
25
- split_key: 'overall',
 
26
  recent_window: 'season',
27
  weighted_mode: 'weighted',
28
- matchup_score: 82.4,
29
- ceiling_score: 79.5,
30
- zone_fit_score: 70.1,
 
 
31
  likely_starter_score: 98.2,
32
  xwoba: 0.398,
 
33
  hard_hit_pct: 48.2,
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
34
  },
35
  ]],
36
  ['https://example.test/daily/2026-04-06/hitter_pitcher_exclusions.parquet', []],
 
 
 
 
 
37
  ]);
38
  const calls = [];
39
  const source = new HostedArtifactSource(
@@ -50,6 +80,7 @@ test('hosted artifact source resolves latest available daily slate and caches pa
50
  }
51
  return responses.get(url);
52
  },
 
53
  logger: { debug() {}, warn() {} },
54
  }
55
  );
@@ -72,6 +103,8 @@ test('hosted artifact source matches common team aliases against team abbreviati
72
  game_pk: 20,
73
  away_team: 'ATH',
74
  home_team: 'NYY',
 
 
75
  away_probable_pitcher: 'JP Sears',
76
  home_probable_pitcher: 'Carlos Rodon',
77
  away_probable_hand: 'L',
@@ -96,6 +129,10 @@ test('hosted artifact source matches common team aliases against team abbreviati
96
  avg_launch_angle: 14.2,
97
  },
98
  ]],
 
 
 
 
99
  ['https://example.test/daily/2026-04-07/hitter_pitcher_exclusions.parquet', []],
100
  ['https://example.test/daily/2026-04-07/rosters.parquet', [
101
  {
@@ -121,6 +158,7 @@ test('hosted artifact source matches common team aliases against team abbreviati
121
  }
122
  return responses.get(url);
123
  },
 
124
  logger: { debug() {}, warn() {} },
125
  }
126
  );
@@ -186,6 +224,24 @@ test('hosted best matchups uses pitcher-hand split rows from hosted slate inputs
186
  avg_launch_angle: 20,
187
  },
188
  ]],
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
189
  ['https://example.test/daily/2026-04-07/hitter_pitcher_exclusions.parquet', []],
190
  ['https://example.test/daily/2026-04-07/rosters.parquet', [
191
  { team: 'MIL', player_id: 123, player_name: 'Gary Sanchez' },
@@ -207,6 +263,7 @@ test('hosted best matchups uses pitcher-hand split rows from hosted slate inputs
207
  }
208
  return responses.get(url);
209
  },
 
210
  logger: { debug() {}, warn() {} },
211
  }
212
  );
@@ -286,6 +343,12 @@ test('hosted best matchups keeps only the top three hitters per game before rank
286
  { team: 'BOS', hitter_name: '23', batter: 23, stand: 'L', split_key: 'vs_rhp', recent_window: 'season', weighted_mode: 'weighted', xwoba: 0.350, swstr_pct: 0.10, barrel_bbe_pct: 0.13, barrel_bip_pct: 0.11, pulled_barrel_pct: 0.07, sweet_spot_pct: 0.33, fb_pct: 0.24, hard_hit_pct: 0.41, avg_launch_angle: 16 },
287
  { team: 'BAL', hitter_name: '24', batter: 24, stand: 'L', split_key: 'vs_rhp', recent_window: 'season', weighted_mode: 'weighted', xwoba: 0.330, swstr_pct: 0.11, barrel_bbe_pct: 0.11, barrel_bip_pct: 0.09, pulled_barrel_pct: 0.06, sweet_spot_pct: 0.31, fb_pct: 0.22, hard_hit_pct: 0.39, avg_launch_angle: 14 },
288
  ]],
 
 
 
 
 
 
289
  ['https://example.test/daily/2026-04-07/hitter_pitcher_exclusions.parquet', []],
290
  ['https://example.test/daily/2026-04-07/rosters.parquet', [
291
  { team: 'NYY', player_id: 11, player_name: 'Game One A' },
@@ -314,6 +377,7 @@ test('hosted best matchups keeps only the top three hitters per game before rank
314
  }
315
  return responses.get(url);
316
  },
 
317
  logger: { debug() {}, warn() {} },
318
  }
319
  );
 
11
  game_pk: 10,
12
  away_team: 'CHC',
13
  home_team: 'MIL',
14
+ away_probable_pitcher_id: 1001,
15
+ home_probable_pitcher_id: 1002,
16
  away_probable_pitcher: 'Shota Imanaga',
17
  home_probable_pitcher: 'Freddy Peralta',
18
  away_probable_hand: 'L',
 
22
  ['https://example.test/daily/2026-04-06/daily_hitter_metrics.parquet', [
23
  {
24
  team: 'CHC',
25
+ hitter_name: '100',
26
  batter: 100,
27
+ stand: 'R',
28
+ split_key: 'vs_rhp',
29
  recent_window: 'season',
30
  weighted_mode: 'weighted',
31
+ swstr_pct: 0.08,
32
+ barrel_bbe_pct: 0.16,
33
+ barrel_bip_pct: 0.14,
34
+ pulled_barrel_pct: 0.11,
35
+ sweet_spot_pct: 0.37,
36
  likely_starter_score: 98.2,
37
  xwoba: 0.398,
38
+ fb_pct: 0.29,
39
  hard_hit_pct: 48.2,
40
+ avg_launch_angle: 17.2,
41
+ },
42
+ ]],
43
+ ['https://example.test/daily/2026-04-06/daily_pitcher_metrics.parquet', [
44
+ {
45
+ pitcher_id: 1001,
46
+ pitcher_name: 'Shota Imanaga',
47
+ p_throws: 'L',
48
+ split_key: 'overall',
49
+ recent_window: 'season',
50
+ weighted_mode: 'weighted',
51
+ },
52
+ {
53
+ pitcher_id: 1002,
54
+ pitcher_name: 'Freddy Peralta',
55
+ p_throws: 'R',
56
+ split_key: 'overall',
57
+ recent_window: 'season',
58
+ weighted_mode: 'weighted',
59
  },
60
  ]],
61
  ['https://example.test/daily/2026-04-06/hitter_pitcher_exclusions.parquet', []],
62
+ ['https://example.test/daily/2026-04-06/rosters.parquet', [
63
+ { team: 'CHC', player_id: 100, player_name: 'Seiya Suzuki' },
64
+ ]],
65
+ ['https://example.test/daily/2026-04-06/daily_batter_zone_profiles.parquet', []],
66
+ ['https://example.test/daily/2026-04-06/daily_pitcher_zone_profiles.parquet', []],
67
  ]);
68
  const calls = [];
69
  const source = new HostedArtifactSource(
 
80
  }
81
  return responses.get(url);
82
  },
83
+ fetchTextImpl: async () => '',
84
  logger: { debug() {}, warn() {} },
85
  }
86
  );
 
103
  game_pk: 20,
104
  away_team: 'ATH',
105
  home_team: 'NYY',
106
+ away_probable_pitcher_id: 2001,
107
+ home_probable_pitcher_id: 2002,
108
  away_probable_pitcher: 'JP Sears',
109
  home_probable_pitcher: 'Carlos Rodon',
110
  away_probable_hand: 'L',
 
129
  avg_launch_angle: 14.2,
130
  },
131
  ]],
132
+ ['https://example.test/daily/2026-04-07/daily_pitcher_metrics.parquet', [
133
+ { pitcher_id: 2001, pitcher_name: 'JP Sears', p_throws: 'L', split_key: 'overall', recent_window: 'season', weighted_mode: 'weighted' },
134
+ { pitcher_id: 2002, pitcher_name: 'Carlos Rodon', p_throws: 'L', split_key: 'overall', recent_window: 'season', weighted_mode: 'weighted' },
135
+ ]],
136
  ['https://example.test/daily/2026-04-07/hitter_pitcher_exclusions.parquet', []],
137
  ['https://example.test/daily/2026-04-07/rosters.parquet', [
138
  {
 
158
  }
159
  return responses.get(url);
160
  },
161
+ fetchTextImpl: async () => '',
162
  logger: { debug() {}, warn() {} },
163
  }
164
  );
 
224
  avg_launch_angle: 20,
225
  },
226
  ]],
227
+ ['https://example.test/daily/2026-04-07/daily_pitcher_metrics.parquet', [
228
+ {
229
+ pitcher_id: 501,
230
+ pitcher_name: 'Garrett Crochet',
231
+ p_throws: 'L',
232
+ split_key: 'overall',
233
+ recent_window: 'season',
234
+ weighted_mode: 'weighted',
235
+ },
236
+ {
237
+ pitcher_id: 601,
238
+ pitcher_name: 'Freddy Peralta',
239
+ p_throws: 'R',
240
+ split_key: 'overall',
241
+ recent_window: 'season',
242
+ weighted_mode: 'weighted',
243
+ },
244
+ ]],
245
  ['https://example.test/daily/2026-04-07/hitter_pitcher_exclusions.parquet', []],
246
  ['https://example.test/daily/2026-04-07/rosters.parquet', [
247
  { team: 'MIL', player_id: 123, player_name: 'Gary Sanchez' },
 
263
  }
264
  return responses.get(url);
265
  },
266
+ fetchTextImpl: async () => '',
267
  logger: { debug() {}, warn() {} },
268
  }
269
  );
 
343
  { team: 'BOS', hitter_name: '23', batter: 23, stand: 'L', split_key: 'vs_rhp', recent_window: 'season', weighted_mode: 'weighted', xwoba: 0.350, swstr_pct: 0.10, barrel_bbe_pct: 0.13, barrel_bip_pct: 0.11, pulled_barrel_pct: 0.07, sweet_spot_pct: 0.33, fb_pct: 0.24, hard_hit_pct: 0.41, avg_launch_angle: 16 },
344
  { team: 'BAL', hitter_name: '24', batter: 24, stand: 'L', split_key: 'vs_rhp', recent_window: 'season', weighted_mode: 'weighted', xwoba: 0.330, swstr_pct: 0.11, barrel_bbe_pct: 0.11, barrel_bip_pct: 0.09, pulled_barrel_pct: 0.06, sweet_spot_pct: 0.31, fb_pct: 0.22, hard_hit_pct: 0.39, avg_launch_angle: 14 },
345
  ]],
346
+ ['https://example.test/daily/2026-04-07/daily_pitcher_metrics.parquet', [
347
+ { pitcher_id: 101, pitcher_name: 'Away One', p_throws: 'R', split_key: 'overall', recent_window: 'season', weighted_mode: 'weighted' },
348
+ { pitcher_id: 201, pitcher_name: 'Home One', p_throws: 'L', split_key: 'overall', recent_window: 'season', weighted_mode: 'weighted' },
349
+ { pitcher_id: 301, pitcher_name: 'Away Two', p_throws: 'R', split_key: 'overall', recent_window: 'season', weighted_mode: 'weighted' },
350
+ { pitcher_id: 401, pitcher_name: 'Home Two', p_throws: 'R', split_key: 'overall', recent_window: 'season', weighted_mode: 'weighted' },
351
+ ]],
352
  ['https://example.test/daily/2026-04-07/hitter_pitcher_exclusions.parquet', []],
353
  ['https://example.test/daily/2026-04-07/rosters.parquet', [
354
  { team: 'NYY', player_id: 11, player_name: 'Game One A' },
 
377
  }
378
  return responses.get(url);
379
  },
380
+ fetchTextImpl: async () => '',
381
  logger: { debug() {}, warn() {} },
382
  }
383
  );