Hardest/Easiest Pitchers to Face in Fantasy Baseball
All data as of May 27th, 2025
Intoduction:
I am currently sitting at my desk, furious about why my fantasy baseball team full of superstars have allowed me to fall to last in my league's standings. In this chapter of "How Will Coding Save My Fantasy Baseball Team" I want to tackle the question of, "Who will I not let my batters face?" Additionally, I want to recognize who are the pitchers that my team will certainly have a field day with. In achieving this, I not only want to determine which pitcher allow the most points to opposing batters, but determine which pitchers SHOULD allow the most points to opposing batters.
Methodology
Its pretty easy to translate basic stats into fantasy points allowed but its a whole different story to turn expected stats into fantasy points. This methodology section will be split into two sections; Fantasy Points Allowed and Expected Fantasy Points Allowed.
Fantasy Points Allowed:
In my specific league, fantasy points for batters are calculated as:
Total Bases = 1pt, Walks = 1pt, Runs Scored = 1pt, RBIs = 1pt, Stolen Bases = 1pt, Strikeouts = -1pt, Intentional Walks = 1pt, Hit by Pitch = 1pt, Ground into Double Play = -2pts, Hitting for the Cycle = 5pts.
*Since hitting for the cycle is more batter dependent and incredibly rare, we will not use 'hitting for the cycle' in our calculation of fantasy points allowed.
In order to caluclate the total amount of fantasy points a pitcher has allowed, we must simply count the occurances of each point-altering event, and apply points to each. (i.e. If a pitcher has recorded 10 strikeouts, their fantasy points allowed would be -10pts. If a pitcher has given up 20 singles and 5 doubles, their fantasy points allowed would be 20pts + (5 * 2pts) which equals 30pts)
My code:
mutate(fantasy_points_allowed = (-1 * (K + CS)) + (-2 * GIDP) + SB + RBI + IBB + BB + TB + R)
I then divided the resulting column in my code by the amount of games in order to find the fantasy points allowed per game.
My code: mutate(fantasy_ppg = fantasy_points_allowed / G)
Expected Fantasy Points Allowed
Calculating how many fantasy points a pitcher SHOULD allow depends on a tricky principle. The secret ingredient is going to be xSLG or "Expected Slugging Percentage." The premise of this stat is how well should a batter be slugging the ball based on a combination of the exit velocity his batted ball event produces, and its launch angle. Since 2015, Statcast has kept records of every batted ball (event) and its accompanying profile (exit velo and launch angle) and recorded how often these batted balls in play resulted in singles, doubles, triples, home runs, outs, and total feet carried. Through this collection of events, Statcast has determined the expected batting average and expected total amount of bases based on the combination of exit velocity and launch angle based on all recorded in play batted ball events (i.e. a ball hit 109mph off the bat with a launch angle of 36 occured 5 times this year, 4 home runs, one out, 16 total bases, batting average .800, slugging 3.200).
Xslg is calculated by taking a ball's launch angle and exit velo and comparing it to the slugging percentage of every batted ball that fits that same launch angle and exit velo. Additionally, you can calculate the expected total bases of a batted ball event by finding the average total bases per batted ball event of the same batted ball profile. xSLG is calculated for a season by taking the total expected bases divided by the player's total at-bats. As it compares to fantasy baseball, a pitcher's expected fantasy points allowed now depends on their EXPECTED total bases allowed. Additionally, expected fantasy points allowed per game is the total expected fantasy points allowed divided by total games played. Thankfully, Statcast has provided the xSLG records necessary for our investigation.
My code:
mutate(xBases = AB * xSLG) %>% mutate(xFantasyPPA = (-1 * (K + CS)) + (-2 * GIDP) + SB + RBI + IBB + BB + xBases + R) %>% mutate(xFantasyPPAPG = xFantasyPPA / G) %>%
Things to note:
Cumulative stats can be misleading. Just because a pitcher has only allowed a few fantasy points in the current season, does not certainly mean that they're going to give your batter the business. Always compare your sample size of games. Using rate stats like fantasy point allowed per game is more preferred tp determine the easiness/difficulty of facing a pitcher. The more games played, the more reliable the observation.
Hardest Pitchers to Face in Fantasy Baseball
Below contains data taken from Baseball Savant and FanGraphs. Side note: wOBA measures a players offensive production per plate appearance. Its calculated similarly to slugging but every batted ball event correlates to a weight based on their run expectancy. The weights pertaining to each batted ball event changes by the season as a result of the batting environment for that year. The current wOBA calculation goes as:
((.695xBB)+(.727xHBP)+(.890x1B)+(1.267x2B)+(1.606x3B)+(2.073xHR)) / (AB + BB - IBB + SF + HBP)
League average for this year 0.314, ranges from .511 (Aaron Judge, NYY) to .213 (Joey Ortiz, MIL) amongst qualified batters. In order to be a qualified batter, they must record at least 3.1 plate appearances per team game played.
xwOBA similarly measures expected production per plate appearance. It is calculated the same at wOBA except each batted ball event is replaced by their expected batted ball events, 1B, 2B, 3B and HR. xwOBA is great for determining how valuable a player is expected to be based on their batted balls. If you're interested in understanding who is the hardest pitcher to face, in a non-fantasy baseball context, I recommend seeing which pitchers produce the lowest xwOBA.
library(readr)
library(data.table)
library(readxl)
library(dplyr)
library(ggplot2)
library(ggrepel)
library(tibble)
library(tidyr)
library(data.table)
PitcherStats <- read.csv("stats (19).csv")
PitcherStats <- PitcherStats %>%
mutate(name = as.character(last_name..first_name), K = as.numeric(strikeout), BB = as.numeric(walk), SLG = round(as.numeric(slg_percent), 3), R = as.numeric(p_run), SB = as.numeric(p_total_stolen_base), RBI = as.numeric(p_rbi), GIDP = as.numeric(p_gnd_into_dp), IBB = as.numeric(p_intent_walk), TB = as.numeric(p_total_bases), CS = as.numeric(p_total_caught_stealing)) %>%
mutate(fantasy_points_allowed = (-1 * (K + CS)) + (-2 * GIDP) + SB + RBI + IBB + BB + TB + R)
PitcherStats <- PitcherStats[, c("name", "K", "BB", "SLG", "R", "SB", "RBI", "GIDP", "IBB", "TB", "CS", "fantasy_points_allowed", "K")]
mlb_teams.df <- read_excel("MLB Database(AutoRecovered).xlsx", sheet = 5)
all_pitchers_teams <- read_excel("MLB Database(AutoRecovered).xlsx", sheet = 8)
team_colors <- c("#114078", "#AB1B28", "#122245", "#DF4605", "#F8C52D", "#C30D32", "#FFC525", "#E56D1F", "#FF5104", "#D40133", "#0E3487", "#1F2C60", "#E11938", "#005D5D", "#BD3039", "#014787", "#02426D", "#C1092C", "#FE5B1D", "#BE0B19", "#C51118", "#072E5C", "#003831", "#C10300", "#C70222", "#09A4DE", "#FFC82A", "#B9001E", "#33016F", "#FBFFFE")
alternate_team_colors <- c("#E1D8CF", "#02B3CD", "#DD0429", "#454748", "#96BCD6", "#467CB3", "#EF2D7F", "#1E2766", "#551C68", "#0350AB", "#4D80B6", "#557FD2", "#7C0014", "#F7D24A", "#4F756E", "#5EA9E0", "#F3471B", "#3350A2", "#848485", "#DCE2E5", "#E6E1D1", "#7B67B0", "#F0B52F", "#F5C3CE", "#343434", "#F4293A", "#1F1F1E", "#E0CEB5", "#183831", "#161112")
SavantStatsPitcher <- read.csv("stats (19).csv") %>%
filter(p_formatted_ip > 20)
SavantStatsPitcher <- SavantStatsPitcher %>%
mutate(name = as.character(last_name..first_name), G = as.numeric(p_game), AVG = round(as.numeric(batting_avg), 3), SLG = round(as.numeric(slg_percent), 3), OBP = round(as.numeric(on_base_percent), 3), OPS = round(as.numeric(on_base_plus_slg), 3), xBA = round(as.numeric(xba), 3), xSLG = round(as.numeric(xslg), 3), wOBA = round(as.numeric(woba), 3), xwOBA = round(as.numeric(xwoba), 3))
all_pitcher_stats <- SavantStatsPitcher %>%
left_join(PitcherStats, by = "name")
all_pitcher_stats <- all_pitcher_stats %>%
filter(p_formatted_ip > 20) %>%
select("name", "G", "AVG", "SLG.x", "OBP", "OPS", "xBA", "xSLG", "wOBA", "xwOBA", "K", "BB", "R", "SB", "RBI", "GIDP", "IBB", "TB", "CS", "fantasy_points_allowed") %>%
mutate(SLG = SLG.x) %>%
select("name", "G", "AVG", "SLG", "OBP", "OPS", "xBA", "xSLG", "wOBA", "xwOBA", "K", "BB", "R", "SB", "RBI", "GIDP", "IBB", "TB", "CS", "fantasy_points_allowed") %>%
mutate(fantasy_ppg = fantasy_points_allowed / G) %>%
mutate(AB = TB / SLG) %>%
mutate(xBases = AB * xSLG) %>%
mutate(xFantasyPA = (-1 * (K + CS)) + (-2 * GIDP) + SB + RBI + IBB + BB + xBases + R) %>%
mutate(xFantasyPPAPG = xFantasyPA / G) %>%
mutate("xFanPPAPG - FPApg" = xFantasyPPAPG - fantasy_ppg) %>%
mutate(short_name = name) %>%
separate(col = short_name, into = c("Last", "First"), sep = " ") %>%
mutate(First = substr(First, 1, 1), Last = substr(Last, 1, 1)) %>%
mutate(short_name = paste(First, Last, sep = "")) %>%
select("name", "G", "fantasy_points_allowed", "xFantasyPPAPG", "AVG", "SLG", "OBP", "OPS", "xBA", "xSLG", "wOBA", "xwOBA", "K", "BB", "R", "SB", "RBI", "GIDP", "IBB", "TB", "CS", "fantasy_ppg", "short_name")
mlb_teams.df <- mlb_teams.df %>%
mutate(team_colors = team_colors, alternate_team_colors = alternate_team_colors)
mlb_teams_limited <- mlb_teams.df %>%
select("Team", "Team Name", "team_colors", "alternate_team_colors")
all_pitchers_teams <- all_pitchers_teams %>%
left_join(mlb_teams_limited, by = "Team") %>%
separate(col = Name, into = c("First", "Last"), sep = " ") %>%
mutate(Name = paste(Last, First, sep = ", "))
all_pitcher_stats_and_teams <- all_pitcher_stats %>%
left_join(all_pitchers_teams, by = c("name" = "Name")) %>%
mutate(First = substr(First, 1, 1), Last = substr(Last, 1, 1)) %>%
mutate(short_name = paste(First, Last, sep = "")) %>%
mutate(name_and_team = paste(name, Team, sep = ", "))
all_pitcher_stats_and_teams
xtop10pitchers <- all_pitcher_stats_and_teams %>%
select(name, fantasy_ppg, xFantasyPPAPG, short_name, G, AVG, SLG, xBA, xSLG, wOBA, xwOBA, name_and_team, team_colors) %>%
arrange(desc(xFantasyPPAPG)) %>%
slice_min(order_by = xFantasyPPAPG, n = 10)
xbottom10pitchers <- all_pitcher_stats_and_teams %>%
select(name, fantasy_ppg, xFantasyPPAPG, short_name, G, AVG, SLG, xBA, xSLG, wOBA, xwOBA, name_and_team, team_colors) %>%
arrange(desc(xFantasyPPAPG)) %>%
slice_max(order_by = xFantasyPPAPG, n = 10)
today_pitchers.df <- all_pitcher_stats_and_teams %>%
filter(grepl("Houser|Luzardo|Littell|Hendricks|Mikolas|Ohtani|Lugo|Soroka|Senzatela|Falter", name))
today_pitchers.df
mean(all_pitcher_stats_and_teams$xFantasyPPAPG)Above is the product returned from the code I produced to find the easiest and hardest pitchers, both expected and realized, to face in a fantasy baseball context. I obtained data from Fangraphs and BaseballSavant in order to find basic and expected stats and converted them to fantasy baseball points allowed. For expected fantasy points, I converted expected slugging into expected total bases and substituted the expected total bases in the fantasy points allowed calculation. I then calculated fantasy points allowed per game by dividing the total fantasy points allowed by the
Hardest/Easiest Pitchers to Face in Fantasy Baseball
Below are the filtered and graphed results for the top 10 hardest pitchers to face in fantasy baseball. This is determined by finding the lowest fantasy points allowed per game played for each pitcher.
top10pitchers <- all_pitcher_stats_and_teams %>%
select(name, fantasy_ppg, xFantasyPPAPG, short_name, G, AVG, SLG, xBA, xSLG, wOBA, xwOBA, team_colors, alternate_team_colors, name_and_team) %>%
arrange(desc(fantasy_ppg)) %>%
slice_min(order_by = fantasy_ppg, n = 10)
top10pitchersHere are the hardest pitchers to face for batters in a fantasy baseball context. To nobody's surprise, Tarik Skubal of the Detroit Tigers allows the least fantasy points to opposing batters. Although opposing batters have a higher batting average against Skubal than others on the top ten, he makes up for it by striking out batters at a high rate. He averages 8.36 strikeouts per game and only allows 1.81 fantasy points to batters per game. If I see that my favorite players on my team are facing Tarik, I may let them sit and watch from the bench and hope their replacement is facing someone on the next list. Notable entries on the list are suprising star, Kris Bubic boasting a 2.18 fantasy points allowed per game coming at 2nd place; old head Nathan Eovaldi who is keeping the Texas Rangers relevant, and Paul Skenes anchoring the top ten averaging an impressive four points allowed per game.
top10pitchersBar <- ggplot(top10pitchers, aes(short_name, fantasy_ppg, fill = name_and_team)) +
geom_col() +
scale_fill_manual(values = setNames(top10pitchers$team_colors, top10pitchers$name_and_team)) +
labs(title = "Hardest Pitchers To Face", subtitle = "In Fantasy Baseball") +
labs(caption = "(based on data from Fangraphs as of 5/27/2025)") +
labs(x = "Pitcher", y = "Fantasy Points Allowed to Batters Per Game") +
theme_bw() +
geom_text(aes(y = fantasy_ppg, label = round(fantasy_ppg, digits=2)), vjust = -0.5,
check_overlap = TRUE) +
guides(fill = guide_legend(title = "Name"))
top10pitchersBarThis visual above shows some pretty interesting stuff. In a game where there really is a clear ceiling for pitching talent, somehow Skubal has widened the gap against everyone with regards to fantasy pitching dominance. With all the hype around Skenes, Skubal still allows less than half the total points per game than the young star. Him, Bubic, and Yamamoto are the clear aces that you don't want your batters to face.
Here are the easiest pitchers to face for batters in a fantasy baseball context. Some know these names, some for the wrong reasons, and many don't know these names. Its very sad to see early Cy Young winner Sandy Alcantara at the top of this list. Similarly, All-Star Tanner Houck and emerging prospect Chase Dollander to be labeled an easy pitcher to face. Unfortunately, the stats don't lie. With this list below, it does not matter how bad your batter is, he will score points against these individuals. Antonio Senzatela of the Rockies headlines this list, awarding 17.27 points per game to oposing batters. In a lineup of 9 batters, each batter averages about two points per game on this pitcher. This is not good. Four total Colorado Rockies make this list, probably due to the awful conditions for pitch spin and the even worse condition for high flying hits and a massive outfield. Amongst these are Germán Márquez who oddly enough is doin on par for his career yet will go down as their greatest franchise pitcher due to their lack of lush pitching history. Additionally is young flame thrower Chase Dollander and Kyle Freeland.
bottom10pitchers <- all_pitcher_stats_and_teams %>%
select(name, fantasy_ppg, xFantasyPPAPG, short_name, G, AVG, SLG, xBA, xSLG, wOBA, xwOBA, team_colors, alternate_team_colors, name_and_team) %>%
arrange(desc(fantasy_ppg)) %>%
slice_max(order_by = fantasy_ppg, n = 10)
bottom10pitchersBelow is the graph depicting the easiest ten pitchers to face. Unfortunately, there is a lot of purple. This not only is a testiment to how bad their pitching is, but how good hitters play in Colorado. Its not all bad news for their pitching but apparently with their lack of spending and horrible farm system, their owner is appaled at how bad the team is. I hope this graph makes it to his desk and compells him to retool their philosophy because its getting miserable. The noteworthy thing to mention from the graph is that we know what Sandy Alcantary can do. Despite allowing the second most fantasy points per outing, he still does have some nasty pitches.
bottom10pitchersBar <- ggplot(bottom10pitchers, aes(short_name, fantasy_ppg, fill = name_and_team)) +
geom_col() +
scale_fill_manual(values = setNames(bottom10pitchers$team_colors, bottom10pitchers$name_and_team)) +
labs(title = "Easiest Pitchers To Face", subtitle = "In Fantasy Baseball") +
labs(caption = "(based on data from Fangraphs as of 5/27/2025)") +
labs(x = "Pitcher", y = "Fantasy Points Allowed to Batters Per Game") +
theme_bw() +
geom_text(aes(y = fantasy_ppg, label = round(xFantasyPPAPG, digits=1)), vjust = -0.5,
check_overlap = TRUE) +
guides(fill = guide_legend(title = "Name"))
bottom10pitchersBarEXPECTED Hardest/Easiest Pitchers to Face
Reminder: This list shouldn't be too different because the list showcases talent. Such talent is delivered day in and day out by these pitchers yet the expected stats tell a slightly different story. This list demonstrates the quality of contact from batters and tries to estimate the fantasy points each pitcher allows per game dependent on the bases they expect to give to batters. The only condition that changed is expected bases. Walks, strikeouts, stolen bases, all of the rest stayed the same in the calculation. Therefore, this list can be draw conclusions about how lucky or unlucky these pitchers are.
New names on this list include: Cole Ragans of the Kansas City Royals, Christopher Sanchez of the Phillidelphia Phillies, and Kodai Senga of the New York Mets. This is a representation of how these pitchers, especially Ragans, has been unlucky this year. Making the top ten on this list and not the other signifies that many fantasy points that they're awarding to hitters have come off of weak contact, and poor fielding. Ragans is expected to be a top five pitcher according to expected fantasy points allowed yet
xtop10pitchersHere is the EXPECTED hardest pitchers to face in fantasy baseball. This list is a little different. Absent from this list but present on the first list is Nathan Eovaldi, Max Fried, and Tyler Mahle. Additionally, present on this list and not on the first list is Kodai Senga, Christopher Sanchez, and Cole Ragans. This means that pitchers like Eovaldi and Fried are getting rather lucky. A hypothesis for Fried's luck is the smaller outfield in the Bronx, causes better hit linedrives to be caught. Players like Sanchez and Ragans are considered unlucky because they SHOULD allow less points per outing based on the quality of hits they allow. Similarly, Kansas City has a very large outfield, therefore Ragans may fall victim to weak contact hits.
Below is a graph for the players on the above list. An observations to be made from this graph is that; the numbers are inflated upwards, which assumes that the players on the first list are relatively lucky compared to those players, who appear on the first list. Despite the tendency for amazing pitchers to expect to give up more fantasy points per game, Zach Wheeler of the Phillidelphia Phillies is the only pitcher on the first list that is allowing more fantasy points per game than expected. This means that while all other pitchers who are dominating in a fantasy perspective, Zach Wheeler is the only one who is considered unlucky.
The variety of teams shown demonstrates a competitive pitching landscape in the league. I wouldn't say that this observation is an indicator of who will win the CY Young this year but if I had to make a guess, the winners in each league probably appear on this list. Many of the data points pulled for this observation are similar to those used in the determination for CY Young. These are strikeouts, bases, and runs and walks; the same metrics that determine wins/losses, and ERA. As we approach the midway point of the 2025 season, this observational study has given me the encouragement to look for a CY Young favorite though data visualization.