QuantConnect: Trailing Stops

In this article, we will provide a code snippet/example of how to implement a trailing stop on Quantconnect. At the time of writing, the LEAN API currently does not support this type of stop. Therefore, if we want to use a trailing stop, we need to craft it ourselves with the tools at our disposal.

Trailing Stops

To implement a basic trailing stop, all we need to do is update our stop-loss order frequently. This means determining whether the stop should move up and if so, just moving it ourselves with an order update. Admittedly, this is a bit of a workaround because you can only update your stop level at certain points in time. For example, when OnData()is called. Furthermore, If you are trading on a daily timeframe, that may not be frequent enough for you. However, before you start moving for the close button on the browser, this “workaround” does have a lot of power in disguise. By giving yourself control over when the stop loss level moves, you can create some really interesting trailing stops. Want to trail only behind swings in price action? You can do that! Volatility has picked up and you want to give the stop some more breathing room? Again, you can do that! I am sure you get the idea.

Example Code

class ParticleResistanceCircuit(QCAlgorithm):

    def Initialize(self):
        self.SetStartDate(2018, 11, 20)  # Set Start Date
        self.SetStartDate(2018, 11, 21)  # Set Start Date
        self.SetCash(100000)  # Set Strategy Cash
        self.symbol = "SPY"
        self.AddEquity(self.symbol, Resolution.Minute)
        
        # Trailing distance in $
        self.trail_dist = 10  
        
        # Declare an attribute that we shall use for storing our
        # stop loss ticket. 
        self.sl_order = None
        # Declare an attribute that we will use to store the last trail level
        # used. We will use this to decide whether to move the stop
        self.last_trail_level = None
        
    def OnOrderEvent(self, OrderEvent):
        '''Event when the order is filled. Debug log the order fill. :OrderEvent:'''

        if OrderEvent.FillQuantity == 0:
            return

        # Get the filled order
        Order = self.Transactions.GetOrderById(OrderEvent.OrderId)
        
        # Log the filled order details
        self.Log("ORDER NOTIFICATION >> {} >> Status: {} Symbol: {}. Quantity: "
                    "{}. Direction: {}. Fill Price {}".format(str(Order.Tag),
                                                   str(OrderEvent.Status),
                                                   str(OrderEvent.Symbol),
                                                   str(OrderEvent.FillQuantity),
                                                   str(OrderEvent.Direction),
                                                   str(OrderEvent.FillPrice)))

    def OnData(self, data):
        '''OnData event is the primary entry point for your algorithm. Each new data point will be pumped in here.
            Arguments:
                data: Slice object keyed by symbol containing the stock data
        '''
        
        # We need to check that the symbol has data before trying to access
        # OHLC. Otherwise an exception is raised if the data is missing. 
        if data.ContainsKey(self.symbol) and data.HasData:
            
            # Create some Alias
            # ------------------------------------------
            holdings = self.Portfolio[self.symbol].Quantity
            value = self.Portfolio.TotalPortfolioValue
            cash = self.Portfolio.Cash
            
            # Even then, occassionally, there is no Open attribute
            try:
                O = round(data[self.symbol].Open, 2)
                H = round(data[self.symbol].High, 2)
                L = round(data[self.symbol].Low, 2)
                C = round(data[self.symbol].Close, 2)
            except AttributeError:
                self.Log('>> {} >> Missing Data')
                return
            
            # Calcuate our base SL level. This is used for the initial entry. 
            # We will also use it to compare to the previous trail level.
            base_sl_level = round(C - self.trail_dist,2)
            
            # Log OHLC - This can be useful for debugging to see where price is moving
            self.Log('>> {}      >> ON DATA >> >> >> >> >> >>'.format(self.symbol))
            self.Log('>> OHLC     >> O:{} H:{} L:{} C:{}'.format(O,H,L,C))
            self.Log('>> SL       >> Base Level:{} Last Trail Level:{}'.format(base_sl_level, self.last_trail_level))
            self.Log('>> Account  >> Cash:{}, Val:{}, Holdings:{}'.format(cash, value, holdings))
                        
            if not self.Portfolio.Invested:
                self.MarketOrder(self.symbol, 10, False, 'Long Entry')
                self.sl_order = self.StopMarketOrder(self.symbol, -10, base_sl_level, 'SL')
                self.last_trail_level = base_sl_level
                
            else:
                if base_sl_level > self.last_trail_level:
                    self.Log('>> Updating Trailing Stop >>')
                    
                    # Upate our stoploss order! 
                    update_order_fields = UpdateOrderFields()
                    update_order_fields.StopPrice = base_sl_level
                    self.sl_order.Update(update_order_fields)
                    
                    # Log last sl_level
                    self.last_trail_level = base_sl_level

Commentary

Because we want to edit and update our stop loss as we go, the first thing we need to do is create an attribute (variable) that will store our stop-loss ticket. Tickets are returned from the API methods (functions) that create orders and we need them to update the order later. For this reason, one of the first things we do is to give the ticket a place to live during initialization.
# Declare an attribute that we shall use for storing our
# stop loss ticket. 
self.sl_order = None
At the same time, we also create another attribute to track the last trail stop level. This will be used on each bar to check whether the current stop loss level is higher than the last trailing level. If it is, we move the stop loss up. Note that you could also tackle this part by using a rolling window. If you wish to learn more about that, click on that link for an introduction on accessing previous values. The reason why a rolling window was not used in the example, is simply because we never need to access more than the most recent previous value and we are able to track it without the need for a rolling window.

On Data

The rest of the magic happens when OnData()is called. For the purposes of this tutorial, we enter a position immediately and send a stop loss order at the same time. Here, you will notice that we make use of self.sl_order.
if not self.Portfolio.Invested:
    self.MarketOrder(self.symbol, 10, False, 'Long Entry')
    self.sl_order = self.StopMarketOrder(self.symbol, -10, sl_level, 'SL')
    self.last_trail_level = sl_level
Next, we can take a look at the trailing calculation. To do this, we just calculate a “base” stop loss value. That is the current close minus thetrail_dist. If price has moved in our favour, this base value will be above our self.last_trail_level. If so, we then update the ticket with the lines:
# Upate our stoploss order! 
update_order_fields = UpdateOrderFields()
update_order_fields.StopPrice = sl_level
self.sl_order.Update(update_order_fields)
Note: For more information regarding the use of UpdateOrderFields()see the official docs here: https://www.quantconnect.com/docs/algorithm-reference/trading-and-orders After updating the order, notice how we now update the self.last_trail_level at the same time. This will be the new hurdle to beat in subsequent bars. Finally, if not, we ignore this bar and keep the stop loss level as is. Assuming price never moves in our favor again, this would continue until the SL is hit. This is just as a real trailing stop would work.

Verification

Since there are not any nice plots on the chart, we need to turn to our logs to verify what is going on. Run the backtest and open up your logs. You should see something like this: Trailing Stop Log Noice, how the trailing stop starts at $254.66 and is updated on the following bar when price moves and the new base level is calculated at $254.99.

Find This Post Useful?

If this post saved you time and effort, please consider support the site! There are many ways to support us and some won’t even cost you a penny.