First Half in Review: Hitting and Pitching Trends, Team Momentum, and the Reds
Baseball
Featured
Season Recap
How league-wide hitting and pitching have shifted since March, which teams are actually trending up or down heading into the second half, and a data-driven look at where Cincinnati’s offense and pitching staff really stand.
Published
July 13, 2026
Every team has now played most of its schedule, and with our Statcast pipeline holding a full first half of pitch-level data (March 25 through July 12), and having just covered the All-Star rosters themselves, it’s a good moment to ask three broader questions: how has hitting and pitching around the league actually changed since Opening Day, which teams are genuinely trending up or down heading into the second half, and how do our own team’s hitting and pitching numbers, the Cincinnati Reds, actually add up.
How the league has hit and pitched so far
from google.cloud import bigqueryimport pandas as pdclient = bigquery.Client()monthly_query ="""SELECT FORMAT_DATE('%Y-%m', CAST(game_date AS DATE)) AS month, ROUND(SUM(woba_value)/SUM(woba_denom), 3) AS lg_woba, ROUND(SAFE_DIVIDE(COUNTIF(events = 'strikeout'), COUNTIF(events IS NOT NULL)), 3) AS k_rate, ROUND(SAFE_DIVIDE(COUNTIF(events = 'home_run'), COUNTIF(events IS NOT NULL)), 4) AS hr_rate, ROUND(AVG(IF(pitch_type IN ('FF', 'SI'), release_speed, NULL)), 1) AS avg_fastball_velo, ROUND(SAFE_DIVIDE(COUNTIF(description IN ('swinging_strike', 'swinging_strike_blocked')), COUNTIF(description IS NOT NULL)), 3) AS whiff_rateFROM `maydaystats.mlb_statcast.pitches`WHERE game_type = 'R'GROUP BY monthORDER BY month"""monthly = client.query(monthly_query).to_dataframe()monthly_tbl = monthly.reset_index(drop=True)monthly_tbl.index +=1monthly_tbl
month
lg_woba
k_rate
hr_rate
avg_fastball_velo
whiff_rate
1
2026-03
0.317
0.244
0.0279
94.5
0.119
2
2026-04
0.328
0.216
0.0281
94.3
0.106
3
2026-05
0.320
0.218
0.0284
94.5
0.107
4
2026-06
0.334
0.222
0.0342
94.7
0.109
5
2026-07
0.332
0.221
0.0353
94.7
0.108
import matplotlib.pyplot as pltfig, ax = plt.subplots(figsize=(8, 5))ax.bar(monthly["month"], monthly["hr_rate"], color="#2c3e50")ax.set_xlabel("Month")ax.set_ylabel("Home runs per plate appearance")ax.set_title("League HR Rate by Month, 2026 First Half")ax.spines[["top", "right"]].set_visible(False)plt.tight_layout()plt.show()
Figure 1: League-wide home run rate per plate appearance, by month
A couple of these columns are worth defining before going further. wOBA (weighted on-base average) is the rate stat used throughout this post instead of batting average or OPS, because it assigns each outcome, a walk, a single, a double, a home run, and so on, its actual measured run value rather than treating every hit the same way or adding two differently-scaled numbers together. A .330 wOBA is roughly league average; anything north of .370 is excellent. Whiff rate is simply the share of pitches a hitter swings at and misses entirely, a proxy for how often pitchers are missing bats regardless of the final result.
With those defined, the league-wide numbers tell a fairly consistent story as the season has warmed up: home run rate has climbed from 2.8% of plate appearances in March to 3.5% in July, a jump of roughly a quarter, and wOBA has followed the same direction, up from .317 to .332. Strikeout rate has eased at the same time, down from 24.4% to 22.1%. The pitching side of the ledger hasn’t moved much to explain that: average fastball velocity has held steady around 94.5 mph all season, and whiff rate has barely budged (11.9% in March, 10.8% in July). Pitchers aren’t throwing softer or missing fewer bats; hitters are simply doing more damage on the contact they were already making. Two things typically get credited for that, and this data can’t cleanly separate them: the ball carries better in warmer summer air, and hitters are also just more locked in by June and July, with a few extra months of live at-bats to groove their timing against pitching they’ve now often seen once already. None of this is unique to this season, but it’s a useful reminder that stats collected in April and stats collected in July aren’t quite the same measurement, which matters for the next question: which individual hitters and pitchers are actually trending, separate from that league-wide drift.
movers_query ="""WITH pa AS ( SELECT batter, CAST(game_date AS DATE) AS game_date, woba_value, woba_denom FROM `maydaystats.mlb_statcast.pitches` WHERE game_type = 'R' AND woba_denom IS NOT NULL),max_date AS (SELECT MAX(game_date) AS d FROM pa),splits AS ( SELECT batter, SUM(IF(game_date < DATE_SUB((SELECT d FROM max_date), INTERVAL 45 DAY), woba_value, 0)) AS early_val, SUM(IF(game_date < DATE_SUB((SELECT d FROM max_date), INTERVAL 45 DAY), woba_denom, 0)) AS early_pa, SUM(IF(game_date >= DATE_SUB((SELECT d FROM max_date), INTERVAL 45 DAY), woba_value, 0)) AS recent_val, SUM(IF(game_date >= DATE_SUB((SELECT d FROM max_date), INTERVAL 45 DAY), woba_denom, 0)) AS recent_pa FROM pa GROUP BY batter)SELECT b.player_name, b.team_name, s.early_pa, ROUND(s.early_val / s.early_pa, 3) AS early_woba, s.recent_pa, ROUND(s.recent_val / s.recent_pa, 3) AS recent_woba, ROUND(s.recent_val / s.recent_pa - s.early_val / s.early_pa, 3) AS woba_deltaFROM splits sJOIN `maydaystats.mlb_season_stats.batting_latest` b ON b.player_id = s.batterWHERE s.early_pa >= 100 AND s.recent_pa >= 100ORDER BY woba_delta DESC"""movers = client.query(movers_query).to_dataframe()risers = movers.head(5).reset_index(drop=True)risers.index +=1fallers = movers.tail(5).sort_values("woba_delta").reset_index(drop=True)fallers.index +=1
Splitting each qualified hitter’s plate appearances into “before the last 45 days” and “the last 45 days” isolates real in-season swings from that seasonal drift. The clearest risers:
Table 2: Biggest wOBA decliners, early season vs. last 45 days (min. 100 PA in each window)
player_name
team_name
early_woba
recent_woba
woba_delta
1
Drake Baldwin
Atlanta Braves
0.409
0.225
-0.184
2
Luke Raley
Seattle Mariners
0.377
0.254
-0.123
3
Ildemaro Vargas
Arizona Diamondbacks
0.363
0.243
-0.120
4
Oswald Peraza
Los Angeles Angels
0.363
0.244
-0.119
5
Travis Bazzana
Cleveland Guardians
0.393
0.276
-0.117
The Cubs’ Pete Crow-Armstrong tops the risers, and two names on the decliner list are worth pausing on: Atlanta’s Drake Baldwin, the NL’s elected starting catcher, is the single biggest decliner in baseball over this stretch, and Cleveland’s Travis Bazzana, an AL player-elected pick, isn’t far off that pace either, both All-Stars in our earlier piece on the fan vote. That’s not an argument against either selection; the vote reflects the season as a whole, and both still have real first halves behind them. It’s just a sign of where each of them stands heading into the second half.
The same split works just as well from the pitcher’s side of the ball, using opponent wOBA (what a pitcher’s batters have actually done against him) instead of a pitcher’s own wOBA. Starters and relievers are shown separately below, since the two roles face lineups differently (starters work through a batting order multiple times, relievers usually face it once) and mixing them together makes both lists harder to read.
pitcher_movers_query ="""WITH pa AS ( SELECT pitcher, CAST(game_date AS DATE) AS game_date, woba_value, woba_denom FROM `maydaystats.mlb_statcast.pitches` WHERE game_type = 'R' AND woba_denom IS NOT NULL),max_date AS (SELECT MAX(game_date) AS d FROM pa),splits AS ( SELECT pitcher, SUM(IF(game_date < DATE_SUB((SELECT d FROM max_date), INTERVAL 45 DAY), woba_value, 0)) AS early_val, SUM(IF(game_date < DATE_SUB((SELECT d FROM max_date), INTERVAL 45 DAY), woba_denom, 0)) AS early_bf, SUM(IF(game_date >= DATE_SUB((SELECT d FROM max_date), INTERVAL 45 DAY), woba_value, 0)) AS recent_val, SUM(IF(game_date >= DATE_SUB((SELECT d FROM max_date), INTERVAL 45 DAY), woba_denom, 0)) AS recent_bf FROM pa GROUP BY pitcher)SELECT p.player_name, p.team_name, IF(SAFE_DIVIDE(p.gamesStarted, p.gamesPitched) >= 0.5, 'Starter', 'Reliever') AS role, s.early_bf, ROUND(s.early_val / s.early_bf, 3) AS early_woba_against, s.recent_bf, ROUND(s.recent_val / s.recent_bf, 3) AS recent_woba_against, ROUND(s.recent_val / s.recent_bf - s.early_val / s.early_bf, 3) AS woba_against_deltaFROM splits sJOIN `maydaystats.mlb_season_stats.pitching_latest` p ON p.player_id = s.pitcherWHERE s.early_bf >= 60 AND s.recent_bf >= 60ORDER BY woba_against_delta ASC"""pitcher_movers = client.query(pitcher_movers_query).to_dataframe()cols = ["player_name", "team_name", "early_woba_against", "recent_woba_against", "woba_against_delta"]starters = pitcher_movers[pitcher_movers["role"] =="Starter"].sort_values("woba_against_delta")starter_movers = pd.concat([starters.head(4), starters.tail(4)])[cols].reset_index(drop=True)starter_movers.index +=1relievers = pitcher_movers[pitcher_movers["role"] =="Reliever"].sort_values("woba_against_delta")reliever_movers = pd.concat([relievers.head(4), relievers.tail(4)])[cols].reset_index(drop=True)reliever_movers.index +=1
Starters, biggest opponent-wOBA improvers and decliners:
starter_movers
Table 3: Starters: biggest opponent-wOBA swings, early season vs. last 45 days (min. 60 batters faced in each window)
player_name
team_name
early_woba_against
recent_woba_against
woba_against_delta
1
Jack Flaherty
Detroit Tigers
0.384
0.267
-0.117
2
Brayan Bello
Boston Red Sox
0.396
0.280
-0.116
3
Trevor Rogers
Baltimore Orioles
0.382
0.269
-0.113
4
Eury Pérez
Miami Marlins
0.338
0.228
-0.110
5
Stephen Kolek
Kansas City Royals
0.249
0.400
0.151
6
Mitch Keller
Pittsburgh Pirates
0.270
0.426
0.155
7
Andrew Painter
Philadelphia Phillies
0.350
0.507
0.157
8
Randy Vásquez
San Diego Padres
0.324
0.489
0.164
Relievers, the same split:
reliever_movers
Table 4: Relievers: biggest opponent-wOBA swings, early season vs. last 45 days (min. 60 batters faced in each window)
player_name
team_name
early_woba_against
recent_woba_against
woba_against_delta
1
Trevor Megill
Milwaukee Brewers
0.289
0.179
-0.110
2
Greg Weissert
Boston Red Sox
0.346
0.239
-0.107
3
Paul Blackburn
New York Yankees
0.316
0.216
-0.099
4
Yohan Ramírez
Pittsburgh Pirates
0.366
0.268
-0.098
5
Gregory Soto
Pittsburgh Pirates
0.211
0.399
0.188
6
Tobias Myers
New York Mets
0.294
0.498
0.203
7
Joel Kuhnel
Milwaukee Brewers
0.276
0.486
0.210
8
Antonio Senzatela
Colorado Rockies
0.217
0.434
0.217
Two names on the starter improvers list are worth flagging now, because they show up again in the next section: Detroit’s Jack Flaherty and Miami’s Eury Pérez have both cut real ground off their opponent wOBA, and both of their teams are about to turn up as the two clearest hot streaks in baseball.
Which teams are trending, and is any of it real
Records can move for two different reasons: a team is genuinely playing better or worse baseball, or the same underlying performance is just running into better or worse luck. Splitting each team’s early season from its last 45 days, the same way as the hitters above, and pairing the win rate with the underlying offensive and pitching quality (wOBA for and against), separates the two.
team_trend_query ="""WITH games AS ( SELECT game_pk, CAST(ANY_VALUE(game_date) AS DATE) AS game_date, ANY_VALUE(home_team) AS home_team, ANY_VALUE(away_team) AS away_team, MAX(post_home_score) AS home_final, MAX(post_away_score) AS away_final FROM `maydaystats.mlb_statcast.pitches` WHERE game_type = 'R' GROUP BY game_pk),team_games AS ( SELECT game_pk, game_date, home_team AS team, IF(home_final > away_final, 1, 0) AS win FROM games UNION ALL SELECT game_pk, game_date, away_team AS team, IF(away_final > home_final, 1, 0) AS win FROM games),max_date AS (SELECT MAX(game_date) AS d FROM team_games),records AS ( SELECT team, ROUND(SAFE_DIVIDE(SUM(IF(game_date < DATE_SUB((SELECT d FROM max_date), INTERVAL 45 DAY), win, 0)), SUM(IF(game_date < DATE_SUB((SELECT d FROM max_date), INTERVAL 45 DAY), 1, 0))), 3) AS early_wpct, ROUND(SAFE_DIVIDE(SUM(IF(game_date >= DATE_SUB((SELECT d FROM max_date), INTERVAL 45 DAY), win, 0)), SUM(IF(game_date >= DATE_SUB((SELECT d FROM max_date), INTERVAL 45 DAY), 1, 0))), 3) AS recent_wpct FROM team_games GROUP BY team),pa AS ( SELECT CAST(game_date AS DATE) AS game_date, IF(inning_topbot = 'Top', away_team, home_team) AS bat_team, IF(inning_topbot = 'Top', home_team, away_team) AS pit_team, woba_value, woba_denom FROM `maydaystats.mlb_statcast.pitches` WHERE game_type = 'R' AND woba_denom IS NOT NULL),max_pa_date AS (SELECT MAX(game_date) AS d FROM pa),off AS ( SELECT bat_team AS team, ROUND(SAFE_DIVIDE(SUM(IF(game_date < DATE_SUB((SELECT d FROM max_pa_date), INTERVAL 45 DAY), woba_value, 0)), SUM(IF(game_date < DATE_SUB((SELECT d FROM max_pa_date), INTERVAL 45 DAY), woba_denom, 0))), 3) AS off_early, ROUND(SAFE_DIVIDE(SUM(IF(game_date >= DATE_SUB((SELECT d FROM max_pa_date), INTERVAL 45 DAY), woba_value, 0)), SUM(IF(game_date >= DATE_SUB((SELECT d FROM max_pa_date), INTERVAL 45 DAY), woba_denom, 0))), 3) AS off_recent FROM pa GROUP BY bat_team),def AS ( SELECT pit_team AS team, ROUND(SAFE_DIVIDE(SUM(IF(game_date < DATE_SUB((SELECT d FROM max_pa_date), INTERVAL 45 DAY), woba_value, 0)), SUM(IF(game_date < DATE_SUB((SELECT d FROM max_pa_date), INTERVAL 45 DAY), woba_denom, 0))), 3) AS def_early, ROUND(SAFE_DIVIDE(SUM(IF(game_date >= DATE_SUB((SELECT d FROM max_pa_date), INTERVAL 45 DAY), woba_value, 0)), SUM(IF(game_date >= DATE_SUB((SELECT d FROM max_pa_date), INTERVAL 45 DAY), woba_denom, 0))), 3) AS def_recent FROM pa GROUP BY pit_team)SELECT r.team, r.early_wpct, r.recent_wpct, ROUND(r.recent_wpct - r.early_wpct, 3) AS wpct_delta, o.off_early, o.off_recent, ROUND(o.off_recent - o.off_early, 3) AS off_delta, d.def_early, d.def_recent, ROUND(d.def_recent - d.def_early, 3) AS def_deltaFROM records rJOIN off o ON o.team = r.teamJOIN def d ON d.team = r.teamORDER BY wpct_delta DESC"""team_trend = client.query(team_trend_query).to_dataframe()TEAM_NAMES = {"ATH": "Athletics", "ATL": "Atlanta Braves", "AZ": "Arizona Diamondbacks","BAL": "Baltimore Orioles", "BOS": "Boston Red Sox", "CHC": "Chicago Cubs","CIN": "Cincinnati Reds", "CLE": "Cleveland Guardians", "COL": "Colorado Rockies","CWS": "Chicago White Sox", "DET": "Detroit Tigers", "HOU": "Houston Astros","KC": "Kansas City Royals", "LAA": "Los Angeles Angels", "LAD": "Los Angeles Dodgers","MIA": "Miami Marlins", "MIL": "Milwaukee Brewers", "MIN": "Minnesota Twins","NYM": "New York Mets", "NYY": "New York Yankees", "PHI": "Philadelphia Phillies","PIT": "Pittsburgh Pirates", "SD": "San Diego Padres", "SEA": "Seattle Mariners","SF": "San Francisco Giants", "STL": "St. Louis Cardinals", "TB": "Tampa Bay Rays","TEX": "Texas Rangers", "TOR": "Toronto Blue Jays", "WSH": "Washington Nationals",}team_trend["team_name"] = team_trend["team"].map(TEAM_NAMES)movers_teams = pd.concat([team_trend.head(5), team_trend.tail(5)])
fig, ax = plt.subplots(figsize=(8, 6))plot_teams = movers_teams.iloc[::-1]colors = ["#2c3e50"if v >0else"#c0392b"for v in plot_teams["wpct_delta"]]ax.barh(plot_teams["team_name"], plot_teams["wpct_delta"], color=colors)ax.set_xlabel("Win rate change (recent 45 days minus early season)")ax.set_title("Biggest Team Win-Rate Swings")ax.spines[["top", "right"]].set_visible(False)ax.axvline(0, color="#888888", linewidth=0.8)plt.tight_layout()plt.show()
Figure 2: Biggest win-rate swings, early season vs. last 45 days
Table 5: Win rate, offensive wOBA, and opponent wOBA allowed (pitching) trend for the biggest movers
team_name
wpct_delta
batting_woba_delta
pitching_woba_allowed_delta
1
Miami Marlins
0.194
0.037
-0.007
2
Detroit Tigers
0.157
0.019
-0.019
3
Boston Red Sox
0.149
0.007
-0.018
4
Texas Rangers
0.130
0.026
0.025
5
Colorado Rockies
0.112
0.054
0.021
6
Arizona Diamondbacks
-0.125
-0.019
0.027
7
Athletics
-0.132
0.002
0.035
8
San Diego Padres
-0.149
0.021
0.047
9
Cincinnati Reds
-0.177
-0.011
-0.001
10
Atlanta Braves
-0.199
-0.026
0.035
Miami and Detroit are the two clearest hot streaks backed by real substance, not just a stretch of good luck. Miami’s win rate has jumped from .456 to .650 over the last 45 days, driven mostly by a real gain in team batting wOBA, with Eury Pérez’s improvement from the last section doing its part on the mound too. Detroit’s climb from .393 to .550 is just as real, and backed on both sides of the ball: batting wOBA up, and less wOBA allowed by the pitching staff, with Jack Flaherty leading that charge. Atlanta sits at the other end: a win rate that fell from .661 down to .462, matched by real decline in both their hitting and their pitching.
Cincinnati and San Diego both show declines where the win rate moved further than the underlying numbers would suggest, which is a sign that some of the drop is bad sequencing rather than the whole roster suddenly playing worse. For the Reds, that’s worth a full section of its own; for San Diego, the driver looks specifically like the pitching staff, whose opponent wOBA allowed climbed sharply even as the offense actually improved.
A closer look at the Reds
standings_query ="""WITH games AS ( SELECT game_pk, ANY_VALUE(home_team) AS home_team, ANY_VALUE(away_team) AS away_team, MAX(post_home_score) AS home_final, MAX(post_away_score) AS away_final FROM `maydaystats.mlb_statcast.pitches` WHERE game_type = 'R' GROUP BY game_pk),team_games AS ( SELECT home_team AS team, IF(home_final > away_final, 1, 0) AS win FROM games UNION ALL SELECT away_team AS team, IF(away_final > home_final, 1, 0) AS win FROM games)SELECT team, COUNT(*) AS games, SUM(win) AS wins, COUNT(*) - SUM(win) AS losses, ROUND(SAFE_DIVIDE(SUM(win), COUNT(*)), 3) AS win_pctFROM team_gamesWHERE team IN ('MIL', 'CHC', 'STL', 'PIT', 'CIN')GROUP BY teamORDER BY win_pct DESC"""standings = client.query(standings_query).to_dataframe()standings["team"] = standings["team"].map(TEAM_NAMES)standings_tbl = standings.reset_index(drop=True)standings_tbl.index +=1standings_tbl
team
games
wins
losses
win_pct
1
Milwaukee Brewers
96
59
37
0.615
2
Chicago Cubs
96
54
42
0.563
3
St. Louis Cardinals
95
50
45
0.526
4
Pittsburgh Pirates
97
50
47
0.515
5
Cincinnati Reds
95
43
52
0.453
The Reds go into the break in last place in the NL Central, nine games under .500. Before getting into why, it’s worth checking the most basic question directly: how many runs has this team actually been scoring and allowing.
runs_query ="""WITH games AS ( SELECT game_pk, CAST(ANY_VALUE(game_date) AS DATE) AS game_date, ANY_VALUE(home_team) AS home_team, ANY_VALUE(away_team) AS away_team, MAX(post_home_score) AS home_final, MAX(post_away_score) AS away_final FROM `maydaystats.mlb_statcast.pitches` WHERE game_type = 'R' GROUP BY game_pk),team_games AS ( SELECT game_pk, game_date, home_team AS team, home_final AS runs_scored, away_final AS runs_allowed FROM games UNION ALL SELECT game_pk, game_date, away_team AS team, away_final AS runs_scored, home_final AS runs_allowed FROM games),max_date AS (SELECT MAX(game_date) AS d FROM team_games)SELECT team, ROUND(SUM(runs_scored) / COUNT(*), 2) AS rpg_season, ROUND(SUM(runs_allowed) / COUNT(*), 2) AS runs_allowed_pg_season, ROUND(SAFE_DIVIDE(SUM(IF(game_date >= DATE_SUB((SELECT d FROM max_date), INTERVAL 45 DAY), runs_scored, 0)), SUM(IF(game_date >= DATE_SUB((SELECT d FROM max_date), INTERVAL 45 DAY), 1, 0))), 2) AS rpg_recent, ROUND(SAFE_DIVIDE(SUM(IF(game_date >= DATE_SUB((SELECT d FROM max_date), INTERVAL 45 DAY), runs_allowed, 0)), SUM(IF(game_date >= DATE_SUB((SELECT d FROM max_date), INTERVAL 45 DAY), 1, 0))), 2) AS runs_allowed_pg_recentFROM team_gamesGROUP BY team"""runs_all = client.query(runs_query).to_dataframe()lg_avg = runs_all["rpg_season"].mean()reds_runs = runs_all[runs_all["team"] =="CIN"].copy()reds_runs["team"] ="Cincinnati Reds"reds_runs_tbl = reds_runs.reset_index(drop=True)reds_runs_tbl.index +=1reds_runs_tbl
team
rpg_season
runs_allowed_pg_season
rpg_recent
runs_allowed_pg_recent
1
Cincinnati Reds
4.16
4.79
3.75
4.6
The Reds are averaging 4.16 runs a game for the season, against a league average of 4.52, which puts them in the bottom third of baseball for scoring. That’s not a recent development; it’s been true most of the year. What has changed is the size of the gap: scoring dropped further over the last 45 days, down to 3.75 runs a game, while runs allowed actually improved slightly, from 4.79 a game for the season to 4.60 recently. In plain terms: this has been a below-average offense all year, not a good offense that collapsed, and pitching has quietly held up better than the win-loss record suggests.
reds_hitters_query ="""SELECT player_name, position, gamesPlayed, homeRuns, rbi, CAST(avg AS FLOAT64) AS avg, CAST(obp AS FLOAT64) AS obp, CAST(slg AS FLOAT64) AS slg, CAST(ops AS FLOAT64) AS ops, plateAppearancesFROM `maydaystats.mlb_season_stats.batting_latest`WHERE team_name = 'Cincinnati Reds' AND plateAppearances >= 50ORDER BY ops DESC"""reds_hitters = client.query(reds_hitters_query).to_dataframe()reds_hitters_tbl = reds_hitters.reset_index(drop=True)reds_hitters_tbl.index +=1reds_hitters_tbl
player_name
position
gamesPlayed
homeRuns
rbi
avg
obp
slg
ops
plateAppearances
1
JJ Bleday
LF
67
16
43
0.242
0.350
0.496
0.846
284
2
Elly De La Cruz
SS
76
15
44
0.274
0.347
0.492
0.839
340
3
Sal Stewart
1B
95
19
65
0.256
0.338
0.474
0.812
414
4
Nathaniel Lowe
DH
70
10
26
0.247
0.335
0.465
0.800
224
5
Spencer Steer
1B
91
14
37
0.247
0.328
0.421
0.749
358
6
Dane Myers
CF
63
3
14
0.256
0.358
0.385
0.743
137
7
Blake Dunn
CF
38
2
7
0.282
0.335
0.387
0.722
155
8
Tyler Stephenson
C
73
6
24
0.238
0.319
0.361
0.680
257
9
Eugenio Suárez
DH
67
11
35
0.208
0.285
0.388
0.673
267
10
Will Benson
RF
51
3
6
0.188
0.310
0.333
0.643
114
11
Noelvi Marte
RF
41
6
13
0.195
0.248
0.375
0.623
137
12
Matt McLain
2B
83
8
25
0.190
0.293
0.328
0.621
309
13
Jose Trevino
C
25
1
8
0.239
0.250
0.328
0.578
70
14
Edwin Arroyo
2B
33
0
4
0.240
0.280
0.290
0.570
108
15
TJ Friedl
CF
59
3
11
0.181
0.258
0.271
0.529
214
16
Ke'Bryan Hayes
3B
46
2
5
0.143
0.200
0.222
0.422
135
JJ Bleday narrowly leads the everyday lineup in OPS (.846), just ahead of Elly De La Cruz (.839). The underlying contact data points to De La Cruz as the more dangerous hitter of the two, though: he produces the hardest, fastest contact on the entire roster, well clear of second place.
reds_contact_query ="""SELECT b.player_name, ROUND(AVG(p.launch_speed), 1) AS avg_exit_velo, ROUND(SAFE_DIVIDE(COUNTIF(p.launch_speed >= 95), COUNTIF(p.launch_speed IS NOT NULL)), 3) AS hard_hit_rate, ROUND(AVG(p.bat_speed), 1) AS avg_bat_speed, COUNT(p.launch_speed) AS batted_ballsFROM `maydaystats.mlb_season_stats.batting_latest` bJOIN `maydaystats.mlb_statcast.pitches` p ON p.batter = b.player_idWHERE b.team_name = 'Cincinnati Reds' AND p.launch_speed IS NOT NULL AND p.game_type = 'R'GROUP BY b.player_nameHAVING batted_balls >= 100ORDER BY avg_exit_velo DESC"""reds_contact = client.query(reds_contact_query).to_dataframe()reds_contact_tbl = reds_contact.reset_index(drop=True)reds_contact_tbl.index +=1reds_contact_tbl
player_name
avg_exit_velo
hard_hit_rate
avg_bat_speed
batted_balls
1
Elly De La Cruz
86.3
0.336
73.9
345
2
Ke'Bryan Hayes
85.4
0.300
69.9
140
3
Sal Stewart
84.8
0.257
71.7
486
4
Tyler Stephenson
84.7
0.265
70.4
298
5
Spencer Steer
84.5
0.263
70.3
407
6
Eugenio Suárez
83.0
0.201
70.2
288
7
JJ Bleday
82.7
0.281
73.1
352
8
Dane Myers
82.6
0.196
70.0
153
9
Will Benson
82.6
0.220
72.5
109
10
Noelvi Marte
82.4
0.185
71.6
162
11
Edwin Arroyo
81.9
0.188
68.7
144
12
Matt McLain
81.9
0.218
70.7
344
13
Nathaniel Lowe
81.4
0.206
73.3
301
14
TJ Friedl
80.1
0.197
67.2
229
15
Blake Dunn
79.9
0.201
69.2
169
That contact-quality table also surfaces the more useful story on this roster: a gap between how well some of these hitters are actually squaring up the ball and what their batting average says.
reds_luck_query ="""SELECT b.player_name, ROUND(AVG(p.estimated_ba_using_speedangle), 3) AS xba, CAST(b.avg AS FLOAT64) AS actual_avg, ROUND(CAST(b.avg AS FLOAT64) - AVG(p.estimated_ba_using_speedangle), 3) AS avg_minus_xba, COUNT(*) AS batted_ballsFROM `maydaystats.mlb_season_stats.batting_latest` bJOIN `maydaystats.mlb_statcast.pitches` p ON p.batter = b.player_idWHERE b.team_name = 'Cincinnati Reds' AND p.estimated_ba_using_speedangle IS NOT NULL AND p.game_type = 'R'GROUP BY b.player_name, b.avgHAVING batted_balls >= 90ORDER BY avg_minus_xba ASC"""reds_luck = client.query(reds_luck_query).to_dataframe()reds_luck_tbl = reds_luck.head(5)[["player_name", "xba", "actual_avg", "avg_minus_xba"]].reset_index(drop=True)reds_luck_tbl.index +=1reds_luck_tbl
player_name
xba
actual_avg
avg_minus_xba
1
Ke'Bryan Hayes
0.313
0.143
-0.170
2
Matt McLain
0.329
0.190
-0.139
3
Nathaniel Lowe
0.382
0.247
-0.135
4
Elly De La Cruz
0.397
0.274
-0.123
5
Eugenio Suárez
0.321
0.208
-0.113
Ke’Bryan Hayes is the extreme case: Statcast’s contact-quality model expects a .313 batting average from the way he’s hitting the ball, and he’s actually running a .143, the largest gap on the roster among players with a meaningful sample of batted balls. His exit velocity and hard-hit rate are both solidly average-or-better for this team; the results just haven’t shown up yet. Elly De La Cruz carries a smaller version of the same gap (an expected .397 against an actual .274), which matters more here because his actual season is already excellent; if that gap closes at all, his results have real room to get even better. None of this guarantees a second-half turnaround, but it lines up with the team-wide finding from the last section: results that have fallen further than the process backing them up.
reds_pitching_query ="""SELECT player_name, IF(SAFE_DIVIDE(gamesStarted, gamesPitched) >= 0.5, 'Starter', 'Reliever') AS role, wins, losses, CAST(era AS FLOAT64) AS era, strikeOuts, CAST(whip AS FLOAT64) AS whip, CAST(inningsPitched AS FLOAT64) AS ipFROM `maydaystats.mlb_season_stats.pitching_latest`WHERE team_name = 'Cincinnati Reds' AND CAST(inningsPitched AS FLOAT64) >= 20ORDER BY role, era ASC"""reds_pitching = client.query(reds_pitching_query).to_dataframe()reds_pitching_tbl = reds_pitching.reset_index(drop=True)reds_pitching_tbl.index +=1reds_pitching_tbl
player_name
role
wins
losses
era
strikeOuts
whip
ip
1
Tejay Antone
Reliever
1
0
2.25
25
0.89
28.0
2
Brock Burke
Reliever
3
4
3.02
39
1.39
44.2
3
Graham Ashcraft
Reliever
1
1
3.33
32
1.22
27.0
4
Sam Moll
Reliever
1
6
4.19
43
1.32
38.2
5
Pierce Johnson
Reliever
2
1
4.23
25
1.30
27.2
6
Chase Petty
Reliever
1
2
4.38
11
1.09
24.2
7
Tony Santillan
Reliever
1
4
5.23
28
1.39
31.0
8
Connor Phillips
Reliever
1
0
5.53
28
1.77
27.2
9
Chase Burns
Starter
11
1
2.54
118
1.11
102.2
10
Andrew Abbott
Starter
5
5
4.11
84
1.42
105.0
11
Nick Lodolo
Starter
3
2
4.60
50
1.47
62.2
12
Brady Singer
Starter
3
9
4.72
76
1.47
89.2
13
Rhett Lowder
Starter
3
6
4.91
59
1.54
69.2
14
Brandon Williamson
Starter
2
3
6.11
19
1.64
28.0
Splitting by role clarifies who is actually holding the staff together. Among starters, Chase Burns is the headline: an 11-1 record and a 2.54 ERA over 102.2 innings, averaging just under 95 mph with plus spin on his fastball, the profile of a legitimate front-of-rotation arm rather than a first-half surprise. Among relievers, Tejay Antone’s 2.25 ERA is the clear standout. Beyond those two, both groups thin out quickly.
The same early-vs-recent split used earlier in this post, applied to the Reds staff specifically, shows why that thinness matters more than any single number in the season table above.
reds_pitch_trend_query ="""WITH pa AS ( SELECT pitcher, CAST(game_date AS DATE) AS game_date, woba_value, woba_denom FROM `maydaystats.mlb_statcast.pitches` WHERE game_type = 'R' AND woba_denom IS NOT NULL),max_date AS (SELECT MAX(game_date) AS d FROM pa),splits AS ( SELECT pitcher, SUM(IF(game_date < DATE_SUB((SELECT d FROM max_date), INTERVAL 45 DAY), woba_value, 0)) AS early_val, SUM(IF(game_date < DATE_SUB((SELECT d FROM max_date), INTERVAL 45 DAY), woba_denom, 0)) AS early_bf, SUM(IF(game_date >= DATE_SUB((SELECT d FROM max_date), INTERVAL 45 DAY), woba_value, 0)) AS recent_val, SUM(IF(game_date >= DATE_SUB((SELECT d FROM max_date), INTERVAL 45 DAY), woba_denom, 0)) AS recent_bf FROM pa GROUP BY pitcher)SELECT p.player_name, IF(SAFE_DIVIDE(p.gamesStarted, p.gamesPitched) >= 0.5, 'Starter', 'Reliever') AS role, ROUND(SAFE_DIVIDE(s.early_val, s.early_bf), 3) AS early_woba_against, ROUND(SAFE_DIVIDE(s.recent_val, s.recent_bf), 3) AS recent_woba_againstFROM splits sJOIN `maydaystats.mlb_season_stats.pitching_latest` p ON p.player_id = s.pitcherWHERE p.team_name = 'Cincinnati Reds' AND s.early_bf >= 20 AND s.recent_bf >= 20ORDER BY role, recent_woba_against ASC"""reds_pitch_trend = client.query(reds_pitch_trend_query).to_dataframe()reds_pitch_trend_tbl = reds_pitch_trend.reset_index(drop=True)reds_pitch_trend_tbl.index +=1reds_pitch_trend_tbl
player_name
role
early_woba_against
recent_woba_against
1
Tejay Antone
Reliever
0.328
0.279
2
Tony Santillan
Reliever
0.397
0.300
3
Chase Petty
Reliever
0.334
0.313
4
Brock Burke
Reliever
0.352
0.318
5
Pierce Johnson
Reliever
0.322
0.408
6
Sam Moll
Reliever
0.293
0.432
7
Chase Burns
Starter
0.267
0.330
8
Brady Singer
Starter
0.428
0.331
9
Andrew Abbott
Starter
0.331
0.333
10
Nick Lodolo
Starter
0.404
0.346
11
Rhett Lowder
Starter
0.321
0.383
It’s a mixed picture, not a uniform one. Tejay Antone and Brady Singer have both gotten better against opposing hitters recently, and Andrew Abbott has held steady. Chase Burns has actually seen his opponent wOBA rise somewhat over the last 45 days, still solid, just not quite as dominant as his season line suggests. The clearer problem is further down the staff: Rhett Lowder and Sam Moll have both gotten meaningfully worse against opposing hitters recently, and neither was especially strong to begin with. A rotation and bullpen this dependent on two pitchers performing at their best has less margin for error than a staff with the same season-long ERA spread more evenly.
Put together, the Reds’ first half looks less like a team that fell apart and more like a team that was already below average on offense, got worse there over the last 45 days, and has real pitching depth questions once you get past Burns and Antone. The individual talent is real (De La Cruz, Burns, Antone), and a couple of hitters, Hayes especially, look likely to see their results catch up to their contact quality. Whether that’s enough to climb out of last place in the NL Central is a second-half question, but it will take more than better luck; the offense needs to actually produce more runs than it has all year, not just more than its recent stretch.
Note
This post uses Quarto’s frozen execution (freeze: auto): the numbers above reflect the Statcast and mlb_season_stats data as of whenever this was last rendered locally, not a live query on every page load. “Last 45 days” is calculated relative to the most recent game date in the pipeline at render time, not a fixed calendar date.