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)
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?
- Bot starts up, loads 50 days of historical data
- Calculates the current SMA values
- Starts monitoring for new crossovers
- 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 │
└────────────┴────────┴─────────┴────────┴────────────┘
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%) │
└────────────┴────────────┴─────────────┴────────────┴──────────────────────┘
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?" │
└─────────────────┴─────────────────────────────────────────────────────────────────────────────┘
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"])
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)