Streaky Freakies vs. Steady Eddies: Does Volatility Matter?

Fret not, dear reader. This is not that kind of article.
Introduction
In Part 1 of my valuation series, I made a passing reference to player streakiness, stating that it mattered when distinguishing player value between head-to-head and roto leagues with the same categories. But does it?
It seems intuitive enough. In a roto league, whether a player has big ups and downs throughout the year matters relatively less (as long as you keep them in your lineup) because the season standings are just that – season standings. It all comes out a wash in the end. Meanwhile, in head-to-head leagues, where your wins and losses are more granular, a player having a dud week might sink your chances.
After taking a deeper look, I’ve come away fairly convinced that streakiness, at least in the way I’ve measured it, doesn’t particularly matter. Or, in other words, Betteridge’s Law strikes again.
Housekeeping and Definition
The most basic requirement for this analysis is determining how one measures streakiness. Here, I will refer to streakiness and volatility interchangeably and have defined it as the month-to-month standard deviations in a player’s performance in the five basic roto categories: runs, RBI, home runs, batting average, and stolen bases. I restricted my sample to players who played more than 18 games in every month (March/April and September/October were clubbed together) of the 2025 season. I did this in order to pull injuries out of the sample pool. A player who missed a month’s worth of games spread across two months will have much lower stats in those months, which, in my setup, would get incorrectly measured as volatility. Those types of instances should be excluded based on this filter.
I then calculated each player’s coefficient of variation (standard deviation divided by mean) for performance in each category, calculated the z-scores of CV for each player with respect to the full pool, and then averaged z-scores across all of a given player’s categories as their measure of volatility. I used coefficient of variation because players with high totals in each stat have naturally higher standard deviation values, so using raw standard deviation is going to be significantly influenced by general player quality. By calculating the CV, I am leveling the playing field to allow players who are less good and volatile to appear on the same scale as better players who are equally volatile.
A couple notes on the overall method: first, in a future analysis, one could build on this by tweaking the split and playing time constraints to be more granular. This would identify injuries more accurately and ultimately allow for a larger player sample, which would provide more options for the optimization exercise described later. Second, there are surely numerous ways to measure a player’s “true talent” streakiness, and the way I have defined it above is probably not best suited for that type of analysis. If I were doing this for a team, or even across the aisle under the FanGraphs banner, I would probably start by looking at a player’s rolling wOBA. You could even go as far as measuring rolling contact quality outcomes based on Savant data. But RotoGraphs is a fantasy analysis website, and rolling wOBA only really matters to the extent it impacts fantasy scoring. So, here, I have chosen to focus on the variability of a players’ fantasy categories throughout the course of a season.
With that said, before diving into the actual exercise, let’s take a look at some of the most and least volatile players by this definition. I think there will be a few surprises. Note – negative scores mean less volatile, while positive scores mean more volatile. And remember, these were based on z-scores. So a rating of “1” means that, averaged across all of a player’s categories, they are one standard deviation more variable than the sample.
| # | Player | Dollars | Volatility Z-Score |
|---|---|---|---|
| 1 | Julio Rodríguez | $37 | -1.00 |
| 2 | Cody Bellinger | $27 | -0.95 |
| 3 | Michael Busch | $18 | -0.93 |
| 4 | Lawrence Butler | $7 | -0.91 |
| 5 | Josh Naylor | $30 | -0.84 |
| 6 | Carlos Correa | $-10 | -0.82 |
| 7 | Cal Raleigh | $54 | -0.81 |
| 8 | Brandon Nimmo | $19 | -0.78 |
| 9 | Junior Caminero | $37 | -0.77 |
| 10 | Bobby Witt Jr. | $34 | -0.75 |
Informed by my tour of duty rostering him in 2023-24, I was stunned to see Julio Rodríguez rated as a net negative volatility player, let alone the least volatile in the entire sample. I thought, surely this cannot be the case. Then I looked at his splits. Sure enough, month-to-month, he was rock steady in virtually every category:
| Month | G | HR | R | RBI | SB | AVG |
|---|---|---|---|---|---|---|
| Mar/Apr | 30 | 4 | 21 | 14 | 5 | 0.207 |
| May | 26 | 6 | 16 | 18 | 2 | 0.282 |
| Jun | 27 | 1 | 15 | 10 | 6 | 0.290 |
| Jul | 26 | 8 | 18 | 14 | 7 | 0.236 |
| Aug | 27 | 7 | 18 | 22 | 4 | 0.321 |
| Sep/Oct | 24 | 6 | 18 | 17 | 6 | 0.272 |
Meanwhile, the most volatile performers were a categorically different sort of fantasy player:
| Player | Dollars | Volatility Z-Score |
|---|---|---|
| Paul Goldschmidt | -$2 | 1.66 |
| Gavin Lux | -$12 | 1.49 |
| Brenton Doyle | -$5 | 1.39 |
| Jung Hoo Lee | -$1 | 1.30 |
| Cedric Mullins | -$4 | 1.15 |
| Mike Yastrzemski | -$8 | 1.11 |
| Bo Naylor | -$15 | 1.05 |
| Eric Wagaman | -$11 | 0.96 |
| Brice Turang | $26 | 0.93 |
| William Contreras | $16 | 0.90 |
This confirms what we know: Josh Naylor continues to find ways to be the Mario to Bo’s Luigi, and that the ability to perform consistently is a major component of what makes good players good. In fact, volatility (by this measure) is pretty demonstrably linked to player value:
| Bucket | n | Average Volatility Z-Score |
|---|---|---|
| -$10 to $0 | 19 | 0.358 |
| $0 to $10 | 28 | -0.096 |
| $10 to $20 | 16 | -0.226 |
| $20+ | 34 | -0.289 |
So, in an absolute sense, volatility clearly does matter. But the question at hand is whether it makes a difference between roto versus head-to-head settings, which is what I discuss below.
Linear Optimization
In my initial conception for this article, I imagined a fun little exercise where I hand-picked players at each position from the most and least variable lists, constructing 10-hitter rosters like a jigsaw puzzle to be roughly the same in total auction value, and then measure how the teams performed across the course of an entire season.
I quickly learned that this was much more difficult than it seemed on its face. As noted above, good players just aren’t that streaky. And it turns out that the design of this exercise is actually a linear optimization problem with several constraints: at least one player of a certain minimum quality at each position, total values high enough to contain good players but also be roughly equal between teams, and sufficient volatility separation between the teams.
Operations Research (one of the applied mathematical fields in which linear optimization is heavily utilized) cooked me in grad school so I certainly didn’t have the math to figure this one out on my own.
But! Numerous linear solvers exist in R so I wrote some code (I will provide code in the comments) to set this problem up. After a bit of trial and error, I found a few parameters at which I could set the robots to this task. I set it to create pairs of teams whose lineups must satisfy the following constraints:
- All 10 positions must be correctly filled
- Player value could not drop below -$15
- Average volatility for players on each team could not drop below 0.75. That is, the average player on the high volatility team must be at least 0.75 standard deviations more volatile than the average player on the low volatility team.
- It must maximize total dollar value of the teams while not allowing them to differ by more than $10 total
With these constraints, I made a loop to iterate over incremental increases to the volatility constraint to 1.5 and incremental decreases to the auction sum constraint down to $1. So, first it ran for the constraint set above. Then, it changed the dollar constraint to not exceed a $9 difference. Then $8, and so on. Once it finished that, it repeated the exercise for volatility that doesn’t drop below 0.8. Then, 0.85, etc.
Not every constraint was solvable based on the data, and not every solve was unique. In the end, my code gave me 53 unique configurations of teams to pit against each other. Here is one example:
| Postition | Player | Dollars | Volatility Z-Score |
|---|---|---|---|
| C | William Contreras | $16 | 0.90 |
| 1B | Pete Alonso | $32 | 0.67 |
| 2B | Brice Turang | $26 | 0.93 |
| 3B | José Ramírez | $46 | -0.58 |
| SS | Francisco Lindor | $32 | -0.53 |
| OF | Pete Crow-Armstrong | $30 | 0.33 |
| OF | James Wood | $23 | 0.05 |
| OF | Aaron Judge | $67 | -0.17 |
| UTIL | Christian Yelich | $21 | 0.25 |
| UTIL | Kyle Schwarber | $38 | -0.67 |
| Postition | Player | Dollars | Volatility Z-Score |
|---|---|---|---|
| C | Cal Raleigh | $54 | -0.81 |
| 1B | Josh Naylor | $30 | -0.84 |
| 2B | Nico Hoerner | $18 | -0.33 |
| 3B | Junior Caminero | $37 | -0.77 |
| SS | Bobby Witt Jr. | $34 | -0.75 |
| OF | Juan Soto | $51 | -0.51 |
| OF | Cody Bellinger | $27 | -0.95 |
| OF | Julio Rodríguez | $37 | -1.00 |
| UTIL | Marcell Ozuna | $-12 | -0.09 |
| UTIL | Shohei Ohtani | $54 | -0.44 |
Fun, right? You’ll note that not every player on the high volatility team can be considered high volatility in an absolute sense. This is because, as mentioned, it is hard to find highly volatile good players. But as you can see, across each position, the volatility is generally rank ordered. In other words, José Ramírez is not a volatile player, but he was more volatile in production than Junior Caminero was. Likewise for Francisco Lindor compared to Bobby Witt Jr. Aaron Judge was not volatile, but he was more volatile than any of the three outfielders on the lower volatility team. This is the average variance constraint in action, and can help us examine whether higher volatility makes a difference, if not necessarily high volatility in the strictest sense.
2025 “Simulation”
With my teams in hand, I needed to construct my comparison exercise. To do this, I used Retrosheet data to tabulate the weekly totals throughout 2025 of players in each roster for each matchup. I tallied them by three methods: season-long roto scoring, head-to-head single-win, and head-to-head multi-category. Then, I averaged the tallies from each matchup to create a final result.
In the header for this section, I wrote “Simulation” in quotes because that is what this exercise feels like. But it’s not – it’s simply using a similar framework to illustrate events which actually happened in 2025.
Ideally, we’d see these teams roughly equal each other in the season-long roto categories. Then, if we expect streakiness to impact head-to-head results, we should see that reflected in some way (either positively or negatively). First, the roto results:
| Team | HR | R | RBI | SB | AVG |
|---|---|---|---|---|---|
| Streaky Freakies | 274 | 959 | 875 | 180 | 0.271 |
| Steady Eddies | 324 | 917 | 926 | 179 | 0.262 |
This is…remarkably dead-on. There is variation within the categories, but the Streaky Freakies won two cats (batting average, runs), the Steady Eddies won two (home runs, RBI), and the teams finished in a virtual tie in stolen bases. In a season of roto standings, this would be a 2-2-1 record. Smell test, passed. Now (drumroll), what you’ve all been waiting for:
| Result Type | SF Multi-Win Cats | SE Multi-Win Cats | SF Single-Win | SE Single-Win |
|---|---|---|---|---|
| Median | 63.5 | 66.5 | 13.5 | 12.5 |
| Average | 64.4 | 65.6 | 13.4 | 12.6 |
Some interesting, if anticlimactic results. The Steady Eddies have a slight edge in a multi-cat setting, while the Streaky Freakies maintain a slight edge in a single-win format. You could construct a story to fit these data – head-to-head multi-cat is the most granular setting, and therefore rewards steady players who consistently perform. Meanwhile, single-win settings introduce extra variance by collapsing matchups and allow the streaky team to steal a few wins.
Conclusion
But ultimately, the end result just isn’t that persuasive in one direction or another. I would (cautiously) interpret these results to mean that volatility, as measured in the way I have, is not going to play a role in your fantasy season unless your team is systematically more or less volatile than every other team in your league. And even then, the difference will not be large.
As I said before, this doesn’t mean that volatility doesn’t matter altogether; your highly volatile team will most likely not be very good. Likewise, there may be more precise ways to measure streakiness or eventually glean some sort of causal impact of a player’s volatility on their fantasy value. But what I think what this analysis can do is help fight the negativity bias driving aversion to streaky players — i.e. the notion that cold streaks cause more pain than hot streaks cause joy — as you seek to value your players more objectively.
Jonathan is a contributor for RotoGraphs. He is a Tigers fan living in Philadelphia with his wife and dog and requests that you leave your best pizza topping combinations in the comments.
# ══════════════════════════════════════════════════════════════════
# FANTASY BASEBALL VOLATILITY TEAM BUILDER
# ══════════════════════════════════════════════════════════════════
# ── Dependencies ───────────────────────────────────────────────
if (!requireNamespace(“baseballr”, quietly = TRUE)) install.packages(“baseballr”)
if (!requireNamespace(“lpSolve”, quietly = TRUE)) install.packages(“lpSolve”)
library(baseballr)
library(lpSolve)
library(dplyr)
library(tidyr)
# ══════════════════════════════════════════════════════════════════
# SECTION 1: STATIC CONFIG
# ══════════════════════════════════════════════════════════════════
SEASON <- “2025”
ACTIVE_MONTHS <- 4:9
MIN_GAMES <- 18
MIN_MONTHS <- 6
DOLLAR_FLOOR <- -15
VALUES_PATH <- “/Users/jvl/Desktop/RotoGraphs/Valuing Players/Part 2/Raw Data/values_2025.csv”
RETRO_PATH <- “~/Desktop/retrosheet/download.folder/unzipped/all2025.csv”
# ── Optimization Configs to Try ────────────────────────────────
configs <- expand.grid(
vol_gap = seq(0.75, 1.5, by = 0.05),
budget_diff = c(1,2,3,4,5,6,7,8,9,10)
) %>%
mutate(
vol_gap_rhs = vol_gap * 10,
config_id = row_number()
)
# ══════════════════════════════════════════════════════════════════
# SECTION 2: DATA PULL — runs once, reused across all configs
# ══════════════════════════════════════════════════════════════════
# ── FanGraphs Monthly Splits ────────────────────────────────────
monthly_splits <- list()
for (mo in as.character(1:12)) {
key <- paste0(SEASON, “_m”, mo)
message(“Pulling: “, key)
result <- tryCatch(
fg_batter_leaders(
startseason = SEASON, endseason = SEASON,
month = mo, qual = “0”, ind = “0”
),
error = function(e) NULL
)
if (!is.null(result) && nrow(result) > 0) {
result$season <- as.integer(SEASON)
result$month <- as.integer(mo)
monthly_splits[[key]] <- result
}
}
all_monthly <- dplyr::bind_rows(monthly_splits)
all_monthly_trim <- all_monthly %>%
filter(month %in% ACTIVE_MONTHS) %>%
select(season, month, PlayerName, playerid, G, HR, R, RBI, SB, AVG)
qualified_players <- all_monthly_trim %>%
filter(G >= MIN_GAMES) %>%
group_by(playerid) %>%
summarise(months_qualified = n(), .groups = “drop”) %>%
filter(months_qualified == MIN_MONTHS) %>%
pull(playerid)
all_monthly_trim <- all_monthly_trim %>%
filter(playerid %in% qualified_players)
message(“Qualified players: “, n_distinct(all_monthly_trim$playerid))
# ── Volatility Calculation ──────────────────────────────────────
volatility_cv <- all_monthly_trim %>%
mutate(AVG_scaled = AVG * G) %>%
group_by(playerid, PlayerName) %>%
summarise(
cv_HR = sd(HR) / mean(HR),
cv_R = sd(R) / mean(R),
cv_RBI = sd(RBI) / mean(RBI),
cv_SB = sd(SB) / mean(SB),
cv_AVG = sd(AVG_scaled) / mean(AVG_scaled),
.groups = “drop”
) %>%
mutate(across(starts_with(“cv_”), ~ifelse(is.nan(.), 0, .)))
volatility_final <- volatility_cv %>%
mutate(
z_HR = scale(cv_HR) [,1],
z_R = scale(cv_R) [,1],
z_RBI = scale(cv_RBI)[,1],
z_SB = scale(cv_SB) [,1],
z_AVG = scale(cv_AVG)[,1]
) %>%
mutate(
volatility_score = rowMeans(select(., z_HR, z_R, z_RBI, z_SB, z_AVG))
) %>%
arrange(desc(volatility_score))
# ── Join to Values ──────────────────────────────────────────────
values <- read.csv(VALUES_PATH, header = TRUE)
combined <- volatility_final %>%
inner_join(select(values, PlayerId, Dollars, POS), by = c(“playerid” = “PlayerId”)) %>%
mutate(
elig_C = as.integer(grepl(“C”, POS)),
elig_1B = as.integer(grepl(“1B”, POS)),
elig_2B = as.integer(grepl(“2B”, POS)),
elig_3B = as.integer(grepl(“3B”, POS)),
elig_SS = as.integer(grepl(“SS”, POS)),
elig_OF = as.integer(grepl(“OF”, POS)),
elig_UTIL = 1L
)
# ── Load Retrosheet ─────────────────────────────────────────────
retro_raw <- read.csv(RETRO_PATH, header = TRUE, stringsAsFactors = FALSE)
# ── Chadwick ID Lookup ──────────────────────────────────────────
chadwick_lu <- get_chadwick_lu()
id_lookup <- chadwick_lu %>%
filter(!is.na(key_fangraphs), key_retro != “”) %>%
select(key_retro, key_fangraphs)
# ── Precompute Runs Data from Full Retrosheet ───────────────────
# Done once here so it doesn’t rerun inside the loop
runs_data <- retro_raw %>%
mutate(
game_date = as.Date(substr(game_id, 4, 11), format = “%Y%m%d”),
week = as.integer(format(game_date, “%V”))
) %>%
select(week, bat_id,
bat_dest = bat_dest_id,
run1_id = base1_run_id, run1_dest = run1_dest_id,
run2_id = base2_run_id, run2_dest = run2_dest_id,
run3_id = base3_run_id, run3_dest = run3_dest_id) %>%
mutate(across(c(bat_dest, run1_dest, run2_dest, run3_dest), as.integer))
# ── FanGraphs Season Totals for Validation ──────────────────────
fg_season_full <- fg_batter_leaders(
startseason = “2025”, endseason = “2025”,
month = “0”, qual = “0”, ind = “0”
) %>%
select(PlayerName, playerid, AB, H, HR, R, RBI, SB)
# ══════════════════════════════════════════════════════════════════
# SECTION 3: BUILD LP CONSTRAINT MATRIX — runs once
# ══════════════════════════════════════════════════════════════════
n <- nrow(combined)
obj <- c(combined$Dollars, combined$Dollars)
high_con <- cbind(
rbind(combined$elig_C, combined$elig_1B, combined$elig_2B,
combined$elig_3B, combined$elig_SS, combined$elig_OF, rep(1, n)),
matrix(0, nrow = 7, ncol = n)
)
low_con <- cbind(
matrix(0, nrow = 7, ncol = n),
rbind(combined$elig_C, combined$elig_1B, combined$elig_2B,
combined$elig_3B, combined$elig_SS, combined$elig_OF, rep(1, n))
)
both_teams <- cbind(diag(n), diag(n))
ineligible <- as.integer(combined$Dollars < DOLLAR_FLOOR)
min_dollars_high <- c(ineligible, rep(0, n))
min_dollars_low <- c(rep(0, n), ineligible)
vol_gap_con <- c(combined$volatility_score, -combined$volatility_score)
# base constraint matrix without budget_diff or vol_gap RHS
# those are filled in per config in the loop
con_matrix <- rbind(high_con, low_con, both_teams,
c(combined$Dollars, -combined$Dollars), # budget_diff_1
c(-combined$Dollars, combined$Dollars), # budget_diff_2
min_dollars_high, min_dollars_low,
vol_gap_con)
con_dir <- c(rep(“=”, 6), “=”,
rep(“=”, 6), “=”,
rep(“<=”, n),
“<=”, “<=”,
“<=”, “<=”,
“>=”)
# ══════════════════════════════════════════════════════════════════
# SECTION 4: OPTIMIZATION LOOP
# ══════════════════════════════════════════════════════════════════
results <- list()
for (i in 1:nrow(configs)) {
cfg <- configs[i, ]
message(“Running config “, cfg$config_id,
” | vol_gap=”, cfg$vol_gap,
” | budget_diff=”, cfg$budget_diff)
con_rhs_i <- c(1, 1, 1, 1, 1, 3, 10,
1, 1, 1, 1, 1, 3, 10,
rep(1, n),
cfg$budget_diff, cfg$budget_diff,
0, 0,
cfg$vol_gap_rhs)
sol_i <- lp(
direction = “max”,
objective.in = obj,
const.mat = con_matrix,
const.dir = con_dir,
const.rhs = con_rhs_i,
all.bin = TRUE
)
if (sol_i$status == 0) {
high_i <- combined[sol_i$solution[1:n] == 1, ] %>%
select(PlayerName, playerid, POS, Dollars, volatility_score) %>%
mutate(team = “high”)
low_i <- combined[sol_i$solution[(n+1):(2*n)] == 1, ] %>%
select(PlayerName, playerid, POS, Dollars, volatility_score) %>%
mutate(team = “low”)
results[[cfg$config_id]] <- list(
config = cfg, high = high_i, low = low_i, status = “solved”
)
} else {
results[[cfg$config_id]] <- list(config = cfg, status = “infeasible”)
}
}
# ── Deduplicate by Roster Fingerprint ──────────────────────────
roster_fingerprints <- sapply(results, function(x) {
if (x$status != “solved”) return(NA)
paste(paste(sort(x$high$PlayerName), collapse = “|”),
paste(sort(x$low$PlayerName), collapse = “|”), sep = “||”)
})
config_summary <- data.frame(
config_id = configs$config_id,
vol_gap = configs$vol_gap,
budget_diff = configs$budget_diff,
status = sapply(results, function(x) x$status),
fingerprint = roster_fingerprints
) %>%
mutate(duplicate = is.na(fingerprint) | duplicated(fingerprint))
cat(“── Config Summary ──\n”)
print(config_summary %>% select(config_id, vol_gap, budget_diff, status, duplicate))
unique_configs <- config_summary %>%
filter(status == “solved”, !duplicate) %>%
pull(config_id)
cat(“\nUnique solved configs:”, length(unique_configs), “\n”)
# ══════════════════════════════════════════════════════════════════
# SECTION 5: H2H SIMULATION LOOP
# ══════════════════════════════════════════════════════════════════
# ══════════════════════════════════════════════════════════════════
# SECTION 5: H2H SIMULATION LOOP
# ══════════════════════════════════════════════════════════════════
h2h_results <- list()
for (cfg_id in unique_configs) {
message(“Running H2H for config “, cfg_id)
high_ids <- results[[cfg_id]]$high
low_ids <- results[[cfg_id]]$low
team_players <- bind_rows(high_ids, low_ids) %>%
inner_join(id_lookup, by = c(“playerid” = “key_fangraphs”))
# ── Weekly Batting Stats ──────────────────────────────────────
retro_teams <- retro_raw %>%
filter(bat_id %in% team_players$key_retro) %>%
mutate(
game_date = as.Date(substr(game_id, 4, 11), format = “%Y%m%d”),
week = as.integer(format(game_date, “%V”))
)
weekly_stats <- retro_teams %>%
mutate(
ab = as.integer(ab_fl == “TRUE”),
h = as.integer(as.integer(h_fl) > 0),
hr = as.integer(event_cd == 23),
rbi = as.integer(rbi_ct)
) %>%
group_by(bat_id, week) %>%
summarise(AB = sum(ab), H = sum(h), HR = sum(hr), RBI = sum(rbi), .groups = “drop”)
# ── Runs ───────────────────────────────────────────────────────
runs_fixed <- bind_rows(
runs_data %>% filter(bat_dest >= 4, bat_id %in% team_players$key_retro) %>% select(week, player_id = bat_id),
runs_data %>% filter(run1_dest >= 4, run1_id %in% team_players$key_retro) %>% select(week, player_id = run1_id),
runs_data %>% filter(run2_dest >= 4, run2_id %in% team_players$key_retro) %>% select(week, player_id = run2_id),
runs_data %>% filter(run3_dest >= 4, run3_id %in% team_players$key_retro) %>% select(week, player_id = run3_id)
) %>%
group_by(bat_id = player_id, week) %>%
summarise(R = n(), .groups = “drop”)
# ── SB ─────────────────────────────────────────────────────────
sb_data <- retro_raw %>%
mutate(
game_date = as.Date(substr(game_id, 4, 11), format = “%Y%m%d”),
week = as.integer(format(game_date, “%V”))
) %>%
select(week,
run1 = base1_run_id, sb1 = run1_sb_fl,
run2 = base2_run_id, sb2 = run2_sb_fl,
run3 = base3_run_id, sb3 = run3_sb_fl) %>%
pivot_longer(cols = c(run1, run2, run3), names_to = “base”, values_to = “runner_id”) %>%
mutate(sb_fl = case_when(
base == “run1” ~ as.logical(sb1),
base == “run2” ~ as.logical(sb2),
base == “run3” ~ as.logical(sb3)
)) %>%
filter(runner_id %in% team_players$key_retro, sb_fl == TRUE) %>%
group_by(bat_id = runner_id, week) %>%
summarise(SB = n(), .groups = “drop”)
# ── Join All Stats ─────────────────────────────────────────────
weekly_stats <- weekly_stats %>%
left_join(runs_fixed, by = c(“bat_id”, “week”)) %>%
left_join(sb_data, by = c(“bat_id”, “week”)) %>%
mutate(
R = ifelse(is.na(R), 0L, R),
SB = ifelse(is.na(SB), 0L, SB),
AVG = ifelse(AB > 0, H / AB, NA)
) %>%
left_join(select(team_players, key_retro, PlayerName, team),
by = c(“bat_id” = “key_retro”))
# ── Validate Against FanGraphs ─────────────────────────────────
fg_season <- fg_season_full %>%
filter(playerid %in% c(high_ids$playerid, low_ids$playerid))
retro_season <- weekly_stats %>%
group_by(bat_id) %>%
summarise(
AB_r = sum(AB),
H_r = sum(H),
HR_r = sum(HR),
R_r = sum(R),
RBI_r = sum(RBI),
SB_r = sum(SB),
.groups = “drop”
) %>%
left_join(select(team_players, key_retro, PlayerName), by = c(“bat_id” = “key_retro”))
validation <- fg_season %>%
left_join(retro_season, by = “PlayerName”) %>%
mutate(
diff_AB = AB – AB_r,
diff_H = H – H_r,
diff_HR = HR – HR_r,
diff_R = R – R_r,
diff_RBI = RBI – RBI_r,
diff_SB = SB – SB_r
)
if (any(abs(validation %>% select(starts_with(“diff_”))) > 2, na.rm = TRUE)) {
warning(“Config “, cfg_id, “: validation failed — diffs > 2 detected”)
} else {
message(“Config “, cfg_id, “: validation passed”)
}
# ── Team Weekly Totals ─────────────────────────────────────────
team_weekly <- weekly_stats %>%
group_by(team, week) %>%
summarise(
AB = sum(AB), H = sum(H), HR = sum(HR),
RBI = sum(RBI), R = sum(R), SB = sum(SB),
AVG = H / AB, .groups = “drop”
) %>%
mutate(week = case_when(week == 12 ~ 13L, week == 29 ~ 30L, TRUE ~ week)) %>%
group_by(team, week) %>%
summarise(
AB = sum(AB), H = sum(H), HR = sum(HR),
RBI = sum(RBI), R = sum(R), SB = sum(SB), AVG = H / AB,
.groups = “drop”
) %>%
arrange(team, week)
# ── H2H Matchups ───────────────────────────────────────────────
matchups <- team_weekly %>%
pivot_wider(names_from = team, values_from = c(HR, R, RBI, SB, AVG, AB, H)) %>%
mutate(
w_HR = case_when(HR_high > HR_low ~ 1, HR_high == HR_low ~ 0.5, TRUE ~ 0),
w_R = case_when(R_high > R_low ~ 1, R_high == R_low ~ 0.5, TRUE ~ 0),
w_RBI = case_when(RBI_high > RBI_low ~ 1, RBI_high == RBI_low ~ 0.5, TRUE ~ 0),
w_SB = case_when(SB_high > SB_low ~ 1, SB_high == SB_low ~ 0.5, TRUE ~ 0),
w_AVG = case_when(AVG_high > AVG_low ~ 1, AVG_high == AVG_low ~ 0.5, TRUE ~ 0),
cats_high = w_HR + w_R + w_RBI + w_SB + w_AVG,
cats_low = 5 – cats_high,
win_high = case_when(cats_high > 2.5 ~ 1, cats_high == 2.5 ~ 0.5, TRUE ~ 0),
win_low = case_when(cats_low > 2.5 ~ 1, cats_low == 2.5 ~ 0.5, TRUE ~ 0)
)
# ── Store Results ──────────────────────────────────────────────
h2h_results[[cfg_id]] <- list(
config = results[[cfg_id]]$config,
high = high_ids,
low = low_ids,
team_weekly = team_weekly,
matchups = matchups,
season_totals = team_weekly %>%
group_by(team) %>%
summarise(HR = sum(HR), R = sum(R), RBI = sum(RBI),
SB = sum(SB), AVG = sum(H) / sum(AB), .groups = “drop”),
cats_high = sum(matchups$cats_high),
cats_low = sum(matchups$cats_low),
wins_high = sum(matchups$win_high),
wins_low = sum(matchups$win_low)
)
}
# ══════════════════════════════════════════════════════════════════
# SECTION 6: SUMMARY
# ══════════════════════════════════════════════════════════════════
# ── H2H Results by Config ───────────────────────────────────────
cat(“── H2H Results by Config ──\n”)
do.call(rbind, lapply(unique_configs, function(cfg_id) {
r <- h2h_results[[cfg_id]]
data.frame(
config_id = cfg_id,
vol_gap = r$config$vol_gap,
budget_diff = r$config$budget_diff,
cats_high = r$cats_high,
cats_low = r$cats_low,
wins_high = r$wins_high,
wins_low = r$wins_low
)
})) %>% print()
# ── Average H2H Across All Configs ─────────────────────────────
cat(“\n── Average H2H Results ──\n”)
do.call(rbind, lapply(unique_configs, function(cfg_id) {
r <- h2h_results[[cfg_id]]
data.frame(cats_high = r$cats_high, cats_low = r$cats_low,
wins_high = r$wins_high, wins_low = r$wins_low)
})) %>%
summarise(across(everything(), mean)) %>%
print()
# ── Average Roto Totals by Team ─────────────────────────────────
cat(“\n── Average Roto Totals by Team ──\n”)
do.call(rbind, lapply(unique_configs, function(cfg_id) {
h2h_results[[cfg_id]]$season_totals %>% mutate(config_id = cfg_id)
})) %>%
group_by(team) %>%
summarise(
mean_HR = mean(HR),
mean_R = mean(R),
mean_RBI = mean(RBI),
mean_SB = mean(SB),
mean_AVG = mean(AVG),
.groups = “drop”
) %>%
print()
# ── Median H2H Results ──────────────────────────────────────────
cat(“\n── Median H2H Results ──\n”)
do.call(rbind, lapply(unique_configs, function(cfg_id) {
r <- h2h_results[[cfg_id]]
data.frame(cats_high = r$cats_high, cats_low = r$cats_low,
wins_high = r$wins_high, wins_low = r$wins_low)
})) %>%
summarise(across(everything(), median)) %>%
print()
# ── Export Top/Bottom 10 Volatility ────────────────────────────
volatility_export <- volatility_final %>%
inner_join(select(values, PlayerId, Dollars, POS), by = c(“playerid” = “PlayerId”)) %>%
select(PlayerName, POS, Dollars, volatility_score, z_HR, z_R, z_RBI, z_SB, z_AVG)
volatility_export %>%
arrange(desc(volatility_score)) %>%
head(10) %>%
mutate(
volatility_score = round(volatility_score, 2),
Dollars = paste0(“$”, round(Dollars, 0))
) %>%
select(PlayerName, Dollars, volatility_score) %>%
write.csv(“most_volatile.csv”, row.names = FALSE, quote = FALSE)
volatility_export %>%
arrange(volatility_score) %>%
head(10) %>%
mutate(
volatility_score = round(volatility_score, 2),
Dollars = paste0(“$”, round(Dollars, 0))
) %>%
select(PlayerName, Dollars, volatility_score) %>%
write.csv(“least_volatile.csv”, row.names = FALSE, quote = FALSE)
all_monthly_trim %>%
filter(PlayerName == “Julio Rodríguez”) %>%
mutate(
month = case_when(
month == 4 ~ “Mar/Apr”,
month == 5 ~ “May”,
month == 6 ~ “Jun”,
month == 7 ~ “Jul”,
month == 8 ~ “Aug”,
month == 9 ~ “Sep/Oct”
),
AVG = round(AVG, 3)
) %>%
select(month, G, HR, R, RBI, SB, AVG) %>%
write.csv(“julio_rodriguez_monthly.csv”, row.names = FALSE, quote = FALSE)
volatility_export %>%
filter(Dollars >= 5) %>%
arrange(desc(volatility_score)) %>%
head(10) %>%
mutate(
volatility_score = round(volatility_score, 2),
Dollars = paste0(“$”, round(Dollars, 0))
) %>%
select(PlayerName, Dollars, volatility_score) %>%
write.csv(“most_volatile_5plus.csv”, row.names = FALSE, quote = FALSE)
volatility_export %>%
filter(Dollars >= 15) %>%
arrange(desc(volatility_score)) %>%
head(10) %>%
mutate(
volatility_score = round(volatility_score, 2),
Dollars = paste0(“$”, round(Dollars, 0))
) %>%
select(PlayerName, Dollars, volatility_score) %>%
write.csv(“most_volatile_15plus.csv”, row.names = FALSE, quote = FALSE)
combined %>%
filter(Dollars >= -10) %>%
mutate(dollar_bucket = case_when(
Dollars < 0 ~ “-$10-0”,
Dollars < 10 ~ “$0-10”,
Dollars < 20 ~ “$10-20”,
TRUE ~ “$20+”
)) %>%
group_by(dollar_bucket) %>%
summarise(
n = n(),
mean_vol = round(mean(volatility_score), 3),
.groups = “drop”
) %>%
write.csv(“volatility_by_dollar_bucket.csv”, row.names = FALSE, quote = FALSE)
cor(combined$volatility_score, combined$Dollars)
# ── Position Sort Order ─────────────────────────────────────────
pos_order <- c(“C”, “1B”, “2B”, “3B”, “SS”, “OF”, “UTIL”)
set.seed(42)
sample_configs <- sample(unique_configs, 3)
for (cfg_id in sample_configs) {
for (team_name in c(“high”, “low”)) {
h2h_results[[cfg_id]][[team_name]] %>%
mutate(
volatility_score = round(volatility_score, 2),
Dollars = paste0(“$”, round(Dollars, 0)),
primary_pos = case_when(
grepl(“C”, POS) ~ “C”,
grepl(“1B”, POS) ~ “1B”,
grepl(“2B”, POS) ~ “2B”,
grepl(“3B”, POS) ~ “3B”,
grepl(“SS”, POS) ~ “SS”,
grepl(“OF”, POS) ~ “OF”,
TRUE ~ “UTIL”
),
primary_pos = factor(primary_pos, levels = pos_order)
) %>%
arrange(primary_pos) %>%
select(primary_pos, PlayerName, Dollars, volatility_score) %>%
write.csv(paste0(team_name, “_team_config_”, cfg_id, “.csv”), row.names = FALSE, quote = FALSE)
}
message(“Exported config “, cfg_id)
}
This is really interesting but leaves out the emotional decision making of an owner. If you have a player who is looking terrible, moved down the order or sitting against same handed pitching, you might bench them. Of course the week you bench them the volatility swings and they hit multiple hrs and have a an incredible week. Unless you treat you volatile hitters like steady producers you are most likely going to lose out on stats. Also I understand the method used for creating the volatile and steady roster. But those are unrealistic roster configuration for an owner to have. It seems like the argument you made is that if you have players who had a great year, it doesn’t matter if they were streaky or steady as long as you rostered them all year. How would I adapt this research to my draft or auction next year?
I would love to see this adapted for best ball points formats. If there is a signal:noise where the Freaky Steakys should have a noticeable advantage, it would be in this format.
That’s because you have a large bench (usually about the size of your lineup) and only the best scores count each week, with the season total determining the winner. So while floor is far from irrelevant, you mostly want spiky players to put up big weeks. No mid-season pickups; the whole point is it’s draft only, so injury aversion matters too (this is why there are large benches; otherwise, it’s guess who gets hurt).
I think that streakier hitters gain value in this specific format which I happen to love so I can draft lots of teams without ruining my life. But a steady floor seems to be underrated by drafters as well.
P.S. I prefer CBS points since it’s close to linear weights and splits the difference on Ks (0.5 plus for P minus for bats), but I do think it would be similar for most points formats. The steal-heavy formats that try to replicate roto might be even spikier week-to-week.