Backtesting·Libraries·Intermediate

Backtrader Example

Walk through a complete strategy implementation in Backtrader — broker config, data feeds, indicators, and trade logging.

backtraderframeworkstrategy

Backtesting Framework: Backtrader

[1]
!pip install pandas numpy backtrader plotly
Requirement already satisfied: pandas in /usr/local/lib/python3.12/dist-packages (2.2.2)
Requirement already satisfied: numpy in /usr/local/lib/python3.12/dist-packages (2.0.2)
Collecting backtrader
  Downloading backtrader-1.9.78.123-py2.py3-none-any.whl.metadata (6.8 kB)
Requirement already satisfied: plotly in /usr/local/lib/python3.12/dist-packages (5.24.1)
Requirement already satisfied: python-dateutil>=2.8.2 in /usr/local/lib/python3.12/dist-packages (from pandas) (2.9.0.post0)
Requirement already satisfied: pytz>=2020.1 in /usr/local/lib/python3.12/dist-packages (from pandas) (2025.2)
Requirement already satisfied: tzdata>=2022.7 in /usr/local/lib/python3.12/dist-packages (from pandas) (2026.1)
Requirement already satisfied: tenacity>=6.2.0 in /usr/local/lib/python3.12/dist-packages (from plotly) (9.1.4)
Requirement already satisfied: packaging in /usr/local/lib/python3.12/dist-packages (from plotly) (26.1)
Requirement already satisfied: six>=1.5 in /usr/local/lib/python3.12/dist-packages (from python-dateutil>=2.8.2->pandas) (1.17.0)
Downloading backtrader-1.9.78.123-py2.py3-none-any.whl (419 kB)
   ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ 419.5/419.5 kB 7.4 MB/s eta 0:00:00
[?25hInstalling collected packages: backtrader
Successfully installed backtrader-1.9.78.123

1. Dependency Installation

[2]
import warnings
warnings.filterwarnings("ignore")

import pandas as pd
import numpy as np
import backtrader as bt
import plotly.graph_objects as go
from plotly.subplots import make_subplots

2. Library Imports


3. Backtrader Overview

Backtrader is an event-driven Python framework designed for backtesting trading strategies, featuring a class-based API. Strategies are implemented as subclasses of bt.Strategy, with trading logic defined in the next() method, which executes upon the arrival of each new data bar. The framework manages order execution, position tracking, capital accounting, and comprehensive performance analysis internally.

Core Concepts:

| Concept | Description | |---| | bt.Strategy | Base class for strategy implementation. Requires overriding __init__ for indicator setup and next() for trading logic execution. | | bt.Cerebro | The primary backtesting engine. Responsible for orchestrating data feeds, strategies, broker simulation, and performance analyzers. | | bt.feeds.PandasData | An adapter facilitating the integration of pandas DataFrames as data feeds within Backtrader. | | bt.indicators.* | A collection of integrated technical analysis indicators (e.g., Simple Moving Average (SMA), Exponential Moving Average (EMA), Relative Strength Index (RSI), Moving Average Convergence Divergence (MACD), Average True Range (ATR)). | | bt.analyzers.* | A suite of performance analysis modules (e.g., Sharpe Ratio, DrawDown, TradeAnalyzer). | | self.buy(size) | Initiates a market buy order for a specified size at the subsequent bar's opening price. | | self.close() | Closes the current open position at the subsequent bar's opening price. |

Vectorized Backtesting Superiority: Backtrader accurately models order lifecycle, executing orders placed within the next() method at the subsequent bar's opening price. This design inherently mitigates look-ahead bias, eliminating the need for explicit data shifting operations often required in vectorized backtesting approaches.


3. Data Generation

[3]
def generate_data(periods: int) -> pd.DataFrame:
    start_date     = pd.to_datetime("2024-01-01 00:00:00+00:00")
    datetime_index = pd.date_range(start_date, periods=periods, freq="1min", tz="UTC")
    price_data = []; last_close = 42000

    # Adjusted parameters for more dynamic price action and frequent crossovers
    # Using absolute values for price changes to prevent exponential trend growth
    base_volatility_per_period = 50 # Average absolute price change per minute
    wick_scale                 = 0.002 # Percentage of price for wick size

    for _ in range(periods):
        # Simulate random walk for open and close prices with less consistent drift
        price_change_open = np.random.normal(0, base_volatility_per_period * 0.1)
        open_price  = last_close + price_change_open

        price_change_close = np.random.normal(0, base_volatility_per_period * 0.5)
        close_price = open_price + price_change_close

        # Ensure prices stay positive and are reasonable
        open_price = max(1, open_price)
        close_price = max(1, close_price)

        body_high   = max(open_price, close_price)
        body_low    = min(open_price, close_price)

        # Wicks based on a percentage of the current price, with a minimum value
        high_price  = max(body_high + abs(np.random.normal(0, body_high * wick_scale)), open_price, close_price)
        low_price   = min(body_low  - abs(np.random.normal(0, body_low * wick_scale)), open_price, close_price)

        # Ensure high is always >= low
        if high_price < low_price:
            high_price, low_price = low_price, high_price

        price_data.append({
            "open":  max(1, int(open_price)),
            "high":  max(1, int(high_price)),
            "low":   max(1, int(low_price)),
            "close": max(1, int(close_price))
        })
        last_close = close_price

    df = pd.DataFrame(price_data, index=datetime_index)
    df.index.name = "datetime"
    df["volume"]   = np.random.uniform(100.0, 500.0, periods)

    # Backtrader requires the index to be named 'datetime' and timezone-naive
    df.index = df.index.tz_localize(None)
    return df

df = generate_data(45000) # Increased periods to generate data for a longer duration (e.g., ~31 days)
display(df.head())
open high low close volume
datetime
2024-01-01 00:00:00 42000 42248 41916 42012 133.605054
2024-01-01 00:01:00 42015 42081 41959 42039 460.039403
2024-01-01 00:02:00 42038 42165 42016 42045 112.672945
2024-01-01 00:03:00 42044 42127 41900 42017 344.611199
2024-01-01 00:04:00 42016 42124 41967 42021 179.338042

4. Strategy Definition

[4]
class MACrossStrategy(bt.Strategy):
    """
    Moving average crossover strategy implemented as a Backtrader Strategy.

    Parameters
    ----------
    fast : int — Fast SMA period.
    slow : int — Slow SMA period.
    stake : int — Number of units to buy/sell per trade.

    Logic
    -----
    - Enter long when fast SMA crosses above slow SMA and no position is held.
    - Close position when fast SMA crosses below slow SMA.
    - All orders are market orders filled at the next bar's open.
    """
    params = dict(fast=10, slow=30, stake=1)

    def __init__(self):
        self.fast_ma   = bt.indicators.SMA(self.data.close, period=self.p.fast)
        self.slow_ma   = bt.indicators.SMA(self.data.close, period=self.p.slow)
        self.crossover = bt.indicators.CrossOver(self.fast_ma, self.slow_ma)

        self.order = None # Keep track of pending orders

        # Lists for custom trade logging of EXECUTED trades
        self.executed_trade_dates  = []
        self.executed_trade_types  = []
        self.executed_trade_prices = []
        self.executed_trade_sizes = []

    def notify_order(self, order):
        if order.status in [order.Submitted, order.Accepted]:
            return # Order submitted/accepted - Nothing to do

        if order.status in [order.Completed]:
            if order.isbuy():
                self.executed_trade_dates.append(self.data.datetime.datetime())
                self.executed_trade_types.append("buy")
                self.executed_trade_prices.append(order.executed.price)
                self.executed_trade_sizes.append(order.executed.size)
            elif order.issell(): # This handles both sells to close and short sells
                self.executed_trade_dates.append(self.data.datetime.datetime())
                self.executed_trade_types.append("sell")
                self.executed_trade_prices.append(order.executed.price)
                self.executed_trade_sizes.append(order.executed.size)
            self.order = None # No pending order anymore
        elif order.status in [order.Canceled, order.Margin, order.Rejected]:
            # Order cancelled/margin/rejected
            # print(f'Order Canceled/Margin/Rejected: {order.getstatusname()}') # Optional logging
            self.order = None

    def next(self):
        if self.order: # An order is pending, don't issue a new one
            return

        if not self.position:  # Not in the market
            if self.crossover > 0:  # Fast MA crosses above Slow MA
                self.order = self.buy(size=self.p.stake)
        else:  # In the market
            if self.crossover < 0:  # Fast MA crosses below Slow MA
                self.order = self.close() # Close current position

    # The log method is not strictly necessary for this revised strategy, as notify_order handles logging.

Strategy Logic and Implementation Details:

  • bt.indicators.SMA: Backtrader automatically computes and updates the Simple Moving Average (SMA) for each bar within the __init__ method, eliminating the need for manual rolling calculations.
  • bt.indicators.CrossOver: This indicator yields +1 when the fast SMA crosses above the slow SMA, -1 when it crosses below, and 0 otherwise. This obviates manual comparison of shifted data arrays.
  • self.notify_order(): This method is invoked by the broker upon order status changes. It is utilized here to log details of executed buy and sell orders, providing accurate records of actual trades.
  • self.buy(size=self.p.stake) / self.close(): Orders are submitted to the simulated broker and are filled at the subsequent bar's opening price by default, which is a design feature preventing look-ahead bias. The stake parameter in self.buy() defines the number of units to trade.
  • self.position: This internal Backtrader object represents the current market position (e.g., flat, long, short). It evaluates to False when no position is held and True when a position is open.

5. Backtesting Engine Configuration and Execution

[5]
# --- Configure Cerebro ---
cerebro = bt.Cerebro()
cerebro.addstrategy(MACrossStrategy, fast=10, slow=30, stake=1)

# --- Convert DataFrame to Backtrader data feed ---
data_feed = bt.feeds.PandasData(
    dataname = df,
    open     = "open",
    high     = "high",
    low      = "low",
    close    = "close",
    volume   = "volume",
    openinterest = -1,
)
cerebro.adddata(data_feed)

# --- Broker configuration ---
cerebro.broker.set_cash(1_000_000) # Increased cash for sufficient trading capital
cerebro.broker.setcommission(commission=0.001)   # 0.1% per trade

# --- Analyzers ---
cerebro.addanalyzer(bt.analyzers.SharpeRatio,   _name="sharpe",   riskfreerate=0.0)
cerebro.addanalyzer(bt.analyzers.DrawDown,       _name="drawdown")
cerebro.addanalyzer(bt.analyzers.TradeAnalyzer,  _name="trades")
cerebro.addanalyzer(bt.analyzers.Returns,        _name="returns")

print(f"Starting Portfolio Value : ${cerebro.broker.getvalue():,.2f}")

# --- Run ---
results  = cerebro.run()
strat    = results[0]

print(f"Final Portfolio Value    : ${cerebro.broker.getvalue():,.2f}")

# --- Extract analytics ---
sharpe   = strat.analyzers.sharpe.get_analysis().get("sharperatio", None)
max_dd   = strat.analyzers.drawdown.get_analysis().get("max", {}).get("drawdown", None)
trade_an = strat.analyzers.trades.get_analysis()
n_total  = trade_an.get("total", {}).get("total", 0)
n_won    = trade_an.get("won",   {}).get("total", 0)
win_rate = (n_won / n_total * 100) if n_total > 0 else 0

print(f"\n--- Performance Metrics ---")
print(f"  Sharpe Ratio  : {sharpe:.4f}"  if sharpe  else "  Sharpe Ratio  : N/A")
print(f"  Max Drawdown  : {max_dd:.2f}%" if max_dd  else "  Max Drawdown  : N/A")
print(f"  Total Trades  : {n_total}")
print(f"  Win Rate      : {win_rate:.1f}%")
Starting Portfolio Value : $1,000,000.00
Final Portfolio Value    : $935,658.23

--- Performance Metrics ---
  Sharpe Ratio  : N/A
  Max Drawdown  : 6.44%
  Total Trades  : 863
  Win Rate      : 20.2%

Configuration and Execution Details:

  • bt.feeds.PandasData: This component converts a pandas DataFrame into a Backtrader-compatible data feed. Explicit column mapping is provided for open, high, low, close, and volume. openinterest=-1 disables the open interest field, which is not present in the generated OHLCV data.
  • cerebro.broker.set_cash(1_000_000): Sets the initial capital for the backtest to $1,000,000.
  • cerebro.broker.setcommission(commission=0.001): Establishes a 0.1% commission rate per trade side. This commission is automatically applied by Backtrader during buy() and close() operations.
  • bt.analyzers.SharpeRatio: Calculates the annualized Sharpe ratio based on the portfolio's daily return series.
  • bt.analyzers.TradeAnalyzer: Provides a comprehensive summary of all closed trades, including counts, average profit/loss, and consecutive win/loss streaks.

6. Performance Visualization

[8]
import plotly.graph_objects as go
from plotly.subplots import make_subplots

# Extract executed trade log from strategy
trade_df = pd.DataFrame({
    "datetime": strat.executed_trade_dates,
    "type":     strat.executed_trade_types,
    "price":    strat.executed_trade_prices,
    "size":     strat.executed_trade_sizes
})

buys  = trade_df[trade_df["type"] == "buy"]
sells = trade_df[trade_df["type"] == "sell"]

# Reconstruct equity curve from close prices + positions
df_vis = df.copy().reset_index() # Ensure df_vis uses the updated, longer 'df'
df_vis.columns = [c if c != "datetime" else "datetime" for c in df_vis.columns]

# Merge buys and sells with df_vis to get the correct x-indices for plotting
# The 'index' column from df_vis.reset_index() will hold the correct x-coordinates
buys_with_indices = pd.merge(buys, df_vis[['datetime']].reset_index(), on='datetime', how='left')
sells_with_indices = pd.merge(sells, df_vis[['datetime']].reset_index(), on='datetime', how='left')

fig = make_subplots(
    rows=2, cols=1, shared_xaxes=True,
    subplot_titles=[
        "Price + MA Crossover + Backtrader Trade Signals",
        "Approximate Equity Curve ", # Renamed for clarity
    ],
    row_heights=[0.6, 0.4],
)

fig.add_trace(go.Candlestick(
    x=df_vis.index,
    open=df_vis["open"], high=df_vis["high"],
    low=df_vis["low"],   close=df_vis["close"],
    name="Price"), row=1, col=1)

fast_ma_series = df_vis["close"].rolling(10).mean()
slow_ma_series = df_vis["close"].rolling(30).mean()

fig.add_trace(go.Scatter(
    x=df_vis.index, y=fast_ma_series,
    mode="lines", name="Fast SMA (10)",
    line=dict(color="blue",   width=1)), row=1, col=1)

fig.add_trace(go.Scatter(
    x=df_vis.index, y=slow_ma_series,
    mode="lines", name="Slow SMA (30)",
    line=dict(color="orange", width=1)), row=1, col=1)

if len(buys_with_indices) > 0:
    fig.add_trace(go.Scatter(
        x=buys_with_indices['index'], y=buys_with_indices["price"] * 0.999, # Slightly below price for visibility
        mode="markers",
        marker=dict(symbol="triangle-up",   size=10, color="green"),
        name="Buy Signal"), row=1, col=1)

if len(sells_with_indices) > 0:
    fig.add_trace(go.Scatter(
        x=sells_with_indices['index'], y=sells_with_indices["price"] * 1.001, # Slightly above price for visibility
        mode="markers",
        marker=dict(symbol="triangle-down", size=10, color="red"),
        name="Sell Signal"), row=1, col=1)

# Approximate equity curve from vectorized position (for comparison, not Backtrader's actual equity)
# This calculation assumes a fixed stake and does not precisely reflect Backtrader's internal broker logic for fractional shares, commissions, etc.
sig    = np.where(fast_ma_series > slow_ma_series, 1, 0)
pos    = pd.Series(sig).shift(1).fillna(0)
ret    = df_vis["close"].pct_change()
equity = 1_000_000 * (1 + pos * ret - pos.diff().abs() * 0.001).cumprod() # Adjusted initial capital

fig.add_trace(go.Scatter(
    x=df_vis.index, y=equity,
    mode="lines", name="Approx. Equity Curve",
    line=dict(color="green", width=2)), row=2, col=1)

fig.update_layout(
    title_text="Backtrader — MA Crossover Backtest",
    xaxis_rangeslider_visible=False,
    height=800,
    yaxis=dict(autorange=True),
    xaxis2_title="Bar Index",
    yaxis_title="Price",
    yaxis2_title="Portfolio Value ($)",
    hovermode="x unified"
)
fig.show()
Output hidden; open in https://colab.research.google.com to view.
[6]