Stop Losses in Backtesting.py
Stop losses can be a confusing topic in backtesting.py. The exact details of how they're executed aren't made clear in the documentation. And yet they're a critical part of testing your strategy, and a misunderstanding about execution can cost you your shirt.
Philosophically, backtesting.py is adversarial when it comes to execution. That is it tries to be as pessimistic as possible. The same applies to stop-losses. If a stop-loss and a take-profit get triggered inside the same bar, it will assume that the stop was triggered first, hence reducing our profits.
In order to simulate a super-simple example involving take-profits, the following is enough:
1from backtesting import Backtest 2from backtesting import Strategy 3from backtesting.test import GOOG 4 5class Strat(Strategy): 6 7 def init(self): 8 pass 9 10 # Step through bars one by one 11 def next(self): 12 price = self.data.Close[-1] 13 14 if self.position: 15 pass 16 else: 17 # Look at _Broker class for details on order processing 18 # What happens when we have sl and tp, etc. 19 self.buy(size=1, sl=price - 5, tp=price + 10) 20 21# might want trade_on_close = True to deal with gapping for sl and tp 22bt = Backtest(GOOG, Strat, cash=10_000) 23bt.run() 24 25bt.plot()
If this looks like a load of nonsense to you I'd recommend checking out my beginners tutorial on backtesting.py. All we're doing here is setting a take profit and stop loss a few dollars either side of the closing price when our buy is signalled. This particular detail is important when it comes to data with large gaps, like daily equities data that we're using here. Often the close price on one day and the open price on the next can be wildly different. Given that by default Backtesting.py buys on the next days open, this can cause unexpected behaviour like exiting a trade much earlier than you'd expect.
If you're super interested in exactly how orders are processed, you may want to check out the
_Broker class inside the backtesting.py file on the Backtesting.py Github.
Trailing Stop Losses
The above example isn't overly interesting because you can only set a fixed stop loss at the time you make the trade. In the real world you might want to move your stop loss based on the price action that you're seeing.
Built-in Trailing Stop Loss
Backtesting.lib has a built in trailing stop loss strategy class that we can use to create new strategies. There's an example in the docs. Here we're using a simple RSI strategy and setting a trailing stop loss at
stop_range multiplied by the ATR, which is what the
TrailingStrategy uses to calculate the new stop-loss on each bar.
1from backtesting import Backtest 2from backtesting import Strategy 3from backtesting.test import GOOG 4from backtesting.lib import crossover, TrailingStrategy 5 6import ta 7 8def RsiIndicator(close, window): 9 rsi = ta.momentum.RSIIndicator(close, window=window) 10 return rsi.rsi() 11 12class RsiOscillator(TrailingStrategy): 13 """ 14 Buy when we cross the lower bound to the upside 15 hold until we get stopped out 16 """ 17 18 rsi_window = 14 19 lower_bound = 70 20 stop_range = 10 21 22 def init(self): 23 super().init() 24 super().set_trailing_sl(self.stop_range) 25 self.rsi = self.I(RsiIndicator, self.data.Close.s, self.rsi_window) 26 27 def next(self): 28 super().next() 29 30 if len(self.trades) == 0: 31 if crossover(self.rsi, self.lower_bound): 32 self.buy() 33 34bt = Backtest(GOOG, RsiOscillator, cash = 10_000, commission=0.002) 35 36stats = bt.run( 37 rsi_window = 21, 38 lower_bound = 40, 39 stop_range = 5, 40 ) 41 42print(stats) 43bt.plot()
Custom Trailing Stop Loss
TrailingStrategy is cool and all, but it'd be really nice to be able to implement a trailing stop loss that didn't have to use some multiple of the ATR. To fix this, we can of course just implement our own version of
TrailingStrategy, which I attempt below.
1from backtesting import Backtest 2from backtesting import Strategy 3from backtesting.test import GOOG 4from backtesting.lib import crossover 5 6class TrailingStrategy(Strategy): 7 8 __sl_amount = 6. 9 10 def init(self): 11 super().init() 12 13 def set_trailing_sl(self, sl_amount: float = 6): 14 """ 15 Set the trailing stop loss as $n below the current price (for long positions) 16 Works for future bars only 17 """ 18 self.__sl_amount = sl_amount 19 20 def next(self): 21 super().next() 22 # Can't use index=-1 because self.__atr is not an Indicator type 23 index = len(self.data)-1 24 25 for trade in self.trades: 26 if trade.is_long: 27 trade.sl = max(trade.sl or -np.inf, 28 self.data.Close[index] - self.__sl_amount) 29 else: 30 trade.sl = min(trade.sl or np.inf, 31 self.data.Close[index] + self.__sl_amount)
In my example here I've edited
TrailingStrategy to simply set the trailing stop loss at
sl_amount below the current closing price.
After you've written your own
TrailingStrategy, you can use the same strategy as above, inheriting from your new
If you're still a little unsure after reading through this post, here's the video version of this tutorial: