๐Ÿ”ต Movement Profile (pitcher POV)
๐Ÿ“Š Pitch Usage & Shape
๐Ÿ“‹ Arsenal Summary Table
๐ŸŸ  Aggregate Scores
Per-Pitch Stuff+ / Location+ / Pitching+
๐Ÿ”ต Pitch Movement Profile
๐ŸŸข Exit Velo vs. Launch Angle (Batted Ball Profile)
๐Ÿ“Š Quality Zones
๐Ÿ“‹ Batted Ball Summary
๐ŸŸก Swing Heat Map (all pitches)
๐ŸŸก Whiff Map (swings only)
๐Ÿ“‹ Plate Discipline Metrics
๐Ÿ“Š Composite Pitcher Score (CPS) โ€” Weight Calibration
Adjust weights (must sum to 100%)


                  
                
๐Ÿ“Š Composite Hitter Score (CHS) โ€” Weight Calibration
Adjust weights (must sum to 100%)


                  
                
MLB Leaderboard

Data Sources

Baseball Savant (Statcast)

FanGraphs (Stuff+ / Location+ / Pitching+)

  • R function: baseballr::fg_pitcher_leaders(type=36) โ€” type 36 = Stuff+ leaderboard
  • Fields: Stuff+, Location+, Pitching+, CSW%, Whiff%, xFIP
  • Note: Requires FanGraphs leaderboard access; no API key needed for public data

PitchingNinja Grades

  • Source: Manual input from @PitchingNinja (Rob Friedman) public grades
  • Integration: Enter in yellow cells in ๐ŸŸ  STUFF_PLUS sheet or Shiny grade form

Chadwick Bureau Player ID Crosswalk

  • R function: baseballr::playerid_lookup() โ€” resolves name โ†’ MLBAM + FG IDs
  • Required: To join Statcast (MLBAM IDs) with FanGraphs (FG IDs)

Stuff+ / Location+ / Pitching+ Primer

What is Stuff+?

Stuff+ quantifies pitch nastiness independent of location and outcomes. A score of 100 = MLB average. 120 = very good. 140+ = elite.

Inputs (FanGraphs Model โ€” Eno Sarris, 2023+)

  • Release velocity
  • Induced Vertical Break (iVB = pfx_z ร— 12, pitcher perspective)
  • Horizontal Break (HB = pfx_x ร— 12)
  • Spin rate (RPM)
  • Spin axis
  • Release extension
  • Velocity differential vs. primary fastball
  • iVB differential vs. primary fastball

Location+

Measures how well a pitcher places their pitches โ€” independent of stuff quality. 100 = average. > 100 = above-average command.

Pitching+

Combined model: Pitching+ โ‰ˆ f(Stuff+, Location+). Best single-number pitcher quality metric.

PitchingNinja Integration

Rob Friedmanโ€™s grades are qualitative but highly predictive of future whiff rates. Scale: 0โ€“100. Elite โ‰ฅ 90. Input manually in the Shiny app grade form.

R Data Pull

library(baseballr)
# Stuff+ leaderboard (type=36)
stuff_df <- fg_pitcher_leaders(startseason=2024, endseason=2024, type=36, qual=0)
head(stuff_df[, c('PlayerName','Team','IP','Stuff+','Location+','Pitching+')])

Model Methodology

Composite Pitcher Score (CPS)

Weighted combination of 9 metrics, each normalized to 0โ€“100 via MLB percentile rank.

Metric Default Weight Direction
CSW% 20% Higher โ†‘
Whiff% 15% Higher โ†‘
xBA Against 15% Lower โ†“
Avg Velocity 10% Higher โ†‘
Extension 5% Higher โ†‘
Spin Rate 10% Higher โ†‘
Stuff+ (FG) 15% Higher โ†‘
Location+ (FG) 5% Higher โ†‘
PN Score 5% Higher โ†‘

Composite Hitter Score (CHS)

Metric Default Weight Direction
xwOBA 35% Higher โ†‘
Barrel% 20% Higher โ†‘
Avg Exit Velo 15% Higher โ†‘
Hard Hit% 15% Higher โ†‘
Discipline 10% Higher โ†‘
Sprint Speed 5% Higher โ†‘

Validation

CPS is benchmarked against FIP-, xFIP-, SIERA. CHS is benchmarked against wRC+, OPS+, DRC+.

R Code Snippets

Load a pitcherโ€™s Statcast data

library(baseballr)
# Look up player ID
gerrit <- playerid_lookup('Cole', 'Gerrit')
mlbam  <- gerrit$key_mlbam[1]

# Pull pitch-level Statcast data
sc <- statcast_search_pitchers(
  start_date = '2024-03-01',
  end_date   = '2024-09-30',
  pitcherid  = mlbam
)

Calculate Stuff+ inputs

library(tidyverse)
arsenal <- sc |>
  filter(!is.na(pitch_type)) |>
  group_by(pitch_type) |>
  summarise(
    n        = n(),
    usage    = n() / nrow(sc),
    avg_velo = mean(release_speed, na.rm=TRUE),
    avg_spin = mean(release_spin_rate, na.rm=TRUE),
    ivb      = mean(pfx_z * 12, na.rm=TRUE),   # inches, pitcher POV
    hb       = mean(pfx_x * 12, na.rm=TRUE),   # inches, pitcher POV
    avg_ext  = mean(release_extension, na.rm=TRUE),
    whiff_pct = sum(description %in% c('swinging_strike','swinging_strike_blocked')) /
                sum(description %in% c('swinging_strike','swinging_strike_blocked',
                                       'foul','hit_into_play')),
    csw_pct  = sum(description %in% c('called_strike','swinging_strike',
                                      'swinging_strike_blocked')) / n()
  )

Pull FanGraphs Stuff+ (type=36)

stuff_lb <- fg_pitcher_leaders(
  startseason = 2024, endseason = 2024,
  type = 36,   # <-- key: Stuff+ / Location+ / Pitching+ tab
  qual = 20    # min 20 IP
)
stuff_lb |> select(PlayerName, Team, IP, `Stuff+`, `Location+`, `Pitching+`) |> head()

Run CPS model

source('R/utils_calc.R')
weights <- readRDS('data/weights_default.rds')

stuff_data <- stuff_lb |> filter(playerid == gerrit$key_fangraphs[1])
cps <- calc_cps(sc, stuff_data, weights$cps)
cat('CPS:', cps$cps_raw, '|', cps$grade)