Backtesting.py - A complete 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
There are two main functions you need to worry about inside your strategy. The first is
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
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.
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
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
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 [$]')
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.
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.
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
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.
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
If you'd prefer a video tutorial, you can check out this video on my channel: