Case Study: Are NBA Win Streaks Real or Random?

Introduction

Every NBA season brings stories of teams on "hot streaks" and "cold slumps." Commentators speak of momentum as though it were a physical force. When the 2015-16 Golden State Warriors won their first 24 games, many assumed some extraordinary force was at work beyond mere talent. But how much of what we observe in win-loss sequences can be explained by pure randomness? If a team wins 60% of its games, the binomial distribution tells us exactly how likely various streak lengths are under the assumption of independence. If observed streaks significantly exceed these expectations, we have evidence for genuine momentum effects. If not, the "hot streak" narrative is likely an illusion born from the human tendency to see patterns in randomness.

This case study uses the binomial distribution and Monte Carlo simulation to rigorously test whether NBA win streaks are "real" (exceeding random expectations) or whether they are exactly what we should expect from teams with fixed win probabilities playing 82-game seasons.

The Null Hypothesis: Independent Bernoulli Trials

Under the null hypothesis, each game is an independent Bernoulli trial. A team with a true win probability p has that same probability of winning every single game, regardless of what happened in previous games. This is the "hot hand is a fallacy" model.

Under this model, the number of wins in an 82-game season follows a Binomial(n=82, p) distribution, and the sequence of wins and losses is equivalent to flipping a biased coin 82 times. Streaks emerge naturally from such sequences, and they are often longer than people intuitively expect.

The probability of a win streak of length k or longer appearing somewhere within an n-game season can be approximated, but exact computation is complex. This is precisely why simulation is valuable.

Setting Up the Simulation

We begin by writing the core simulation engine that generates random seasons and measures streak properties.

"""
NBA Win Streak Analysis — Simulation Engine
"""
import numpy as np
import pandas as pd
from scipy import stats
from typing import List, Tuple

np.random.seed(42)


def simulate_season(
    win_prob: float,
    num_games: int = 82,
) -> np.ndarray:
    """
    Simulate a single NBA season as independent Bernoulli trials.

    Args:
        win_prob: Per-game win probability.
        num_games: Number of games in the season.

    Returns:
        Array of 1s (win) and 0s (loss).
    """
    return np.random.binomial(1, win_prob, size=num_games)


def compute_streaks(results: np.ndarray) -> dict:
    """
    Compute streak statistics from a win-loss sequence.

    Args:
        results: Array of 1s (win) and 0s (loss).

    Returns:
        Dictionary containing longest win streak, longest loss streak,
        all win streak lengths, and all loss streak lengths.
    """
    win_streaks = []
    loss_streaks = []
    current_streak = 1

    for i in range(1, len(results)):
        if results[i] == results[i - 1]:
            current_streak += 1
        else:
            if results[i - 1] == 1:
                win_streaks.append(current_streak)
            else:
                loss_streaks.append(current_streak)
            current_streak = 1

    # Don't forget the final streak
    if results[-1] == 1:
        win_streaks.append(current_streak)
    else:
        loss_streaks.append(current_streak)

    return {
        "longest_win_streak": max(win_streaks) if win_streaks else 0,
        "longest_loss_streak": max(loss_streaks) if loss_streaks else 0,
        "win_streaks": win_streaks,
        "loss_streaks": loss_streaks,
        "num_win_streaks": len(win_streaks),
        "num_loss_streaks": len(loss_streaks),
        "total_wins": int(np.sum(results)),
    }


def simulate_many_seasons(
    win_prob: float,
    num_games: int = 82,
    num_simulations: int = 100_000,
) -> pd.DataFrame:
    """
    Simulate many seasons and collect streak statistics.

    Returns:
        DataFrame with one row per simulated season.
    """
    records = []
    for _ in range(num_simulations):
        season = simulate_season(win_prob, num_games)
        streak_stats = compute_streaks(season)
        records.append(streak_stats)

    return pd.DataFrame(records)

Analyzing Expected Streak Lengths

Let us first establish the baseline: what does randomness predict? We simulate 100,000 seasons for teams at various win probabilities.

"""
NBA Win Streak Analysis — Expected Streak Lengths Under Randomness
"""

win_probs = [0.400, 0.500, 0.550, 0.600, 0.650, 0.700, 0.750, 0.800]

print("Expected Longest Win Streak by Win Probability (82-game season)")
print("=" * 75)
print(f"{'Win Prob':>10s} {'Mean':>8s} {'Median':>8s} {'P(8+)':>8s} "
      f"{'P(10+)':>8s} {'P(15+)':>8s} {'P(20+)':>8s}")
print("-" * 75)

streak_distributions = {}

for p in win_probs:
    sim = simulate_many_seasons(p, num_games=82, num_simulations=100_000)
    streak_distributions[p] = sim["longest_win_streak"]

    mean_streak = sim["longest_win_streak"].mean()
    median_streak = sim["longest_win_streak"].median()
    p_8_plus = (sim["longest_win_streak"] >= 8).mean()
    p_10_plus = (sim["longest_win_streak"] >= 10).mean()
    p_15_plus = (sim["longest_win_streak"] >= 15).mean()
    p_20_plus = (sim["longest_win_streak"] >= 20).mean()

    print(f"{p:>10.3f} {mean_streak:>8.2f} {median_streak:>8.0f} "
          f"{p_8_plus:>8.4f} {p_10_plus:>8.4f} {p_15_plus:>8.4f} {p_20_plus:>8.4f}")

The results are striking. A team that wins 65% of its games (a strong playoff team, roughly 53 wins) will have a longest win streak averaging around 8 games just by chance. A team winning 73% (approximately 60 wins, an elite team) will average a longest streak of about 10-11 games. The Warriors' 73-win season (89% win rate) makes a streak of 24 very plausible under pure randomness.

Comparing Observed Streaks to Simulated Distributions

Now we test specific observed streaks against our simulated null distribution. We take several notable NBA seasons and ask: is the observed longest win streak unusually long given the team's overall win rate?

"""
NBA Win Streak Analysis — Observed vs. Expected
"""

# Notable NBA team-seasons
observed_data = [
    {"team": "2015-16 Warriors", "wins": 73, "losses": 9, "longest_streak": 24},
    {"team": "2012-13 Heat", "wins": 66, "losses": 16, "longest_streak": 27},
    {"team": "1995-96 Bulls", "wins": 72, "losses": 10, "longest_streak": 18},
    {"team": "2006-07 Mavericks", "wins": 67, "losses": 15, "longest_streak": 17},
    {"team": "Typical 50-win team", "wins": 50, "losses": 32, "longest_streak": 10},
]

print("\nObserved Streak Analysis vs. Random Expectation")
print("=" * 90)

for team_data in observed_data:
    total_games = team_data["wins"] + team_data["losses"]
    win_prob = team_data["wins"] / total_games
    observed_streak = team_data["longest_streak"]

    # Simulate under null hypothesis
    sim = simulate_many_seasons(win_prob, total_games, num_simulations=100_000)

    expected_streak = sim["longest_win_streak"].mean()
    p_value = (sim["longest_win_streak"] >= observed_streak).mean()
    percentile = (sim["longest_win_streak"] < observed_streak).mean() * 100

    print(f"\n{team_data['team']}:")
    print(f"  Win Rate: {win_prob:.3f} ({team_data['wins']}-{team_data['losses']})")
    print(f"  Observed Longest Win Streak: {observed_streak}")
    print(f"  Expected Longest Win Streak (mean): {expected_streak:.1f}")
    print(f"  P(streak >= {observed_streak} | random): {p_value:.4f}")
    print(f"  Percentile: {percentile:.1f}th")

    if p_value < 0.05:
        print(f"  VERDICT: Streak is UNUSUAL (p < 0.05) — possible momentum effect")
    else:
        print(f"  VERDICT: Streak is CONSISTENT with randomness (p >= 0.05)")

This analysis typically reveals that most observed streaks, including several that seemed extraordinary at the time, fall within the range that randomness produces. The 2012-13 Heat's 27-game streak is one of the rare cases that may warrant further investigation, as it is quite extreme even for a team with a 0.805 win rate.

The Runs Test: A Formal Statistical Approach

Beyond comparing streak lengths, we can use the Wald-Wolfowitz runs test to formally assess whether the sequence of wins and losses shows more or fewer "runs" (alternations between W and L) than expected under independence.

"""
NBA Win Streak Analysis — Runs Test
"""

def runs_test(results: np.ndarray) -> dict:
    """
    Perform the Wald-Wolfowitz runs test on a binary sequence.

    A 'run' is a maximal sequence of consecutive identical values.
    Under independence, the number of runs follows approximately a
    normal distribution.

    Args:
        results: Array of 1s and 0s.

    Returns:
        Dictionary with test statistics and p-value.
    """
    n = len(results)
    n1 = int(np.sum(results))       # Number of wins
    n0 = n - n1                      # Number of losses

    # Count runs
    runs = 1
    for i in range(1, n):
        if results[i] != results[i - 1]:
            runs += 1

    # Expected number of runs and standard deviation under H0
    expected_runs = (2 * n0 * n1) / n + 1
    variance_runs = (2 * n0 * n1 * (2 * n0 * n1 - n)) / (n ** 2 * (n - 1))
    std_runs = np.sqrt(variance_runs)

    # Z-statistic
    z = (runs - expected_runs) / std_runs

    # Two-sided p-value
    p_value = 2 * (1 - stats.norm.cdf(abs(z)))

    return {
        "observed_runs": runs,
        "expected_runs": expected_runs,
        "std_runs": std_runs,
        "z_statistic": z,
        "p_value": p_value,
        "n_wins": n1,
        "n_losses": n0,
    }


# Simulate a specific team and run the test
# Let's test 1000 simulated "seasons" for a 60% team
print("\nRuns Test on 1000 Simulated 60%-Win Seasons")
print("=" * 60)

p_values = []
for _ in range(1000):
    season = simulate_season(0.60, 82)
    result = runs_test(season)
    p_values.append(result["p_value"])

p_values = np.array(p_values)
print(f"Proportion of seasons rejecting H0 at alpha=0.05: {(p_values < 0.05).mean():.3f}")
print(f"Expected under true H0: 0.050")
print(f"Interpretation: If approximately 5% reject, the sequences are indeed random.")

# Now test a season with artificial momentum
print("\n\nRuns Test on Season WITH Artificial Momentum")
print("=" * 60)

def simulate_momentum_season(
    base_prob: float,
    momentum_boost: float,
    num_games: int = 82,
) -> np.ndarray:
    """
    Simulate a season where winning increases next-game win probability.

    After a win, win_prob = base_prob + momentum_boost.
    After a loss, win_prob = base_prob - momentum_boost.
    """
    results = np.zeros(num_games, dtype=int)
    current_prob = base_prob

    for i in range(num_games):
        results[i] = np.random.binomial(1, min(max(current_prob, 0.01), 0.99))
        if results[i] == 1:
            current_prob = base_prob + momentum_boost
        else:
            current_prob = base_prob - momentum_boost

    return results


p_values_momentum = []
for _ in range(1000):
    season = simulate_momentum_season(0.60, 0.10, 82)
    result = runs_test(season)
    p_values_momentum.append(result["p_value"])

p_values_momentum = np.array(p_values_momentum)
print(f"Proportion rejecting H0 at alpha=0.05: {(p_values_momentum < 0.05).mean():.3f}")
print(f"If significantly above 0.05, the runs test detects the momentum effect.")

Visualizing Randomness vs. Reality

Visualization makes the comparison between random sequences and actual seasons much more intuitive.

"""
NBA Win Streak Analysis — Visualization
"""
import matplotlib.pyplot as plt


def plot_streak_distribution(
    win_prob: float,
    observed_streak: int,
    team_name: str,
    num_simulations: int = 100_000,
):
    """Plot the distribution of longest win streaks under randomness."""
    sim = simulate_many_seasons(win_prob, 82, num_simulations)
    streaks = sim["longest_win_streak"]

    fig, ax = plt.subplots(figsize=(10, 6))

    # Histogram of simulated longest streaks
    max_streak = int(streaks.max())
    bins = np.arange(0.5, max_streak + 1.5, 1)
    ax.hist(streaks, bins=bins, density=True, alpha=0.7, color="steelblue",
            edgecolor="white", label="Simulated (random)")

    # Mark observed streak
    ax.axvline(observed_streak, color="red", linewidth=2, linestyle="--",
               label=f"Observed: {observed_streak} games")

    # Mark expected value
    ax.axvline(streaks.mean(), color="green", linewidth=2, linestyle="-",
               label=f"Expected: {streaks.mean():.1f} games")

    p_value = (streaks >= observed_streak).mean()
    ax.set_title(f"{team_name}\n"
                 f"Win Rate: {win_prob:.1%} | "
                 f"P(streak >= {observed_streak}): {p_value:.4f}",
                 fontsize=13)
    ax.set_xlabel("Longest Win Streak in Season")
    ax.set_ylabel("Density")
    ax.legend(fontsize=11)
    plt.tight_layout()
    plt.savefig(f"streak_analysis_{team_name.replace(' ', '_')}.png", dpi=150)
    plt.show()


# Generate plots for notable seasons
plot_streak_distribution(0.890, 24, "2015-16 Warriors (73-9)")
plot_streak_distribution(0.805, 27, "2012-13 Heat (66-16)")
plot_streak_distribution(0.878, 18, "1995-96 Bulls (72-10)")


def plot_season_timeline(results: np.ndarray, team_name: str):
    """Visualize a season as a timeline showing streaks."""
    fig, ax = plt.subplots(figsize=(14, 3))

    colors = ["#d32f2f" if r == 0 else "#2e7d32" for r in results]
    ax.bar(range(len(results)), [1] * len(results), color=colors, width=1.0,
           edgecolor="white", linewidth=0.5)

    ax.set_xlim(-0.5, len(results) - 0.5)
    ax.set_ylim(0, 1)
    ax.set_yticks([])
    ax.set_xlabel("Game Number")
    ax.set_title(f"{team_name} — Season Timeline (Green=Win, Red=Loss)")
    plt.tight_layout()
    plt.savefig(f"timeline_{team_name.replace(' ', '_')}.png", dpi=150)
    plt.show()


# Show a random 60% season to illustrate how streaky randomness looks
random_season = simulate_season(0.60, 82)
plot_season_timeline(random_season, "Random 60% Win Team")

Loss Streaks and Cold Slumps

The analysis is symmetric: we should also examine whether observed losing streaks are unusual. A team with a 60% win rate loses 40% of the time. A run of 5 losses in a row, while alarming to fans, may be perfectly normal.

"""
NBA Win Streak Analysis — Loss Streaks
"""

print("\nExpected Longest LOSS Streak by Win Probability (82-game season)")
print("=" * 70)
print(f"{'Win Prob':>10s} {'Loss Prob':>10s} {'Mean Loss Streak':>18s} "
      f"{'P(5+ loss)':>12s} {'P(7+ loss)':>12s}")
print("-" * 70)

for p in win_probs:
    sim = simulate_many_seasons(p, num_games=82, num_simulations=100_000)
    loss_streak = sim["longest_loss_streak"]

    mean_loss = loss_streak.mean()
    p_5_plus = (loss_streak >= 5).mean()
    p_7_plus = (loss_streak >= 7).mean()

    print(f"{p:>10.3f} {1-p:>10.3f} {mean_loss:>18.2f} "
          f"{p_5_plus:>12.4f} {p_7_plus:>12.4f}")

print("\nNote: Even a 65%-win team has a ~30% chance of a 5+ game losing streak!")

Betting Implications

This analysis has direct implications for sports bettors:

1. Do not overreact to streaks. When a team goes on a 7-game winning streak, the natural impulse is to bet on them to keep winning. But our analysis shows that a 7-game streak is common for any team with a 60%+ win rate. The streak alone is not evidence that the team's true quality has changed.

2. Fading streaks may not work either. The gambler's fallacy — believing a team is "due" to lose after a long win streak — is equally unfounded. If games are independent, the team's probability of winning the next game is the same regardless of their recent record.

3. Market overreaction creates opportunities. If the public overvalues streaks, lines may shift too far. A team on a 5-game losing streak may be undervalued by the market if their underlying quality has not changed. Conversely, a team on a hot streak may become overvalued. The bettor who understands the binomial distribution can identify when the market is overreacting.

4. Season win totals are more predictable than they feel. The binomial distribution provides tight predictions for season win totals. A team with a 60% win rate will almost certainly finish between 42 and 57 wins (this is roughly the 1st to 99th percentile range). Extreme outliers are rare.

"""
NBA Win Streak Analysis — Betting Strategy Simulation
"""

def simulate_streak_fading_strategy(
    true_win_prob: float,
    streak_threshold: int,
    num_seasons: int = 10_000,
    bet_amount: float = 100.0,
    odds: float = -110,
) -> dict:
    """
    Simulate betting AGAINST a team after they win 'streak_threshold'
    consecutive games. Compare to never betting.

    Args:
        true_win_prob: Team's actual per-game win probability.
        streak_threshold: Bet against team after this many consecutive wins.
        num_seasons: Number of seasons to simulate.
        bet_amount: Flat bet amount.
        odds: American odds for the bet.

    Returns:
        Dictionary with strategy performance metrics.
    """
    decimal_odds = (100 / abs(odds)) + 1 if odds < 0 else (odds / 100) + 1

    total_bets = 0
    total_wins = 0
    total_profit = 0.0

    for _ in range(num_seasons):
        season = simulate_season(true_win_prob, 82)
        current_streak = 0

        for i in range(len(season)):
            if i > 0 and current_streak >= streak_threshold:
                # Bet against the team
                total_bets += 1
                if season[i] == 0:  # Team loses (our bet wins)
                    total_wins += 1
                    total_profit += bet_amount * (decimal_odds - 1)
                else:
                    total_profit -= bet_amount

            # Update streak
            if season[i] == 1:
                current_streak += 1
            else:
                current_streak = 0

    win_rate = total_wins / total_bets if total_bets > 0 else 0
    avg_profit_per_bet = total_profit / total_bets if total_bets > 0 else 0
    roi = total_profit / (total_bets * bet_amount) if total_bets > 0 else 0

    return {
        "total_bets": total_bets,
        "win_rate": win_rate,
        "total_profit": total_profit,
        "avg_profit_per_bet": avg_profit_per_bet,
        "roi": roi,
    }


print("\nStreak-Fading Strategy Performance (True Win Prob = 0.60)")
print("=" * 75)
print(f"{'Threshold':>12s} {'Total Bets':>12s} {'Win Rate':>10s} "
      f"{'ROI':>10s} {'Verdict':>15s}")
print("-" * 75)

for threshold in [3, 5, 7, 10]:
    result = simulate_streak_fading_strategy(
        true_win_prob=0.60,
        streak_threshold=threshold,
        num_seasons=10_000,
    )
    verdict = "Profitable" if result["roi"] > 0 else "Unprofitable"
    print(f"{threshold:>12d} {result['total_bets']:>12d} "
          f"{result['win_rate']:>10.4f} {result['roi']:>10.4f} {verdict:>15s}")

print("\nConclusion: Under true independence, fading streaks simply bets")
print("against a good team at a disadvantage. The strategy loses money")
print("because the team's next-game probability is unaffected by the streak.")

When Are Streaks Real?

While our analysis shows that most observed streaks are consistent with randomness, this does not mean momentum never exists. There are legitimate reasons why win probabilities might not be constant:

  1. Injuries and roster changes can shift a team's true quality mid-season.
  2. Schedule strength varies; a streak against weak opponents is different from one against strong teams.
  3. Psychological factors such as confidence, pressure, and team chemistry may create genuine short-term shifts in performance.
  4. Fatigue from travel and back-to-back games can systematically lower performance.

The key insight is that the burden of proof lies with the streak narrative. Before attributing a streak to momentum, we must first show that it exceeds what randomness alone would produce. In most cases, it does not.

Conclusion

This case study demonstrated how the binomial distribution and Monte Carlo simulation provide a rigorous framework for evaluating win streaks in the NBA:

  1. Random sequences produce surprisingly long streaks. A 65%-win team will average a longest streak of about 8 games per season purely by chance.
  2. Most "hot streaks" are consistent with randomness. When we compare observed streaks to simulated null distributions, the vast majority fall within the expected range.
  3. The runs test provides a formal statistical test for whether a sequence shows more or fewer alternations than expected under independence.
  4. Betting strategies based on streaks are unlikely to be profitable unless the market systematically overreacts to recent results.
  5. The bettor's edge comes from understanding base rates, not from chasing or fading streaks.

The human brain is wired to detect patterns, even where none exist. The binomial distribution is the antidote to this bias: it tells us exactly what randomness looks like, so we can recognize when something genuinely unusual is happening — and when it is not.


Key Takeaways:

  • Win streaks in the NBA are almost always consistent with what the binomial distribution predicts for a team's given win rate.
  • Monte Carlo simulation is a powerful tool for establishing null distributions when analytical formulas are complex or unavailable.
  • The Wald-Wolfowitz runs test provides a formal hypothesis test for sequential independence.
  • Betting strategies that rely on streaks (either following or fading them) are not profitable under independence and require genuine momentum effects to work.
  • Understanding the difference between streakiness and randomness is a fundamental skill for the quantitative sports bettor.

End of Case Study 2