Problem Description
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:
- Calculate Volatility: Use the ATR indicator to measure the average price range over a specific period (e.g., 14 days).
- 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).
- 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
self.atr: We initialize the Average True Range indicator. This is the mathematical representation of "market noise."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."
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.
- We place the stop loss here rather than in
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).