Case Study: The Professional Bettor's First Season --- An EV Analysis
Chapter 3, Case Study 1
Estimated reading time: 25 minutes
Code reference: code/case-study-code.py (Section 1)
Prerequisites: Chapter 3 core material, basic Python and pandas
Introduction
Meet Sarah Chen. She is not real, but her story is built from patterns that repeat across hundreds of documented accounts from professional bettors who have shared their early experiences in interviews, forums, and published memoirs. Sarah has a background in statistics, spent six months building and testing a predictive model for NFL sides and totals, and has decided to commit to a full season of disciplined, positive expected value betting. She begins with a bankroll of $10,000 and a flat-staking plan of $100 per bet --- one percent of her starting bankroll.
Her model, backtested across three seasons of historical data, identifies bets where her estimated probability of winning diverges meaningfully from the implied probability embedded in the sportsbook's line. On average, her model finds an edge of approximately 3% on the bets she selects. At standard juice of -110, this means she expects to win roughly 55.5% of her bets against an implied break-even rate of 52.4%. She is disciplined, methodical, and has read everything she can find about bankroll management and variance.
By the end of her first 1,000 bets, she will have learned more about the lived experience of expected value than any textbook chapter could convey on its own. This case study follows her journey in detail, using synthetic data that faithfully represents the statistical properties of real-world sports betting.
Data Dictionary
Before we generate and analyze Sarah's betting data, we define the structure precisely.
| Column | Type | Description |
|---|---|---|
bet_id |
int | Unique identifier for each bet (1 through 1000) |
date |
datetime | Date the bet was placed, spanning a full NFL/NBA season |
sport |
str | Sport: NFL or NBA |
stake |
float | Amount wagered, fixed at $100 |
decimal_odds |
float | Decimal odds at which the bet was placed |
implied_prob |
float | Implied probability from the line (1 / decimal_odds) |
true_prob |
float | Sarah's model estimated true win probability |
edge |
float | true_prob - implied_prob, the estimated advantage |
ev_per_bet |
float | Expected value in dollars: stake * (true_prob * (decimal_odds - 1) - (1 - true_prob)) |
result |
str | "win" or "loss" (no pushes for simplicity) |
profit |
float | Actual profit: stake * (decimal_odds - 1) if win, -stake if loss |
cumulative_profit |
float | Running total of profit through this bet |
cumulative_ev |
float | Running total of expected value through this bet |
bet_month |
str | Calendar month for aggregation (e.g., "2024-09") |
Synthetic Data Generation
The data generation process is designed to capture the essential statistical features of a real betting record while allowing us to control and understand the underlying parameters.
import numpy as np
import pandas as pd
np.random.seed(42)
N_BETS = 1000
STAKE = 100.0
BASE_EDGE = 0.03 # 3% average edge
# Generate dates spanning Sep 2024 through Apr 2025
dates = pd.date_range(start="2024-09-01", end="2025-04-30", periods=N_BETS)
# Generate decimal odds centered around 1.91 (standard -110)
# with some variation for occasional plus-money spots
decimal_odds = np.clip(
np.random.normal(loc=1.91, scale=0.15, size=N_BETS),
a_min=1.50, a_max=2.80
)
implied_prob = 1.0 / decimal_odds
# True probability: model's edge over the market
# Edge varies per bet (not every bet has exactly 3% edge)
edges = np.clip(
np.random.normal(loc=BASE_EDGE, scale=0.015, size=N_BETS),
a_min=0.005, a_max=0.08
)
true_prob = implied_prob + edges
# EV per bet
ev_per_bet = STAKE * (true_prob * (decimal_odds - 1) - (1 - true_prob))
# Simulate outcomes based on true probability
random_draws = np.random.uniform(0, 1, size=N_BETS)
results = np.where(random_draws < true_prob, "win", "loss")
profits = np.where(
results == "win",
STAKE * (decimal_odds - 1),
-STAKE
)
Several modeling decisions deserve explanation. First, the edge is drawn from a normal distribution centered at 3% with a standard deviation of 1.5%, reflecting the reality that not every bet carries the same edge. Some are strong plays with 5--7% edge; others are marginal at 1--2%. Second, the decimal odds vary around the standard -110 line (1.909 decimal) but include some plus-money situations, as Sarah occasionally finds value on underdogs. Third, each bet's outcome is determined by an independent Bernoulli trial at the true probability, which is the statistically correct way to simulate individual bet outcomes.
Cumulative Results: What Sarah Actually Experienced
After running the simulation, we construct the full betting record and compute cumulative metrics.
df = pd.DataFrame({
"bet_id": range(1, N_BETS + 1),
"date": dates,
"stake": STAKE,
"decimal_odds": decimal_odds,
"implied_prob": implied_prob,
"true_prob": true_prob,
"edge": edges,
"ev_per_bet": ev_per_bet,
"result": results,
"profit": profits,
})
df["cumulative_profit"] = df["profit"].cumsum()
df["cumulative_ev"] = df["ev_per_bet"].cumsum()
df["bet_month"] = df["date"].dt.to_period("M").astype(str)
The headline numbers after 1,000 bets tell one story:
| Metric | Value |
|---|---|
| Total bets | 1,000 |
| Wins | ~555 (varies by seed) |
| Losses | ~445 |
| Win rate | ~55.5% |
| Total profit | ~$2,800 |
| Total expected profit | ~$2,750 |
| ROI | ~2.8% |
| Yield | ~2.8% (same as ROI with flat stakes) |
These numbers are solid. A 2.8% ROI on 1,000 bets at $100 each means Sarah turned $100,000 in total action into roughly $2,800 in profit. Her model works. But these aggregated numbers conceal the experience of getting there, which is what this case study is really about.
The Equity Curve: Expected vs. Actual
The most instructive visualization in any bettor's toolkit is the plot of cumulative actual profit against cumulative expected profit. The expected profit line rises steadily --- it is the sum of the EV of each bet, which is positive for every bet Sarah places. The actual profit line, by contrast, wanders drunkenly around the expected line.
import matplotlib.pyplot as plt
fig, ax = plt.subplots(figsize=(14, 7))
ax.plot(df["bet_id"], df["cumulative_ev"], label="Cumulative Expected Profit",
color="blue", linewidth=2, linestyle="--")
ax.plot(df["bet_id"], df["cumulative_profit"], label="Cumulative Actual Profit",
color="green", linewidth=1.5, alpha=0.85)
ax.axhline(y=0, color="red", linewidth=0.8, linestyle=":")
ax.set_xlabel("Bet Number", fontsize=12)
ax.set_ylabel("Profit ($)", fontsize=12)
ax.set_title("Sarah's First 1,000 Bets: Expected vs. Actual Profit", fontsize=14)
ax.legend(fontsize=11)
ax.grid(True, alpha=0.3)
plt.tight_layout()
plt.savefig("cumulative_profit_vs_ev.png", dpi=150)
plt.show()
What this plot typically reveals --- and what Sarah experienced --- is that the actual profit line spends long stretches below the expected profit line, and sometimes below zero. There may be a run of 200 bets where Sarah is in the red despite placing exclusively positive EV wagers. The expected profit line, meanwhile, marches upward at a nearly constant slope of roughly $2.75 per bet (the average EV per wager).
This is the central lesson: positive expected value guarantees nothing about the short run. It guarantees only the direction of the long-run average.
Month-by-Month Breakdown
Aggregating by month reveals the emotional texture of the season.
monthly = df.groupby("bet_month").agg(
bets=("bet_id", "count"),
wins=("result", lambda x: (x == "win").sum()),
total_profit=("profit", "sum"),
total_ev=("ev_per_bet", "sum"),
avg_edge=("edge", "mean"),
).reset_index()
monthly["win_rate"] = monthly["wins"] / monthly["bets"]
monthly["roi"] = monthly["total_profit"] / (monthly["bets"] * STAKE)
A typical monthly breakdown might look like this:
| Month | Bets | Wins | Win Rate | Profit | Expected Profit | ROI |
|---|---|---|---|---|---|---|
| 2024-09 | 85 | 50 | 58.8% | +$540 | +$230 | +6.4% | |
| 2024-10 | 110 | 57 | 51.8% | -$180 | +$300 | -1.6% | |
| 2024-11 | 130 | 74 | 56.9% | +$610 | +$360 | +4.7% | |
| 2024-12 | 140 | 72 | 51.4% | -$350 | +$385 | -2.5% | |
| 2025-01 | 145 | 83 | 57.2% | +$720 | +$400 | +5.0% | |
| 2025-02 | 140 | 80 | 57.1% | +$680 | +$385 | +4.9% | |
| 2025-03 | 130 | 69 | 53.1% | +$30 | +$355 | +0.2% | |
| 2025-04 | 120 | 70 | 58.3% | +$750 | +$335 | +6.3% |
Notice that two months --- October and December --- are outright losers. In October, Sarah won only 51.8% of her bets, barely above the implied break-even rate of 52.4%. In December, she dipped below it. These losing months occurred despite her model maintaining a consistent 3% edge, as confirmed by the expected profit column which remains positive every month. The variance inherent in 110--140 individual coin-flip-like events (each with only a slight tilt in her favor) is more than sufficient to produce losing months.
For Sarah, December was the hardest. She entered the month riding a profitable November and felt confident. By December 20th, she was down $500 for the month and $200 for the overall season. The cumulative profit line had dipped below zero for the first time since early October. She questioned her model. She considered increasing her stakes to "catch up." She did neither, because she understood the mathematics of what was happening.
Drawdown Analysis
A drawdown is the peak-to-trough decline in cumulative profit. It measures how much a bettor's bankroll falls from its highest point before recovering to a new high.
df["cumulative_max"] = df["cumulative_profit"].cummax()
df["drawdown"] = df["cumulative_profit"] - df["cumulative_max"]
max_drawdown = df["drawdown"].min()
max_dd_idx = df["drawdown"].idxmin()
max_dd_bet = df.loc[max_dd_idx, "bet_id"]
In Sarah's simulation, the maximum drawdown is typically in the range of $800 to $1,500. To put that in perspective, her final profit is approximately $2,800. This means that at her worst point, she had given back 30--50% of her eventual total profit, and in some simulations she was in a drawdown that exceeded her current total profit. A drawdown of $1,200 on a $10,000 bankroll represents a 12% decline --- noticeable and psychologically taxing, but survivable with flat $100 stakes.
fig, axes = plt.subplots(2, 1, figsize=(14, 10), sharex=True)
axes[0].plot(df["bet_id"], df["cumulative_profit"], color="green", linewidth=1.5)
axes[0].plot(df["bet_id"], df["cumulative_max"], color="blue",
linewidth=1, linestyle="--", alpha=0.7)
axes[0].set_ylabel("Cumulative Profit ($)", fontsize=12)
axes[0].set_title("Cumulative Profit and Peak", fontsize=14)
axes[0].legend(["Cumulative Profit", "Running Peak"], fontsize=10)
axes[0].grid(True, alpha=0.3)
axes[1].fill_between(df["bet_id"], df["drawdown"], 0,
color="red", alpha=0.4)
axes[1].set_ylabel("Drawdown ($)", fontsize=12)
axes[1].set_xlabel("Bet Number", fontsize=12)
axes[1].set_title("Drawdown from Peak", fontsize=14)
axes[1].grid(True, alpha=0.3)
plt.tight_layout()
plt.savefig("drawdown_analysis.png", dpi=150)
plt.show()
The drawdown chart is humbling. Even for a bettor with a genuine, consistent edge, the red fill --- representing the distance below the all-time high --- covers large portions of the timeline. Drawdowns are not exceptions; they are the norm. The bettor spends far more time in drawdown than at new highs.
Streak Analysis: Hot and Cold Runs
Human psychology is wired to detect patterns in sequences, and betting outcomes provide a fertile ground for this tendency. We analyze Sarah's winning and losing streaks to understand what is normal under her true probability distribution.
from itertools import groupby
streaks = []
for key, group in groupby(df["result"]):
length = sum(1 for _ in group)
streaks.append({"type": key, "length": length})
streak_df = pd.DataFrame(streaks)
win_streaks = streak_df[streak_df["type"] == "win"]["length"]
loss_streaks = streak_df[streak_df["type"] == "loss"]["length"]
Typical findings from 1,000 bets at a 55.5% win rate:
| Metric | Value |
|---|---|
| Longest winning streak | 10--14 bets |
| Longest losing streak | 7--11 bets |
| Average winning streak | 2.2 bets |
| Average losing streak | 1.8 bets |
| Number of 5+ win streaks | 15--25 |
| Number of 5+ loss streaks | 8--15 |
A losing streak of 8 or more bets is entirely unremarkable at a 55.5% win rate. The probability of losing 8 consecutive bets when each bet has a 44.5% chance of losing is 0.445^8 = 0.0019, or about 1 in 520. But over 1,000 bets, there are roughly 993 starting positions for a potential 8-bet streak, so the expected number of such streaks is approximately 993 * 0.0019 = 1.9. In other words, you should expect to experience at least one 8-bet losing streak in every 1,000-bet sample. It is not a sign that the model is broken.
fig, axes = plt.subplots(1, 2, figsize=(14, 5))
axes[0].hist(win_streaks, bins=range(1, win_streaks.max() + 2),
color="green", alpha=0.7, edgecolor="black", align="left")
axes[0].set_title("Distribution of Winning Streaks", fontsize=13)
axes[0].set_xlabel("Streak Length", fontsize=11)
axes[0].set_ylabel("Frequency", fontsize=11)
axes[1].hist(loss_streaks, bins=range(1, loss_streaks.max() + 2),
color="red", alpha=0.7, edgecolor="black", align="left")
axes[1].set_title("Distribution of Losing Streaks", fontsize=13)
axes[1].set_xlabel("Streak Length", fontsize=11)
axes[1].set_ylabel("Frequency", fontsize=11)
plt.tight_layout()
plt.savefig("streak_distributions.png", dpi=150)
plt.show()
The Convergence of Actual to Expected
Perhaps the most important visualization is one that shows the ratio of actual profit to expected profit over time. Early in the season, this ratio swings wildly. After 50 bets, actual profit might be 200% of expected or -50%. But as the sample grows, the ratio converges toward 1.0, illustrating the law of large numbers in action.
df["profit_ratio"] = df["cumulative_profit"] / df["cumulative_ev"]
fig, ax = plt.subplots(figsize=(14, 6))
# Start from bet 20 to avoid division-by-near-zero issues
plot_df = df[df["bet_id"] >= 20]
ax.plot(plot_df["bet_id"], plot_df["profit_ratio"],
color="purple", linewidth=1.2, alpha=0.8)
ax.axhline(y=1.0, color="black", linewidth=1.5, linestyle="--")
ax.set_xlabel("Bet Number", fontsize=12)
ax.set_ylabel("Actual / Expected Profit Ratio", fontsize=12)
ax.set_title("Convergence: Actual Profit Approaches Expected Profit", fontsize=14)
ax.set_ylim(-2, 4)
ax.grid(True, alpha=0.3)
plt.tight_layout()
plt.savefig("convergence_ratio.png", dpi=150)
plt.show()
By bet 500, the ratio typically stabilizes in the range of 0.6 to 1.4. By bet 1,000, it has usually narrowed to 0.8 to 1.2. This convergence is precisely what the law of large numbers predicts, but the speed of convergence is slower than most people intuitively expect. One thousand bets is a large sample by everyday standards, but in the context of slightly-better-than-fair coin flips, it is only barely sufficient to distinguish signal from noise.
Statistical Significance Testing
At the end of the season, Sarah wants to know whether her results are statistically distinguishable from chance. She performs a one-sample proportion test.
from scipy import stats
n_wins = (df["result"] == "win").sum()
n_total = len(df)
observed_rate = n_wins / n_total
null_rate = 0.524 # break-even at -110
z_stat = (observed_rate - null_rate) / np.sqrt(null_rate * (1 - null_rate) / n_total)
p_value = 1 - stats.norm.cdf(z_stat)
print(f"Observed win rate: {observed_rate:.4f}")
print(f"Null hypothesis rate: {null_rate:.4f}")
print(f"Z-statistic: {z_stat:.3f}")
print(f"P-value (one-tailed): {p_value:.4f}")
With a 55.5% win rate over 1,000 bets against a null of 52.4%, the z-statistic is approximately 1.96, yielding a p-value around 0.025. This is statistically significant at the 5% level but not overwhelmingly so. It would take approximately 2,500--3,000 bets at the same edge to achieve a p-value below 0.01. This underscores a critical reality: even genuinely skilled bettors need thousands of bets to prove their skill is not luck. Many give up, run out of bankroll, or lose confidence long before reaching that threshold.
Key Takeaways
-
Positive EV is a long-run guarantee, not a short-run promise. Sarah's model had a genuine 3% edge, yet she experienced two losing months and a maximum drawdown exceeding $1,000. A bettor who abandons a sound strategy after a bad month is making an error that no amount of modeling sophistication can correct.
-
The variance swamps the edge in the short run. The standard deviation of profit per bet at -110 odds with a $100 stake is approximately $100 (it is essentially the stake itself). The expected profit per bet is only $2.75. The signal-to-noise ratio per bet is 0.0275. It takes hundreds of bets for the signal to emerge from the noise.
-
Streaks are normal, not diagnostic. Losing 8 bets in a row is an expected event over 1,000 bets at Sarah's win rate. Reacting to streaks by changing strategy, increasing stakes, or abandoning the model is a well-documented path to failure.
-
Monthly accounting is misleading. Evaluating a betting strategy on a monthly basis is like evaluating a stock-picking strategy by looking at daily returns. The time horizon is too short to draw meaningful conclusions. Quarterly or seasonal evaluations are the minimum reasonable cadence.
-
Statistical proof of skill requires patience. After 1,000 bets, Sarah's results are suggestive but not conclusive. The p-value of 0.025 would not survive the scrutiny of multiple hypothesis testing or a skeptical audience. This is not because her edge is not real; it is because the edge is small relative to the variance inherent in binary outcomes.
What Happened Next
Sarah continued betting through a second season. By the end of 2,000 bets, her cumulative profit had grown to approximately $5,800, her win rate had settled at 55.3%, and the p-value on her skill had dropped below 0.005. She increased her stake to $150 per bet --- a measured adjustment that reflected her growing bankroll and accumulated evidence. She also began tracking closing line value (the subject of Case Study 2) as a real-time diagnostic for whether her bets were likely to be profitable in the long run, without having to wait for results to accumulate.
The most important thing Sarah learned in her first season was not a mathematical formula or a modeling technique. It was the psychological discipline to trust a process whose outcomes are, on any given day, indistinguishable from noise.
The complete code for this case study, including data generation, analysis, and all visualizations, is available in code/case-study-code.py.