bbdata-cli 0.1.1 → 0.2.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 (35) hide show
  1. package/dist/bin/bbdata.js +109 -6
  2. package/dist/bin/bbdata.js.map +1 -1
  3. package/dist/src/index.d.ts +3 -1
  4. package/dist/src/index.js +103 -2
  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-vs-pitch-type.ts +78 -0
  9. package/dist/templates/queries/index.ts +24 -0
  10. package/dist/templates/queries/leaderboard-comparison.ts +72 -0
  11. package/dist/templates/queries/leaderboard-custom.ts +90 -0
  12. package/dist/templates/queries/matchup-pitcher-vs-hitter.ts +81 -0
  13. package/dist/templates/queries/matchup-situational.ts +68 -0
  14. package/dist/templates/queries/pitcher-arsenal.ts +89 -0
  15. package/dist/templates/queries/pitcher-handedness-splits.ts +81 -0
  16. package/dist/templates/queries/pitcher-velocity-trend.ts +73 -0
  17. package/dist/templates/queries/registry.ts +73 -0
  18. package/dist/templates/queries/trend-rolling-average.ts +86 -0
  19. package/dist/templates/queries/trend-year-over-year.ts +73 -0
  20. package/dist/templates/reports/advance-lineup.hbs +29 -0
  21. package/dist/templates/reports/advance-sp.hbs +60 -0
  22. package/dist/templates/reports/college-hitter-draft.hbs +49 -0
  23. package/dist/templates/reports/college-pitcher-draft.hbs +48 -0
  24. package/dist/templates/reports/dev-progress.hbs +29 -0
  25. package/dist/templates/reports/draft-board-card.hbs +35 -0
  26. package/dist/templates/reports/hs-prospect.hbs +48 -0
  27. package/dist/templates/reports/partials/footer.hbs +7 -0
  28. package/dist/templates/reports/partials/header.hbs +12 -0
  29. package/dist/templates/reports/post-promotion.hbs +25 -0
  30. package/dist/templates/reports/pro-hitter-eval.hbs +65 -0
  31. package/dist/templates/reports/pro-pitcher-eval.hbs +69 -0
  32. package/dist/templates/reports/registry.ts +215 -0
  33. package/dist/templates/reports/relief-pitcher-quick.hbs +29 -0
  34. package/dist/templates/reports/trade-target-onepager.hbs +45 -0
  35. package/package.json +1 -1
@@ -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,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,24 @@
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
+
15
+ export {
16
+ getTemplate,
17
+ getAllTemplates,
18
+ getTemplatesByCategory,
19
+ listTemplates,
20
+ type QueryTemplate,
21
+ type QueryTemplateParams,
22
+ type QueryResult,
23
+ type QueryCategory,
24
+ } 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);
@@ -0,0 +1,68 @@
1
+ import { registerTemplate, type QueryTemplate } from './registry.js';
2
+ import type { PlayerStats } from '../../adapters/types.js';
3
+
4
+ const template: QueryTemplate = {
5
+ id: 'matchup-situational',
6
+ name: 'Situational Splits',
7
+ category: 'matchup',
8
+ description: 'Performance in key situations — RISP, high leverage, close & late, by inning',
9
+ preferredSources: ['fangraphs', 'mlb-stats-api'],
10
+ requiredParams: ['player'],
11
+ optionalParams: ['season'],
12
+ examples: [
13
+ 'bbdata query matchup-situational --player "Juan Soto" --season 2025',
14
+ ],
15
+
16
+ buildQuery(params) {
17
+ return {
18
+ player_name: params.player,
19
+ season: params.season ?? new Date().getFullYear(),
20
+ stat_type: 'batting',
21
+ };
22
+ },
23
+
24
+ columns() {
25
+ return ['Situation', 'PA', 'AVG', 'OBP', 'SLG', 'K %', 'BB %'];
26
+ },
27
+
28
+ transform(data) {
29
+ // Situational splits typically come pre-computed from FanGraphs
30
+ // With raw pitch data this would require game state context
31
+ // This template works best with FanGraphs/MLB API aggregated splits
32
+ const stats = data as PlayerStats[];
33
+
34
+ if (stats.length === 0) return [];
35
+
36
+ const player = stats[0];
37
+ const s = player.stats;
38
+
39
+ // Return whatever aggregated stats we have — format for display
40
+ return [
41
+ {
42
+ Situation: 'Overall',
43
+ PA: s.plateAppearances ?? s.PA ?? '—',
44
+ AVG: formatStat(s.avg ?? s.AVG),
45
+ OBP: formatStat(s.obp ?? s.OBP),
46
+ SLG: formatStat(s.slg ?? s.SLG),
47
+ 'K %': formatPct(s.strikeOuts ?? s.SO, s.plateAppearances ?? s.PA),
48
+ 'BB %': formatPct(s.baseOnBalls ?? s.BB, s.plateAppearances ?? s.PA),
49
+ },
50
+ ];
51
+ },
52
+ };
53
+
54
+ function formatStat(val: unknown): string {
55
+ if (val === null || val === undefined) return '—';
56
+ const n = Number(val);
57
+ if (isNaN(n)) return String(val);
58
+ return n < 1 ? n.toFixed(3) : n.toFixed(1);
59
+ }
60
+
61
+ function formatPct(num: unknown, denom: unknown): string {
62
+ const n = Number(num);
63
+ const d = Number(denom);
64
+ if (isNaN(n) || isNaN(d) || d === 0) return '—';
65
+ return ((n / d) * 100).toFixed(1) + '%';
66
+ }
67
+
68
+ registerTemplate(template);
@@ -0,0 +1,89 @@
1
+ import { registerTemplate, type QueryTemplate, type QueryTemplateParams } 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: 'pitcher-arsenal',
7
+ name: 'Pitcher Arsenal Profile',
8
+ category: 'pitcher',
9
+ description: 'Pitch usage rates, velocity, spin, movement, and whiff rates by pitch type',
10
+ preferredSources: ['savant', 'fangraphs', 'mlb-stats-api'],
11
+ requiredParams: ['player'],
12
+ optionalParams: ['season', 'pitchType'],
13
+ examples: [
14
+ 'bbdata query pitcher-arsenal --player "Corbin Burnes" --season 2025',
15
+ 'bbdata query pitcher-arsenal --player "Spencer Strider"',
16
+ ],
17
+
18
+ buildQuery(params) {
19
+ return {
20
+ player_name: params.player,
21
+ season: params.season ?? new Date().getFullYear(),
22
+ stat_type: 'pitching',
23
+ pitch_type: params.pitchType ? [params.pitchType] : undefined,
24
+ };
25
+ },
26
+
27
+ columns() {
28
+ return [
29
+ 'Pitch Type',
30
+ 'Usage %',
31
+ 'Avg Velo',
32
+ 'Avg Spin',
33
+ 'H Break',
34
+ 'V Break',
35
+ 'Whiff %',
36
+ 'Put Away %',
37
+ 'Pitches',
38
+ ];
39
+ },
40
+
41
+ transform(data, _params) {
42
+ const pitches = data as PitchData[];
43
+ if (pitches.length === 0) return [];
44
+
45
+ // Group by pitch type
46
+ const byType = new Map<string, PitchData[]>();
47
+ for (const pitch of pitches) {
48
+ if (!pitch.pitch_type) continue;
49
+ const group = byType.get(pitch.pitch_type) ?? [];
50
+ group.push(pitch);
51
+ byType.set(pitch.pitch_type, group);
52
+ }
53
+
54
+ const total = pitches.length;
55
+
56
+ return Array.from(byType.entries())
57
+ .map(([type, group]) => {
58
+ const count = group.length;
59
+ const swings = group.filter((p) =>
60
+ p.description.includes('swing') || p.description.includes('foul'),
61
+ );
62
+ const whiffs = group.filter((p) =>
63
+ p.description.includes('swinging_strike'),
64
+ );
65
+ const twoStrikes = group.filter((p) =>
66
+ p.description.includes('strikeout') || p.description.includes('swinging_strike'),
67
+ );
68
+
69
+ return {
70
+ 'Pitch Type': pitchTypeName(type),
71
+ 'Usage %': ((count / total) * 100).toFixed(1) + '%',
72
+ 'Avg Velo': (group.reduce((s, p) => s + p.release_speed, 0) / count).toFixed(1) + ' mph',
73
+ 'Avg Spin': Math.round(group.reduce((s, p) => s + p.release_spin_rate, 0) / count) + ' rpm',
74
+ 'H Break': (group.reduce((s, p) => s + p.pfx_x, 0) / count).toFixed(1) + ' in',
75
+ 'V Break': (group.reduce((s, p) => s + p.pfx_z, 0) / count).toFixed(1) + ' in',
76
+ 'Whiff %': swings.length > 0
77
+ ? ((whiffs.length / swings.length) * 100).toFixed(1) + '%'
78
+ : '—',
79
+ 'Put Away %': twoStrikes.length > 0
80
+ ? ((whiffs.length / count) * 100).toFixed(1) + '%'
81
+ : '—',
82
+ 'Pitches': count,
83
+ };
84
+ })
85
+ .sort((a, b) => (b.Pitches as number) - (a.Pitches as number));
86
+ },
87
+ };
88
+
89
+ registerTemplate(template);