This tutorial shows some of the features of backtesting.py, a Python framework for backtesting trading strategies.
Backtesting.py is a small and lightweight, blazing fast backtesting framework that uses state-of-the-art Python structures and procedures (Python 3.6+, Pandas, NumPy, Bokeh). It has a very small and simple API that is easy to remember and quickly shape towards meaningful results. The library doesn't really support stock picking or trading strategies that rely on arbitrage or multi-asset portfolio rebalancing; instead, it works with an individual tradeable asset at a time and is best suited for optimizing position entrance and exit signal strategies, decisions upon values of technical indicators, and it's also a versatile interactive trade visualization and statistics tool.
You bring your own data. Backtesting ingests _all kinds of
data_ (stocks, forex, futures, crypto, ...) as a
'Close' and (optionally)
'Volume'. Such data is widely obtainable (see:
Besides these, your data frames can have additional columns which are accessible in your strategies in a similar manner.
DataFrame should ideally be indexed with a datetime index (convert it with
pd.to_datetime()), otherwise a simple range index will do.
# Example OHLC daily data for Google Inc. from backtesting.test import GOOG GOOG.tail()
Let's create our first strategy to backtest on these Google data, a simple moving average (MA) cross-over strategy.
Backtesting.py doesn't ship its own set of technical analysis indicators. Users favoring TA should probably refer to functions from proven indicator libraries, such as TA-Lib or Tulipy, but for this example, we can define a simple helper moving average function ourselves:
import pandas as pd def SMA(values, n): """ Return simple moving average of `values`, at each step taking into account `n` previous values. """ return pd.Series(values).rolling(n).mean()
init() is invoked before the strategy is run. Within it, one ideally precomputes in efficient, vectorized manner whatever indicators and signals the strategy depends on.
next() is then iteratively called by the
instance, once for each data point (data frame row), simulating the incremental availability of each new full candlestick bar.
Note, backtesting.py cannot make decisions / trades within candlesticks — any new orders are executed on the next candle's open (or the current candle's close if
If you find yourself wishing to trade within candlesticks (e.g. daytrading), you instead need to begin with more fine-grained (e.g. hourly) data.
from backtesting import Strategy from backtesting.lib import crossover class SmaCross(Strategy): # Define the two MA lags as *class variables* # for later optimization n1 = 10 n2 = 20 def init(self): # Precompute the two moving averages self.sma1 = self.I(SMA, self.data.Close, self.n1) self.sma2 = self.I(SMA, self.data.Close, self.n2) def next(self): # If sma1 crosses above sma2, close any existing # short trades, and buy the asset if crossover(self.sma1, self.sma2): self.position.close() self.buy() # Else, if sma1 crosses below sma2, close any existing # long trades, and sell the asset elif crossover(self.sma2, self.sma1): self.position.close() self.sell()
init() as well as in
next(), the data the strategy is simulated on is available as an instance variable
init(), we declare and compute indicators indirectly by wrapping them in
The wrapper is passed a function (our
SMA function) along with any arguments to call it with (our close values and the MA lag). Indicators wrapped in this way will be automatically plotted, and their legend strings will be intelligently inferred.
next(), we simply check if the faster moving average just crossed over the slower one. If it did and upwards, we close the possible short position and go long; if it did and downwards, we close the open long position and go short. Note, we don't adjust order size, so Backtesting.py assumes maximal possible position. We use
function instead of writing more obscure and confusing conditions, such as:
%%script echo def next(self): if (self.sma1[-2] < self.sma2[-2] and self.sma1[-1] > self.sma2[-1]): self.position.close() self.buy() elif (self.sma1[-2] > self.sma2[-2] and # Ugh! self.sma1[-1] < self.sma2[-1]): self.position.close() self.sell()
init(), the whole series of points was available, whereas in
next(), the length of
self.data and all declared indicators is adjusted on each
next() call so that
self.sma1[-1]) always contains the most recent value,
array[-2] the previous value, etc. (ordinary Python indexing of ascending-sorted 1D arrays).
self.data and any indicators wrapped with
self.sma1) are NumPy arrays for performance reasons. If you prefer pandas Series or DataFrame objects, use
Strategy.data.df accessors respectively. You could also construct the series manually, e.g.
We might avoid
self.position.close() calls if we primed the
from backtesting import Backtest bt = Backtest(GOOG, SmaCross, cash=10_000, commission=.002) stats = bt.run() stats
Start 2004-08-19 00:00:00 End 2013-03-01 00:00:00 Duration 3116 days 00:00:00 Exposure Time [%] 97.07 Equity Final [$] 68221.97 Equity Peak [$] 68991.22 Return [%] 582.22 Buy & Hold Return [%] 703.46 Return (Ann.) [%] 25.27 Volatility (Ann.) [%] 38.38 Sharpe Ratio 0.66 Sortino Ratio 1.29 Calmar Ratio 0.76 Max. Drawdown [%] -33.08 Avg. Drawdown [%] -5.58 Max. Drawdown Duration 688 days 00:00:00 Avg. Drawdown Duration 41 days 00:00:00 # Trades 94 Win Rate [%] 54.26 Best Trade [%] 57.12 Worst Trade [%] -16.63 Avg. Trade [%] 2.07 Max. Trade Duration 121 days 00:00:00 Avg. Trade Duration 33 days 00:00:00 Profit Factor 2.19 Expectancy [%] 2.61 SQN 1.99 _strategy SmaCross _equity_curve Equ... _trades Size EntryB... dtype: object
method returns a pandas Series of simulation results and statistics associated with our strategy. We see that this simple strategy makes almost 600% return in the period of 9 years, with maximum drawdown 33%, and with longest drawdown period spanning almost two years ...
method provides the same insights in a more visual form.
We hard-coded the two lag parameters (
n2) into our strategy above. However, the strategy may work better with 15–30 or some other cross-over. We declared the parameters as optimizable by making them class variables.
We optimize the two parameters by calling
method with each parameter a keyword argument pointing to its pool of possible values to test. Parameter
n1 is tested for values in range between 5 and 30 and parameter
n2 for values between 10 and 70, respectively. Some combinations of values of the two parameters are invalid, i.e.
n1 should not be larger than or equal to
n2. We limit admissible parameter combinations with an ad hoc constraint function, which takes in the parameters and returns
True (i.e. admissible) whenever
n1 is less than
n2. Additionally, we search for such parameter combination that maximizes return over the observed period. We could instead choose to optimize any other key from the returned
%%time stats = bt.optimize(n1=range(5, 30, 5), n2=range(10, 70, 5), maximize='Equity Final [$]', constraint=lambda param: param.n1 < param.n2) stats
CPU times: user 177 ms, sys: 16.1 ms, total: 193 ms Wall time: 2.41 s
Start 2004-08-19 00:00:00 End 2013-03-01 00:00:00 Duration 3116 days 00:00:00 Exposure Time [%] 99.07 Equity Final [$] 103949.43 Equity Peak [$] 108327.72 Return [%] 939.49 Buy & Hold Return [%] 703.46 Return (Ann.) [%] 31.61 Volatility (Ann.) [%] 44.74 Sharpe Ratio 0.71 Sortino Ratio 1.49 Calmar Ratio 0.72 Max. Drawdown [%] -44.00 Avg. Drawdown [%] -6.14 Max. Drawdown Duration 690 days 00:00:00 Avg. Drawdown Duration 43 days 00:00:00 # Trades 153 Win Rate [%] 51.63 Best Trade [%] 61.56 Worst Trade [%] -19.78 Avg. Trade [%] 1.55 Max. Trade Duration 83 days 00:00:00 Avg. Trade Duration 21 days 00:00:00 Profit Factor 1.98 Expectancy [%] 1.98 SQN 1.60 _strategy SmaCross(n1=10,n... _equity_curve Eq... _trades Size Entry... dtype: object
We can look into
stats['_strategy'] to access the Strategy instance and its optimal parameter values (10 and 15).