Backtesting·Backtesting Libraries·Intermediate

Bt Backtest

Build flexible portfolio-level backtests using the bt library with composable strategy definitions, automatic benchmark comparison, comprehensive risk and return tear-sheet reporting, and multi-asset allocation backtesting capabilities.

backtestingbacktesting-libraries

Backtesting: A Comprehensive Guide

Backtesting is a crucial process in quantitative finance, used to test the viability of a trading strategy using historical data. It allows traders and investors to simulate how a strategy would have performed in the past, providing insights into its potential profitability, risk, and overall effectiveness before risking real capital.

1. What is Backtesting?

Backtesting involves applying a set of trading rules to historical market data to see how the strategy would have performed. It's a simulated environment where a trading algorithm makes buy and sell decisions based on past prices and other market data. The outcome of a backtest is a set of performance metrics that help evaluate the strategy's historical effectiveness.

Why does it matter?

  • Validation: Confirm if a strategy has a statistical edge.
  • Optimization: Tune parameters to improve performance.
  • Risk Assessment: Understand potential drawdowns and volatility.
  • Confidence: Build conviction before deploying live capital.

Limitations:

  • Curve Fitting: Over-optimization to historical data may not perform well in the future.
  • Data Snooping: Using the same data for research and backtesting can lead to biased results.
  • Transaction Costs/Slippage: Often simplified or ignored, but critical in real-world trading.
  • Market Regime Changes: Past performance is not indicative of future results; market conditions evolve.
[ ]
# Import necessary libraries
import pandas as pd
import numpy as np
import matplotlib.pyplot as plt
import matplotlib.dates as mdates

# Configure plot style
plt.style.use('seaborn-v0_8-darkgrid')
plt.rcParams['figure.figsize'] = (12, 7)
plt.rcParams['lines.linewidth'] = 2
plt.rcParams['axes.labelsize'] = 14
plt.rcParams['xtick.labelsize'] = 12
plt.rcParams['ytick.labelsize'] = 12
plt.rcParams['legend.fontsize'] = 12

2. Key Components of a Backtest

A typical backtesting framework consists of several core components:

  1. Historical Data: Reliable and clean historical market data (e.g., OHLCV).
  2. Trading Strategy: A set of predefined rules for generating buy/sell signals.
  3. Execution Model: Rules simulating how orders are placed and filled (e.g., slippage, commissions).
  4. Portfolio Management: How capital is allocated, position sizing, and risk management.
  5. Performance Metrics: Quantitative measures to evaluate the strategy's effectiveness.

2.1. Data Generation

For demonstration purposes, we will generate mock daily stock price data. In a real-world scenario, you would typically load historical data from a reliable data provider.

[ ]
def generate_mock_data(start_date, end_date, initial_price=100, volatility=0.01, drift=0.0005):
    """
    Generates mock daily stock price data.

    Inputs:
    - start_date (str): Start date in 'YYYY-MM-DD' format.
    - end_date (str): End date in 'YYYY-MM-DD' format.
    - initial_price (float): Starting price of the asset.
    - volatility (float): Daily volatility of the asset price.
    - drift (float): Daily drift (average daily return) of the asset price.

    Outputs:
    - pd.DataFrame: DataFrame with 'Date' and 'Close' columns.
    """
    dates = pd.date_range(start=start_date, end=end_date, freq='B') # Business days
    n_days = len(dates)

    prices = [initial_price]
    for _ in range(1, n_days):
        # Simulate daily price movement using a geometric Brownian motion-like process
        daily_return = np.random.normal(drift, volatility)
        new_price = prices[-1] * (1 + daily_return)
        prices.append(new_price)

    df = pd.DataFrame({
        'Date': dates,
        'Close': prices
    })
    df.set_index('Date', inplace=True)
    return df

# Generate 2 years of mock data
data = generate_mock_data('2022-01-01', '2023-12-31')
print(f"Generated {len(data)} daily price points.")
Generated 520 daily price points.
[ ]
# Display the first few rows of the generated data
display(data.head())
Close
Date
2022-01-03 100.000000
2022-01-04 100.071523
2022-01-05 101.128303
2022-01-06 101.662098
2022-01-07 100.509316

2.2. Trading Strategy: Moving Average Crossover

For this example, we'll use a simple yet popular trading strategy: the Moving Average Crossover.

Rules:

  • Buy Signal: When the short-term Moving Average (SMA) crosses above the long-term SMA.
  • Sell Signal: When the short-term SMA crosses below the long-term SMA.

Inputs:

  • data (pd.DataFrame): Historical price data with a 'Close' column.
  • short_window (int): Number of periods for the short-term moving average.
  • long_window (int): Number of periods for the long-term moving average.

Output:

  • signals (pd.DataFrame): DataFrame with 'Short_MA', 'Long_MA', and 'Signal' columns.
    • Signal = 1 for a buy signal.
    • Signal = -1 for a sell signal.
    • Signal = 0 otherwise.
[ ]
def moving_average_crossover_strategy(data, short_window=40, long_window=100):
    """
    Generates trading signals based on a Moving Average Crossover strategy.

    Inputs:
    - data (pd.DataFrame): DataFrame with 'Close' prices.
    - short_window (int): Lookback period for the short-term moving average.
    - long_window (int): Lookback period for the long-term moving average.

    Outputs:
    - pd.DataFrame: Contains 'Short_MA', 'Long_MA', and 'Signal' columns.
    """
    signals = pd.DataFrame(index=data.index)
    signals['Close'] = data['Close']
    signals['Short_MA'] = data['Close'].rolling(window=short_window, min_periods=1).mean()
    signals['Long_MA'] = data['Close'].rolling(window=long_window, min_periods=1).mean()

    # Generate signals
    signals['Signal'] = 0.0
    # Use .loc for assignment to avoid FutureWarning
    signals.loc[signals.index[short_window:], 'Signal'] = np.where(
        signals['Short_MA'][short_window:] > signals['Long_MA'][short_window:], 1.0, 0.0
    )

    # Differentiate to get actual trading orders (-1 for sell, 1 for buy)
    signals['Positions'] = signals['Signal'].diff()

    return signals.dropna()

# Generate signals using the strategy
signals = moving_average_crossover_strategy(data)
print("Value counts for signals['Positions']:")
print(signals['Positions'].value_counts())
display(signals.head())
Value counts for signals['Positions']:
Positions
 0.0    514
 1.0      3
-1.0      2
Name: count, dtype: int64
Close Short_MA Long_MA Signal Positions
Date
2022-01-04 100.071523 100.035761 100.035761 0.0 0.0
2022-01-05 101.128303 100.399942 100.399942 0.0 0.0
2022-01-06 101.662098 100.715481 100.715481 0.0 0.0
2022-01-07 100.509316 100.674248 100.674248 0.0 0.0
2022-01-10 100.483181 100.642404 100.642404 0.0 0.0

2.3. Backtesting Engine

The backtesting engine simulates the trading process. It takes the signals from the strategy and executes trades, keeping track of the portfolio's value over time. For simplicity, we'll assume trades are executed at the closing price of the day the signal is generated.

Inputs:

  • signals (pd.DataFrame): DataFrame containing 'Close' prices and 'Positions' (trading signals).
  • initial_capital (float): Starting capital for the backtest.
  • commission (float): Commission per trade (as a percentage of trade value).

Output:

  • portfolio (pd.DataFrame): DataFrame containing daily portfolio value, holdings, cash, and returns.
[ ]
def run_backtest(signals, initial_capital=100000.0, commission=0.001):
    """
    Simulates the execution of a trading strategy.
    """
    portfolio = pd.DataFrame(index=signals.index)
    portfolio['Holdings'] = 0.0
    portfolio['Cash'] = initial_capital
    portfolio['Total Assets'] = initial_capital

    # Keep track of current position (1 = long, 0 = out)
    current_position = 0  # Start with no position
    shares = 0

    print(f"Initial Capital: {initial_capital}")

    for i in range(len(signals)):
        current_date = signals.index[i]
        current_close = signals['Close'].iloc[i]
        signal = signals['Positions'].iloc[i]  # This is the diff

        # Execute trades based on signal
        if signal == 1.0 and current_position == 0:  # Buy signal and not in position
            # Buy with all available cash
            shares = int(portfolio.loc[current_date, 'Cash'] / current_close)
            cost = shares * current_close
            commission_cost = cost * commission

            if portfolio.loc[current_date, 'Cash'] >= cost + commission_cost:
                portfolio.loc[current_date, 'Cash'] -= (cost + commission_cost)
                current_position = 1
                print(f"BUY on {current_date} at ${current_close:.2f}, Shares: {shares}")

        elif signal == -1.0 and current_position == 1:  # Sell signal and in position
            # Sell all shares
            sale_revenue = shares * current_close
            commission_cost = sale_revenue * commission

            portfolio.loc[current_date, 'Cash'] += (sale_revenue - commission_cost)
            shares = 0
            current_position = 0
            print(f"SELL on {current_date} at ${current_close:.2f}, Revenue: ${sale_revenue:.2f}")

        # Update holdings value for current day
        portfolio.loc[current_date, 'Holdings'] = shares * current_close
        portfolio.loc[current_date, 'Total Assets'] = portfolio.loc[current_date, 'Cash'] + portfolio.loc[current_date, 'Holdings']

        # Carry forward cash if no trade
        if i + 1 < len(signals):
            portfolio.loc[signals.index[i+1], 'Cash'] = portfolio.loc[current_date, 'Cash']

    # Calculate returns
    portfolio['Daily Return'] = portfolio['Total Assets'].pct_change()
    portfolio['Cumulative Return'] = (1 + portfolio['Daily Return']).cumprod() - 1

    print(f"Final Capital: ${portfolio['Total Assets'].iloc[-1]:.2f}")
    return portfolio.fillna(0)

3. Performance Metrics

After running a backtest, it's essential to evaluate its performance using various metrics. These metrics quantify aspects like profitability, risk, and consistency.

Here, we'll calculate some common metrics:

  • Total Return: The overall percentage gain or loss from the initial capital.
    • Formula: (Final_Total_Assets - Initial_Capital) / Initial_Capital
  • Annualized Return: The average annual return.
  • Sharpe Ratio: Measures risk-adjusted return. Higher is better.
    • Formula: (Annualized_Return - Risk_Free_Rate) / Annualized_Standard_Deviation
  • Maximum Drawdown: The largest percentage drop from a peak in the portfolio's value.
    • Formula: (Peak_Value - Trough_Value) / Peak_Value (where Trough_Value is the lowest point after a Peak_Value)
  • Annualized Volatility: Standard deviation of daily returns, annualized.
[ ]
def calculate_performance_metrics(portfolio, risk_free_rate=0.02):
    """
    Calculates key performance metrics for a backtest.

    Inputs:
    - portfolio (pd.DataFrame): DataFrame from run_backtest.
    - risk_free_rate (float): Annual risk-free rate (e.g., treasury bond yield).

    Outputs:
    - dict: A dictionary of performance metrics.
    """
    metrics = {}

    # Total Return
    total_return = (portfolio['Total Assets'].iloc[-1] / portfolio['Total Assets'].iloc[0]) - 1
    metrics['Total Return'] = total_return

    # Annualized Return
    num_years = (portfolio.index[-1] - portfolio.index[0]).days / 365.25
    metrics['Annualized Return'] = (1 + total_return)**(1 / num_years) - 1 if num_years > 0 else 0

    # Daily Returns (excluding the first NaN)
    daily_returns = portfolio['Daily Return'].dropna()

    # Annualized Volatility
    annualized_volatility = daily_returns.std() * np.sqrt(252) # 252 trading days in a year
    metrics['Annualized Volatility'] = annualized_volatility

    # Sharpe Ratio
    if annualized_volatility != 0:
        metrics['Sharpe Ratio'] = (metrics['Annualized Return'] - risk_free_rate) / annualized_volatility
    else:
        metrics['Sharpe Ratio'] = np.nan

    # Maximum Drawdown
    # Calculate the running maximum (peak)
    peak = portfolio['Total Assets'].cummax()
    # Calculate the daily drawdown from the peak
    drawdown = (portfolio['Total Assets'] - peak) / peak
    metrics['Maximum Drawdown'] = drawdown.min()

    return metrics

# Calculate and display performance metrics
performance_metrics = calculate_performance_metrics(portfolio)

print("--- Backtest Performance Metrics ---")
for metric, value in performance_metrics.items():
    print(f"{metric:<20}: {value:,.4f}")
--- Backtest Performance Metrics ---
Total Return        : 0.1800
Annualized Return   : 0.0871
Annualized Volatility: 0.1094
Sharpe Ratio        : 0.6133
Maximum Drawdown    : -0.0942

4. Visualizing Backtest Results

Visualizations are crucial for understanding the performance and behavior of a trading strategy. They can highlight trends, drawdowns, and trading activity.

4.1. Equity Curve

The equity curve plots the portfolio's total assets over time. It's the most fundamental visualization, showing the cumulative performance of the strategy.

[ ]
fig1, ax1 = plt.subplots(figsize=(14, 7))
ax1.plot(portfolio.index, portfolio['Total Assets'], label='Portfolio Value', color='blue')
ax1.set_title('Equity Curve (Portfolio Value Over Time)', fontsize=16)
ax1.set_xlabel('Date', fontsize=14)
ax1.set_ylabel('Portfolio Value ($)', fontsize=14)
ax1.legend()
ax1.grid(True)
plt.xticks(rotation=45)
plt.tight_layout()
plt.show()

print("Interpretation: This chart shows how the portfolio's total value changed over the backtesting period. An upward sloping curve indicates profitability, while steep drops indicate drawdowns.")
cell output
Interpretation: This chart shows how the portfolio's total value changed over the backtesting period. An upward sloping curve indicates profitability, while steep drops indicate drawdowns.
[1]
# @title step_artifacts
num_fig = "1" # @param {type:"string"}
step = 'DataVisualization'  # @param ["DataLoading", "DataExploration", "DataCleaning", "DataWrangling", "DataVisualization", "DataSplitting", "ModelDevelopment", "ModelOptimization", "ModelEvaluation", "Summary"] {type:"string"}
# Assume `upload_plt_to_gcs` is defined elsewhere or not strictly required for this notebook.

4.2. Trades on Price Chart

This visualization overlays the trading signals (buy/sell points) on the historical price chart, along with the moving averages. This helps to visually inspect whether the strategy's signals align with price movements as expected.

[ ]
fig2, ax2 = plt.subplots(figsize=(14, 8))

# Plot the closing price
ax2.plot(data.index, data['Close'], label='Close Price', alpha=0.7)

# Plot the moving averages
ax2.plot(signals.index, signals['Short_MA'], label='Short MA', color='orange', linestyle='--')
ax2.plot(signals.index, signals['Long_MA'], label='Long MA', color='red', linestyle='--')

# Plot buy signals
ax2.plot(
    signals.loc[signals['Positions'] == 1.0].index,
    signals['Short_MA'][signals['Positions'] == 1.0],
    '^', markersize=10, color='green', lw=0, label='Buy Signal'
)

# Plot sell signals
ax2.plot(
    signals.loc[signals['Positions'] == -1.0].index,
    signals['Short_MA'][signals['Positions'] == -1.0],
    'v', markersize=10, color='red', lw=0, label='Sell Signal'
)

ax2.set_title('Price Chart with Trading Signals and Moving Averages', fontsize=16)
ax2.set_xlabel('Date', fontsize=14)
ax2.set_ylabel('Price ($)', fontsize=14)
ax2.legend()
ax2.grid(True)
plt.xticks(rotation=45)
plt.tight_layout()
plt.show()

print("Interpretation: This chart visually confirms where the strategy generated buy (green up arrows) and sell (red down arrows) signals relative to the price action and moving averages. It helps in understanding the mechanics of the strategy's entry and exit points.")
cell output
Interpretation: This chart visually confirms where the strategy generated buy (green up arrows) and sell (red down arrows) signals relative to the price action and moving averages. It helps in understanding the mechanics of the strategy's entry and exit points.
[ ]
# @title step_artifacts
num_fig = "2" # @param {type:"string"}
step = 'DataVisualization'  # @param ["DataLoading", "DataExploration", "DataCleaning", "DataWrangling", "DataVisualization", "DataSplitting", "ModelDevelopment", "ModelOptimization", "ModelEvaluation", "Summary"] {type:"string"}
# Assume `upload_plt_to_gcs` is defined elsewhere or not strictly required for this notebook.

5. Conclusion

Backtesting is an indispensable tool for developing and validating quantitative trading strategies. By simulating strategy performance on historical data, traders can gain valuable insights into profitability, risk, and overall effectiveness. However, it's crucial to be aware of its limitations, such as the potential for curve fitting and the assumption that past performance predicts future results.

A robust backtesting process involves:

  • High-quality data.
  • Well-defined strategies.
  • Realistic execution models.
  • Thorough performance evaluation.
  • Careful interpretation of results.

While this notebook demonstrated a basic backtesting framework, real-world systems often incorporate more sophisticated features like slippage, variable position sizing, stop-losses, take-profits, and more advanced performance attribution.