Backtesting a Simple Strategy
Backtesting is the process of testing a trading strategy on historical data to see how it would have performed. It helps you validate your trading ideas before risking real money. In this tutorial, you’ll build a simple moving average crossover strategy and backtest it using Python.
By the end, you’ll understand how to define a strategy, run a backtest, and interpret key performance metrics.
Setting Up Your Environment
First, install the required libraries. You’ll need yfinance for data and backtesting for running the backtest:
pip install yfinance backtesting pandas
Now import the libraries in your Python script:
import yfinance as yf
from backtesting import Backtest, Strategy
from backtesting.lib import crossover
from backtesting.test import SMA
The backtesting.lib module provides utilities like crossover for detecting when one indicator crosses another. The SMA function calculates simple moving averages.
Downloading Historical Data
Use yfinance to download stock price data. This example uses Apple stock (AAPL) over roughly 9 years:
# Download Apple stock data from 2015 to 2024
data = yf.download('AAPL', start='2015-01-01', end='2024-01-01')
print(data.head())
print(f"Downloaded {len(data)} days of data")
The data includes Open, High, Low, Close, Adj Close, and Volume columns. The backtesting library expects specific column names.
Defining the Moving Average Crossover Strategy
A moving average crossover strategy uses two moving averages: a fast one (short period) and a slow one (long period). When the fast MA crosses above the slow MA, that’s a buy signal. When it crosses below, that’s a sell signal.
Here’s how to implement it:
class SMACrossover(Strategy):
# Define the lookback periods as strategy parameters
fast_period = 10
slow_period = 20
def init(self):
# Calculate moving averages during the initialization phase
close = self.data.Close
self.sma_fast = self.I(SMA, close, self.fast_period)
self.sma_slow = self.I(SMA, close, self.slow_period)
def next(self):
# Check for crossover signals on each bar
if crossover(self.sma_fast, self.sma_slow):
# Fast MA crossed above slow MA -> Buy signal
self.position.close()
self.buy()
elif crossover(self.sma_slow, self.sma_fast):
# Fast MA crossed below slow MA -> Sell signal
self.position.close()
self.sell()
The init method runs once before the backtest starts. It calculates indicators efficiently using vectorized operations. The next method runs on each bar of data, making decisions based on the current state.
Running the Backtest
Now run the backtest with initial capital and commission settings:
# Run the backtest
bt = Backtest(
data,
SMACrossover,
cash=10000, # Starting capital
commission=0.002, # 0.2% commission per trade
exclusive_orders=True # One trade at a time
)
# Execute and store results
results = bt.run()
print(results)
The exclusive_orders=True parameter ensures you hold only one position at a time. The commission rate of 0.2% is realistic for many online brokers.
Understanding the Results
The backtest output shows key performance metrics:
Start 2015-01-02 00:00:00
End 2023-12-29 00:00:00
Duration 3281 days
Exposure Time [%] 89.34
Equity Final [$] 25412.67
Return [%] 154.13
Return (Ann.) [%] 10.85
Volatility (Ann.) [%] 26.32
Sharpe Ratio 0.41
Sortino Ratio 0.58
Max. Drawdown [%] -33.91
# Trades 32
Win Rate [%] 56.25
Best Trade [%] 40.12
Worst Trade [%] -18.34
Here’s what each metric means:
- Return [%] — Total percentage gain or loss over the period. A 154% return means your $10,000 grew to $25,412.
- Sharpe Ratio — Risk-adjusted return. Higher is better. Above 1.0 is generally considered good. This strategy scores 0.41, which is modest.
- Max. Drawdown [%] — The largest peak-to-trough decline. A 33.91% drawdown means at some point your account was down that much from its peak.
- Win Rate [%] — Percentage of profitable trades. 56.25% means more than half of trades made money.
- Exposure Time [%] — Percentage of time actually invested. At 89.34%, this strategy was mostly in the market.
Optimizing the Strategy
The parameters 10 and 20 for the moving averages were chosen arbitrarily. You can optimize them to find better values:
# Test different parameter combinations
bt.optimize(
fast_period=range(5, 30, 5),
slow_period=range(20, 100, 10),
maximize='Sharpe Ratio'
)
print(f"Best parameters: {results._strategy}")
print(f"Best Sharpe Ratio: {results['Sharpe Ratio']:.2f}")
The optimizer tests all combinations within the specified ranges. It finds the parameters that maximize your chosen metric—in this case, the Sharpe Ratio.
Be careful not to over-optimize. A strategy that perfectly fits historical data may not perform well on future data. This is called overfitting.
Plotting the Results
Visualize the backtest to understand when the strategy traded and how it performed:
# Plot the results
bt.plot()
This opens an interactive chart showing the stock price, moving averages, buy/sell signals, and equity curve. You can zoom in on specific periods to see how the strategy behaved during different market conditions.
Summary
You now know how to:
- Download historical stock data using yfinance
- Define a trading strategy with entry and exit rules
- Run a backtest with realistic parameters
- Interpret key performance metrics like return, Sharpe ratio, and drawdown
- Optimize strategy parameters to improve performance
- Visualize backtest results
Backtesting isn’t perfect. It suffers from look-ahead bias, survivor bias, and doesn’t account for slippage or market impact. But it’s an essential tool for validating trading ideas.
Next Steps
Continue building your finance skills with more tutorials in this series. Each new concept builds on what you’ve learned here, taking you from basic data fetching through increasingly sophisticated quantitative analysis.