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

import matplotlib.pyplot as plt

fig, 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_delta
FROM splits s
JOIN `maydaystats.mlb_season_stats.batting_latest` b ON b.player_id = s.batter
WHERE s.early_pa >= 100 AND s.recent_pa >= 100
ORDER BY woba_delta DESC
"""

movers = client.query(movers_query).to_dataframe()
risers = movers.head(5).reset_index(drop=True)
risers.index += 1
fallers = 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:

risers[["player_name", "team_name", "early_woba", "recent_woba", "woba_delta"]]
Table 1: Biggest wOBA gainers, early season vs. last 45 days (min. 100 PA in each window)
player_name team_name early_woba recent_woba woba_delta
1 Pete Crow-Armstrong Chicago Cubs 0.307 0.507 0.200
2 Kyle Karros Colorado Rockies 0.289 0.460 0.170
3 Caleb Durbin Boston Red Sox 0.236 0.385 0.149
4 Victor Caratini Minnesota Twins 0.256 0.404 0.148
5 Tyler Soderstrom Athletics 0.314 0.461 0.147

And the clearest decliners:

fallers[["player_name", "team_name", "early_woba", "recent_woba", "woba_delta"]]
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_delta
FROM splits s
JOIN `maydaystats.mlb_season_stats.pitching_latest` p ON p.player_id = s.pitcher
WHERE s.early_bf >= 60 AND s.recent_bf >= 60
ORDER 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 += 1

relievers = 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.

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_pct
FROM team_games
WHERE team IN ('MIL', 'CHC', 'STL', 'PIT', 'CIN')
GROUP BY team
ORDER 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 += 1
standings_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_recent
FROM team_games
GROUP 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 += 1
reds_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, plateAppearances
FROM `maydaystats.mlb_season_stats.batting_latest`
WHERE team_name = 'Cincinnati Reds' AND plateAppearances >= 50
ORDER 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 += 1
reds_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_balls
FROM `maydaystats.mlb_season_stats.batting_latest` b
JOIN `maydaystats.mlb_statcast.pitches` p ON p.batter = b.player_id
WHERE b.team_name = 'Cincinnati Reds' AND p.launch_speed IS NOT NULL AND p.game_type = 'R'
GROUP BY b.player_name
HAVING batted_balls >= 100
ORDER 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 += 1
reds_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_balls
FROM `maydaystats.mlb_season_stats.batting_latest` b
JOIN `maydaystats.mlb_statcast.pitches` p ON p.batter = b.player_id
WHERE 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.avg
HAVING batted_balls >= 90
ORDER 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 += 1
reds_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 ip
FROM `maydaystats.mlb_season_stats.pitching_latest`
WHERE team_name = 'Cincinnati Reds' AND CAST(inningsPitched AS FLOAT64) >= 20
ORDER 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 += 1
reds_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_against
FROM splits s
JOIN `maydaystats.mlb_season_stats.pitching_latest` p ON p.player_id = s.pitcher
WHERE p.team_name = 'Cincinnati Reds' AND s.early_bf >= 20 AND s.recent_bf >= 20
ORDER 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 += 1
reds_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.