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 TrailingStrategy
class.
Video Tutorial
If you're still a little unsure after reading through this post, here's the video version of this tutorial: