🚀 AI One-Click Generation of quantconnect Strategy Code

How to Build a Momentum Strategy for ETFs with ATR-Based Stops

quantconnect | Published on: 2025/11/27
The following content is generated by EasyQuant .

Problem Description

Write a momentum trading algorithm for ETFs that automatically sets a stop-loss and take-profit for each trade.

Solution

Here is a complete, robust momentum trading algorithm for ETFs implemented in Python for the QuantConnect Lean engine.

Strategy Logic

  1. Universe: A selection of liquid ETFs (SPY, QQQ, IWM, etc.).
  2. 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.
  3. Position Sizing: Allocates a fixed percentage of equity per trade.
  4. 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

  1. SymbolData Class:

    • Instead of cluttering the main algorithm with lists of indicators, we create a helper class. This stores the ROC, SMA, and ATR specific to each ETF.
    • It also stores the stop_ticket and limit_ticket so we can reference them later to cancel orders.
  2. on_order_event Logic:

    • 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.
  3. 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).