CH
CalcHub
Back to Guides
Intermediate

Backtesting a Trading Strategy in Python

Backtesting means testing a trading strategy on historical data to see how it would have performed. It is the most important step before risking real money. This guide walks through a complete backtest in about 50 lines of Python.

What Is Backtesting?

Imagine you had a time machine. You go back to 2020 and apply your trading rules every single day. At the end, you check: did the strategy make money? How much risk did it take? That is backtesting โ€” except Python is the time machine.

The Strategy: Moving Average Crossover

One of the simplest and most famous strategies. The rules:

  • BUY when the 50-day moving average crosses ABOVE the 200-day moving average (golden cross)
  • SELL when the 50-day moving average crosses BELOW the 200-day moving average (death cross)

Complete Backtest Code

import yfinance as yf import pandas as pd import numpy as np import matplotlib.pyplot as plt # 1. Download data ticker = "AAPL" df = yf.download(ticker, start="2019-01-01", end="2024-01-01") # 2. Calculate moving averages df["MA_50"] = df["Close"].rolling(50).mean() df["MA_200"] = df["Close"].rolling(200).mean() # 3. Generate signals # Signal = 1 when MA_50 > MA_200 (bullish), 0 otherwise df["Signal"] = 0 df.loc[df["MA_50"] > df["MA_200"], "Signal"] = 1 # Position changes: 1 = buy, -1 = sell, 0 = hold df["Position"] = df["Signal"].diff() # 4. Calculate returns df["Market_Return"] = df["Close"].pct_change() # Strategy return: market return ONLY when we hold (Signal = 1 from previous day) df["Strategy_Return"] = df["Market_Return"] * df["Signal"].shift(1) # 5. Cumulative returns df["Market_Cumulative"] = (1 + df["Market_Return"]).cumprod() df["Strategy_Cumulative"] = (1 + df["Strategy_Return"]).cumprod() # 6. Performance metrics total_market = df["Market_Cumulative"].iloc[-1] - 1 total_strategy = df["Strategy_Cumulative"].iloc[-1] - 1 # Maximum drawdown rolling_max = df["Strategy_Cumulative"].cummax() drawdown = (df["Strategy_Cumulative"] - rolling_max) / rolling_max max_drawdown = drawdown.min() # Win rate (days with positive returns when in a position) in_market = df[df["Signal"].shift(1) == 1]["Strategy_Return"] win_rate = (in_market > 0).sum() / len(in_market) print(f"=== {{ticker}} Backtest Results ===") print(f"Buy & Hold return:  {{total_market:.2%}}") print(f"Strategy return:    {{total_strategy:.2%}}") print(f"Max drawdown:       {{max_drawdown:.2%}}") print(f"Win rate (daily):   {{win_rate:.2%}}") print(f"Days in market:     {{df['Signal'].sum():.0f}} / {{len(df)}}") # 7. Plot comparison plt.figure(figsize=(12, 6)) plt.plot(df.index, df["Market_Cumulative"], label="Buy & Hold", alpha=0.7) plt.plot(df.index, df["Strategy_Cumulative"], label="MA Crossover", alpha=0.7) # Mark buy/sell points buys = df[df["Position"] == 1] sells = df[df["Position"] == -1] plt.scatter(buys.index, df.loc[buys.index, "Strategy_Cumulative"],            marker="^", color="lime", s=80, label="Buy", zorder=5) plt.scatter(sells.index, df.loc[sells.index, "Strategy_Cumulative"],            marker="v", color="red", s=80, label="Sell", zorder=5) plt.title(f"{{ticker}}: MA Crossover vs Buy & Hold") plt.xlabel("Date") plt.ylabel("Growth of $1") plt.legend() plt.grid(True, alpha=0.3) plt.tight_layout() plt.show()

Important Warnings

Past performance does NOT guarantee future results. This strategy worked in hindsight โ€” that does not mean it will work going forward. Backtests are also vulnerable to overfitting: if you tweak the parameters until it works on historical data, you are essentially memorising the past, not predicting the future.

Common Backtesting Pitfalls

  • Look-ahead bias โ€” Using future data in your signals (e.g., using today's close to decide today's trade)
  • Survivorship bias โ€” Only testing on stocks that exist today (ignoring those that went bankrupt)
  • Overfitting โ€” Tweaking parameters until the backtest looks perfect (it won't work live)
  • Ignoring costs โ€” Real trades have spreads, commissions, and slippage
  • Data snooping โ€” Testing dozens of strategies and only reporting the one that worked