DEV Community

Ashish Agarwal
Ashish Agarwal

Posted on

The Warmup Period Problem: Why Your Python Backtest Doesn't Match Live Trading

I ran the same SMA crossover strategy through a pandas backtest and a live-trading simulator. Same rules. Same data. Same time period.

Different results.

Metric Python/Pandas Live Simulation
Total Return 13.23% 16.77%
Total Trades 3 2
Max Drawdown 14.50% 13.78%

This isn't a bug in either system. It's a fundamental difference in how they handle indicator warmup periods - and understanding it will change how you write backtests.

The Strategy

Classic moving average crossover on AAPL:

  • Entry: Buy when 20-day SMA crosses above 50-day SMA
  • Exit: Sell when 20-day SMA crosses below 50-day SMA
  • Capital: $10,000
  • Period: 365 days

Simple enough that implementation differences should be minimal. Yet one found 3 trades, the other found 2.

The Typical Pandas Approach

Here's how most of us write this:

def calculate_moving_averages(df, short_window, long_window):
  df = df.copy()
  df["sma_short"] = df["close"].rolling(window=short_window).mean()
  df["sma_long"] = df["close"].rolling(window=long_window).mean()
  return df

def generate_signals(df):
  df = df.copy()
  df["signal"] = 0
  df.loc[df["sma_short"] > df["sma_long"], "signal"] = 1
  df.loc[df["sma_short"] < df["sma_long"], "signal"] = -1
  df["position"] = df["signal"].diff()
  return df

# Run the backtest
df = calculate_moving_averages(df, 20, 50)
df = generate_signals(df)
df = df.dropna()  # <-- This line is the problem
results = backtest(df)
Enter fullscreen mode Exit fullscreen mode

See that dropna()? It removes the first 49 rows where the 50-day SMA is NaN. Perfectly reasonable.

But here's the hidden assumption: from row 50 onward, your backtest processes every signal as if you were watching the market in real-time from day 1.

What Live Trading Actually Looks Like

Imagine you deploy a trading bot today. What happens?

  1. Bot starts up, loads 50 days of historical data
  2. Calculates the current SMA values
  3. Starts monitoring for new crossovers
  4. Enters trades only when it detects a crossover after it started running

The bot can't act on a crossover that happened last week. It missed it. It has to wait for the next opportunity.

The Trade Pandas Found That Live Missed

Here are the complete trade logs:

Python/Pandas (3 trades):

┌────────────┬────────┬─────────┬────────┬────────────┐
│    Date    │ Action │  Price  │ Shares │   Value    │
├────────────┼────────┼─────────┼────────┼────────────┤
│ 2025-06-10 │ BUY    │ $202.67 │ 49     │ $9,930.83  │
├────────────┼────────┼─────────┼────────┼────────────┤
│ 2025-06-16 │ SELL   │ $198.42 │ 49     │ $9,722.58  │
├────────────┼────────┼─────────┼────────┼────────────┤
│ 2025-07-11 │ BUY    │ $211.16 │ 46     │ $9,713.36  │
├────────────┼────────┼─────────┼────────┼────────────┤
│ 2026-01-07 │ SELL   │ $260.33 │ 46     │ $11,975.18 │
├────────────┼────────┼─────────┼────────┼────────────┤
│ 2026-02-23 │ BUY    │ $266.18 │ 45     │ $11,978.10 │
├────────────┼────────┼─────────┼────────┼────────────┤
│ 2026-03-18 │ SELL   │ $249.94 │ 45     │ $11,247.30 │
└────────────┴────────┴─────────┴────────┴────────────┘
Enter fullscreen mode Exit fullscreen mode

Live Simulation (2 trades):

┌────────────┬────────────┬─────────────┬────────────┬──────────────────────┐
│   Entry    │    Exit    │ Entry Price │ Exit Price │         P&L          │
├────────────┼────────────┼─────────────┼────────────┼──────────────────────┤
│ 2025-07-10 │ 2026-01-06 │ $211.16     │ $260.33    │ +$2,328.57 (+23.29%) │
├────────────┼────────────┼─────────────┼────────────┼──────────────────────┤
│ 2026-02-22 │ 2026-03-17 │ $266.18     │ $249.94    │ -$610.11 (-6.10%)    │
└────────────┴────────────┴─────────────┴────────────┴──────────────────────┘
Enter fullscreen mode Exit fullscreen mode

The June 2025 trade is completely missing from the live simulation.

Why the June Trade Was Skipped

The crossover that triggered the June 10 entry happened around day 48 - during the warmup period, before the indicators were valid.

~Day 48:

SMA(20) crosses above SMA(50) ← During warmup

Day 50:

Warmup ends, but crossover already happened

2025-06-10:

Pandas enters (sees historical crossover)
Live sim has no position (crossover was during warmup)

2025-06-16:

SMA(20) crosses below SMA(50)
Pandas exits with -$208.25 loss
Live sim has nothing to exit

Pandas processed the historical data and saw "oh, there's a bullish crossover active, let's enter." A live bot that just started running wouldn't see a crossover event - it would
just see that SMA(20) > SMA(50), which isn't an entry signal.

Which Approach Is Correct?

Neither is wrong. They answer different questions:

┌─────────────────┬─────────────────────────────────────────────────────────────────────────────┐
│    Approach     │                             Question It Answers                             │
├─────────────────┼─────────────────────────────────────────────────────────────────────────────┤
│ Pandas backtest │ "How would this strategy have performed with perfect historical knowledge?" │
├─────────────────┼─────────────────────────────────────────────────────────────────────────────┤
│ Live simulation │ "What would happen if I deployed this strategy today?"                      │
└─────────────────┴─────────────────────────────────────────────────────────────────────────────┘
Enter fullscreen mode Exit fullscreen mode

For academic research and strategy development, the pandas approach is standard. For estimating real-world performance, the live simulation approach is more accurate.

How to Fix Your Pandas Backtest

If you want your backtest to match live behavior, you need explicit warmup logic:

def backtest_with_warmup(df, warmup_period, initial_capital=10000.0):
  capital = initial_capital
  shares = 0
  trades = []
  in_warmup = True

  for i, (idx, row) in enumerate(df.iterrows()):
      # Skip warmup period
      if i < warmup_period:
          continue

      # On first non-warmup day, mark warmup complete
      # but DON'T act on existing crossover state
      if in_warmup:
          in_warmup = False
          prev_signal = row["signal"]  # Record current state
          continue

      current_price = row["close"]

      # Only act on CHANGES in signal (new crossovers)
      if row["position"] == 2 and capital > 0:  # New bullish crossover
          shares = capital // current_price
          capital -= shares * current_price
          trades.append({"date": idx, "action": "BUY", "price": current_price})

      elif row["position"] == -2 and shares > 0:  # New bearish crossover
          capital += shares * current_price
          trades.append({"date": idx, "action": "SELL", "price": current_price})
          shares = 0

  return trades, capital + (shares * df.iloc[-1]["close"])
Enter fullscreen mode Exit fullscreen mode

The key insight: don't just drop NaN rows - track whether you've completed warmup and only act on signal changes that occur after.

The Bigger Picture

In this specific case, the missed trade was a loser (-$208.25), so the live simulation outperformed. But this cuts both ways - sometimes warmup will cause you to miss winners.

The real takeaway: expect a few percent variance between backtest and live results. The warmup period, execution delays, slippage, and market impact all create differences between
simulation and reality.

When your backtest shows 50% annual returns and live trading shows 45%, the warmup period might be part of that gap.


I wrote a more detailed breakdown with the full Python code and comparison methodology at: https://quantdock.io/blog/the-warmup-period-problem

Top comments (0)