bbdata-cli 0.1.0 → 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.
- package/dist/bin/bbdata.js +128 -16
- package/dist/bin/bbdata.js.map +1 -1
- package/dist/src/index.d.ts +3 -1
- package/dist/src/index.js +122 -12
- package/dist/src/index.js.map +1 -1
- package/dist/templates/queries/hitter-batted-ball.ts +66 -0
- package/dist/templates/queries/hitter-hot-cold-zones.ts +81 -0
- package/dist/templates/queries/hitter-vs-pitch-type.ts +78 -0
- package/dist/templates/queries/index.ts +24 -0
- package/dist/templates/queries/leaderboard-comparison.ts +72 -0
- package/dist/templates/queries/leaderboard-custom.ts +90 -0
- package/dist/templates/queries/matchup-pitcher-vs-hitter.ts +81 -0
- package/dist/templates/queries/matchup-situational.ts +68 -0
- package/dist/templates/queries/pitcher-arsenal.ts +89 -0
- package/dist/templates/queries/pitcher-handedness-splits.ts +81 -0
- package/dist/templates/queries/pitcher-velocity-trend.ts +73 -0
- package/dist/templates/queries/registry.ts +73 -0
- package/dist/templates/queries/trend-rolling-average.ts +86 -0
- package/dist/templates/queries/trend-year-over-year.ts +73 -0
- package/dist/templates/reports/advance-lineup.hbs +29 -0
- package/dist/templates/reports/advance-sp.hbs +60 -0
- package/dist/templates/reports/college-hitter-draft.hbs +49 -0
- package/dist/templates/reports/college-pitcher-draft.hbs +48 -0
- package/dist/templates/reports/dev-progress.hbs +29 -0
- package/dist/templates/reports/draft-board-card.hbs +35 -0
- package/dist/templates/reports/hs-prospect.hbs +48 -0
- package/dist/templates/reports/partials/footer.hbs +7 -0
- package/dist/templates/reports/partials/header.hbs +12 -0
- package/dist/templates/reports/post-promotion.hbs +25 -0
- package/dist/templates/reports/pro-hitter-eval.hbs +65 -0
- package/dist/templates/reports/pro-pitcher-eval.hbs +69 -0
- package/dist/templates/reports/registry.ts +215 -0
- package/dist/templates/reports/relief-pitcher-quick.hbs +29 -0
- package/dist/templates/reports/trade-target-onepager.hbs +45 -0
- 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);
|