May cohort is now open: How to secure your spot:

Backtesting with Backtrader: Step-by-step

PQN #028: Backtesting with Backtrader: Step-by-step

Backtesting with Backtrader: Step-by-step

By reading today’s newsletter, you will be able to backtest a real trading strategy with Backtrader.

A backtest is a way to test trading ideas against historic market data. It’s a simulation of how a strategy might have performed in the market. Traders use backtests to test the robustness of strategies to different market conditions.

You should not consider a backtest a perfect reflection of how your strategy will perform in the future. Use it as an experiment to make sure the performance is not due to chance.

Don’t reinvent the wheel—instead use Backtrader

It might sound fun to build your own backtesting framework.

Don’t.

It is easy to introduce bias to your analysis and skew your results. Bias like look-ahead bias (using data from the future), curve fitting bias (over-optimizing parameters), and data mining bias (optimizing against a one-off event) are common in backtests. All will make you think your strategy is great when it’s not.

And you will lose money.

Instead, use an event-driven backtesting framework designed to remove bias, like Backtrader. It’s a library that makes it easy to build and test trading strategies in a reusable way. It also has direct connections to some brokers to automate your trading.

Backtrader is great, but unfortunately, most beginners struggle to get started with it.

By the end of this newsletter, you will be able to:

  • Get data from OpenBB
  • Build a backtest using Backtrader
  • Assess the results using QuantStats

Here’s how to do it in Python, step by step.

Step 1: Get data from OpenBB

Start by importing pandas, the OpenBB SDK, QuantStats, and Backtrader.

import datetime as dt

import pandas as pd

from openbb_terminal.sdk import openbb
import quantstats as qs
import backtrader as bt

There’s an unsolved issue with Backtrader that prevents it from downloading data. Here’s a simple workaround.

def openbb_data_to_bt_data(symbol, start_date, end_date):
    
    df = openbb.stocks.load(symbol, start_date=start_date, end_date=end_date)
    
    fn = f"{symbol.lower()}.csv"
    df.to_csv(fn)
    
    return bt.feeds.YahooFinanceCSVData(
        dataname=fn,
        fromdate=dt.datetime.strptime(start_date, '%Y-%m-%d'),
        todate=dt.datetime.strptime(end_date, '%Y-%m-%d')
    )

This function downloads the data from the OpenBB SDK, converts it to a CSV, and reads it into Backtrader’s YahooFinanceCSVData.

Next, build the strategy.

Step 2: Build a backtest using Backtrader

Fund managers report their holdings every month. They don’t want to tell investors they lost money on the latest meme stock. So they sell them before the reporting deadline and buy higher-quality assets, like bonds.

You might be able to take advantage of this effect by buying bonds toward the end of the month and selling them at the beginning.

Start with a simple helper function that gets the last day of the month.

def last_day_of_month(any_day):
    # The day 28 exists in every month. 4 days later, it's always next month
    next_month = any_day.replace(day=28) + dt.timedelta(days=4)
    
    # subtracting the number of the current day brings us back one month
    return (next_month - dt.timedelta(days=next_month.day)).day

Next, setup the Backtrader strategy. All Backtrader strategies are built as classes and inherit bt.Strategy.

class MonthlyFlows(bt.Strategy):
    
    params = (
        ("end_of_month", 23),
        ("start_of_month", 7),
    )
    
    def __init__(self):
        self.order = None
        self.dataclose = self.datas[0].close
        
    def notify_order(self, order):
        # No more orders
        self.order = None    
    
    def next(self):
        
        # Get today's date, day of month, and last day of current month
        dt_ = self.datas[0].datetime.date(0)
        dom = dt_.day
        ldm = last_day_of_month(dt_)
        
        # If an order is pending, exit
        if self.order:
            return
        
        # Check if we are in the market
        if not self.position:
            
            # We're in the first week of the month, sell
            if dom <= self.params.start_of_month:

                # Sell the entire portfolio
                self.order = self.order_target_percent(target=-1)
                
                print(f"Created SELL of {self.order.size} at {self.data_close[0]} on day {dom}")
            
            # We're in the last week of the month, buy
            if dom >= self.params.end_of_month:

                # Buy the entire portfolio
                self.order = self.order_target_percent(target=1)
                
                print(f"Created BUY of {self.order.size} {self.data_close[0]} on day {dom}")
        
        # We are not in the market
        else:
            
            # If we're long
            if self.position.size > 0:
                
                # And not within the last week of the month, close
                if not self.params.end_of_month <= dom <= ldm:
                    
                    print(f"Created CLOSE of {self.position.size} at {self.data_close[0]} on day {dom}")

                    self.order = self.order_target_percent(target=0.0)
            
            # If we're short
            if self.position.size < 0:
                
                # And not within the first week of the month, close
                if not 1 <= dom <= self.params.start_of_month:
                    
                    print(f"Created CLOSE of {self.position.size} at {self.data_close[0]} on day {dom}")

                    # self.order = self.close()
                    self.order = self.order_target_percent(target=0.0)

First, set up the strategy parameters. end_of_month is the first day I want to be long TLT and start_of_month is the last day I want to be short.

The strategy logic is in the next method.

This code tests if there’s a position in the market. If not, it checks if the current day is within the first week of the month and creates a short position. Otherwise, if the current day is within the last week, it creates a long position.

The last step closes the open positions.

The strategy closes the long position if the current day is no longer within the last week of the month. The strategy closes the short position if the current day is no longer within the first week of the month.

Now, run the backtest.

data = openbb_data_to_bt_data(
    "TLT", 
    start_date="2002-01-01",
    end_date="2022-06-30"
)

cerebro = bt.Cerebro(stdstats=False)

cerebro.adddata(data)
cerebro.broker.setcash(1000.0)

cerebro.addstrategy(MonthlyFlows)

cerebro.addobserver(bt.observers.Value)

cerebro.addanalyzer(
    bt.analyzers.Returns, _name="returns"
)
cerebro.addanalyzer(
    bt.analyzers.TimeReturn, _name="time_return"
)

backtest_result = cerebro.run()

The first step is to create a backtesting engine (Backtrader calls it Cerebro). Then add the data, initial cash, and strategy logic. Backtrader has built-in observers that track variables and performance throughout the backtest.

After setting up the backtest, run it.

The last step is to convert the results into a pandas DataFrame.

# Get the strategy returns as a dictionary
returns_dict = backtest_result[0].analyzers.time_return.get_analysis()

# Convert the dictionary to a DataFrame
returns_df = (
    pd.DataFrame(
        list(returns_dict.items()),
        columns = ["date", "return"]
    )
    .set_index("date")
)

Step 3: Assess the results using key performance metrics

Trading takes time, money, and effort. To make sure you’re better off not being long TLT, compare the strategy results to a long-only strategy.

QuantStats makes it easy.

QuantStats is a library jam-packed with trading performance and risk metrics. One of the best parts of Backtrader is that it works well with QuantStats.

Here’s how to use it.

bench = openbb.stocks.load(
    "TLT",
    start_date="2002-01-01",
    end_date="2022-06-30"
)["Adj Close"]

qs.reports.metrics(
    returns_df,
    benchmark=bench,
    mode="full"
)

Running this code prints 70 different performance and risk metrics. Here are a few key ones to look at when comparing the trading strategy and the long-only strategy.

  • Cumulative return: 115.1% versus 171.9%
  • Sharpe: 0.47 versus 0.43
  • Max drawdown: -27.7% versus -34.8%
  • Annualized volatility: 9% versus 14.1%
  • CVaR: -0.9% versus -1.4%
  • Profit factor: 1.12 versus 1.08

The strategy underperforms the long-only strategy on an absolute basis. But, it has better risk-adjusted returns, lower drawdowns, and lower volatility. It also has a better profit factor—which is important for active strategies.

Despite underperforming on an absolute basis, it has better risk-adjusted performance.

By reading today’s newsletter, you can backtest a real trading strategy with Backtrader. Now you can get data, backtest the strategy, and analyze the results to test the performance of your strategies.

That’s it for today. I hope you enjoyed it!

See you again next week.

Cheers,

Jason