Why Backtesting Comes Before Real Money
Every profitable trading strategy starts as a hypothesis: "If I buy when the 20-day moving average crosses above the 50-day, I'll make money." The problem is that a hypothesis feels convincing right up until it quietly loses you real capital. Backtesting is how you interrogate that hypothesis against years of historical data before a single dollar is at risk.
Backtesting is the process of simulating a strategy against historical price data to measure how it would have performed. It won't guarantee future returns — markets change, and past performance is famously not a promise of anything — but it will ruthlessly expose strategies that only ever worked in your imagination. A rule that looks brilliant on a whiteboard often falls apart the moment you feed it ten years of real candles.
This guide is part one of our Python algorithmic trading series. If you're new to the broader topic, start with our Python automated trading strategy walkthrough for the conceptual foundation, then come back here to actually test something.
Choosing a Backtesting Library
Python has several mature backtesting frameworks, and the right choice depends on how you think about your data.
- Backtrader — event-driven, object-oriented, and beginner-friendly. It models the market bar-by-bar the way a live system would, which makes the mental model closer to real trading. This is what we'll use here.
- vectorbt — vectorized and blazing fast, built on NumPy and pandas. Ideal for sweeping thousands of parameter combinations in seconds, but the API is less intuitive for first-timers.
- zipline-reloaded — the community-maintained successor to Quantopian's engine. Powerful, but heavier to set up.
We pick Backtrader for this tutorial because its event-driven model teaches you the right habits. When you later move to live paper trading, the code structure barely changes — the strategy logic you write against historical bars is almost identical to what you'd run against a live broker feed.
Setting Up Your Environment
Backtesting is a perfect candidate for an isolated, reproducible environment. Never install quant libraries into your system Python — pin them in a virtual environment so your results are repeatable.
python3 -m venv venv
source venv/bin/activate
pip install backtrader yfinance pandas matplotlib
A quick note on reproducibility: if you plan to run backtests in CI or share them with a team, containerize this. The same discipline we preach for application builds in our Docker multi-stage builds guide applies here — a locked, minimal image means your backtest produces identical numbers on your laptop and on a colleague's machine six months from now.
Loading Historical Data
Backtrader needs a data feed. The easiest free source is Yahoo Finance via the yfinance library. Let's pull five years of daily Apple data.
import backtrader as bt
import yfinance as yf
import datetime
# Download historical daily data
data_df = yf.download(
"AAPL",
start="2020-01-01",
end="2025-01-01",
auto_adjust=True,
)
# Wrap the pandas DataFrame in a Backtrader data feed
data_feed = bt.feeds.PandasData(dataname=data_df)
The auto_adjust=True flag matters more than it looks. It adjusts prices for splits and dividends, so a stock split doesn't show up as a fake 50% crash in your backtest. Feeding un-adjusted data into a backtest is one of the most common ways beginners get garbage results.
Sanity-Checking Your Data Before You Trust It
Garbage data produces garbage backtests, and the failure is silent — you still get numbers, they just happen to be fiction. Before you feed any DataFrame into Cerebro, spend thirty seconds validating it. This tiny habit catches the majority of "why is my Sharpe ratio suddenly 9.0?" moments.
# Quick data-quality checks before backtesting
assert not data_df.empty, "No data returned — check ticker and date range"
print("Rows:", len(data_df))
print("Date range:", data_df.index.min(), "to", data_df.index.max())
print("Missing values:\n", data_df.isna().sum())
# Flag suspicious single-day moves (often bad ticks or unadjusted splits)
returns = data_df["Close"].pct_change()
suspect = returns[returns.abs() > 0.5]
if not suspect.empty:
print("WARNING: extreme moves detected:\n", suspect)
Missing values (NaN) inside a price series quietly poison indicator calculations — a moving average over a window that contains a gap is no longer the average you think it is. Drop or forward-fill them deliberately, never by accident. Extreme single-day moves above 50 percent almost always mean a data error rather than a real market event, and they will hand you an imaginary fortune in the backtest. Thirty seconds of validation here saves hours of chasing a phantom edge later.
Writing Your First Strategy
In Backtrader, a strategy is a class that subclasses bt.Strategy. You define your indicators in __init__ and your trading logic in next(), which the engine calls once for every bar of data.
Here's a classic Golden Cross strategy: buy when the fast moving average crosses above the slow one, sell when it crosses back below.
class GoldenCross(bt.Strategy):
params = (
("fast_period", 20),
("slow_period", 50),
)
def __init__(self):
self.fast_ma = bt.indicators.SMA(
self.data.close, period=self.params.fast_period
)
self.slow_ma = bt.indicators.SMA(
self.data.close, period=self.params.slow_period
)
# crossover > 0 means fast crossed above slow
self.crossover = bt.indicators.CrossOver(self.fast_ma, self.slow_ma)
def next(self):
if not self.position: # not in the market
if self.crossover > 0:
self.buy()
elif self.crossover < 0:
self.close()
The logic reads almost like English. If we hold no position and the fast MA crosses above the slow MA, buy. If we're already holding and the fast MA crosses back below, close the position. That self.position check is critical — without it you'd keep firing buy orders on every bar.
Running the Backtest
Backtrader orchestrates everything through a Cerebro engine (Spanish for "brain"). You add your data, your strategy, set your starting cash, and run.
cerebro = bt.Cerebro()
cerebro.addstrategy(GoldenCross)
cerebro.adddata(data_feed)
cerebro.broker.setcash(10000.0)
# Realistic commission: 0.1% per trade
cerebro.broker.setcommission(commission=0.001)
# Size each position at 95% of available cash
cerebro.addsizer(bt.sizers.PercentSizer, percents=95)
print(f"Starting Portfolio Value: {cerebro.broker.getvalue():.2f}")
cerebro.run()
print(f"Final Portfolio Value: {cerebro.broker.getvalue():.2f}")
Notice two details that separate a toy backtest from a credible one: commission and position sizing. Setting a 0.1% commission means every trade costs you something, just like reality. Skipping commissions is the fastest way to make a mediocre strategy look profitable. The PercentSizer keeps a small cash buffer instead of going all-in, which prevents unrealistic all-or-nothing allocation.
The Next-Bar Execution Gotcha
Here is the single most misunderstood detail in event-driven backtesting, and it trips up almost everyone the first time. When your next() method calls self.buy(), the order does not fill at the current bar's close. It fills at the next bar's open. Backtrader does this on purpose, and it is correct: at the instant you observe today's closing price, today is already over. You cannot realistically trade on a price you only knew once the bar had finished.
This matters enormously for how you read your results. If you assume fills happen at the signal bar's close, you are quietly baking in a form of look-ahead bias and your live results will underperform your backtest for reasons you cannot explain. You can inspect exactly when and at what price your orders filled by logging them:
class GoldenCross(bt.Strategy):
# ... params and __init__ as before ...
def notify_order(self, order):
if order.status == order.Completed:
side = "BUY " if order.isbuy() else "SELL"
print(
f"{self.data.datetime.date(0)} {side} "
f"filled @ {order.executed.price:.2f} "
f"size {order.executed.size}"
)
The notify_order callback fires every time an order changes state. Printing the fill price next to the signal date makes the one-bar delay visible, and once you have seen it you will never again confuse a signal with an execution. If your strategy depends on filling at the exact close, it will not survive contact with a real broker.
Measuring Performance the Right Way
A final portfolio value alone tells you almost nothing. A strategy that turns 10,000 dollars into 12,000 dollars sounds great — until you learn it dropped to 4,000 dollars along the way and you'd have panic-sold. This is where analyzers come in. Backtrader ships with analyzers for the metrics that actually matter.
cerebro.addanalyzer(bt.analyzers.SharpeRatio, _name="sharpe")
cerebro.addanalyzer(bt.analyzers.DrawDown, _name="drawdown")
cerebro.addanalyzer(bt.analyzers.Returns, _name="returns")
results = cerebro.run()
strat = results[0]
print("Sharpe Ratio:", strat.analyzers.sharpe.get_analysis()["sharperatio"])
print("Max Drawdown %:", strat.analyzers.drawdown.get_analysis()["max"]["drawdown"])
print("Annual Return %:", strat.analyzers.returns.get_analysis()["rnorm100"])
Here's how to read the three metrics that matter most:
| Metric | What it tells you | Rough target |
|---|---|---|
| Sharpe Ratio | Return earned per unit of risk | Above 1.0 is decent; above 2.0 is strong |
| Max Drawdown | Worst peak-to-trough loss | Lower is better; over 30% is painful to live through |
| Annual Return | Compounded yearly growth | Compare against buy-and-hold, not zero |
The single most important discipline: always compare against a buy-and-hold baseline. If your clever crossover strategy returns 8% a year but simply holding the S&P 500 returned 11%, your strategy is actively destroying value. Beating zero is easy; beating the index after commissions is the real bar.
Avoiding the Traps That Fake Profits
Backtests lie when you let them. The three deadliest mistakes:
- Look-ahead bias — using information that wouldn't have existed at the moment of the trade. Backtrader's event-driven model protects you here because
next()only ever sees data up to the current bar, but you can still sabotage yourself by peeking at future rows in custom logic. - Overfitting — tuning parameters until the backtest looks perfect on your historical sample. A strategy optimized to death on 2020–2024 data often collapses on 2025 data. Always reserve an out-of-sample period the optimizer never touches.
- Survivorship bias — backtesting only stocks that still exist today silently ignores every company that went bankrupt, inflating your returns.
Treating these risks seriously is really just good engineering hygiene. The same mindset that drives blameless incident management runbooks — assume the system will fail, and design to catch the failure early — is exactly what keeps a backtest honest.
Validating on Data the Strategy Has Never Seen
The single most powerful defence against overfitting is an out-of-sample split. The idea borrows directly from machine learning: you tune on one slice of history and prove the strategy on a completely separate slice it never influenced. If the numbers hold up on the untouched slice, you have real evidence; if they collapse, you overfit.
Split your data by date. Use the earlier years as your in-sample (training) period where you are allowed to tweak parameters, and quarantine the most recent year or two as out-of-sample (test) data you only ever run once, at the very end.
# In-sample: optimise here
train_df = data_df.loc["2020-01-01":"2023-12-31"]
# Out-of-sample: touch this exactly once, at the end
test_df = data_df.loc["2024-01-01":"2025-01-01"]
def run(df, fast, slow):
cerebro = bt.Cerebro()
cerebro.adddata(bt.feeds.PandasData(dataname=df))
cerebro.addstrategy(GoldenCross, fast_period=fast, slow_period=slow)
cerebro.broker.setcash(10000.0)
cerebro.broker.setcommission(commission=0.001)
cerebro.addsizer(bt.sizers.PercentSizer, percents=95)
cerebro.run()
return cerebro.broker.getvalue()
best = run(train_df, 20, 50)
verdict = run(test_df, 20, 50)
print(f"In-sample final value: {best:.2f}")
print(f"Out-of-sample final value: {verdict:.2f}")
If the out-of-sample result is wildly worse than the in-sample one, your parameters are memorising history, not capturing a durable edge. A strategy that survives honest out-of-sample testing is the only kind worth risking real capital on — and even then, only after you have watched it paper trade. Resist the temptation to run the test set more than once; the moment you tune against it, it stops being out-of-sample and becomes just more training data.
Where to Go From Here
You now have a complete, credible backtesting loop: real adjusted data, an event-driven strategy, realistic commissions and sizing, and the risk-adjusted metrics that separate luck from skill.
This is part one of the series. Next, we'll build a strategy from scratch using technical indicators like RSI and MACD, then move into advanced backtesting with vectorbt for parameter sweeps, and finally into live paper trading against a broker API so you can watch your strategy trade in real time without risking capital. Until those are published, revisit the Python automated trading strategy pillar to solidify the fundamentals.
Backtest first. Deploy capital never before you've seen how a strategy behaves across a full market cycle — including the crashes.