🚀 AI One-Click Generation of quantconnect Strategy Code

How to Set a Dynamic Stop-Loss Based on Average True Range (ATR) in Python

quantconnect | Published on: 2025/12/9
The following content is generated by EasyQuant .

Problem Description

How can I create a stop-loss that is wider in volatile markets and tighter in calm markets?

Solution

To create a stop-loss that adapts to market volatility, the standard industry approach is to use the Average True Range (ATR) indicator.

The logic is as follows:

  1. Calculate Volatility: Use the ATR indicator to measure the average price range over a specific period (e.g., 14 days).
  2. Determine Stop Distance: Multiply the ATR value by a factor (e.g., 2.0 or 3.0).
    • High Volatility: ATR is high $\rightarrow$ Stop distance is wider (prevents getting shaken out by noise).
    • Low Volatility: ATR is low $\rightarrow$ Stop distance is tighter (protects profits when price shouldn't be moving much).
  3. Set Price:
    • Long Position: $Stop Price = Entry Price - (ATR \times Multiplier)$
    • Short Position: $Stop Price = Entry Price + (ATR \times Multiplier)$

Implementation Strategy

The following Python code implements this logic using the QuantConnect Lean API. It uses OnOrderEvent to capture the exact fill price of the entry order and immediately places a calculated Stop Market order.

# region imports
from AlgorithmImports import *
# endregion

class VolatilityBasedStopLoss(QCAlgorithm):

    def initialize(self):
        self.set_start_date(2020, 1, 1)
        self.set_end_date(2023, 1, 1)
        self.set_cash(100000)

        # 1. Select Asset
        self.symbol = self.add_equity("SPY", Resolution.DAILY).symbol

        # 2. Define Indicators
        # 14-period Average True Range for volatility measurement
        self.atr = self.atr(self.symbol, 14, MovingAverageType.SIMPLE, Resolution.DAILY)
        
        # 200-period SMA for simple trend filtering (Entry logic)
        self.sma = self.sma(self.symbol, 200, Resolution.DAILY)

        # 3. Strategy Parameters
        self.atr_multiplier = 2.0  # How many ATRs away to place the stop
        self.ticket = None         # To track the stop loss order ticket

        # Warm up indicators
        self.set_warm_up(200)

    def on_data(self, data: Slice):
        # Ensure indicators are ready
        if self.is_warming_up or not self.atr.is_ready or not self.sma.is_ready:
            return

        # Check if we have data for the symbol
        if not data.bars.get(self.symbol):
            return

        price = data.bars[self.symbol].close

        # --- Entry Logic ---
        # If we are not invested and price is above 200 SMA (Uptrend)
        if not self.portfolio.invested:
            if price > self.sma.current.value:
                # Buy 100% of portfolio
                # We use a tag "Entry" to identify this order in OnOrderEvent
                self.set_holdings(self.symbol, 1.0, tag="Entry")

        # --- Trailing Logic (Optional) ---
        # If you wanted to update the stop loss as volatility changes, 
        # you would update self.ticket here. 
        # For this example, we leave it as a fixed initial risk stop.

    def on_order_event(self, order_event: OrderEvent):
        # We only care about Filled orders
        if order_event.status != OrderStatus.FILLED:
            return

        # Get the order object to check the tag
        order = self.transactions.get_order_by_id(order_event.order_id)

        # --- Stop Loss Placement Logic ---
        # Check if this was our "Entry" order
        if order.tag == "Entry":
            
            # 1. Get the execution price
            entry_price = order_event.fill_price
            
            # 2. Get current Volatility (ATR)
            current_atr = self.atr.current.value
            
            # 3. Calculate Stop Price
            # Stop is placed below entry for a long position
            stop_distance = current_atr * self.atr_multiplier
            stop_price = entry_price - stop_distance
            
            # 4. Place the Stop Market Order
            # We sell the same quantity we just bought
            quantity = order_event.fill_quantity
            
            self.ticket = self.stop_market_order(
                self.symbol, 
                -quantity, 
                stop_price, 
                tag=f"Stop Loss (ATR: {current_atr:.2f})"
            )
            
            self.log(f"Entry: {entry_price} | ATR: {current_atr:.2f} | Stop Price: {stop_price}")

        # If the Stop Loss was filled, we are now flat.
        # We can reset the ticket variable.
        if self.ticket is not None and order_event.order_id == self.ticket.order_id:
            self.log(f"Stop Loss Triggered at {order_event.fill_price}")
            self.ticket = None

Key Components Explained

  1. self.atr: We initialize the Average True Range indicator. This is the mathematical representation of "market noise."
  2. self.atr_multiplier: This variable controls the sensitivity.
    • A value of 1.5 to 2.0 is common for day trading.
    • A value of 2.5 to 3.0 is common for swing trading to allow more "breathing room."
  3. on_order_event:
    • We place the stop loss here rather than in on_data.
    • This ensures we use the actual fill price (order_event.fill_price) as the anchor for our calculation, rather than the theoretical close price of the previous bar.
    • We check order.tag == "Entry" to ensure we only place a stop loss when we open a new trade, preventing infinite loops of orders.

Q&A: Volatility-Based Stops

Q: Why use ATR instead of a fixed percentage (e.g., 5%)?
A: A fixed 5% stop might be too wide in a calm market (risking unnecessary capital) and too tight in a volatile market (getting stopped out by normal daily noise). ATR normalizes risk based on the current market environment.

Q: Can I make this a Trailing Stop?
A: Yes. In the on_data method, you can check if the price has moved in your favor. If current_price - (ATR * Multiplier) is higher than your existing stop price, you can use self.ticket.update() to move the stop price up. This creates a "Chandelier Exit."

Q: Does this work for Short positions?
A: Yes, but the math must be inverted. For a short position, the stop price is Entry Price + (ATR * Multiplier).