Backtesting.py - A complete guide

Backtestingpy a Complete Quickstart Guide

Backtesting.py is a lightweight backtesting framework in python. It very much takes its syntax from Backtrader. So if you're familiar with Backtrader at all you'll find Backtesting.py very natural and easy to pickup.

Why use Backtesting.py?

Backtesting.py is great when you just want something that works. It's easy to install, has excellent documentation, and doesn't suffer from the bloat of other backtesting libraries with 10,000 different live-trading integrations.

As I mentioned, the syntax will be very familiar to those of you who have used Backtrader. I see it as a spiritual successor to Backtrader, and much more lightweight. It is event-driven, so you program the logic that is run on each bar successively much like you were actually trading the strategy day-to-day.

You can also bring your own indicator libraries, as it doesn't come pre-loaded with any. So feel free to use something lightening fast like TA-lib or something friendly like pandas-ta.

You don't need any data to follow along with this tutorial, as backtesting.py comes pre-loaded with some GOOG daily data that we can use.

The simplest backtest

Here's the code for the simplest possible backtest you can do in backtesting.py.

 1import datetime
 2import pandas_ta as ta
 3import pandas as pd
 4
 5from backtesting import Backtest
 6from backtesting import Strategy
 7from backtesting.lib import crossover
 8from backtesting.test import GOOG
 9
10class RsiOscillator(Strategy):
11
12    upper_bound = 70
13    lower_bound = 30
14    rsi_window = 14
15
16    # Do as much initial computation as possible
17    def init(self):
18        self.rsi = self.I(ta.rsi, pd.Series(self.data.Close), self.rsi_window)
19
20    # Step through bars one by one
21    # Note that multiple buys are a thing here
22    def next(self):
23        if crossover(self.rsi, self.upper_bound):
24            self.position.close()
25        elif crossover(self.lower_bound, self.rsi):
26            self.buy()
27
28bt = Backtest(GOOG, RsiOscillator, cash=10_000, commission=.002)
29stats = bt.run()
30bt.plot()

Make sure you pip install the relevant libraries. I'm using pandas-ta here because it's a little easier to install than ta-lib but the principle is the same.

To get anywhere in Backtesting.py, you need to create a Strategy. You do that by creating a class that inherits from backtesting.Strategy.

There are two main functions you need to worry about inside your strategy. The first is init:

1...  
2  # Do as much initial computation as possible
3    def init(self):
4        self.rsi = self.I(ta.rsi, pd.Series(self.data.Close), self.rsi_window)

This function is called as soon as the object is created and is only run once. You should put in here anything that can be pre-calculated ahead of time before your backtest, such as any technical indicators.

The I function is well worth a look at as it's what allows you to build indicators within the backtesting.py ecosystem. Then there's the next function:

1def next(self):
2        if crossover(self.rsi, self.upper_bound):
3            self.position.close()
4        elif crossover(self.lower_bound, self.rsi):
5            self.buy()

next is called on every candle as the backtest progresses. Essentially ask yourself, if you were there at the time of the candle, what would your logic look like. In our case we check if the rsi is above our upper_bound. If it is close any positions we have (long or short). Otherwise check if it's below lower_bound, in which case we should buy as much as we can of the asset. Note that backtesting.py only buys whole numbers of shares.

Finally we run the backtest:

1bt = Backtest(GOOG, RsiOscillator, cash=10_000, commission=.002)
2stats = bt.run()
3bt.plot()

Which should open up a nice backtesting dashboard in your browser.

The backtesting.py dashboard interface

You can also print out stats to get a nice overview of your strategy and common metrics:

 1Start                     2004-08-19 00:00:00
 2End                       2013-03-01 00:00:00
 3Duration                   3116 days 00:00:00
 4Exposure Time [%]                   29.329609
 5Equity Final [$]                  15411.94704
 6Equity Peak [$]                   15570.14704
 7Return [%]                           54.11947
 8Buy & Hold Return [%]              703.458242
 9Return (Ann.) [%]                    5.205671
10Volatility (Ann.) [%]               23.556885
11Sharpe Ratio                         0.220983
12Sortino Ratio                        0.369511
13Calmar Ratio                           0.1055
14Max. Drawdown [%]                  -49.342925
15Avg. Drawdown [%]                   -6.457642
16Max. Drawdown Duration     1609 days 00:00:00
17Avg. Drawdown Duration       99 days 00:00:00
18# Trades                                    9
19Win Rate [%]                        77.777778
20Best Trade [%]                      21.155672
21Worst Trade [%]                    -20.607876
22Avg. Trade [%]                       5.015784
23Max. Trade Duration         202 days 00:00:00
24Avg. Trade Duration         101 days 00:00:00
25Profit Factor                        3.418112
26Expectancy [%]                       5.723001
27SQN                                  1.171459

Parameter optimization

One really powerful feature of Backtesting.py is the ability to optimize the parameters used in your strategy. You can select a range of values for any of the class variables defined in your strategy and it will grid-search to find the best portfolio for whatever metric you've defined.

To get this running for yourself, simply replace stats=bt.run() with:

1stats = bt.optimize(
2        upper_bound = range(50,85,5),
3        lower_bound = range(15,45,5),
4        rsi_window = range(10,30,2),
5        maximize='Equity Final [$]')

For maximize you can set any statistic that appears in the stats dataframe, like the Sharpe ratio, the win rate, etc. Note that the optimizer always assumes higher is better, so if you put volitility as the parameter to optimize it will find the most volitile portfolio.

The stats you get in return will be the stats of the best performing set of parameters. You can find the exact parameters used either in the url of the web page that opens up, or else by indexing into stats and accessing the class variables

1>> strategy = stats["_strategy"]
2>> strategy.upper_bound
375
4>> strategy.lower_bound
520

Custom Optimization Functions

If one of the default metrics doesn't quite capture the metric you'd like to optimize for, you can create your own function.

1def optim_func(series):
2    if series['# Trades'] < 10:
3        return -1
4    else:
5        return series['Equity Final [$]']/series['Exposure Time [%]']

This function ingests the stats dataframe and returns a number. We want to remove all backtests that have less than 10 trades, so we return -1 for those backtests. Otherwise our metric is the total amount of equity divided by the amount of time we're in the market. Basically trying to make the most money in the least amount of time.

After you've defined your metric funtion you'll want to integrate it into the backtester:

1stats = bt.optimize(
2        upper_bound = range(50,85,5),
3        lower_bound = range(15,45,5),
4        rsi_window = range(10,30,2),
5        maximize=optim_func)

You'll have to play around with this to get something that you like that's not massively over-fit. But it allows you to optimize for things like lower volitility, etc.

Parameter Heatmaps

As a way of visualizing which parameter combinations perform best it can be nice to draw a heatmap comparing two variables and how changing them changes the metric you're optimizing for. You'll want to replace the code below your strategy definition with:

 1import matplotlib.pyplot as plt
 2import seaborn as sns
 3...
 4class RsiOscillator(Strategy):
 5    ...
 6...
 7
 8bt = Backtest(GOOG, RsiOscillator, cash=10_000, commission=.002)
 9
10stats, heatmap = bt.optimize(
11        upper_bound = range(50,85,5),
12        lower_bound = range(15,45,5),
13        rsi_window = range(10,30,2),
14        maximize='Equity Final [$]',
15        return_heatmap=True)
16
17# choose your colormaps from here
18# https://matplotlib.org/stable/tutorials/colors/colormaps.html
19hm = heatmap.groupby(["upper_bound","lower_bound"]).mean().unstack()
20sns.heatmap(hm, cmap="plasma")
21plt.show()

Since a heatmap can only display variations of two variables, we need to use a pandas group_by to select the two variables we want to compare against eachother. Then apply an aggregation to simplify each group down to a single value. Finally we unstack the whole thing into a matrix that we can easily plot with seaborn.

There's a whole host of color pallets available at the link in the code block above.

A parameter optimization heatmap

Multi-timeframe Strategies

For those of you fond of TA, we often want to examine a strategy on multiple timeframes. Say on the weekly and the daily. Thankfully there's a nice utility function called resample_apply. It leverages Pandas to down-sample the data, apply our indicator, then up-sample it and forward fill the missing values. Here's a re-written version of our init function that gives us the weekly rsi:

1# All initial calculations
2    def init(self):
3        self.daily_rsi = self.I(ta.rsiI, pd.Series(self.data.Close), self.rsi_window)
4
5        # This magical function does all the resampling 
6        self.weekly_rsi = resample_apply(
7            'W-FRI', ta.rsi, pd.Series(self.data.Close), self.rsi_window)

All you have to do is feed in a pandas compatible timeframe, the function you want to apply, the data and any extra keywords that need to be passed into the function. At this point we can re-write our next function to make use of our weekly_rsi.

1def next(self):
2
3        if (crossover(self.daily_rsi, self.upper_bound) and
4                self.weekly_rsi[-1] > self.upper_bound):
5            self.position.close()
6
7        elif (crossover(self.lower_bound, self.daily_rsi) and
8                self.lower_bound > self.weekly_rsi[-1]):
9            self.buy()

Now we only trade if there's a crossover on the daily candles, and the weekly rsi is also above our threshold.

Take Profit and Stop Loss

You can add a stop loss or take profit level in both your long and short trades as follows

1def next(self):
2
3        price = self.data.Close[-1]
4
5        if crossover(self.rsi, self.upper_bound):
6            self.position.close()
7
8        elif crossover(self.lower_bound, self.rsi):
9            self.buy(tp=1.15*price, sl=0.95*price)

Here we set a take profit at 15% and a stop loss at 5%. Note that due to how backtesting.py works, the final profit from your trade might be slightly higher/lower than the level you'd expect. This is because it only executes trades on the next bar, so if the price moves significantly overnight, that will affect your backtest. If you need very granular stop-losses and take-profits I'd recommend using more granular intra-day data.

Extracting Trade Data

You can extract the series of trades made by the backtester for further analysis.

1print(stats['_trades'].to_string())
 1    Size EntryBar ExitBar   EntryPrice   ExitPrice    PnL      ReturnPct  EntryTime   ExitTime Duration
 20    27       373      422   362.67390     439.40  2071.60470   0.211557 2006-02-10 2006-04-24  73 days
 31    33       493      525   365.70996     415.46  1641.75132   0.136037 2006-08-03 2006-09-19  47 days
 42    24       862      925   561.83142     557.94   -93.39408  -0.006926 2008-01-23 2008-04-23  91 days
 53    29       987     1126   467.65344     371.28 -2794.82976  -0.206079 2008-07-22 2009-02-09 202 days
 64    19      1367     1424   547.68318     563.00   291.01958   0.027967 2010-01-25 2010-04-16  81 days
 75    22      1437     1534   501.98196     512.86   239.31688   0.021670 2010-05-05 2010-09-22 140 days
 86    19      1653     1740   573.94560     592.49   352.34360   0.032310 2011-03-14 2011-07-18 126 days
 97    20      1873     1913   573.12396     646.60  1469.52080   0.128203 2012-01-26 2012-03-23  57 days
108    20      2072     2129   655.95930     767.69  2234.61400   0.170332 2012-11-09 2013-02-04  87 days

Video tutorial

If you'd prefer a video tutorial, you can check out this video on my channel: