Stop Losses in Backtesting.py

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: