Problem Description
Solution
Here is a complete, robust momentum trading algorithm for ETFs implemented in Python for the QuantConnect Lean engine.
Strategy Logic
- Universe: A selection of liquid ETFs (SPY, QQQ, IWM, etc.).
- Entry Signal:
- Trend Filter: Price must be above the 50-day Simple Moving Average (SMA).
- Momentum: The 20-day Rate of Change (ROC) must be positive.
- Position Sizing: Allocates a fixed percentage of equity per trade.
- Risk Management (Automated):
- Upon entry, the algorithm calculates the Average True Range (ATR).
- Stop Loss: Placed at
Entry Price - (2 * ATR). - Take Profit: Placed at
Entry Price + (4 * ATR)(2:1 Reward-to-Risk ratio). - OCO Logic: If the Stop Loss is hit, the Take Profit is cancelled, and vice versa.
QuantConnect Algorithm Code
# region imports
from AlgorithmImports import *
# endregion
class MomentumETFWithBracketOrders(QCAlgorithm):
def initialize(self):
# 1. Strategy Setup
self.set_start_date(2018, 1, 1)
self.set_end_date(2024, 1, 1)
self.set_cash(100000)
# 2. Universe of ETFs
self.tickers = ["SPY", "QQQ", "IWM", "XLE", "XLF", "TLT", "GLD"]
self.symbols = []
# Dictionary to hold our custom data (indicators, order tickets) per symbol
self.symbol_data = {}
# 3. Parameters
self.lookback = 20 # Momentum lookback
self.trend_period = 50 # Trend filter SMA
self.atr_period = 14 # Volatility period
self.sl_multiplier = 2.0 # Stop Loss ATR multiple
self.tp_multiplier = 4.0 # Take Profit ATR multiple
self.allocation = 1.0 / len(self.tickers) # Equal weight
for ticker in self.tickers:
equity = self.add_equity(ticker, Resolution.DAILY)
symbol = equity.symbol
self.symbols.append(symbol)
# Initialize SymbolData helper for each ETF
self.symbol_data[symbol] = SymbolData(self, symbol, self.lookback, self.trend_period, self.atr_period)
# Warm up indicators
self.set_warm_up(max(self.trend_period, self.atr_period))
def on_data(self, data: Slice):
if self.is_warming_up:
return
for symbol in self.symbols:
# Ensure data exists for this symbol in this slice
if not data.bars.containsKey(symbol):
continue
sd = self.symbol_data[symbol]
# Ensure indicators are ready
if not sd.is_ready:
continue
# Check if we are already invested or have open orders
if self.portfolio[symbol].invested or self.transactions.get_open_orders(symbol):
continue
# --- Entry Logic ---
# 1. Price > 50 SMA (Uptrend)
# 2. ROC > 0 (Positive Momentum)
price = data.bars[symbol].close
if price > sd.sma.current.value and sd.roc.current.value > 0:
# We use MarketOrder to enter.
# The Stop/Limit logic is handled in OnOrderEvent to ensure we get the exact fill price.
quantity = self.calculate_order_quantity(symbol, self.allocation)
self.market_order(symbol, quantity)
def on_order_event(self, order_event: OrderEvent):
# Only process filled orders
if order_event.status != OrderStatus.FILLED:
return
order = self.transactions.get_order_by_id(order_event.order_id)
symbol = order_event.symbol
sd = self.symbol_data[symbol]
# --- Handle Entry (Buy) ---
# If we just bought the asset (Market Buy), place the Bracket Orders
if order.type == OrderType.MARKET and order.direction == OrderDirection.BUY:
fill_price = order_event.fill_price
atr_value = sd.atr.current.value
# Calculate levels
stop_price = fill_price - (atr_value * self.sl_multiplier)
profit_price = fill_price + (atr_value * self.tp_multiplier)
quantity = order_event.fill_quantity # Quantity to close
# Place Stop Loss
sd.stop_ticket = self.stop_market_order(symbol, -quantity, stop_price, "Stop Loss")
# Place Take Profit
sd.limit_ticket = self.limit_order(symbol, -quantity, profit_price, "Take Profit")
self.debug(f"{self.time} Entered {symbol} @ {fill_price}. SL: {stop_price:.2f}, TP: {profit_price:.2f}")
# --- Handle Exit (OCO Logic) ---
# If a Stop or Limit order is filled, cancel the other one to avoid "ghost" positions
if order.type == OrderType.STOP_MARKET or order.type == OrderType.LIMIT:
# If Stop Loss hit, cancel Take Profit
if sd.stop_ticket is not None and order.id == sd.stop_ticket.order_id:
if sd.limit_ticket is not None:
sd.limit_ticket.cancel("Stop Loss Hit")
sd.reset_tickets()
self.debug(f"{self.time} Stop Loss Hit for {symbol}")
# If Take Profit hit, cancel Stop Loss
elif sd.limit_ticket is not None and order.id == sd.limit_ticket.order_id:
if sd.stop_ticket is not None:
sd.stop_ticket.cancel("Take Profit Hit")
sd.reset_tickets()
self.debug(f"{self.time} Take Profit Hit for {symbol}")
class SymbolData:
"""
Helper class to manage indicators and order tickets for a specific symbol.
"""
def __init__(self, algorithm, symbol, roc_period, sma_period, atr_period):
self.symbol = symbol
# Indicators
self.roc = algorithm.roc(symbol, roc_period, Resolution.DAILY)
self.sma = algorithm.sma(symbol, sma_period, Resolution.DAILY)
self.atr = algorithm.atr(symbol, atr_period, MovingAverageType.WILDERS, Resolution.DAILY)
# Order Tickets (to manage OCO logic)
self.stop_ticket = None
self.limit_ticket = None
@property
def is_ready(self):
return self.roc.is_ready and self.sma.is_ready and self.atr.is_ready
def reset_tickets(self):
self.stop_ticket = None
self.limit_ticket = None
Key Implementation Details
-
SymbolDataClass:- Instead of cluttering the main algorithm with lists of indicators, we create a helper class. This stores the
ROC,SMA, andATRspecific to each ETF. - It also stores the
stop_ticketandlimit_ticketso we can reference them later to cancel orders.
- Instead of cluttering the main algorithm with lists of indicators, we create a helper class. This stores the
-
on_order_eventLogic:- This is the core of the automation. We do not place the Stop/Limit orders immediately in
on_data. - We wait for the Market Entry to fill. This ensures we base our Stop Loss and Take Profit math on the actual execution price, not the theoretical close price of the previous bar.
- OCO (One Cancels Other): When the Stop Loss fills, the code automatically finds the corresponding Limit order ticket and cancels it (and vice versa). This prevents leaving an open Limit order that could accidentally short the stock later.
- This is the core of the automation. We do not place the Stop/Limit orders immediately in
-
Risk Management:
- ATR (Average True Range) is used instead of fixed percentages. This adapts the stop distance to the volatility of the specific ETF (e.g., TQQQ will have wider stops than SPY).
Q&A: Momentum Trading on QuantConnect
Q: Why use on_order_event instead of placing all orders in on_data?
A: In live trading, market orders are not filled instantly. If you calculate your Stop Loss based on the current price but your order fills 10 seconds later at a different price, your risk calculations will be off. Using on_order_event ensures you attach the protection orders to the exact filled price.
Q: Can I change the Reward-to-Risk ratio?
A: Yes. Modify self.sl_multiplier and self.tp_multiplier in the initialize method. Currently, it is set to 2.0 (Stop) and 4.0 (Target), providing a 1:2 risk/reward ratio.
Q: How does the algorithm handle wash sales or re-entries?
A: The condition if self.portfolio[symbol].invested in on_data prevents the algorithm from adding to a position that is already open. It will only look for a new entry after the previous position has been fully closed (either by Stop Loss or Take Profit).