Backtrader for Backtesting (Python) – A Complete Guide

27 min read

Get 10-day Free Algo Trading Course

Loading

Last Updated on July 16, 2022

If you want to backtest a trading strategy using Python, you can 1) run your backtests with pre-existing libraries, 2) build your own backtester, or 3) use a cloud trading platform.

Option 1 is our choice. It gets the job done fast and everything is safely stored on your local computer.

(After you become an algorithmic trading expert, you can consider option 2 if the current available solutions don’t fulfill your needs.)

There are 2 popular libraries for backtesting. Backtrader is one of them. The other is Zipline.

In this article, we will focus on Backtrader.

Table of Contents

  1. What is Backtrader?
  2. Why should I learn Backtrader?
  3. Why shouldn’t I learn backtrader?
  4. Overview of how Backtrader works
  5. How to install Backtrader
  6. Choosing which IDE to use with Backtrader
  7. How to configure the basic Backtrader setup
  8. How to get data and import it into Backtrader
  9. How to print or log data using the strategy class in Backtrader
  10. How to run a backtest using Backtrader
  11. How to use the built-in crossover indicator
  12. How to run optimization in Backtrader
  13. How to build a stock screener in Backtrader
  14. How to code an indicator in Backtrader
  15. How to plot in Backtrader
  16. How to use alternative data in Backtrader
  17. How to add visual stats to a backtest
  18. How to save backtest data to a CSV file
  19. Alternatives to Backtrader
  20. Final Thoughts on Backtrader
  21. Download all code and data

What is Backtrader?

Backtrader is a Python library that aids in strategy development and testing for traders of the financial markets.

It is an open-source framework that allows for strategy testing on historical data. Further, it can be used to optimize strategies, create visual plots, and can even be used for live trading.

Why should I learn Backtrader?

Using Backtrader can save you countless hours of writing code to test out market strategies.

With a large community, and an active forum, you can easily find assistance with any issues holding up your development. Further, the extensive documentation on Backtrader’s website might even lead to the discovery of a crucial component for your strategy.

Here are some of the things Backtrader excels at:

Backtesting – This might seem like an obvious one but Backtrader removes the tedious process of cleaning up your data and iterating through it to test strategies. It has built-in templates to use for various data sources to make importing data easier.

Optimizing – Adjusting a few parameters can sometimes be the difference between a profitable strategy and an unprofitable one. After running a backtest, optimizing is easily done by changing a few lines of code.

Plotting – If you’ve worked with a few Python plotting libraries, you’ll know these are not always easy to configure, especially the first time around. A complex chart can be created with a single line of code.

Indicators – Most of the popular indicators are already programmed in the Backtrader platform. This is especially useful if you want to test out an indicator but you’re not sure how effective it will be. Rather than trying to figure out the math behind the indicator, and how to code it, you can test it out first in Backtrader, probably with one line of code.

Support for Complex Strategies – Want to take a signal from one dataset and execute a trade on another? Does your strategy involve multiple timeframes? Or do you need to resample data? Backtrader has accounted for the various ways traders approach the markets and has extensive support.

Open Source – There is a lot of benefit to using open-source software, here are a few of them:

  • You have full access to all the individual components and can build on them if desired.
  • There’s no need to upload your strategy to a third-party server which eases concerns over confidentiality.
  • You’re not obligated to upgrade and deal with unwanted changes as you might with software from a corporation. A good example of this is when Quantopian discontinued live trading a few years ago. It forced many users to migrate to a different platform which can be cumbersome.

Active Development – This might be one area where Backtrader especially stands out. The framework was originally developed in 2015 and constant improvements have been made since then. Just a few weeks ago, a pandas-based technical analysis library was released to address issues in the popular and commonly used TA-Lib framework. Further, with a wide user base, there is also active third-party development.

Live Trading – If you’re happy with your backtesting results, it is easy to migrate to a live environment within Backtrader. This is especially useful if you plan to use the built-in indicators offered by the platform.

» Backtrader is used for backtesting and not live trading.

QuantConnect is a browser-based platform that allows both backtesting and live trading.

Link: QuantConnect – A Complete Guide

Content Highlights:

  • Create strategies based on alpha factors such as sentiment, crypto, corporate actions and macro data (data provided by QuantConnect).
  • Backtest and trade a wide array of asset classes and industries ETFs (data provided by QuantConnect).
  • License strategies to hedge fund (while you keep the IP) via QuantConnect’s Alpha Stream.


Why shouldn’t I learn Backtrader?

A potentially steep learning curve – There is a lot you can do with Backtrader, it is very comprehensive. But the additional functionality can be seen as a double-edged sword. It will take some time to understand the syntax and logic that are used.

Understanding the Library – Building on the previous point, it is a good idea to look through the source code of any library to get a better understanding of the framework. When decompressing the source code, 470 items were extracted. Granted, some of these are examples or datasets. There were also several scripts no longer in use. Nevertheless, there is a lot to go through.

Having to supply data – At one point, integration with the Yahoo Finance API took care of this issue. The API has since deprecated and you will now need to source and supply data. There are methods to connect with a broker that can address this issue, albeit not all that straight forward.

Creating your own framework – Some people prefer to have a full understanding of their software and would rather create a backtesting platform by themselves. In most cases, this will be a lot more work, but there are obvious benefits. If you’re looking to just get a general idea about a simple strategy, it might be easier to just try and iterate over historical data versus learning the library.

Overview of how Backtrader works

Backtrader shows you how your strategy might perform in the market by testing it against past price data.

The library’s most basic functionality is to iterate through historical data and to simulate the execution of trades based on signals given by your strategy.

It extends on this functionality in many ways. A Backtrader “analyzer” can be added to provide useful statistics. We will show an example of this using the commonly used Sharpe Ratio in a optimization test later in this tutorial.

On the subject of optimization, it’s clear a lot of thought has been put in to speeding up the testing of strategies with different parameters. The built in optimization module uses multiprocessing, fully utilizing your multiple CPU cores to speed up the process.

Lastly, Backtrader utilizes the well-known matplotlib library to create charts at the end of your backtest, if desired.

How to install Backtrader

The easiest way to install Backtrader is by command line. Simply type in pip install backtrader.

If you plan to use the charting functionality, you should have matplotlib installed. The minimum version requirement for matplotlib is 1.4.1.

You can confirm it is installed on your system by typing in pip list from the command line to show installed Python packages.

If you need to install it, you can do so either via pip install backtrader[plotting] or pip install matplotlib.

Alternatively, you can run Backtrader from source. Download the zip file from the Backtrader GitHub page – https://github.com/mementum/backtrader/archive/master.zip and unzip the backtrader directory inside your project file.

Choosing which IDE to use with Backtrader

Before diving into code, let’s take a brief moment to discuss IDE’s.

An IDE, or Integrated Development Environment, is simply an editor to write and debug your code from. There are several popular IDE’s out there and choosing the right one often comes down to personal preference.

Python comes bundled with an IDE called IDLE. Some of the popular third-party Python IDE’s out there include VS Code, Sublime Text, PyCharm and Spyder.

Another consideration is whether to use an interactive IDE or not. A popular choice when it comes to interactive IDE’s is Jupyter Notebook.

Interactive IDE’s have the additional capability of executing selected blocks of code without running your entire script. This is very useful when testing out a new library as you can try out different functions without having to comment out or delete your previous code block.

While it is possible to use interactive IDE’s for some functionality in Backtrader, it is not recommended. There are certain functions, such as optimization, that require multiprocessing which does not work well with interactive IDE’s.

If you decide to use an interactive IDE, you should be able to follow along until the optimization portion of this tutorial. Just make sure to point to the exact path where your CSV data file is stored on the next part which covers adding data.

How to configure the basic Backtrader setup

There are two main components to setting up your basic Backtrader script. The strategy class, and the cerebro engine.

import backtrader as bt

class MyStrategy(bt.Strategy):
    def next(self):
        pass #Do something

#Instantiate Cerebro engine
cerebro = bt.Cerebro()

#Add strategy to Cerebro
cerebro.addstrategy(MyStrategy)

#Run Cerebro Engine
cerebro.run()

We will go into the strategy class in more detail in the examples that follow. This is where all the logic goes in determining and executing your trade signals. It is also where indicators can be created or called, and where you can determine what get’s logged or printed to screen.

The cerebro engine is the core of Backtrader. This is the main class and we will add our data and strategies to it before eventually calling the cerebro.run() command.

How to get data and import it into Backtrader

There are several ways to get data. If you’re already signed up with a broker, you might have API access to grab historical data.

Alternatively, there are many third-party API’s available that allow you to download historical data from within your Python console.

For our example, we will download data in CSV format directly from the Yahoo Finance website.

Yahoo Finance

Simply navigate to the Yahoo Finance website and enter in the ticker or company name for the data you’re looking for. Then, click on the Historical Data tab, select your Time Period, and click on Apply. There will be a Download Data link which will save the CSV file to your hard drive.

It’s a good idea to copy the CSV file over to your project directory. Otherwise, you will have to specify a full pathname when adding your data to cerebro.

We can add our data to Backtrader by using the built-in feeds template specifically for Yahoo Finance. The template will take care of any formatting required for Backtrader to properly read the data.

data = bt.feeds.YahooFinanceCSVData(dataname='TSLA.csv') cerebro.adddata(data) 

In the above example, we’ve assigned the CSV dataset to a variable named data. The next step is to add this to cerebro.

Adding data can be done at any point between instantiating cerebro and calling the cerebro.run() command. There are several additional parameters we can specify when loading our data. We will explore this further in our next example.



How to print or log data using the strategy class in Backtrader

To get a bit more familiar with the Strategy class in Backtrader, we will create a simple script that prints the closing prices for our dataset. The Strategy class is where we will be spending most of our time within Backtrader.

The first thing we will do is create a new class called PrintClose which inherits the Backtrader Strategy class.

import backtrader as bt

class PrintClose(bt.Strategy):

    def __init__(self):
        #Keep a reference to the "close" line in the data[0] dataseries
        self.dataclose = self.datas[0].close

In the __init__ function above, we’ve created a variable called dataclose to make it easier to refer to the closing price later on. You will notice that the closing price is stored in datas[0].close. We can just as easily access the open price by referencing datas[0].open. If you’re using multiple data feeds, you can access your second feed by referencing datas[1].close, but more on that later.

An important feature of Backtrader is accessing historical data which we can now do via the dataclose variable. As Backtrader iterates through historical data, this variable will get updated with the latest price from dataclose[0]. We can also look back to the prior data points by accessing the negative index of dataclose. Here is an example.

if dataclose[0] > dataclose [-1]: pass # do something

The above code checks to see if the most recent close is larger than the prior close. We can just as easily access the second last closing price by changing the index like this: dataclose[-2]

The next step is to create a logging function.

    def log(self, txt, dt=None):
        dt = dt or self.datas[0].datetime.date(0)
		print(f'{dt.isoformat()} {txt}') #Print date and close

The log function allows us to pass in data via the txt variable that we want to output to the screen. It will attempt to grab datetime values from the most recent data point,if available, and log it to the screen.

Now that our printing/logging function has been defined, we will overwrite the next function. This is the most important part of the strategy class as most of our code will get executed here. This part gets called every time Backtrader iterates over the next new data point.

    def next(self):
		self.log('Close: ', self.dataclose[0])

All we will do for now is log the closing price.

This is what our complete script looks like at this point:

import backtrader as bt

class PrintClose(bt.Strategy):

    def __init__(self):
        #Keep a reference to the "close" line in the data[0] dataseries
        self.dataclose = self.datas[0].close

    def log(self, txt, dt=None):
        dt = dt or self.datas[0].datetime.date(0)
		print(f'{dt.isoformat()} {txt}') #Print date and close

    def next(self):
		self.log('Close: ', self.dataclose[0])

#Instantiate Cerebro engine
cerebro = bt.Cerebro()

#Add data feed to Cerebro
data = bt.feeds.YahooFinanceCSVData(dataname='TSLA.csv')
cerebro.adddata(data)

#Add strategy to Cerebro
cerebro.addstrategy(PrintClose)

#Run Cerebro Engine
cerebro.run()

And this is what your output should look like:

From this point on, the structure of our script will mostly remain the same and we will write the bulk of our strategies under the next function of the Strategy class.

How to run a backtest using Backtrader

We’ve installed Backtrader, downloaded some historical data, and written our basic script. The next step is to backtest a strategy.

We will test out a moving average crossover strategy. Essentially, it involves monitoring two moving averages and taking a trade when one crosses the other.

The moving average crossover strategy is to trading what the Hello World script is to programming. Neither will likely ever be used in the real world and are mostly used for illustrative purposes. In other words, we don’t expect the strategy to be a profitable one.

There are a few things we will do before diving into the strategy. First, we will separate our strategy into its own file. Throughout this tutorial, we will go over several examples and separating out the strategies from the main script will keep the code in a nice clean format.

The main script, which will have everything cerebro related, will only have minor changes throughout the tutorial while most of the work will be done in the strategies script.

The strategies script will be appropriately named strategies.py.

import backtrader as bt

class PrintClose(bt.Strategy):

    def __init__(self):
        #Keep a reference to the "close" line in the data[0] dataseries
        self.dataclose = self.datas[0].close

    def log(self, txt, dt=None):
        dt = dt or self.datas[0].datetime.date(0)
		print(f'{dt.isoformat()} {txt}') #Print date and close

    def next(self):
		self.log('Close: ', self.dataclose[0])

We also have to separate our data into two parts. This way, we can test our strategy on the first part, run some optimization, and then see how it performs with our optimized parameters on the second set of data.

If you’ve heard the terms in-sample data, or out-of-sample data, this is what it is referring to. Out-of-sample data is simply data set aside for testing after optimization.

There are a lot of benefits to testing and optimizing this way, take a look at What is a Walk-Forward Optimization and How to Run It? if you’d like to get a more thorough understanding of the methodology.

To divide the data, we set a from date and to date when loading our data. Don’t forget to import the DateTime module for this part.

Here is our updated main script which will be called btmain.py:

import datetime
import backtrader as bt
from strategies import *

# Instantiate Cerebro engine
cerebro = bt.Cerebro()

# Set data parameters and add to Cerebro
data = bt.feeds.YahooFinanceCSVData(
    dataname='TSLA.csv',
    fromdate=datetime.datetime(2016, 1, 1),
    todate=datetime.datetime(2017, 12, 25),
)
# settings for out-of-sample data
# fromdate=datetime.datetime(2018, 1, 1),
# todate=datetime.datetime(2019, 12, 25))

cerebro.adddata(data)

# Add strategy to Cerebro
cerebro.addstrategy(AverageTrueRange)

# Default position size
cerebro.addsizer(bt.sizers.SizerFix, stake=3)

if __name__ == '__main__':
    # Run Cerebro Engine
    start_portfolio_value = cerebro.broker.getvalue()

    cerebro.run()

    end_portfolio_value = cerebro.broker.getvalue()
    pnl = end_portfolio_value - start_portfolio_value
    print(f'Starting Portfolio Value: {start_portfolio_value:2f}')
    print(f'Final Portfolio Value: {end_portfolio_value:2f}')
    print(f'PnL: {pnl:.2f}')

We have included from strategy import * which will make it easier to call new strategies from the main script as we create them. Also included towards the end of the script are some details regarding portfolio values and our default position size, which has been set to 3 shares.

The command cerebro.broker.getvalue() allows you to obtain the value of the portfolio at any time. We grab the starting value by calling it before running cerebro and then call it once again after to get the ending portfolio value. We can see our profit or loss by subtracting the end value from the starting value.

Let’s get started on our strategy!

class MAcrossover(bt.Strategy): 
    # Moving average parameters
    params = (('pfast',20),('pslow',50),)

    def log(self, txt, dt=None):
        dt = dt or self.datas[0].datetime.date(0)
        print(f'{dt.isoformat()} {txt}') # Comment this line when running optimization

    def __init__(self):
        self.dataclose = self.datas[0].close
        
		# Order variable will contain ongoing order details/status
        self.order = None

        # Instantiate moving averages
        self.slow_sma = bt.indicators.MovingAverageSimple(self.datas[0], 
                        period=self.params.pslow)
        self.fast_sma = bt.indicators.MovingAverageSimple(self.datas[0], 
                        period=self.params.pfast)

In the code above, we’ve created a new class called MAcrossover which inherits from the Backtrader Strategy class.

We’ve set some parameters for our moving average rather than hard coding them. This will make it easier to optimize the strategy later on.

There are a few new items under the __init__ function. We’ve created an order variable which will store ongoing order details and the order status. This way we will know if we are currently in a trade or if an order is pending.

One thing to note about Backtrader is that when it receives a buy or sell signal, we can instruct it to create an order. However, that order won’t be executed until the next bar is called, at whatever price that may be.

We’ve also created two moving averages by utilizing indicators built into Backtrader. The benefit of using built-in indicators is that Backtrader won’t start looking for orders until this data is made available.

To clarify, the larger of the two moving averages uses an average of the last 50 closing prices. That means the first 50 data points will have a NaN moving average value. Backtrader knows not to look for orders until we have valid moving average data.

The next item we will overwrite is the notify_order function. This is where everything related to trade orders gets processed.

def notify_order(self, order):
	if order.status in [order.Submitted, order.Accepted]:
		# An active Buy/Sell order has been submitted/accepted - Nothing to do
		return

	# Check if an order has been completed
	# Attention: broker could reject order if not enough cash
	if order.status in [order.Completed]:
		if order.isbuy():
			self.log(f'BUY EXECUTED, {order.executed.price:.2f}')
		elif order.issell():
			self.log(f'SELL EXECUTED, {order.executed.price:.2f}')
		self.bar_executed = len(self)

	elif order.status in [order.Canceled, order.Margin, order.Rejected]:
		self.log('Order Canceled/Margin/Rejected')

	# Reset orders
	self.order = None

What the above code does is allow us to log when an order gets executed, and at what price. This section will also provide notification in case an order didn’t go through.

Lastly, we have the next function which contains all of our trade logic.

def next(self):
	# Check for open orders
	if self.order:
		return

	# Check if we are in the market
	if not self.position:
		# We are not in the market, look for a signal to OPEN trades
			
		#If the 20 SMA is above the 50 SMA
		if self.fast_sma[0] > self.slow_sma[0] and self.fast_sma[-1] < self.slow_sma[-1]:
			self.log(f'BUY CREATE {self.dataclose[0]:2f}')
			# Keep track of the created order to avoid a 2nd order
			self.order = self.buy()
		#Otherwise if the 20 SMA is below the 50 SMA   
		elif self.fast_sma[0] < self.slow_sma[0] and self.fast_sma[-1] > self.slow_sma[-1]:
			self.log(f'SELL CREATE {self.dataclose[0]:2f}')
			# Keep track of the created order to avoid a 2nd order
			self.order = self.sell()
	else:
		# We are already in the market, look for a signal to CLOSE trades
		if len(self) >= (self.bar_executed + 5):
			self.log(f'CLOSE CREATE {self.dataclose[0]:2f}')
			self.order = self.close()

We first check for an active order in which case we don’t want to do anything. For this strategy, we only want to be in one position at a time.

If we’re not in the market, we can start looking for a moving average crossover. One thing to be mindful of in this strategy is that our signal comes from the cross of one moving average over another.

To satisfy that requirement, we check to see if the 20 moving average was below the 50 moving average on the last candle but is above it on the current candle or vice versa. This confirms a cross has taken place. Otherwise, we would be constantly getting a signal.

Finally, we have our else statement which gets executed if we are already in the market. For the exit strategy, we will simply exit five bars after entering the trade.

On running the code, the script will output all of our trades and print a final PnL at the end. In this case, we had a $79 profit.

One thing to keep in mind when testing strategies is that the script can end with an open trade in the system. One way to check if there are any open trades is to ensure ‘CLOSE CREATE’ is the second last line output before the portfolio values are printed. Otherwise, an open trade will likely skew your PnL results.

How to use the built-in crossover indicator

In our moving average cross over example, we coded the logic involved in determining if the two moving averages were crossing. Backtrader has developed an indicator that can determine this which can make things a bit easier.

To use the built-in indicator, instantiate it in the __init__ function as follows: self.crossover = bt.indicators.CrossOver(self.fast_sma, self.slow_sma)

Then all you need to do is check the indicator is providing a signal as follows
if self.crossover > 0: # Fast ma crosses above slow ma

    pass # Signal for buy order

elif self.crossover < 0: # Fast ma crosses below slow ma

    pass # Signal for sell order

How to run optimization in Backtrader

Our next step is to try and see if we can increase our profits by changing some of the moving average parameters.

In the Strategy, we will comment out the print statement in the log function. Optimizing involves several backtests with various parameters and we don’t need to log and go through every trade that takes place.

Instead, we will judge the strategy performance based on the Sharpe Ratio.

There are a number of changes to the main script file to run the optimization. Here is the code for the updated main script:

import datetime
import backtrader as bt
from strategies import *

cerebro = bt.Cerebro(optreturn=False)

#Set data parameters and add to Cerebro
data = bt.feeds.YahooFinanceCSVData(
    dataname='TSLA.csv',
    fromdate=datetime.datetime(2016, 1, 1),
    todate=datetime.datetime(2017, 12, 25))
    #settings for out-of-sample data
    #fromdate=datetime.datetime(2018, 1, 1),
    #todate=datetime.datetime(2019, 12, 25))

cerebro.adddata(data)

#Add strategy to Cerebro
cerebro.addanalyzer(bt.analyzers.SharpeRatio, _name='sharpe_ratio')
cerebro.optstrategy(MAcrossover, pfast=range(5, 20), pslow=range(50, 100))  

#Default position size
cerebro.addsizer(bt.sizers.SizerFix, stake=3)

if __name__ == '__main__':
    optimized_runs = cerebro.run()

    final_results_list = []
    for run in optimized_runs:
        for strategy in run:
            PnL = round(strategy.broker.get_value() - 10000,2)
            sharpe = strategy.analyzers.sharpe_ratio.get_analysis()
            final_results_list.append([strategy.params.pfast, 
                strategy.params.pslow, PnL, sharpe['sharperatio']])

    sort_by_sharpe = sorted(final_results_list, key=lambda x: x[3], 
                             reverse=True)
    for line in sort_by_sharpe[:5]:
        print(line)

Let’s run through some of the major changes. When instantiating cerebro, the optreturn=False parameter was added in. Cerebro removes some data output when running optimization to improve speed. However, we require this data, hence the additional parameter.

cerebro.addstrategy was removed and replaced with cerebro.optstrategy. We’ve also added additional parameters that specify a range of values to optimize the moving averages for. Further, an analyzer was added which will calculate the Sharpe Ratio for our results.

You may have noticed that we added an if __name__ == '__main__': block. In our testing, we ran into an error without it in place.

The optimized results are being stored in the variable optimized_runs in the form of a list of lists. The bottom section of the code iterates through the lists to grab the values that we need and appends it to a newly created list.

The last three lines of the code sorts the list and prints out the top five values. This is what our results looked like:

It looks like we have a clear winner. A period of 7 for the fast moving average and a period of 92 for the slow moving average produces a notably higher result for the Sharpe Ratio.

Now it’s time to run some backtests on the out-of-sample data. All it takes is a simple change to the data parameters.

fromdate=datetime.datetime(2018, 1, 1),
todate=datetime.datetime(2019, 12, 25))

Our backtest shows a loss of $63.42 with the same settings we used in our original test, but on the out-of-sample data. Here is the result after changing the moving average settings to the optimized parameters.

A loss of $170.22, even greater than our original settings although this was expected as a few things are impacting our figures.

First, the moving average cross over is an unsophisticated strategy that was expected to produce a loss. The only surprise here was that it produced a profit in our first run.

Second, this is a great example of overfitting. If you’re not familiar with overfitting, definitely check out What is Overfitting in Trading? – it is a crucial element of strategy development.

How to build a stock screener in Backtrader

Screeners are commonly used to filter out stocks based on certain parameters. There aren’t a lot of easy ways to look back to a certain date and see what results a stock screener might have spit out. Fortunately, Backtrader offers exactly this.

We will test out this functionality by building a screener that filters out stocks that are trading two standard deviations below the average price over the prior 20 days.

We will start by creating a subclass of the Backtrader Analyzer class which will form the ‘screener’ component of our strategy.

class Screener_SMA(bt.Analyzer):
    params = (('period',20), ('devfactor',2),)

    def start(self):
        self.bband = {data: bt.indicators.BollingerBands(data,
                period=self.params.period, devfactor=self.params.devfactor)
                for data in self.datas}

    def stop(self):
        self.rets['over'] = list()
        self.rets['under'] = list()

        for data, band in self.bband.items():
            node = data._name, data.close[0], round(band.lines.bot[0], 2)
            if data > band.lines.bot:
                self.rets['over'].append(node)
            else:
                self.rets['under'].append(node)

Under the start function, you’ll notice that we are using Bollinger bands to determine the value for two standard deviations. The syntax is a bit different from prior examples as several datasets are used in a screener.

The stop function is where a bulk of our code falls. We iterate through our Bollinger band items for all of our datasets to filter out the ones that are trading below the lower band.

The stocks that qualify then get appended to a list. The analyzer class has a built-in dictionary with the variable name rets. We will use this dictionary to store our lists.

There isn’t a lot of code required in our main script, but it is quite different from prior examples. Since we are adding several datasets, we’ve created a list of all the tickers that we want to scan. We then iterate through the list to add the corresponding CSV files to cerebro.

import datetime
import backtrader as bt
from strategies import *

#Instantiate Cerebro engine
cerebro = bt.Cerebro()

#Add data to Cerebro
instruments = ['TSLA', 'AAPL', 'GE', 'GRPN']
for ticker in instruments:
    data = bt.feeds.YahooFinanceCSVData(
        dataname='{}.csv'.format(ticker),
        fromdate=datetime.datetime(2016, 1, 1),
        todate=datetime.datetime(2017, 10, 30))
    cerebro.adddata(data) 

#Add analyzer for screener
cerebro.addanalyzer(Screener_SMA)

if __name__ == '__main__':
    #Run Cerebro Engine
    cerebro.run(runonce=False, stdstats=False, writer=True)

Next, we add our newly created screener class to Cerebro as an analyzer.

Finally, we call the cerebro.run command with a few additional parameters. The writer=True parameter calls the built-in writer functionality to display the ouput. stdstats=False removes some of the standard output (more on this later). And lastly, runonce=False ensures that data remains synchronized.

Here are our results:

We can see that TSLA and GE traded at least two standard deviations below their average close price over the prior 20 days on October 30, 2017.

How to code an indicator in Backtrader

There are three ways to code an indicator in Backtrader. You can code one from scratch, utilize a built-in indicator, or use a third-party library.

If you don’t plan to use the live trading functionality of Backtrader, you might want to code your indicator yourself.

Here is an example of an indicator we created:

        range_total = 0
        for i in range(-13, 1):
            true_range = self.datahigh[i] - self.datalow[i]
            range_total += true_range
        ATR = range_total / 14

The above code calculates the Average True Range (ATR). Its aim is to give an estimate of how much an instrument will typically fluctuate in a given period.

It does this by iterating through the last 14 data points which can be done in Backtrader by using a negative index. We take the high and subtract the low for each period, and then average it out.

The code can then be placed within the next function of our strategy class. We can also add a simple log function to log the indicator to the screen like this:

self.log(f'Close: {self.dataclose[0]:.2f} ATR: {ATR:.4f}')

Here is the complete strategy code:

class AverageTrueRange(bt.Strategy):

	def log(self, txt, dt=None):
		dt = dt or self.datas[0].datetime.date(0)
		print(f'{dt.isoformat()} {txt}') #Print date and close
		
	def __init__(self):
		self.dataclose = self.datas[0].close
		self.datahigh = self.datas[0].high
		self.datalow = self.datas[0].low
		
	def next(self):
		range_total = 0
		for i in range(-13, 1):
			true_range = self.datahigh[i] - self.datalow[i]
			range_total += true_range
		ATR = range_total / 14

		self.log(f'Close: {self.dataclose[0]:.2f}, ATR: {ATR:.4f}')

Here is what the output looks like when we put it all together.

You can check out ChartSchool to learn the mathematics and code behind different technical indicators.

How to plot in Backtrader

To plot a chart in Backtrader is incredibly simple. All you need to do is add cerebro.plot() to your code after calling cerebro.run().

Here is an example of a chart with the TSLA data we’ve been using in our examples.

By default, the chart will attempt to show the following

  • Fluctuations in your balance
  • The profit or loss of any trades taken during the backtest
  • Where buy and sell trades took place relative to the price.

If you’re not interested in seeing all of these additional details, simply pass through the following parameter – stdstats=False. You can pass it through either when you instantiate cerebro, or when you call cerebro.run. Both will produce the same result.

Recall that we used this parameter in our stock screener?

If you’re working with two different stocks, you can easily show both on one chart. This can be useful if you’re trying to visualize the correlation between two assets.

import datetime
import backtrader as bt

#Instantiate Cerebro engine
cerebro = bt.Cerebro(stdstats=False)

#Set data parameters and add to Cerebro
data1 = bt.feeds.YahooFinanceCSVData(
    dataname='TSLA.csv',
    fromdate=datetime.datetime(2018, 1, 1),
    todate=datetime.datetime(2020, 1, 1))
cerebro.adddata(data1)

data2 = bt.feeds.YahooFinanceCSVData(
    dataname='AAPL.csv',
    fromdate=datetime.datetime(2018, 1, 1),
    todate=datetime.datetime(2020, 1, 1))

data2.compensate(data1)  # let the system know ops on data1 affect data0
data2.plotinfo.plotmaster = data1
data2.plotinfo.sameaxis = True
cerebro.adddata(data2)

#Run Cerebro Engine
cerebro.run()
cerebro.plot()

The above code will create a chart with TSLA and AAPL price data overlaid on top of each other. This is what the chart looks like:

Lastly, any indicator you might add will automatically get added to the chart. Here is a code example that will show TSLA price data with a 20-day moving average.

import datetime
import backtrader as bt

#simple moving average
class SimpleMA(bt.Strategy):
    def __init__(self):
        self.sma = bt.indicators.SimpleMovingAverage(self.data, period=20, 
                plotname="20 SMA")

#Instantiate Cerebro engine
cerebro = bt.Cerebro(stdstats=False)

#Set data parameters and add to Cerebro
data1 = bt.feeds.YahooFinanceCSVData(
    dataname='TSLA.csv',
    fromdate=datetime.datetime(2018, 1, 1),
    todate=datetime.datetime(2020, 1, 1))
cerebro.adddata(data1)

cerebro.addstrategy(SimpleMA)
#Run Cerebro Engine
cerebro.run()
cerebro.plot()

Notice we passed through a value for plotname. It allows us to change the display value for the moving average in the legend. This is what the chart looks like:

How to use alternative data in Backtrader

In this strategy, we’re going to try and gauge sentiment based on google search data, and execute trades based on any notable shifts in search volume.

We’ve downloaded historical weekly search data from Google Trends for Bitcoin and have obtained price data from Yahoo Finance.

Chart of google trends search for Bitcoin
Alternative Data Finance

Since there was a lot of volatility in late 2017, we will test this strategy from 2018 onward. Search results data and prices both stabilized quite a bit after that point.

The Google Trends data we’ve downloaded does not follow the same open, high, low, close format as our Yahoo Finance data. Therefore, we will use the generic CSV template provided by Backtrader to add in our data. Here is the code:

data2 = bt.feeds.GenericCSVData(
    dataname='BTC_Gtrends.csv',
    fromdate=datetime.datetime(2018, 1, 1),
    todate=datetime.datetime(2020, 1, 1),
    nullvalue=0.0,
    dtformat=('%Y-%m-%d'),
    datetime=0,
    time=-1,
    high=-1,
    low=-1,
    open=-1,
    close=1,
    volume=-1,
    openinterest=-1,
    timeframe=bt.TimeFrame.Weeks)
cerebro.adddata(data2)

We had to define which columns were present and which weren’t. This was done by assigning -1 values for columns not present in our data and assigning an incrementing integer value for columns that were available.

Aside from that, our main code script was pretty much unchanged from the moving average crossover example.

Here is our strategy class:

class BtcSentiment(bt.Strategy):
    params = (('period', 10), ('devfactor', 1),)

    def log(self, txt, dt=None):
        dt = dt or self.datas[0].datetime.date(0)
        print(f'{dt.isoformat()} {txt}')

    def __init__(self):
        self.btc_price = self.datas[0].close
        self.google_sentiment = self.datas[1].close
        self.bbands = bt.indicators.BollingerBands(self.google_sentiment,
                period=self.params.period, devfactor=self.params.devfactor)

        self.order = None

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

        # Check if an order has been completed
        # Attention: broker could reject order if not enough cash
        if order.status in [order.Completed]:
		if order.status in [order.Completed]:
			if order.isbuy():
				self.log(f'BUY EXECUTED, {order.executed.price:.2f}')
			elif order.issell():
				self.log(f'SELL EXECUTED, {order.executed.price:.2f}')
			self.bar_executed = len(self)

        elif order.status in [order.Canceled, order.Margin, 
                              order.Rejected]:
            self.log('Order Canceled/Margin/Rejected')

        # Reset orders
        self.order = None

    def next(self):
        # Check for open orders
        if self.order:
            return

		#Long signal 
		if self.google_sentiment > self.bbands.lines.top[0]:
			# Check if we are in the market
			if not self.position:
				self.log(f'Google Sentiment Value: {self.google_sentiment[0]:.2f}')
				self.log(f'Top band: {self.bbands.lines.top[0]:.2f}')
				# We are not in the market, we will open a trade
				self.log(f'***BUY CREATE {self.btc_price[0]:.2f}')
				# Keep track of the created order to avoid a 2nd order
				self.order = self.buy()       

		#Short signal
		elif self.google_sentiment < self.bbands.lines.bot[0]:
			# Check if we are in the market
			if not self.position:
				self.log(f'Google Sentiment Value: {self.google_sentiment[0]:.2f}')
				self.log(f'Bottom band: {self.bbands.lines.bot[0]:.2f}')
				# We are not in the market, we will open a trade
				self.log(f'***SELL CREATE {self.btc_price[0]:.2f}')
				# Keep track of the created order to avoid a 2nd order
				self.order = self.sell()
		
		#Neutral signal - close any open trades     
		else:
			if self.position:
				# We are in the market, we will close the existing trade
				self.log(f'Google Sentiment Value: {self.google_sentiment[0]:.2f}')
				self.log(f'Bottom band: {self.bbands.lines.bot[0]:.2f}')
				self.log(f'Top band: {self.bbands.lines.top[0]:.2f}')
				self.log(f'CLOSE CREATE {self.btc_price[0]:.2f}')
				self.order = self.close()

We are once again using Bollinger bands. The above script looks for a rise greater than one standard deviation in search volume to enter a long position and vice versa to enter short.

If the search data retreats back within 1 standard deviation of the average of the last 10 data points, we will close our position.

In the __init__ function, we assigned variable names to the two different datasets so that we can reference them easier throughout our strategy. Aside from this, the syntax is very similar to the prior examples.

After running the backtest, here are our results:

btc backtest results

The strategy made a whopping $5859 on a $10,000 starting balance. That’s a nearly 60% return! And that’s without trying to run any optimization.

How to add visual stats to a backtest

Backtrader has quite a few analyzers that provide in-depth detail of the backtest. But rather than programming several analyzers, we can use a third-party library which will show complete statistics of the backtest as well as other visualizations.

A popular library for this is PyFolio which can create a detailed tearsheet with all sorts of information. This is done via Jupyter notebooks.

Another good option is to use the quantstats library. The benefit of this library is that it saves an HTML file of the stats, eliminating the additional step of running a notebook that PyFolio requires.

The easiest way to install quantstats is by pip through the command line.

pip install quantstats

Both quantstats and PyFolio require returns data to calculate stats. We can use a Backtrader analyzer to get this data.

We will build on our previous alternative data example and create a stats tearsheet from our BTC sentiment strategy. The first step is to add the analyzer that will give us returns data. We need to add the following line of code:

cerebro.addanalyzer(bt.analyzers.PyFolio, _name='PyFolio')

The above line of code can be added anywhere in the script as long as it’s before the cerebro.run command and after initializing the cerebro class.

As you may have guessed from the name, this analyzer was created to enable a PyFolio integration. But it works just as well with the quantstats library.

We will need to save the results from our backtest, similar to what we did in the Sharpe Ratio example.

results = cerebro.run()
strat = results[0]

At the end of our script, after our backtest completes, we can add some code to extract the returns data from the analyzer.

portfolio_stats = strat.analyzers.getbyname('PyFolio')
returns, positions, transactions, gross_lev = portfolio_stats.get_pf_items()
returns.index = returns.index.tz_convert(None)

The above code gets all the data obtained by the PyFolio analyzer. We then split the returned data to extract just the returns values.

As you may have guessed from the name, this analyzer was created to enThe returns variable is actually a Pandas DataFrame. To make it compatible with quantstats, we removed the timezone awareness using the built-in tz_convertfunction from Pandas.

Since we are using Pandas, we have to import it into our script. We can also import the quantstat library at the same time. We’ll add the following at the top of our script to do that.

import pandas as pd
import quantstats

Let’s jump back to the bottom of the script and add the functionality to create a stats tearsheet.

quantstats.reports.html(returns, output='stats.html', title='BTC Sentiment')

After running the backtest again, a stats.html file is created in our projects folder. It looks like this:

Full tearsheet can be seen here

How to save backtest data to a CSV file

In the examples here, we’ve printed opened and closed trades to the console. But if you’re running multiple tests and later want to compare them, it might be useful writing your backtest data to a CSV file.

There is a built-in method in backtrader that will create a CSV file for you. It includes data from your data feeds, strategies, indicators, and analyzers.

To use it, simply add the following line to your script. It can be added anywhere in the script as long as it is before cerebro.run and after instantiating the cerebro class.

cerebro.addwriter(bt.WriterFile, csv=True, out='log.csv')

For the out parameter, we’ve specified log.csv. You can name the file anything you want. After running your backtest, there should be a CSV file in your projects directory with all of the earlier mentioned data.

There are other options as well if you’d like a more customized approach. Within the strategy class, we can overwrite the stop() function to save any variable within the class. Here is an example.

class BtcSentiment(bt.Strategy):
	def __init__(self, sp):
		self.log_pnl = []

In the __init__ function, we’ve initialized a variable called log_pnl as a list.

def log(self, txt, dt=None):
    dt = dt or self.datas[0].datetime.date(0)
    print(f'{dt.isoformat()} {txt}') #Print date and close
    self.log_pnl.append(f'{dt.isoformat()} {txt}')

Then under the log function, we’re appending the output (what would normally be printed to the console) to our log_pnl list.

Finally, we can save the list to a file once the backtest is finished running. For this, we use the stop() function which runs one time when the backtest is complete. Here is the code to save the log_pnl to file.

def stop(self):
    with open('custom_log.csv', 'w') as e:
        for line in self.log_file:
            e.write(line + '\n')

You can use this method to save any custom data from backtrader to a file.

In our previous example, we used the backtrader PyFolio analyzer to generate returns and other data that took the form of a Pandas DataFrame. We can save the returns data, or any of the other files by using the built-in to_csv() method from Pandas.

returns.to_csv('returns.csv'))

Alternatives to Backtrader

There are a lot of choices when it comes to backtesting software although there were three names that popped up often in our research – Zipline, PyAlgoTrade, and Backtrader.

Interestingly, the author of Backtrader decided on creating it after playing around with PyAlgoTrade and finding that it lacked the functionality that he was seeking.

And it looks like he’s test-driven a few other backtesting platforms as well. If you’re looking for a larger list of alternatives, check out the Backtrader GitHub page which has a list of 20 alternatives.

Final Thoughts on Backtrader

It is clear a lot of work has gone into Backtrader and it delivers more than what the average user is likely looking for. This could have easily become a commercial solution and we commend the author for keeping it open-source.

After going through this tutorial, you should be in a good position to try out your first strategy in Backtrader. There are a few additional points that we suggest you look into and try to incorporate into your backtesting.

Commissions – Trading fees and commissions add up and these should not be ignored. Backtrader initially only allowed users to set a percentage-based commission for stocks but this has since evolved to accommodate fixed pricing.

Risk Management – our examples did not incorporate much in terms of risk management. The objective here was to highlight the potential of Backtrader and provide a solid foundation for using the platform.

Your backtesting results will likely vary a great deal depending on what type of risk management you implement. The goal is to optimize your strategy to best align with your risk tolerance rather than attempting to maximize profits at the cost of taking great risks.

Lastly, the focus when it comes to strategy development should be to come up with a good foundation and then use optimization for minor tweaks. Sometimes traders fall into the trap of approaching it the other way around which rarely leads to a profitable strategy.

Download code

Github link

Jignesh Davda