Problem Description
Solution
To generate a trade signal and use a specific ATR value for a stop-loss, the most robust approach in QuantConnect is to store the ATR value at the moment of signal generation and then retrieve it within the on_order_event handler.
This ensures that your stop-loss is calculated based on the volatility (ATR) that existed when the decision was made, but applied to the actual fill price of the trade.
Strategy Implementation
The following strategy demonstrates this workflow:
- Initialize: Set up the ATR indicator and a dictionary (
self.signal_data) to store ATR values keyed by Symbol. - OnData: Check for a Buy signal (e.g., Price > SMA). If triggered, store the current ATR in the dictionary and place a Market Order.
- OnOrderEvent: Detect when the entry order is
FILLED. Retrieve the stored ATR for that symbol, calculate the stop price relative to the actual fill price, and submit the Stop Loss order.
# region imports
from AlgorithmImports import *
# endregion
class AtrStopLossAlgorithm(QCAlgorithm):
def initialize(self):
self.set_start_date(2023, 1, 1)
self.set_end_date(2023, 12, 1)
self.set_cash(100000)
# Add Equity
self.symbol = self.add_equity("SPY", Resolution.DAILY).symbol
# Initialize Indicators
self.atr = self.atr(self.symbol, 14, MovingAverageType.WILDERS, Resolution.DAILY)
self.sma = self.sma(self.symbol, 200, Resolution.DAILY)
# Warm up indicators
self.set_warm_up(200)
# Dictionary to store the ATR value at the moment of the signal
# Key: Symbol, Value: ATR float value
self.signal_atr = {}
# Multiplier for the stop loss (e.g., 2x ATR)
self.sl_multiplier = 2.0
def on_data(self, data: Slice):
if self.is_warming_up or not self.atr.is_ready:
return
# Simple Logic: Buy if Price > 200 SMA and we are not invested
if not self.portfolio.invested:
if self.securities[self.symbol].price > self.sma.current.value:
# 1. Capture the ATR value at this specific moment
current_atr = self.atr.current.value
# 2. Store it in our dictionary to pass it to OnOrderEvent
self.signal_atr[self.symbol] = current_atr
# 3. Place the entry order
self.set_holdings(self.symbol, 1.0)
self.log(f"Signal Generated. Stored ATR: {current_atr:.4f}")
def on_order_event(self, order_event: OrderEvent):
# We only care about Filled orders
if order_event.status != OrderStatus.FILLED:
return
# We only care about Entry orders (Buying), not exit orders
# order_event.quantity > 0 implies we bought
if order_event.quantity > 0:
# Retrieve the order details to confirm it's an equity trade
order = self.transactions.get_order_by_id(order_event.order_id)
# Check if we have a stored ATR for this symbol
if order_event.symbol in self.signal_atr:
# Retrieve the ATR value we stored in OnData
atr_value = self.signal_atr[order_event.symbol]
# Calculate Stop Price based on the ACTUAL Fill Price
fill_price = order_event.fill_price
stop_price = fill_price - (atr_value * self.sl_multiplier)
# Place the Stop Market Order
# Note: We negate the quantity to sell what we just bought
self.stop_market_order(order_event.symbol, -order_event.quantity, stop_price)
self.log(f"Entry Filled @ {fill_price}. Placing Stop @ {stop_price} (ATR: {atr_value})")
# Optional: Clear the dictionary entry to keep it clean
# del self.signal_atr[order_event.symbol]
Key Concepts Explained
-
self.signal_atrDictionary:
StandardMarketOrdercalls do not allow you to attach custom metadata (like an ATR value) directly to the order object that persists easily to the event handler. By using a class-level dictionary keyed bySymbol, you create a bridge between the logic inon_dataand the execution inon_order_event. -
Why use
on_order_event?
You could technically place thestop_market_orderimmediately after themarket_orderinon_data. However, inon_data, you do not know the exact Fill Price of the market order yet (slippage).
By waiting for theFILLEDevent inon_order_event, you ensure your stop loss is calculated mathematically correctly from the actual entry price (order_event.fill_price - 2 * ATR). -
Order Quantity Check:
if order_event.quantity > 0:ensures we only place a stop loss when we enter a long position. Without this check, when your stop loss eventually fills (selling the stock), that fill event would trigger this logic again, potentially causing an infinite loop or errors.
Q&A
Q: Can I use this method with the Algorithm Framework (Alpha/Portfolio models)?
A: Yes. If you are using the Framework, you can calculate the ATR in your Alpha Model. When emitting an Insight, you can store the ATR in a shared dictionary or serialize it into the Insight.tag property (though parsing strings is slower). The on_order_event logic remains the same.
Q: What happens if I have multiple positions for the same symbol?
A: The dictionary approach above assumes one active "signal ATR" per symbol. If you scale into positions (pyramiding), you might overwrite the ATR value. For complex scaling, you would need to manage a list of ATRs or use the weighted average price of the portfolio holdings.
Q: How do I handle Trailing Stops with ATR?
A: To implement a trailing stop, you would not use stop_market_order. Instead, you would use self.train or on_data to monitor the price. If current_price - (ATR * multiplier) is higher than your existing stop price, you would use order.update() to move the stop price up.
Q: Does this work for Short selling?
A: The logic needs a slight adjustment for shorting.
- In
on_data:self.set_holdings(symbol, -1.0). - In
on_order_event: Checkif order_event.quantity < 0. - Stop Calculation:
stop_price = fill_price + (atr_value * self.sl_multiplier)(Stop is above entry for shorts). - Order:
self.stop_market_order(symbol, abs(quantity), stop_price).