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

Align hosted matchup scoring with slate

Browse files
Files changed (2) hide show
  1. src/matchups.js +331 -7
  2. test/matchups.test.js +137 -39
src/matchups.js CHANGED
@@ -105,6 +105,8 @@ const SLATE_COLUMNS = [
105
  'game_pk',
106
  'away_team',
107
  'home_team',
 
 
108
  'away_probable_pitcher',
109
  'home_probable_pitcher',
110
  'away_probable_hand',
@@ -188,11 +190,16 @@ const PITCHER_ROLLING_COLUMNS = [
188
  ];
189
 
190
  const BATTER_ZONE_COLUMNS = [
 
 
191
  'batter',
192
  'player_id',
193
  'hitter_name',
 
194
  'zone_label',
195
  'zone',
 
 
196
  'xwoba',
197
  'hard_hit_pct',
198
  'barrel_bip_pct',
@@ -204,8 +211,11 @@ const PITCHER_ZONE_COLUMNS = [
204
  'pitcher_id',
205
  'player_id',
206
  'pitcher_name',
 
 
207
  'zone_label',
208
  'zone',
 
209
  'xwoba_allowed',
210
  'hard_hit_pct_allowed',
211
  'barrel_bip_pct_allowed',
@@ -353,6 +363,266 @@ function sortPitchers(rows) {
353
  );
354
  }
355
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
356
  function buildBestMatchupBoardRows(rows) {
357
  if (!rows?.length) {
358
  return [];
@@ -400,12 +670,14 @@ function mapSlateTeams(slateRows) {
400
  lookup.set(awayTeam, {
401
  gamePk: row.game_pk ?? null,
402
  opponentTeam: homeTeam,
 
403
  opposingPitcherName: row.home_probable_pitcher ?? null,
404
  opposingPitcherHand: row.home_probable_hand ?? null,
405
  });
406
  lookup.set(homeTeam, {
407
  gamePk: row.game_pk ?? null,
408
  opponentTeam: awayTeam,
 
409
  opposingPitcherName: row.away_probable_pitcher ?? null,
410
  opposingPitcherHand: row.away_probable_hand ?? null,
411
  });
@@ -478,6 +750,7 @@ function withDefaults(row, slateLookup, options = {}) {
478
  : row.pitcher_name,
479
  game_pk: row.game_pk ?? slate.gamePk ?? null,
480
  opponent_team: row.opponent_team ?? slate.opponentTeam ?? null,
 
481
  opposing_pitcher_name: row.opposing_pitcher_name ?? slate.opposingPitcherName ?? null,
482
  opposing_pitcher_hand: row.opposing_pitcher_hand ?? slate.opposingPitcherHand ?? null,
483
  };
@@ -680,13 +953,64 @@ export class HostedArtifactSource {
680
  }
681
 
682
  async getBestMatchups(options = {}) {
683
- const result = await this.getTopHitters({
684
- ...options,
685
- limit: limitOrDefault(options.limit ?? (options.team ? 3 : 12), 12),
686
- });
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
687
  return {
688
- ...result,
689
- rows: buildBestMatchupBoardRows(result.rows).slice(0, limitOrDefault(options.limit ?? (options.team ? 3 : 12), 12)),
 
690
  };
691
  }
692
 
@@ -1132,7 +1456,7 @@ export class MatchupService {
1132
  if (this.hosted?.isConfigured?.()) {
1133
  try {
1134
  const hostedResult = await this.hosted[methodName](options);
1135
- if (methodName === 'getTopHitters' || methodName === 'getBestMatchups') {
1136
  return await this.enrichHostedHitters(hostedResult, options, methodName);
1137
  }
1138
  return hostedResult;
 
105
  'game_pk',
106
  'away_team',
107
  'home_team',
108
+ 'away_probable_pitcher_id',
109
+ 'home_probable_pitcher_id',
110
  'away_probable_pitcher',
111
  'home_probable_pitcher',
112
  'away_probable_hand',
 
190
  ];
191
 
192
  const BATTER_ZONE_COLUMNS = [
193
+ 'batter_id',
194
+ 'pitcher_hand_key',
195
  'batter',
196
  'player_id',
197
  'hitter_name',
198
+ 'pitch_type',
199
  'zone_label',
200
  'zone',
201
+ 'hit_rate',
202
+ 'hr_rate',
203
  'xwoba',
204
  'hard_hit_pct',
205
  'barrel_bip_pct',
 
211
  'pitcher_id',
212
  'player_id',
213
  'pitcher_name',
214
+ 'batter_side_key',
215
+ 'pitch_type',
216
  'zone_label',
217
  'zone',
218
+ 'usage_rate',
219
  'xwoba_allowed',
220
  'hard_hit_pct_allowed',
221
  'barrel_bip_pct_allowed',
 
363
  );
364
  }
365
 
366
+ function normalizeSeries(values, inverse = false) {
367
+ const numerics = values.map((value) => numberOrNull(value));
368
+ const finite = numerics.filter((value) => value !== null);
369
+ if (!finite.length) {
370
+ return values.map(() => 0.5);
371
+ }
372
+
373
+ const min = Math.min(...finite);
374
+ const max = Math.max(...finite);
375
+ const normalized = numerics.map((value) => {
376
+ if (value === null || Math.abs(max - min) < 1e-9) {
377
+ return 0.5;
378
+ }
379
+ return (value - min) / (max - min);
380
+ });
381
+
382
+ return normalized.map((value) => inverse ? 1 - value : value);
383
+ }
384
+
385
+ function launchAngleBandScore(value, low = 20, ideal = 27.5, high = 35) {
386
+ const numeric = numberOrNull(value);
387
+ if (numeric === null) {
388
+ return 0.5;
389
+ }
390
+ if (numeric >= low && numeric <= high) {
391
+ if (numeric <= ideal) {
392
+ return 0.8 + 0.2 * ((numeric - low) / Math.max(ideal - low, 1e-9));
393
+ }
394
+ return 0.8 + 0.2 * ((high - numeric) / Math.max(high - ideal, 1e-9));
395
+ }
396
+ if (numeric < low) {
397
+ return Math.max(0, 0.8 - ((low - numeric) / Math.max(low, 1e-9)) * 0.8);
398
+ }
399
+ return Math.max(0, 0.8 - ((numeric - high) / Math.max(high, 1e-9)) * 0.8);
400
+ }
401
+
402
+ function weightedAverage(pairs) {
403
+ const valid = pairs.filter(({ value, weight }) => value !== null && weight !== null && weight > 0);
404
+ if (!valid.length) {
405
+ return null;
406
+ }
407
+ const totalWeight = valid.reduce((sum, item) => sum + item.weight, 0);
408
+ if (totalWeight <= 0) {
409
+ return null;
410
+ }
411
+ return valid.reduce((sum, item) => sum + (item.value * item.weight), 0) / totalWeight;
412
+ }
413
+
414
+ function aggregateBatterZoneMap(rows) {
415
+ if (!rows.length) {
416
+ return [];
417
+ }
418
+
419
+ const grouped = new Map();
420
+ for (const row of rows) {
421
+ const zone = numberOrNull(row.zone);
422
+ if (zone === null) {
423
+ continue;
424
+ }
425
+ const key = String(Math.trunc(zone));
426
+ const bucket = grouped.get(key) ?? [];
427
+ bucket.push(row);
428
+ grouped.set(key, bucket);
429
+ }
430
+
431
+ const output = [];
432
+ for (const [key, group] of grouped.entries()) {
433
+ const zone = Number(key);
434
+ const zoneValue = (
435
+ (weightedAverage(group.map((row) => ({ value: numberOrNull(row.hit_rate), weight: numberOrNull(row.sample_size) }))) ?? 0) * 0.6
436
+ + (weightedAverage(group.map((row) => ({ value: numberOrNull(row.hr_rate), weight: numberOrNull(row.sample_size) }))) ?? 0) * 0.4
437
+ );
438
+ output.push({
439
+ zone,
440
+ sample_size: group.reduce((sum, row) => sum + (numberOrNull(row.sample_size) ?? 0), 0),
441
+ zone_value: zoneValue,
442
+ });
443
+ }
444
+
445
+ return output;
446
+ }
447
+
448
+ function aggregatePitcherZoneMap(rows) {
449
+ if (!rows.length) {
450
+ return [];
451
+ }
452
+
453
+ const grouped = new Map();
454
+ for (const row of rows) {
455
+ const zone = numberOrNull(row.zone);
456
+ if (zone === null) {
457
+ continue;
458
+ }
459
+ const key = String(Math.trunc(zone));
460
+ const bucket = grouped.get(key) ?? [];
461
+ bucket.push(row);
462
+ grouped.set(key, bucket);
463
+ }
464
+
465
+ const output = [];
466
+ for (const [key, group] of grouped.entries()) {
467
+ const zone = Number(key);
468
+ output.push({
469
+ zone,
470
+ sample_size: group.reduce((sum, row) => sum + (numberOrNull(row.sample_size) ?? 0), 0),
471
+ zone_value: group.reduce((sum, row) => sum + (numberOrNull(row.usage_rate) ?? 0), 0),
472
+ });
473
+ }
474
+
475
+ return output;
476
+ }
477
+
478
+ function buildZoneOverlayMap(batterMap, pitcherMap) {
479
+ if (!batterMap.length || !pitcherMap.length) {
480
+ return [];
481
+ }
482
+
483
+ const pitcherByZone = new Map(pitcherMap.map((row) => [row.zone, row]));
484
+ const merged = batterMap
485
+ .filter((row) => pitcherByZone.has(row.zone))
486
+ .map((row) => ({
487
+ zone: row.zone,
488
+ sample_size: Math.min(row.sample_size ?? 0, pitcherByZone.get(row.zone)?.sample_size ?? 0),
489
+ batter_zone_value: row.zone_value,
490
+ pitcher_zone_value: pitcherByZone.get(row.zone)?.zone_value ?? null,
491
+ }));
492
+
493
+ if (!merged.length) {
494
+ return [];
495
+ }
496
+
497
+ const batterScale = normalizeSeries(merged.map((row) => row.batter_zone_value));
498
+ const pitcherScale = normalizeSeries(merged.map((row) => row.pitcher_zone_value));
499
+ return merged.map((row, index) => ({
500
+ zone: row.zone,
501
+ sample_size: row.sample_size,
502
+ zone_value: batterScale[index] * pitcherScale[index],
503
+ }));
504
+ }
505
+
506
+ function overlayZoneFitScore(batterMap, pitcherMap) {
507
+ const overlay = buildZoneOverlayMap(batterMap, pitcherMap);
508
+ if (!overlay.length) {
509
+ return 0.5;
510
+ }
511
+ const sampleSum = overlay.reduce((sum, row) => sum + (numberOrNull(row.sample_size) ?? 0), 0);
512
+ if (sampleSum < 15) {
513
+ return 0.5;
514
+ }
515
+ const score = weightedAverage(overlay.map((row) => ({ value: numberOrNull(row.zone_value), weight: numberOrNull(row.sample_size) })));
516
+ if (score === null) {
517
+ return 0.5;
518
+ }
519
+ return Math.max(0, Math.min(1, score));
520
+ }
521
+
522
+ function selectBatterZoneRows(rows, batterId, opposingPitcherHand) {
523
+ const hitterRows = rows.filter((row) => String(row.batter_id ?? row.batter ?? '') === String(batterId));
524
+ if (!hitterRows.length) {
525
+ return [];
526
+ }
527
+ const handKey = opposingPitcherHand === 'R' ? 'vs_rhp' : opposingPitcherHand === 'L' ? 'vs_lhp' : 'overall';
528
+ const specific = hitterRows.filter((row) => String(row.pitcher_hand_key ?? '') === handKey);
529
+ if (specific.length) {
530
+ return specific;
531
+ }
532
+ const overall = hitterRows.filter((row) => String(row.pitcher_hand_key ?? '') === 'overall');
533
+ return overall.length ? overall : hitterRows;
534
+ }
535
+
536
+ function selectPitcherZoneRows(rows, pitcherId, hitterSide) {
537
+ const pitcherRows = rows.filter((row) => String(row.pitcher_id ?? '') === String(pitcherId));
538
+ if (!pitcherRows.length) {
539
+ return [];
540
+ }
541
+ const sideKey = hitterSide === 'L' ? 'vs_lhh' : hitterSide === 'R' ? 'vs_rhh' : 'overall';
542
+ const specific = pitcherRows.filter((row) => String(row.batter_side_key ?? '') === sideKey);
543
+ if (specific.length) {
544
+ return specific;
545
+ }
546
+ const overall = pitcherRows.filter((row) => String(row.batter_side_key ?? '') === 'overall');
547
+ return overall.length ? overall : pitcherRows;
548
+ }
549
+
550
+ function buildZoneFitScores(rows, batterZoneRows, pitcherZoneRows) {
551
+ const pitcherCache = new Map();
552
+ const batterCache = new Map();
553
+
554
+ return rows.map((row) => {
555
+ const batterId = String(row.batter ?? row.player_id ?? '');
556
+ const pitcherId = String(row.opposing_pitcher_id ?? '');
557
+ if (!batterId || !pitcherId) {
558
+ return 0.5;
559
+ }
560
+
561
+ const batterKey = `${batterId}|${row.opposing_pitcher_hand ?? 'overall'}`;
562
+ const pitcherKey = `${pitcherId}|${row.stand ?? 'overall'}`;
563
+
564
+ if (!batterCache.has(batterKey)) {
565
+ batterCache.set(batterKey, aggregateBatterZoneMap(selectBatterZoneRows(batterZoneRows, batterId, row.opposing_pitcher_hand)));
566
+ }
567
+ if (!pitcherCache.has(pitcherKey)) {
568
+ pitcherCache.set(pitcherKey, aggregatePitcherZoneMap(selectPitcherZoneRows(pitcherZoneRows, pitcherId, row.stand)));
569
+ }
570
+
571
+ return overlayZoneFitScore(batterCache.get(batterKey) ?? [], pitcherCache.get(pitcherKey) ?? []);
572
+ });
573
+ }
574
+
575
+ function addHitterMatchupScore(rows, batterZoneRows, pitcherZoneRows) {
576
+ if (!rows.length) {
577
+ return [];
578
+ }
579
+
580
+ const zoneFitScores = buildZoneFitScores(rows, batterZoneRows, pitcherZoneRows);
581
+ const swstrScores = normalizeSeries(rows.map((row) => row.swstr_pct), true);
582
+ const barrelScores = normalizeSeries(rows.map((row) => row.barrel_bbe_pct));
583
+ const sweetSpotScores = normalizeSeries(rows.map((row) => row.sweet_spot_pct));
584
+ const barrelSupportScores = normalizeSeries(rows.map((row) => row.barrel_bbe_pct));
585
+ const shapeScores = rows.map((row, index) => (
586
+ (launchAngleBandScore(row.avg_launch_angle, 12, 22, 32) * 0.55)
587
+ + (sweetSpotScores[index] * 0.35)
588
+ + (barrelSupportScores[index] * 0.10)
589
+ ));
590
+ const pulledBarrelScales = normalizeSeries(rows.map((row) => row.pulled_barrel_pct));
591
+
592
+ const matchupScored = rows.map((row, index) => {
593
+ const baseScore = ((swstrScores[index] * 0.35) + (barrelScores[index] * 0.30) + (shapeScores[index] * 0.20) + (zoneFitScores[index] * 0.15)) * 100;
594
+ const pulledBarrelBonus = Math.max(0, (pulledBarrelScales[index] - 0.5) / 0.5) * 0.08;
595
+ const matchupScore = Math.max(0, Math.min(100, baseScore * (1 + pulledBarrelBonus)));
596
+ return {
597
+ ...row,
598
+ zone_fit_score: zoneFitScores[index],
599
+ matchup_score: matchupScore,
600
+ _shape_score: shapeScores[index],
601
+ _pulled_barrel_scale: pulledBarrelScales[index],
602
+ };
603
+ });
604
+
605
+ const matchupNorm = normalizeSeries(matchupScored.map((row) => row.matchup_score));
606
+ const barrelBipNorm = normalizeSeries(matchupScored.map((row) => row.barrel_bip_pct));
607
+ const hhNorm = normalizeSeries(matchupScored.map((row) => row.hard_hit_pct));
608
+
609
+ return sortHitters(matchupScored.map((row, index) => ({
610
+ ...row,
611
+ ceiling_score: Math.max(0, Math.min(100,
612
+ (
613
+ (matchupNorm[index] * 0.35)
614
+ + ((row._pulled_barrel_scale ?? 0.5) * 0.30)
615
+ + (barrelBipNorm[index] * 0.20)
616
+ + ((row._shape_score ?? 0.5) * 0.10)
617
+ + (hhNorm[index] * 0.05)
618
+ ) * 100
619
+ )),
620
+ }))).map((row) => {
621
+ const { _shape_score, _pulled_barrel_scale, ...clean } = row;
622
+ return clean;
623
+ });
624
+ }
625
+
626
  function buildBestMatchupBoardRows(rows) {
627
  if (!rows?.length) {
628
  return [];
 
670
  lookup.set(awayTeam, {
671
  gamePk: row.game_pk ?? null,
672
  opponentTeam: homeTeam,
673
+ opposingPitcherId: row.home_probable_pitcher_id ?? null,
674
  opposingPitcherName: row.home_probable_pitcher ?? null,
675
  opposingPitcherHand: row.home_probable_hand ?? null,
676
  });
677
  lookup.set(homeTeam, {
678
  gamePk: row.game_pk ?? null,
679
  opponentTeam: awayTeam,
680
+ opposingPitcherId: row.away_probable_pitcher_id ?? null,
681
  opposingPitcherName: row.away_probable_pitcher ?? null,
682
  opposingPitcherHand: row.away_probable_hand ?? null,
683
  });
 
750
  : row.pitcher_name,
751
  game_pk: row.game_pk ?? slate.gamePk ?? null,
752
  opponent_team: row.opponent_team ?? slate.opponentTeam ?? null,
753
+ opposing_pitcher_id: row.opposing_pitcher_id ?? slate.opposingPitcherId ?? null,
754
  opposing_pitcher_name: row.opposing_pitcher_name ?? slate.opposingPitcherName ?? null,
755
  opposing_pitcher_hand: row.opposing_pitcher_hand ?? slate.opposingPitcherHand ?? null,
756
  };
 
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
  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;
test/matchups.test.js CHANGED
@@ -83,7 +83,8 @@ test('hosted artifact source matches common team aliases against team abbreviati
83
  team: 'NYY',
84
  hitter_name: '592450',
85
  batter: 99,
86
- split_key: 'overall',
 
87
  recent_window: 'season',
88
  weighted_mode: 'weighted',
89
  xwoba: 0.455,
@@ -103,6 +104,8 @@ test('hosted artifact source matches common team aliases against team abbreviati
103
  player_name: 'Aaron Judge',
104
  },
105
  ]],
 
 
106
  ]);
107
 
108
  const source = new HostedArtifactSource(
@@ -130,14 +133,100 @@ test('hosted artifact source matches common team aliases against team abbreviati
130
  assert.equal(result.rows[0].sweet_spot_pct, 36.8);
131
  });
132
 
133
- test('matchup service enriches hosted hitter rows with Cockroach scores and keeps team views to top three', async () => {
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
134
  const teamArgs = [];
135
  const service = new MatchupService(
136
  { databaseUrl: 'postgresql://test', hosted: { baseUrl: 'https://example.test' } },
137
  {
138
  hosted: {
139
  isConfigured: () => true,
140
- async getBestMatchups() {
141
  return {
142
  source: 'hosted',
143
  resolvedDate: '2026-04-07',
@@ -172,7 +261,7 @@ test('matchup service enriches hosted hitter rows with Cockroach scores and keep
172
  }
173
  );
174
 
175
- const result = await service.getBestMatchups({ date: '2026-04-07', team: 'Yankees' });
176
 
177
  assert.equal(result.rows.length, 3);
178
  assert.equal(teamArgs[0], 'Yankees');
@@ -181,46 +270,55 @@ test('matchup service enriches hosted hitter rows with Cockroach scores and keep
181
  assert.equal(result.rows[2].hitter_name, 'Paul Goldschmidt');
182
  });
183
 
184
- test('best matchups keeps only the top three hitters per game before ranking the slate board', async () => {
185
- const service = new MatchupService(
186
- { databaseUrl: 'postgresql://test', hosted: { baseUrl: 'https://example.test' } },
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
187
  {
188
- hosted: {
189
- isConfigured: () => true,
190
- async getBestMatchups() {
191
- return {
192
- source: 'hosted',
193
- resolvedDate: '2026-04-07',
194
- rows: [
195
- { hitter_name: 'Game One A', team: 'NYY', game_pk: 1, matchup_score: 90.0, xwoba: 0.410 },
196
- { hitter_name: 'Game One B', team: 'ATH', game_pk: 1, matchup_score: 80.0, xwoba: 0.380 },
197
- { hitter_name: 'Game One C', team: 'NYY', game_pk: 1, matchup_score: 70.0, xwoba: 0.360 },
198
- { hitter_name: 'Game One D', team: 'ATH', game_pk: 1, matchup_score: 60.0, xwoba: 0.340 },
199
- { hitter_name: 'Game Two A', team: 'BOS', game_pk: 2, matchup_score: 88.0, xwoba: 0.405 },
200
- { hitter_name: 'Game Two B', team: 'BAL', game_pk: 2, matchup_score: 77.0, xwoba: 0.370 },
201
- { hitter_name: 'Game Two C', team: 'BOS', game_pk: 2, matchup_score: 66.0, xwoba: 0.350 },
202
- { hitter_name: 'Game Two D', team: 'BAL', game_pk: 2, matchup_score: 55.0, xwoba: 0.330 },
203
- ],
204
- };
205
- },
206
- async getHealth() {
207
- return { configured: true, latestDate: '2026-04-07' };
208
- },
209
- },
210
- fallback: {
211
- async getHitterSnapshotRows() {
212
- return [];
213
- },
214
- async getHealth() {
215
- return { configured: true, latestDate: '2026-04-07' };
216
- },
217
- async close() {},
218
  },
219
- logger: { warn() {} },
220
  }
221
  );
222
 
223
- const result = await service.getBestMatchups({ date: '2026-04-07' });
224
 
225
  assert.equal(result.rows.length, 6);
226
  assert.deepEqual(
 
83
  team: 'NYY',
84
  hitter_name: '592450',
85
  batter: 99,
86
+ stand: 'R',
87
+ split_key: 'vs_lhp',
88
  recent_window: 'season',
89
  weighted_mode: 'weighted',
90
  xwoba: 0.455,
 
104
  player_name: 'Aaron Judge',
105
  },
106
  ]],
107
+ ['https://example.test/daily/2026-04-07/daily_batter_zone_profiles.parquet', []],
108
+ ['https://example.test/daily/2026-04-07/daily_pitcher_zone_profiles.parquet', []],
109
  ]);
110
 
111
  const source = new HostedArtifactSource(
 
133
  assert.equal(result.rows[0].sweet_spot_pct, 36.8);
134
  });
135
 
136
+ test('hosted best matchups uses pitcher-hand split rows from hosted slate inputs', async () => {
137
+ const responses = new Map([
138
+ ['https://example.test/daily/2026-04-07/slate.parquet', [
139
+ {
140
+ game_pk: 20,
141
+ away_team: 'BOS',
142
+ home_team: 'MIL',
143
+ away_probable_pitcher_id: 501,
144
+ home_probable_pitcher_id: 601,
145
+ away_probable_pitcher: 'Garrett Crochet',
146
+ home_probable_pitcher: 'Freddy Peralta',
147
+ away_probable_hand: 'L',
148
+ home_probable_hand: 'R',
149
+ },
150
+ ]],
151
+ ['https://example.test/daily/2026-04-07/daily_hitter_metrics.parquet', [
152
+ {
153
+ team: 'MIL',
154
+ hitter_name: '123',
155
+ batter: 123,
156
+ stand: 'R',
157
+ split_key: 'overall',
158
+ recent_window: 'season',
159
+ weighted_mode: 'weighted',
160
+ xwoba: 0.200,
161
+ swstr_pct: 0.20,
162
+ barrel_bbe_pct: 0.01,
163
+ barrel_bip_pct: 0.01,
164
+ pulled_barrel_pct: 0.01,
165
+ sweet_spot_pct: 0.20,
166
+ fb_pct: 0.20,
167
+ hard_hit_pct: 0.20,
168
+ avg_launch_angle: 5,
169
+ },
170
+ {
171
+ team: 'MIL',
172
+ hitter_name: '123',
173
+ batter: 123,
174
+ stand: 'R',
175
+ split_key: 'vs_lhp',
176
+ recent_window: 'season',
177
+ weighted_mode: 'weighted',
178
+ xwoba: 0.380,
179
+ swstr_pct: 0.08,
180
+ barrel_bbe_pct: 0.16,
181
+ barrel_bip_pct: 0.14,
182
+ pulled_barrel_pct: 0.11,
183
+ sweet_spot_pct: 0.39,
184
+ fb_pct: 0.31,
185
+ hard_hit_pct: 0.49,
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' },
192
+ ]],
193
+ ['https://example.test/daily/2026-04-07/daily_batter_zone_profiles.parquet', []],
194
+ ['https://example.test/daily/2026-04-07/daily_pitcher_zone_profiles.parquet', []],
195
+ ]);
196
+
197
+ const source = new HostedArtifactSource(
198
+ {
199
+ baseUrl: 'https://example.test',
200
+ cacheTtlMs: 60_000,
201
+ fallbackDays: 0,
202
+ },
203
+ {
204
+ readParquetImpl: async (url) => {
205
+ if (!responses.has(url)) {
206
+ throw new Error(`Missing ${url}`);
207
+ }
208
+ return responses.get(url);
209
+ },
210
+ logger: { debug() {}, warn() {} },
211
+ }
212
+ );
213
+
214
+ const result = await source.getBestMatchups({ date: '2026-04-07', team: 'Brewers' });
215
+
216
+ assert.equal(result.rows.length, 1);
217
+ assert.equal(result.rows[0].hitter_name, 'Gary Sanchez');
218
+ assert.equal(result.rows[0].opposing_pitcher_name, 'Garrett Crochet');
219
+ assert.equal(result.rows[0].matchup_score > 50, true);
220
+ });
221
+
222
+ test('matchup service enriches hosted top hitter rows from Cockroach snapshots', async () => {
223
  const teamArgs = [];
224
  const service = new MatchupService(
225
  { databaseUrl: 'postgresql://test', hosted: { baseUrl: 'https://example.test' } },
226
  {
227
  hosted: {
228
  isConfigured: () => true,
229
+ async getTopHitters() {
230
  return {
231
  source: 'hosted',
232
  resolvedDate: '2026-04-07',
 
261
  }
262
  );
263
 
264
+ const result = await service.getTopHitters({ date: '2026-04-07', team: 'Yankees', limit: 3 });
265
 
266
  assert.equal(result.rows.length, 3);
267
  assert.equal(teamArgs[0], 'Yankees');
 
270
  assert.equal(result.rows[2].hitter_name, 'Paul Goldschmidt');
271
  });
272
 
273
+ test('hosted best matchups keeps only the top three hitters per game before ranking the slate board', async () => {
274
+ const responses = new Map([
275
+ ['https://example.test/daily/2026-04-07/slate.parquet', [
276
+ { game_pk: 1, away_team: 'NYY', home_team: 'ATH', away_probable_pitcher_id: 101, home_probable_pitcher_id: 201, away_probable_pitcher: 'Away One', home_probable_pitcher: 'Home One', away_probable_hand: 'R', home_probable_hand: 'L' },
277
+ { game_pk: 2, away_team: 'BOS', home_team: 'BAL', away_probable_pitcher_id: 301, home_probable_pitcher_id: 401, away_probable_pitcher: 'Away Two', home_probable_pitcher: 'Home Two', away_probable_hand: 'R', home_probable_hand: 'R' },
278
+ ]],
279
+ ['https://example.test/daily/2026-04-07/daily_hitter_metrics.parquet', [
280
+ { team: 'NYY', hitter_name: '11', batter: 11, stand: 'R', split_key: 'vs_lhp', recent_window: 'season', weighted_mode: 'weighted', xwoba: 0.410, swstr_pct: 0.08, barrel_bbe_pct: 0.20, barrel_bip_pct: 0.18, pulled_barrel_pct: 0.13, sweet_spot_pct: 0.40, fb_pct: 0.30, hard_hit_pct: 0.50, avg_launch_angle: 20 },
281
+ { team: 'ATH', hitter_name: '12', batter: 12, stand: 'R', split_key: 'vs_rhp', recent_window: 'season', weighted_mode: 'weighted', xwoba: 0.380, swstr_pct: 0.09, barrel_bbe_pct: 0.16, barrel_bip_pct: 0.14, pulled_barrel_pct: 0.11, sweet_spot_pct: 0.38, fb_pct: 0.28, hard_hit_pct: 0.47, avg_launch_angle: 19 },
282
+ { team: 'NYY', hitter_name: '13', batter: 13, stand: 'L', split_key: 'vs_lhp', recent_window: 'season', weighted_mode: 'weighted', xwoba: 0.360, swstr_pct: 0.10, barrel_bbe_pct: 0.14, barrel_bip_pct: 0.12, pulled_barrel_pct: 0.08, sweet_spot_pct: 0.35, fb_pct: 0.25, hard_hit_pct: 0.43, avg_launch_angle: 17 },
283
+ { team: 'ATH', hitter_name: '14', batter: 14, stand: 'L', split_key: 'vs_rhp', recent_window: 'season', weighted_mode: 'weighted', xwoba: 0.340, swstr_pct: 0.11, barrel_bbe_pct: 0.12, barrel_bip_pct: 0.10, pulled_barrel_pct: 0.07, sweet_spot_pct: 0.32, fb_pct: 0.23, hard_hit_pct: 0.40, avg_launch_angle: 15 },
284
+ { team: 'BOS', hitter_name: '21', batter: 21, stand: 'R', split_key: 'vs_rhp', recent_window: 'season', weighted_mode: 'weighted', xwoba: 0.405, swstr_pct: 0.08, barrel_bbe_pct: 0.19, barrel_bip_pct: 0.17, pulled_barrel_pct: 0.12, sweet_spot_pct: 0.39, fb_pct: 0.29, hard_hit_pct: 0.49, avg_launch_angle: 20 },
285
+ { team: 'BAL', hitter_name: '22', batter: 22, stand: 'R', split_key: 'vs_rhp', recent_window: 'season', weighted_mode: 'weighted', xwoba: 0.370, swstr_pct: 0.09, barrel_bbe_pct: 0.15, barrel_bip_pct: 0.13, pulled_barrel_pct: 0.09, sweet_spot_pct: 0.36, fb_pct: 0.27, hard_hit_pct: 0.45, avg_launch_angle: 18 },
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' },
292
+ { team: 'ATH', player_id: 12, player_name: 'Game One B' },
293
+ { team: 'NYY', player_id: 13, player_name: 'Game One C' },
294
+ { team: 'ATH', player_id: 14, player_name: 'Game One D' },
295
+ { team: 'BOS', player_id: 21, player_name: 'Game Two A' },
296
+ { team: 'BAL', player_id: 22, player_name: 'Game Two B' },
297
+ { team: 'BOS', player_id: 23, player_name: 'Game Two C' },
298
+ { team: 'BAL', player_id: 24, player_name: 'Game Two D' },
299
+ ]],
300
+ ['https://example.test/daily/2026-04-07/daily_batter_zone_profiles.parquet', []],
301
+ ['https://example.test/daily/2026-04-07/daily_pitcher_zone_profiles.parquet', []],
302
+ ]);
303
+
304
+ const source = new HostedArtifactSource(
305
  {
306
+ baseUrl: 'https://example.test',
307
+ cacheTtlMs: 60_000,
308
+ fallbackDays: 0,
309
+ },
310
+ {
311
+ readParquetImpl: async (url) => {
312
+ if (!responses.has(url)) {
313
+ throw new Error(`Missing ${url}`);
314
+ }
315
+ return responses.get(url);
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
316
  },
317
+ logger: { debug() {}, warn() {} },
318
  }
319
  );
320
 
321
+ const result = await source.getBestMatchups({ date: '2026-04-07' });
322
 
323
  assert.equal(result.rows.length, 6);
324
  assert.deepEqual(