Backtrader Example
Walk through a complete strategy implementation in Backtrader — broker config, data feeds, indicators, and trade logging.
Backtesting Framework: Backtrader
!pip install pandas numpy backtrader plotlyRequirement 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) [2K [90m━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━[0m [32m419.5/419.5 kB[0m [31m7.4 MB/s[0m eta [36m0:00:00[0m [?25hInstalling collected packages: backtrader Successfully installed backtrader-1.9.78.123
1. Dependency Installation
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_subplots2. 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
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
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+1when the fastSMAcrosses above the slowSMA,-1when it crosses below, and0otherwise. 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. Thestakeparameter inself.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 toFalsewhen no position is held andTruewhen a position is open.
5. Backtesting Engine Configuration and Execution
# --- 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 foropen,high,low,close, andvolume.openinterest=-1disables 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 duringbuy()andclose()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
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.