/ TRADING

Stop Loss: Explained & The Best Strategy

A stop-loss order protects profit or limits risk on an investor’s open position by exiting at a predetermined price. Placing an order to sell a long stock position if the price drops 5% below the purchase price is an example of a stop-loss order.

In this post, we’re going to dig into what a stop loss is, the different types of stop-losses, understand what a trailing stop-loss is, and analyze the best stop-loss strategy for the S&P 500, ETFs, and equities. I’ll also provide code using Backtrader with data supplied by Intrinio for those interested in trying it out themselves.

How Does A Stop-Loss Order Work?

After a trader opens a long or short position by placing an order with their broker, they will often add a follow-up stop-loss order to limit the amount of money they can lose if the investment moves against them. This order stays open until it reaches the stop price, and at that point, the order becomes a market order and executes.

Because it’s a market order, there is no guarantee that the order will execute at the stop price due to slippage. Unexpected news or market conditions can result in a stop-loss order completing at a price that is dramatically different than the stop price.

A stop-limit order, which executes as a limit instead of a market order, can help alleviate this problem with one significant risk – the order may never execute if the limit price is not hit, causing substantial losses.

Stop-Loss Benefits & Drawbacks

Stop-losses provide many benefits and one big drawback: Automatic execution ensures a trader is limiting their losses to a predefined level, preventing loss aversion. Placing the stop-loss orders in advance with the broker enables a trader to step away from monitoring the markets.

A stop-loss has one primary risk – volatility causing you to hit your stop price too frequently eroding your capital due to fees and slippage. We’ll look at the optimal stop-loss placement below.

How Are Stop-Losses Used by Traders?

Traders usually place stop-losses on every trade using one of two strategies:

  • The exit method for every trade
  • The worst-case scenario

While stop-loss orders help to minimize losses, they can also protect gains. Trailing stop-loss orders can help provide profit protection.

What Is A Trailing Stop-Loss Order?

Trailing Stop Orders follow the price and can help protect profits while providing downside protection. With a Trailing Stop Order, you do not have to adjust for price changes regularly.

Trailing Stop Example

Stock A is trading at $100.00. We place a 5% trailing stop order with our broker. The stop price would be 95% of our high price, which is $95.00. The next day the stock trades to $110.00. Our stop would automatically adjust to $104.50. The following day, the stock drops to $104.50, hitting our stop, and our broker places a market order where our fill price due to slippage is $104.00. We made $4.00 on this trade as we purchased the stock at $100 and sold it automatically through a trailing stop order at $104.

The Best Stop-Loss Strategy

Now that we’ve got the basics covered, let’s dig into the best stop-loss strategy for the S&P 500 index, ETFs, and the S&P 500 constituents.The backtests will use the ten-year period starting in 2010 and ending in 2019. Unless stated otherwise, all charts will use the 5% stop loss.

S&P 500 Stop-Loss

The strategy attempts to buy the S&P 500 index “SPY” with all of the available cash on the first trading day of every month. The strategy sells the S&P 500 with a stop-loss and a trailing stop-loss of 5%, 10% 15%, 20%, 25%, and 30%, respectively.

'SP500 Stop Loss'

Stop-LossP&LMax Drawdown
30%$24,162.8619.31%
25%$24,162.8619.31%
20%$24,162.8619.31%
15%$24,162.8619.31%
10%$22,884.8119.27%
5%$20,624.8220.46%

'SP500 Trailing Stop Loss'

Trailing StopP&LMax Drawdown
30%$24,162.8619.31%
25%$24,162.8619.31%
15%$22,072.5021.18%
20%$21,075.8421.59%
10%$19,856.8219.45%
5%$17,044.7821.15%

The verdict? It appears that the trailing stop-loss performs worse than the stop-loss. Being in the market seems to be the most critical factor for performance in this backtest. The results make sense as economic growth and prosperity are on an upward trend most of the time. Also, the risk of SPY going to zero is much less than with instruments that aren’t as broad. In other words, if SPY goes to zero, it’s unlikely your primary concern at that point would be if a stop-loss or trailing stop-loss performs better!

Sector Stop-Loss

The sector etf stop-loss strategy works similar to the above strategies except that it rebalances the 11 stock sectors equally each month.

'Sector ETF Stop Loss'

Stop-LossP&LMax Drawdown
30%$21,049.1118.33%
15%$19,168.0616.97%
25%$18,802.6717.88%
20%$18,483.9517.68%
10%$17,440.9516.54%
5%$14,504.7913.86%

'Sector ETF Trailing Stop Loss'

Trailing StopP&LMax Drawdown
30%$20,952.0518.37%
25%$18,796.2619.16%
20%$18,740.6217.25%
15%$16,493.2818.19%
10%$15,738.2216.04%
5%$10,319.4712.92%

It appears as though staying in the market is still the number one most important factor for our strategies. This also holds true for rebalancing weekly.

Stop-LossP&LMax Drawdown
30%$20,384.9018.62%
20%$18,274.5819.28%
25%$18,234.5318.19%
15%$17,216.6618.39%
5%$15,780.1617.31%
10%$15,060.6420.02%
Trailing StopP&LMax Drawdown
30%$19,557.3818.56%
25%$18,456.0419.32%
20%$17,570.6818.66%
15%$17,530.9018.31%
5%$13,528.4718.64%
10%$13,006.7421.48%

Equities Stop-Loss

Next up, we’ll try something a little different. We’ll momentum rank the top 20 S&P 500 companies. This strategy will use the same appoach as in the sector momentum backtest.

To get a baseline, I’ve cheated and set a 95% stop-loss as a proxy for no stop-loss:

'No Stock Stop Loss'

Stop-LossP&LMax Drawdown
95%$37,437.9021.85%

'Stock Stop Loss'

Stop-LossP&LMax Drawdown
30%$36,142.7123.05%
25%$35,196.8023.75%
20%$33,876.7023.47%
15%$30,923.3724.81%
10%$23,314.0524.26%
5%$17,284.3018.31%

'Stock Trailing Stop Loss'

Trailing StopP&LMax Drawdown
30%$35,432.7823.27%
25%$31,399.6124.79%
20%$27,693.0224.17%
15%$23,534.3525.68%
10%$14,292.9223.95%
5%$7,180.5414.28%

Average true range, or ATR, is often used as a stop-loss for individual equities. In short, a company whose price moves a lot will have a larger average true range than a company whose price is less volatile. The rationale for using an average true range instead of a fixed amount is that each stop should be tailored to how volatile the individual asset is. For example, what we would not want to do is set a 1% stop loss on a security whose price moves 2% on a daily basis. Graphs below are displayed using an ATR of 1.

'Stocks ATR Stop Loss'

ATR Stop-LossP&LMax Drawdown
5$26,591.5426.63%
4$24,294.8724.84%
3$21,899.0922.39%
2$19,431.5120.02%
1$14,759.7516.23%

'Stocks ATR Trailing Stop Loss'

ATR Trailing StopP&LMax Drawdown
5$21,126.8024.07%
4$15,108.0422.50%
3$10,254.6719.18%
2$8,599.6216.27%
1$3,175.0410.97%

Stop Losses Summarized

This analysis shows how important it is to be in the market. It also indicates setting your stop loss too narrowly can lead to significant underperformance. An excellent next step would be to test further any of these strategies you’re interested in over different timeframes and to analyze where the returns are coming from more deeply. You would also want to look at better performance metrics such as the Sharpe ratio, perform scenario testing, and add slippage if you’re interested in putting a strategy into production.

Either way, the goal here isn’t to be exhaustive. I have to keep some secrets of my own, and my stop strategy may not apply well to your plan. The goal here is to empower and educate you so you can make these decisions for yourself.

The Code

There’s a fair amount of code for all of the examples. Instead of posting all three code examples here, I’m going to upload them to the Analyzing Alpha Github. To make things more concise, the pandas code to import securities has been moved to positions.securities but can still be found in Sector Momentum: Explained & Backtested as the last code snippet. I will be elaborating on the rebalance and stop methods as they haven’t been discussed in previous examples.

The Sector Strategy Rebalancing & Stop Code

The most challenging component with the rebalancing code, which isn’t difficult once you understand the features backtrader provides to you, is keeping track of the stop orders, which we’ll do in a dictionary. Before placing any sell orders, I have to cancel any existing stop orders for the related security; otherwise, we’ll run into a scenario where we may not own a security, but we still have a stop loss for that security or hold a lesser amount. For this reason, I cancel the stop order(s) before selling and then place a new stop order to make sure the quantity owned and stop-loss size always match.

Additionally, I buy the day after I sell to make sure I have enough cash. This could be handled differently by changing the logic and/or using cerebro.broker.checksubmit. Also, I make the assumption that submitted orders are also accepted. In other words, this would need to be improved upon for production, but it works well enough for my purposes here.

I’ve noticed a few posts running into issues when using multiple CPUs. The fix to this is to save broker values in the strategy itself as I’ve done in stop.

from datetime import datetime, timedelta
import backtrader as bt
from positions.securities import get_security_data, get_securities_data,\
                                                    get_sector_tickers

# todo merge get_security_data and get_securities_data

START_DATE = '2010-01-01'
END_DATE = '2019-12-31'
START = datetime.strptime(START_DATE, '%Y-%m-%d')
END = datetime.strptime(END_DATE, '%Y-%m-%d')
BENCHMARK_TICKER = 'SPY'


class Strategy(bt.Strategy):
    params = dict(
        num_positions=2.0,
        stop_loss=0.05,
        trail=False,
        when=bt.timer.SESSION_START,
        timer=True,
        monthdays=[1],
        monthcarry=True,
    )

    def __init__(self):
        self.d_with_len = []
        self.orders = {}
        self.securities = self.datas[1:]
        self.rebalance_date = None
        self.add_timer(
            when=self.p.when,
            monthdays=self.p.monthdays,
            monthcarry=self.p.monthcarry
        )
        for d in self.datas:
            self.orders[d] = []

    def prenext(self):
        # Add data for datas that meet preprocessing requirements
        # And call next even though data is not available for all tickers
        self.d_with_len = [d for d in self.datas[1:] if len(d)]

        if len(self.d_with_len) >= self.p.num_positions:
            self.next()

    def nextstart(self):
        # This is only called once when all data is present
        # So we are not unnecessarily calculating d_with_len
        self.d_with_len = self.datas[1:]
        self.next()
        print("All datas loaded")
    
    def next(self):
        if self.rebalance_date:
            today = self.data.datetime.date(ago=0)
            buy_date = self.rebalance_date + timedelta(days=1)
            if today == buy_date:
                self.rebalance_buy()

    def notify_timer(self, timer, when, *args, **kwargs):
        if len(self.d_with_len) >= self.p.num_positions:
            self.rebalance_sell()

    def rebalance_sell(self):
        # Sell all securities that are above their target allocation
        # but cancel associated stop losses first
        self.rebalance_date = self.data.datetime.date(ago=0)
        positions = len(self.d_with_len)
        target = (self.broker.get_value() / positions)
        for d in self.d_with_len:
            value = self.getposition(d).size * d.close[0]

            if value > target:
                sell_size = (value - target) // d.close[0]
                if sell_size >= 1:
                    stop_size = 0
                    for o in self.orders[d]:
                        if o and o.status == o.Accepted and \
                                (o.getordername() == 'Stop' or
                                 o.getordername() == 'StopTrail') and \
                                stop_size < sell_size:
                            self.cancel(o)
                            stop_size += o.size
                    if stop_size == sell_size:
                        sell_order = self.sell(d,
                                               size=sell_size,
                                               exectype=bt.Order.Market,
                                               transmit=True,
                                               ticker=d.p.name)
                    else:
                        sell_order = self.sell(d,
                                               size=sell_size,
                                               exectype=bt.Order.Market,
                                               transmit=False,
                                               ticker=d.p.name)
                        stop_price = (1.0 - self.p.stop_loss) * d.close[0]

                        # stop_size is negative and sell_size is postive
                        stop_delta = stop_size * -1 - sell_size
                        if self.p.trail:
                            stop_order = self.sell(d,
                                                   size=stop_delta,
                                                   exectype=bt.Order.StopTrail,
                                                   trailpercent=self.p.stop_loss,
                                                   transmit=True,
                                                   parent=sell_order,
                                                   ticker=d.p.name)
                        else:
                            stop_order = self.sell(d,
                                                   price=stop_price,
                                                   size=stop_delta,
                                                   exectype=bt.Order.Stop,
                                                   transmit=True,
                                                   parent=sell_order,
                                                   ticker=d.p.name)

                        self.orders[d].append(sell_order)
                        self.orders[d].append(stop_order)


    def rebalance_buy(self):
        # Buy all securities that are below their target allocation
        # and attach a stop loss.
        positions = len(self.d_with_len)
        target = (self.broker.get_value() / positions)
        for d in self.d_with_len:
            value = self.getposition(d).size * d.close[0]
            if target > value:
                buy_size = (target - value) // d.close[0]
                stop_price = (1.0 - self.p.stop_loss) * d.close[0]
                buy_order = self.buy(d,
                                     size=buy_size,
                                     exectype=bt.Order.Market,
                                     transmit=False,
                                     ticker=d.p.name)
                if self.p.trail:
                    stop_order = self.sell(d,
                                           size=buy_size,
                                           exectype=bt.Order.StopTrail,
                                           trailpercent=self.p.stop_loss,
                                           transmit=True,
                                           parent=buy_order,
                                           ticker=d.p.name)
                else:
                    stop_order = self.sell(d,
                                           price=stop_price,
                                           size=buy_size,
                                           exectype=bt.Order.Stop,
                                           transmit=True,
                                           parent=buy_order,
                                           ticker=d.p.name)

                self.orders[d].append(buy_order)
                self.orders[d].append(stop_order)

    def stop(self):
        self.ending_value = round(self.broker.get_value(), 2)
        self.PnL = round(self.ending_value - startcash, 2)

startcash = 10000
cerebro = bt.Cerebro(stdstats=False, optreturn=False)

# Add Benchmark (datas[0])
benchmark = get_security_data(BENCHMARK_TICKER, START, END)
benchdata = bt.feeds.PandasData(dataname=benchmark, name='SPY', plot=False)
cerebro.adddata(benchdata)

# Add Securities (datas[1:])
tickers = get_sector_tickers()
# tickers = get_sp500_tickers()
securities = get_securities_data(tickers, START_DATE, END_DATE)

# Add securities as datas1:
for ticker, data in securities.groupby(level=0):
    print(f"Adding ticker: {ticker}.")
    d = bt.feeds.PandasData(dataname=data.droplevel(level=0),
                            name=ticker,
                            plot=False)
    d.plotinfo.plotmaster = benchdata
    d.plotinfo.plotlinelabels = True

    cerebro.adddata(d)

print('Starting Portfolio Value: %.2f' % cerebro.broker.getvalue())

# Add Strategy
#cerebro.addstrategy(Strategy)
cerebro.optstrategy(Strategy, stop_loss=(0.05))


# Add observers

cerebro.addobserver(bt.observers.CashValue)
cerebro.addobserver(bt.observers.Benchmark,
                     data=benchdata,
                     _doprenext=True,
                     timeframe=bt.TimeFrame.NoTimeFrame)
cerebro.addobserver(bt.observers.Trades)
cerebro.addobserver(bt.observers.BuySell)

# Add analyzers
cerebro.addanalyzer(bt.analyzers.Returns)
cerebro.addanalyzer(bt.analyzers.DrawDown)

# Run cerebro
opt_results = cerebro.run(tradehistory=False)

# Generate results list
final_results_list = []
    
for run in opt_results:
    for strategy in run:
        value = strategy.ending_value
        PnL = strategy.PnL
        stop_loss = strategy.p.stop_loss
        drawdown = strategy.analyzers.drawdown.get_analysis()['max']['drawdown']
        final_results_list.append([stop_loss, PnL, drawdown])
        print(f"Strategy Total Return: {strategy.analyzers.returns.get_analysis()['rtot']}")

#Sort Results List
by_stop = sorted(final_results_list, key=lambda x: x[0])
by_PnL = sorted(final_results_list, key=lambda x: x[1], reverse=True)

#Print results
print('Results: Ordered by Stop Loss:')
for result in by_stop:
    print('Stop: {}, PnL: {}, Drawdown: {}'.format(result[0], result[1], result[2]))
print('Results: Ordered by Profit:')
for result in by_PnL:
    print('Stop: {}, PnL: {} Drawdown: {}'.format(result[0], result[1], result[2]))

cerebro.plot()
leo

Leo Smigel

Based in Pittsburgh, Analyzing Alpha is a blog by Leo Smigel exploring what works in the markets.