bbdata-cli 0.1.1 → 0.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (41) hide show
  1. package/dist/bin/bbdata.js +1044 -36
  2. package/dist/bin/bbdata.js.map +1 -1
  3. package/dist/src/index.d.ts +55 -2
  4. package/dist/src/index.js +978 -33
  5. package/dist/src/index.js.map +1 -1
  6. package/dist/templates/queries/hitter-batted-ball.ts +66 -0
  7. package/dist/templates/queries/hitter-hot-cold-zones.ts +81 -0
  8. package/dist/templates/queries/hitter-raw-bip.ts +65 -0
  9. package/dist/templates/queries/hitter-vs-pitch-type.ts +78 -0
  10. package/dist/templates/queries/hitter-zone-grid.ts +90 -0
  11. package/dist/templates/queries/index.ts +27 -0
  12. package/dist/templates/queries/leaderboard-comparison.ts +72 -0
  13. package/dist/templates/queries/leaderboard-custom.ts +90 -0
  14. package/dist/templates/queries/matchup-pitcher-vs-hitter.ts +81 -0
  15. package/dist/templates/queries/matchup-situational.ts +68 -0
  16. package/dist/templates/queries/pitcher-arsenal.ts +89 -0
  17. package/dist/templates/queries/pitcher-handedness-splits.ts +81 -0
  18. package/dist/templates/queries/pitcher-raw-pitches.ts +62 -0
  19. package/dist/templates/queries/pitcher-velocity-trend.ts +73 -0
  20. package/dist/templates/queries/registry.ts +73 -0
  21. package/dist/templates/queries/trend-rolling-average.ts +98 -0
  22. package/dist/templates/queries/trend-year-over-year.ts +73 -0
  23. package/dist/templates/reports/advance-lineup.hbs +29 -0
  24. package/dist/templates/reports/advance-sp.hbs +66 -0
  25. package/dist/templates/reports/college-hitter-draft.hbs +49 -0
  26. package/dist/templates/reports/college-pitcher-draft.hbs +48 -0
  27. package/dist/templates/reports/dev-progress.hbs +29 -0
  28. package/dist/templates/reports/draft-board-card.hbs +35 -0
  29. package/dist/templates/reports/hs-prospect.hbs +48 -0
  30. package/dist/templates/reports/partials/footer.hbs +7 -0
  31. package/dist/templates/reports/partials/header.hbs +12 -0
  32. package/dist/templates/reports/post-promotion.hbs +25 -0
  33. package/dist/templates/reports/pro-hitter-eval.hbs +77 -0
  34. package/dist/templates/reports/pro-pitcher-eval.hbs +81 -0
  35. package/dist/templates/reports/registry.ts +215 -0
  36. package/dist/templates/reports/relief-pitcher-quick.hbs +29 -0
  37. package/dist/templates/reports/trade-target-onepager.hbs +45 -0
  38. package/package.json +68 -63
  39. package/src/templates/reports/advance-sp.hbs +66 -60
  40. package/src/templates/reports/pro-hitter-eval.hbs +77 -65
  41. package/src/templates/reports/pro-pitcher-eval.hbs +81 -69
@@ -0,0 +1,66 @@
1
+ import { registerTemplate, type QueryTemplate } from './registry.js';
2
+ import type { PitchData } from '../../adapters/types.js';
3
+
4
+ const template: QueryTemplate = {
5
+ id: 'hitter-batted-ball',
6
+ name: 'Hitter Batted Ball Profile',
7
+ category: 'hitter',
8
+ description: 'Exit velocity, launch angle, hard hit rate, barrel rate, batted ball distribution',
9
+ preferredSources: ['savant', 'fangraphs'],
10
+ requiredParams: ['player'],
11
+ optionalParams: ['season'],
12
+ examples: [
13
+ 'bbdata query hitter-batted-ball --player "Aaron Judge" --season 2025',
14
+ 'bbdata query hitter-batted-ball --player "Juan Soto"',
15
+ ],
16
+
17
+ buildQuery(params) {
18
+ return {
19
+ player_name: params.player,
20
+ season: params.season ?? new Date().getFullYear(),
21
+ stat_type: 'batting',
22
+ };
23
+ },
24
+
25
+ columns() {
26
+ return ['Metric', 'Value'];
27
+ },
28
+
29
+ transform(data) {
30
+ const pitches = data as PitchData[];
31
+ const batted = pitches.filter((p) => p.launch_speed !== null && p.launch_speed > 0);
32
+
33
+ if (batted.length === 0) return [];
34
+
35
+ const evs = batted.map((p) => p.launch_speed!);
36
+ const las = batted.map((p) => p.launch_angle!);
37
+ const avgEv = evs.reduce((s, v) => s + v, 0) / evs.length;
38
+ const avgLa = las.reduce((s, v) => s + v, 0) / las.length;
39
+
40
+ const hardHit = batted.filter((p) => p.launch_speed! >= 95).length;
41
+ // Barrel = EV >= 98 mph and LA between 26-30 (simplified)
42
+ const barrels = batted.filter(
43
+ (p) => p.launch_speed! >= 98 && p.launch_angle! >= 26 && p.launch_angle! <= 30,
44
+ ).length;
45
+
46
+ const linedrives = batted.filter((p) => p.bb_type === 'line_drive').length;
47
+ const flyballs = batted.filter((p) => p.bb_type === 'fly_ball').length;
48
+ const groundballs = batted.filter((p) => p.bb_type === 'ground_ball').length;
49
+ const popups = batted.filter((p) => p.bb_type === 'popup').length;
50
+
51
+ return [
52
+ { Metric: 'Batted Balls', Value: batted.length },
53
+ { Metric: 'Avg Exit Velocity', Value: avgEv.toFixed(1) + ' mph' },
54
+ { Metric: 'Max Exit Velocity', Value: Math.max(...evs).toFixed(1) + ' mph' },
55
+ { Metric: 'Avg Launch Angle', Value: avgLa.toFixed(1) + '°' },
56
+ { Metric: 'Hard Hit Rate (95+ mph)', Value: ((hardHit / batted.length) * 100).toFixed(1) + '%' },
57
+ { Metric: 'Barrel Rate', Value: ((barrels / batted.length) * 100).toFixed(1) + '%' },
58
+ { Metric: 'Line Drive %', Value: ((linedrives / batted.length) * 100).toFixed(1) + '%' },
59
+ { Metric: 'Fly Ball %', Value: ((flyballs / batted.length) * 100).toFixed(1) + '%' },
60
+ { Metric: 'Ground Ball %', Value: ((groundballs / batted.length) * 100).toFixed(1) + '%' },
61
+ { Metric: 'Popup %', Value: ((popups / batted.length) * 100).toFixed(1) + '%' },
62
+ ];
63
+ },
64
+ };
65
+
66
+ registerTemplate(template);
@@ -0,0 +1,81 @@
1
+ import { registerTemplate, type QueryTemplate } from './registry.js';
2
+ import type { PitchData } from '../../adapters/types.js';
3
+
4
+ // 3x3 strike zone grid
5
+ const ZONES = {
6
+ 'High-In': { xMin: -0.83, xMax: -0.28, zMin: 2.83, zMax: 3.5 },
7
+ 'High-Mid': { xMin: -0.28, xMax: 0.28, zMin: 2.83, zMax: 3.5 },
8
+ 'High-Out': { xMin: 0.28, xMax: 0.83, zMin: 2.83, zMax: 3.5 },
9
+ 'Mid-In': { xMin: -0.83, xMax: -0.28, zMin: 2.17, zMax: 2.83 },
10
+ 'Mid-Mid': { xMin: -0.28, xMax: 0.28, zMin: 2.17, zMax: 2.83 },
11
+ 'Mid-Out': { xMin: 0.28, xMax: 0.83, zMin: 2.17, zMax: 2.83 },
12
+ 'Low-In': { xMin: -0.83, xMax: -0.28, zMin: 1.5, zMax: 2.17 },
13
+ 'Low-Mid': { xMin: -0.28, xMax: 0.28, zMin: 1.5, zMax: 2.17 },
14
+ 'Low-Out': { xMin: 0.28, xMax: 0.83, zMin: 1.5, zMax: 2.17 },
15
+ } as const;
16
+
17
+ const template: QueryTemplate = {
18
+ id: 'hitter-hot-cold-zones',
19
+ name: 'Hitter Hot/Cold Zones',
20
+ category: 'hitter',
21
+ description: '3x3 strike zone grid with BA, SLG, whiff rate, and pitch count per zone',
22
+ preferredSources: ['savant'],
23
+ requiredParams: ['player'],
24
+ optionalParams: ['season'],
25
+ examples: [
26
+ 'bbdata query hitter-hot-cold-zones --player "Shohei Ohtani" --season 2025',
27
+ ],
28
+
29
+ buildQuery(params) {
30
+ return {
31
+ player_name: params.player,
32
+ season: params.season ?? new Date().getFullYear(),
33
+ stat_type: 'batting',
34
+ };
35
+ },
36
+
37
+ columns() {
38
+ return ['Zone', 'Pitches', 'Swings', 'Whiff %', 'AVG', 'SLG'];
39
+ },
40
+
41
+ transform(data) {
42
+ const pitches = data as PitchData[];
43
+ if (pitches.length === 0) return [];
44
+
45
+ return Object.entries(ZONES).map(([zoneName, bounds]) => {
46
+ const inZone = pitches.filter(
47
+ (p) =>
48
+ p.plate_x >= bounds.xMin && p.plate_x < bounds.xMax &&
49
+ p.plate_z >= bounds.zMin && p.plate_z < bounds.zMax,
50
+ );
51
+
52
+ const swings = inZone.filter((p) =>
53
+ p.description.includes('swing') || p.description.includes('foul') || p.description.includes('hit_into_play'),
54
+ );
55
+ const whiffs = inZone.filter((p) => p.description.includes('swinging_strike'));
56
+
57
+ const abs = inZone.filter((p) => p.events && !['walk', 'hit_by_pitch'].includes(p.events));
58
+ const hits = abs.filter((p) =>
59
+ ['single', 'double', 'triple', 'home_run'].includes(p.events ?? ''),
60
+ );
61
+ const totalBases = abs.reduce((sum, p) => {
62
+ if (p.events === 'single') return sum + 1;
63
+ if (p.events === 'double') return sum + 2;
64
+ if (p.events === 'triple') return sum + 3;
65
+ if (p.events === 'home_run') return sum + 4;
66
+ return sum;
67
+ }, 0);
68
+
69
+ return {
70
+ Zone: zoneName,
71
+ Pitches: inZone.length,
72
+ Swings: swings.length,
73
+ 'Whiff %': swings.length > 0 ? ((whiffs.length / swings.length) * 100).toFixed(1) + '%' : '—',
74
+ AVG: abs.length > 0 ? (hits.length / abs.length).toFixed(3) : '—',
75
+ SLG: abs.length > 0 ? (totalBases / abs.length).toFixed(3) : '—',
76
+ };
77
+ });
78
+ },
79
+ };
80
+
81
+ registerTemplate(template);
@@ -0,0 +1,65 @@
1
+ import { registerTemplate, type QueryTemplate } from './registry.js';
2
+ import type { PitchData } from '../../adapters/types.js';
3
+
4
+ /**
5
+ * Raw batted-ball projection — one row per ball in play with hit coordinates.
6
+ * Powers the spray chart. Filters to pitches where launch_speed is recorded
7
+ * (i.e., the ball was actually put in play).
8
+ */
9
+ const template: QueryTemplate = {
10
+ id: 'hitter-raw-bip',
11
+ name: 'Hitter Raw Batted Balls',
12
+ category: 'hitter',
13
+ description: 'One row per batted ball with hit coordinates, exit velo, and launch angle',
14
+ preferredSources: ['savant'],
15
+ requiredParams: ['player'],
16
+ optionalParams: ['season'],
17
+ examples: [
18
+ 'bbdata query hitter-raw-bip --player "Aaron Judge" --season 2025 --format json',
19
+ ],
20
+
21
+ buildQuery(params) {
22
+ return {
23
+ player_name: params.player,
24
+ season: params.season ?? new Date().getFullYear(),
25
+ stat_type: 'batting',
26
+ };
27
+ },
28
+
29
+ columns() {
30
+ return [
31
+ 'hc_x',
32
+ 'hc_y',
33
+ 'launch_speed',
34
+ 'launch_angle',
35
+ 'events',
36
+ 'bb_type',
37
+ 'game_date',
38
+ ];
39
+ },
40
+
41
+ transform(data) {
42
+ const pitches = data as PitchData[];
43
+ if (pitches.length === 0) return [];
44
+
45
+ return pitches
46
+ .filter(
47
+ (p) =>
48
+ p.launch_speed != null &&
49
+ p.launch_speed > 0 &&
50
+ p.hc_x != null &&
51
+ p.hc_y != null,
52
+ )
53
+ .map((p) => ({
54
+ hc_x: p.hc_x,
55
+ hc_y: p.hc_y,
56
+ launch_speed: p.launch_speed,
57
+ launch_angle: p.launch_angle,
58
+ events: p.events ?? 'unknown',
59
+ bb_type: p.bb_type ?? 'unknown',
60
+ game_date: p.game_date,
61
+ }));
62
+ },
63
+ };
64
+
65
+ registerTemplate(template);
@@ -0,0 +1,78 @@
1
+ import { registerTemplate, type QueryTemplate } from './registry.js';
2
+ import type { PitchData } from '../../adapters/types.js';
3
+ import { pitchTypeName } from '../../adapters/types.js';
4
+
5
+ const template: QueryTemplate = {
6
+ id: 'hitter-vs-pitch-type',
7
+ name: 'Hitter vs Pitch Type',
8
+ category: 'hitter',
9
+ description: 'Swing rate, whiff rate, exit velocity, and outcomes by pitch type faced',
10
+ preferredSources: ['savant'],
11
+ requiredParams: ['player'],
12
+ optionalParams: ['season'],
13
+ examples: [
14
+ 'bbdata query hitter-vs-pitch-type --player "Mookie Betts" --season 2025',
15
+ ],
16
+
17
+ buildQuery(params) {
18
+ return {
19
+ player_name: params.player,
20
+ season: params.season ?? new Date().getFullYear(),
21
+ stat_type: 'batting',
22
+ };
23
+ },
24
+
25
+ columns() {
26
+ return ['Pitch Type', 'Seen', 'Swing %', 'Whiff %', 'Foul %', 'In Play', 'Avg EV', 'SLG'];
27
+ },
28
+
29
+ transform(data) {
30
+ const pitches = data as PitchData[];
31
+ if (pitches.length === 0) return [];
32
+
33
+ const byType = new Map<string, PitchData[]>();
34
+ for (const p of pitches) {
35
+ if (!p.pitch_type) continue;
36
+ const group = byType.get(p.pitch_type) ?? [];
37
+ group.push(p);
38
+ byType.set(p.pitch_type, group);
39
+ }
40
+
41
+ return Array.from(byType.entries())
42
+ .map(([type, group]) => {
43
+ const swings = group.filter((p) =>
44
+ p.description.includes('swing') || p.description.includes('foul') || p.description.includes('hit_into_play'),
45
+ );
46
+ const whiffs = group.filter((p) => p.description.includes('swinging_strike'));
47
+ const fouls = group.filter((p) => p.description.includes('foul'));
48
+ const inPlay = group.filter((p) => p.description.includes('hit_into_play'));
49
+ const contacted = group.filter((p) => p.launch_speed !== null && p.launch_speed > 0);
50
+
51
+ // SLG on contact
52
+ const totalBases = group.reduce((sum, p) => {
53
+ if (p.events === 'single') return sum + 1;
54
+ if (p.events === 'double') return sum + 2;
55
+ if (p.events === 'triple') return sum + 3;
56
+ if (p.events === 'home_run') return sum + 4;
57
+ return sum;
58
+ }, 0);
59
+ const abs = group.filter((p) => p.events && !['walk', 'hit_by_pitch', 'sac_fly', 'sac_bunt'].includes(p.events)).length;
60
+
61
+ return {
62
+ 'Pitch Type': pitchTypeName(type),
63
+ Seen: group.length,
64
+ 'Swing %': ((swings.length / group.length) * 100).toFixed(1) + '%',
65
+ 'Whiff %': swings.length > 0 ? ((whiffs.length / swings.length) * 100).toFixed(1) + '%' : '—',
66
+ 'Foul %': swings.length > 0 ? ((fouls.length / swings.length) * 100).toFixed(1) + '%' : '—',
67
+ 'In Play': inPlay.length,
68
+ 'Avg EV': contacted.length > 0
69
+ ? (contacted.reduce((s, p) => s + p.launch_speed!, 0) / contacted.length).toFixed(1) + ' mph'
70
+ : '—',
71
+ SLG: abs > 0 ? (totalBases / abs).toFixed(3) : '—',
72
+ };
73
+ })
74
+ .sort((a, b) => (b.Seen as number) - (a.Seen as number));
75
+ },
76
+ };
77
+
78
+ registerTemplate(template);
@@ -0,0 +1,90 @@
1
+ import { registerTemplate, type QueryTemplate } from './registry.js';
2
+ import type { PitchData } from '../../adapters/types.js';
3
+
4
+ // 3x3 strike zone grid (copied from hitter-hot-cold-zones.ts)
5
+ // row: 0 = high, 2 = low. col: 0 = inside (catcher POV left), 2 = outside.
6
+ const ZONES: { name: string; row: number; col: number; xMin: number; xMax: number; zMin: number; zMax: number }[] = [
7
+ { name: 'High-In', row: 0, col: 0, xMin: -0.83, xMax: -0.28, zMin: 2.83, zMax: 3.5 },
8
+ { name: 'High-Mid', row: 0, col: 1, xMin: -0.28, xMax: 0.28, zMin: 2.83, zMax: 3.5 },
9
+ { name: 'High-Out', row: 0, col: 2, xMin: 0.28, xMax: 0.83, zMin: 2.83, zMax: 3.5 },
10
+ { name: 'Mid-In', row: 1, col: 0, xMin: -0.83, xMax: -0.28, zMin: 2.17, zMax: 2.83 },
11
+ { name: 'Mid-Mid', row: 1, col: 1, xMin: -0.28, xMax: 0.28, zMin: 2.17, zMax: 2.83 },
12
+ { name: 'Mid-Out', row: 1, col: 2, xMin: 0.28, xMax: 0.83, zMin: 2.17, zMax: 2.83 },
13
+ { name: 'Low-In', row: 2, col: 0, xMin: -0.83, xMax: -0.28, zMin: 1.5, zMax: 2.17 },
14
+ { name: 'Low-Mid', row: 2, col: 1, xMin: -0.28, xMax: 0.28, zMin: 1.5, zMax: 2.17 },
15
+ { name: 'Low-Out', row: 2, col: 2, xMin: 0.28, xMax: 0.83, zMin: 1.5, zMax: 2.17 },
16
+ ];
17
+
18
+ /**
19
+ * 3x3 zone grid with numeric row/col indices and averaged xwOBA.
20
+ * Powers the zone-profile heatmap visualization. Unlike hitter-hot-cold-zones
21
+ * which returns strings for human display, this returns numerics for charting.
22
+ */
23
+ const template: QueryTemplate = {
24
+ id: 'hitter-zone-grid',
25
+ name: 'Hitter Zone Grid (numeric)',
26
+ category: 'hitter',
27
+ description: '3x3 strike zone grid with numeric row/col/xwoba for heatmap visualization',
28
+ preferredSources: ['savant'],
29
+ requiredParams: ['player'],
30
+ optionalParams: ['season'],
31
+ examples: [
32
+ 'bbdata query hitter-zone-grid --player "Shohei Ohtani" --format json',
33
+ ],
34
+
35
+ buildQuery(params) {
36
+ return {
37
+ player_name: params.player,
38
+ season: params.season ?? new Date().getFullYear(),
39
+ stat_type: 'batting',
40
+ };
41
+ },
42
+
43
+ columns() {
44
+ return ['zone', 'row', 'col', 'pitches', 'xwoba'];
45
+ },
46
+
47
+ transform(data) {
48
+ const pitches = data as PitchData[];
49
+ if (pitches.length === 0) return [];
50
+
51
+ return ZONES.map((z) => {
52
+ const inZone = pitches.filter(
53
+ (p) =>
54
+ p.plate_x >= z.xMin && p.plate_x < z.xMax &&
55
+ p.plate_z >= z.zMin && p.plate_z < z.zMax,
56
+ );
57
+
58
+ // Compute actual xwOBA across all plate-appearance-ending events in
59
+ // this zone (not just balls in play). Walks and HBPs use their canonical
60
+ // wOBA weights; strikeouts contribute zero; batted balls use the
61
+ // Statcast expected wOBA for that event.
62
+ const paEnding = inZone.filter((p) => p.events != null);
63
+ let xwobaSum = 0;
64
+ for (const p of paEnding) {
65
+ if (p.events === 'walk') {
66
+ xwobaSum += 0.69;
67
+ } else if (p.events === 'hit_by_pitch') {
68
+ xwobaSum += 0.72;
69
+ } else if (p.events === 'strikeout') {
70
+ xwobaSum += 0;
71
+ } else {
72
+ // Batted ball: use Statcast estimated wOBA for the contact event
73
+ xwobaSum += p.estimated_woba ?? 0;
74
+ }
75
+ }
76
+ const xwoba = paEnding.length > 0 ? xwobaSum / paEnding.length : 0;
77
+
78
+ return {
79
+ zone: z.name,
80
+ row: z.row,
81
+ col: z.col,
82
+ pitches: inZone.length,
83
+ pa: paEnding.length,
84
+ xwoba: Number(xwoba.toFixed(3)),
85
+ };
86
+ });
87
+ },
88
+ };
89
+
90
+ registerTemplate(template);
@@ -0,0 +1,27 @@
1
+ // Import all templates to trigger registration
2
+ import './pitcher-arsenal.js';
3
+ import './pitcher-velocity-trend.js';
4
+ import './pitcher-handedness-splits.js';
5
+ import './hitter-batted-ball.js';
6
+ import './hitter-vs-pitch-type.js';
7
+ import './hitter-hot-cold-zones.js';
8
+ import './matchup-pitcher-vs-hitter.js';
9
+ import './matchup-situational.js';
10
+ import './leaderboard-custom.js';
11
+ import './leaderboard-comparison.js';
12
+ import './trend-rolling-average.js';
13
+ import './trend-year-over-year.js';
14
+ import './pitcher-raw-pitches.js';
15
+ import './hitter-raw-bip.js';
16
+ import './hitter-zone-grid.js';
17
+
18
+ export {
19
+ getTemplate,
20
+ getAllTemplates,
21
+ getTemplatesByCategory,
22
+ listTemplates,
23
+ type QueryTemplate,
24
+ type QueryTemplateParams,
25
+ type QueryResult,
26
+ type QueryCategory,
27
+ } from './registry.js';
@@ -0,0 +1,72 @@
1
+ import { registerTemplate, type QueryTemplate } from './registry.js';
2
+ import type { PlayerStats } from '../../adapters/types.js';
3
+
4
+ const template: QueryTemplate = {
5
+ id: 'leaderboard-comparison',
6
+ name: 'Player Comparison',
7
+ category: 'leaderboard',
8
+ description: 'Side-by-side comparison of multiple players across key metrics vs league average',
9
+ preferredSources: ['fangraphs', 'mlb-stats-api'],
10
+ requiredParams: ['players'],
11
+ optionalParams: ['season'],
12
+ examples: [
13
+ 'bbdata query leaderboard-comparison --players "Aaron Judge,Juan Soto,Mookie Betts"',
14
+ ],
15
+
16
+ buildQuery(params) {
17
+ return {
18
+ season: params.season ?? new Date().getFullYear(),
19
+ stat_type: 'batting',
20
+ };
21
+ },
22
+
23
+ columns(params) {
24
+ return ['Metric', ...(params.players ?? [])];
25
+ },
26
+
27
+ transform(data, params) {
28
+ const allStats = data as PlayerStats[];
29
+ const playerNames = params.players ?? [];
30
+
31
+ // Match players by name (case-insensitive partial match)
32
+ const matched = playerNames.map((name) => {
33
+ const norm = name.toLowerCase();
34
+ return allStats.find((s) => s.player_name.toLowerCase().includes(norm));
35
+ });
36
+
37
+ // Key batting metrics to compare
38
+ const metrics = [
39
+ 'AVG', 'OBP', 'SLG', 'OPS', 'wRC+', 'WAR', 'HR', 'RBI',
40
+ 'K%', 'BB%', 'ISO', 'BABIP',
41
+ ];
42
+
43
+ return metrics.map((metric) => {
44
+ const row: Record<string, unknown> = { Metric: metric };
45
+ for (let i = 0; i < playerNames.length; i++) {
46
+ const player = matched[i];
47
+ if (!player) {
48
+ row[playerNames[i]] = '—';
49
+ continue;
50
+ }
51
+ const val = findStatValue(player.stats, metric);
52
+ row[playerNames[i]] = val ?? '—';
53
+ }
54
+ return row;
55
+ });
56
+ },
57
+ };
58
+
59
+ function findStatValue(stats: Record<string, unknown>, key: string): string | null {
60
+ const lower = key.toLowerCase().replace(/[+%]/g, '');
61
+ for (const [k, v] of Object.entries(stats)) {
62
+ if (k.toLowerCase().replace(/[+%]/g, '') === lower) {
63
+ if (v === null || v === undefined) return null;
64
+ const n = Number(v);
65
+ if (isNaN(n)) return String(v);
66
+ return n < 1 && n > 0 ? n.toFixed(3) : n % 1 === 0 ? String(n) : n.toFixed(1);
67
+ }
68
+ }
69
+ return null;
70
+ }
71
+
72
+ registerTemplate(template);
@@ -0,0 +1,90 @@
1
+ import { registerTemplate, type QueryTemplate } from './registry.js';
2
+ import type { PlayerStats } from '../../adapters/types.js';
3
+
4
+ const template: QueryTemplate = {
5
+ id: 'leaderboard-custom',
6
+ name: 'Custom Leaderboard',
7
+ category: 'leaderboard',
8
+ description: 'Top N players by any metric — with minimum qualification thresholds',
9
+ preferredSources: ['fangraphs', 'mlb-stats-api'],
10
+ requiredParams: ['stat'],
11
+ optionalParams: ['season', 'team', 'top', 'minPa', 'minIp'],
12
+ examples: [
13
+ 'bbdata query leaderboard-custom --stat barrel_rate --min-pa 200 --top 20',
14
+ 'bbdata query leaderboard-custom --stat ERA --min-ip 100 --top 10 --format table',
15
+ ],
16
+
17
+ buildQuery(params) {
18
+ const pitchingStats = ['era', 'fip', 'xfip', 'siera', 'whip', 'k/9', 'bb/9', 'hr/9', 'ip', 'w', 'sv', 'hld'];
19
+ const isPitching = pitchingStats.includes((params.stat ?? '').toLowerCase());
20
+ return {
21
+ season: params.season ?? new Date().getFullYear(),
22
+ team: params.team,
23
+ stat_type: isPitching ? 'pitching' : 'batting',
24
+ min_pa: params.minPa,
25
+ min_ip: params.minIp,
26
+ };
27
+ },
28
+
29
+ columns(params) {
30
+ return ['Rank', 'Player', 'Team', params.stat ?? 'Stat', 'PA/IP'];
31
+ },
32
+
33
+ transform(data, params) {
34
+ const stats = data as PlayerStats[];
35
+ if (stats.length === 0) return [];
36
+
37
+ const statKey = params.stat ?? '';
38
+ const top = params.top ?? 20;
39
+
40
+ // Find the stat in each player's stats object (case-insensitive search)
41
+ const withStat = stats
42
+ .map((player) => {
43
+ const value = findStat(player.stats, statKey);
44
+ return { player, value };
45
+ })
46
+ .filter((p) => p.value !== null)
47
+ .sort((a, b) => {
48
+ // ERA, FIP, etc. sort ascending; most others descending
49
+ const ascending = ['era', 'fip', 'xfip', 'siera', 'whip', 'bb%'].includes(
50
+ statKey.toLowerCase(),
51
+ );
52
+ return ascending
53
+ ? (a.value as number) - (b.value as number)
54
+ : (b.value as number) - (a.value as number);
55
+ })
56
+ .slice(0, top);
57
+
58
+ return withStat.map((entry, idx) => ({
59
+ Rank: idx + 1,
60
+ Player: entry.player.player_name,
61
+ Team: entry.player.team,
62
+ [statKey]: typeof entry.value === 'number'
63
+ ? entry.value < 1 && entry.value > 0
64
+ ? entry.value.toFixed(3)
65
+ : entry.value.toFixed(1)
66
+ : String(entry.value),
67
+ 'PA/IP': entry.player.stats.plateAppearances ?? entry.player.stats.PA
68
+ ?? entry.player.stats.inningsPitched ?? entry.player.stats.IP ?? '—',
69
+ }));
70
+ },
71
+ };
72
+
73
+ function findStat(stats: Record<string, unknown>, key: string): number | null {
74
+ // Direct match
75
+ if (key in stats) {
76
+ const val = Number(stats[key]);
77
+ return isNaN(val) ? null : val;
78
+ }
79
+ // Case-insensitive match
80
+ const lower = key.toLowerCase();
81
+ for (const [k, v] of Object.entries(stats)) {
82
+ if (k.toLowerCase() === lower || k.toLowerCase().replace(/[_%]/g, '') === lower.replace(/[_%]/g, '')) {
83
+ const val = Number(v);
84
+ return isNaN(val) ? null : val;
85
+ }
86
+ }
87
+ return null;
88
+ }
89
+
90
+ registerTemplate(template);
@@ -0,0 +1,81 @@
1
+ import { registerTemplate, type QueryTemplate } from './registry.js';
2
+ import type { PitchData } from '../../adapters/types.js';
3
+ import { pitchTypeName } from '../../adapters/types.js';
4
+
5
+ const template: QueryTemplate = {
6
+ id: 'matchup-pitcher-vs-hitter',
7
+ name: 'Pitcher vs Hitter Matchup',
8
+ category: 'matchup',
9
+ description: 'Career head-to-head history — PA, H, HR, BB, K, BA, SLG, most common pitches',
10
+ preferredSources: ['savant'],
11
+ requiredParams: ['players'], // expects [pitcher, hitter]
12
+ optionalParams: ['season'],
13
+ examples: [
14
+ 'bbdata query matchup-pitcher-vs-hitter --players "Gerrit Cole,Aaron Judge"',
15
+ ],
16
+
17
+ buildQuery(params) {
18
+ // Query for the pitcher's data — we'll filter for the specific batter in transform
19
+ return {
20
+ player_name: params.players?.[0],
21
+ season: params.season ?? new Date().getFullYear(),
22
+ stat_type: 'pitching',
23
+ };
24
+ },
25
+
26
+ columns() {
27
+ return ['Metric', 'Value'];
28
+ },
29
+
30
+ transform(data, params) {
31
+ const pitches = data as PitchData[];
32
+ const hitterName = (params.players?.[1] ?? '').toLowerCase();
33
+
34
+ // Filter to only PAs against the target hitter
35
+ const matchup = pitches.filter((p) =>
36
+ p.batter_name.toLowerCase().includes(hitterName),
37
+ );
38
+
39
+ if (matchup.length === 0) {
40
+ return [{ Metric: 'Note', Value: `No matchup data found for ${params.players?.[1] ?? 'hitter'}` }];
41
+ }
42
+
43
+ const pas = matchup.filter((p) => p.events !== null);
44
+ const hits = pas.filter((p) => ['single', 'double', 'triple', 'home_run'].includes(p.events ?? ''));
45
+ const hrs = pas.filter((p) => p.events === 'home_run');
46
+ const ks = pas.filter((p) => p.events === 'strikeout');
47
+ const bbs = pas.filter((p) => ['walk', 'hit_by_pitch'].includes(p.events ?? ''));
48
+ const totalBases = pas.reduce((sum, p) => {
49
+ if (p.events === 'single') return sum + 1;
50
+ if (p.events === 'double') return sum + 2;
51
+ if (p.events === 'triple') return sum + 3;
52
+ if (p.events === 'home_run') return sum + 4;
53
+ return sum;
54
+ }, 0);
55
+
56
+ // Most common pitch types
57
+ const pitchCounts = new Map<string, number>();
58
+ for (const p of matchup) {
59
+ pitchCounts.set(p.pitch_type, (pitchCounts.get(p.pitch_type) ?? 0) + 1);
60
+ }
61
+ const topPitches = Array.from(pitchCounts.entries())
62
+ .sort(([, a], [, b]) => b - a)
63
+ .slice(0, 3)
64
+ .map(([type, count]) => `${pitchTypeName(type)} (${count})`)
65
+ .join(', ');
66
+
67
+ return [
68
+ { Metric: 'Total Pitches', Value: matchup.length },
69
+ { Metric: 'Plate Appearances', Value: pas.length },
70
+ { Metric: 'Hits', Value: hits.length },
71
+ { Metric: 'Home Runs', Value: hrs.length },
72
+ { Metric: 'Strikeouts', Value: ks.length },
73
+ { Metric: 'Walks', Value: bbs.length },
74
+ { Metric: 'AVG', Value: pas.length > 0 ? (hits.length / pas.length).toFixed(3) : '—' },
75
+ { Metric: 'SLG', Value: pas.length > 0 ? (totalBases / pas.length).toFixed(3) : '—' },
76
+ { Metric: 'Most Common Pitches', Value: topPitches },
77
+ ];
78
+ },
79
+ };
80
+
81
+ registerTemplate(template);