Problem Description
Solution
Here is a complete, robust Covered Call strategy implemented in Python for the QuantConnect Lean engine.
Strategy Logic
- Underlying Asset: Uses SPY (S&P 500 ETF) as the underlying equity.
- Entry Condition:
- The algorithm checks if it currently holds a short position in a Call option.
- If no option is held, it scans the Option Chain.
- It selects a Call option that is Out-of-the-Money (OTM) (Strike > Current Price) and expires in roughly 30 to 45 days.
- It ensures the portfolio holds 100 shares of SPY. If not, it buys them.
- It sells (writes) 1 Call contract against the 100 shares.
- Exit Condition:
- Expiration: If the option expires worthless, the algorithm automatically detects it has no position and writes a new one.
- Assignment: If the stock price rises above the strike, the shares will be called away (sold) automatically by the brokerage model. The algorithm will then detect it has no shares and no option, restarting the cycle.
Python Code
# region imports
from AlgorithmImports import *
from datetime import timedelta
# endregion
class CoveredCallStrategy(QCAlgorithm):
def initialize(self):
# 1. Set Setup Parameters
self.set_start_date(2020, 1, 1)
self.set_end_date(2023, 12, 31)
self.set_cash(100000)
# 2. Add Underlying Equity (SPY)
self.ticker = "SPY"
equity = self.add_equity(self.ticker, Resolution.MINUTE)
equity.set_data_normalization_mode(DataNormalizationMode.RAW) # Required for Options
self.equity_symbol = equity.symbol
# 3. Add Option Data
option = self.add_option(self.ticker, Resolution.MINUTE)
self.option_symbol = option.symbol
# 4. Set Option Filter
# We look for strikes -2 to +5 levels around the money
# We look for expiry between 30 and 45 days out
option.set_filter(self.universe_filter)
def universe_filter(self, universe):
"""
Filter the option chain to select contracts expiring in 30-45 days
and strikes relatively close to the current price.
"""
return universe.include_weeklys()\
.strikes(-2, 5)\
.expiration(timedelta(30), timedelta(45))
def on_data(self, slice: Slice):
# Ensure we are not warming up
if self.is_warming_up:
return
# Check if we already have a short option position
# If we are invested in an option for this underlying, do nothing and wait.
for symbol, holding in self.portfolio.items():
if holding.invested and holding.type == SecurityType.OPTION and holding.is_short:
return
# If we are here, we are 'flat' on options (no short call).
# Let's try to open a new Covered Call.
# 1. Get the Option Chain for the current time step
chain = slice.option_chains.get(self.option_symbol)
if not chain:
return
# 2. Select the specific contract
# Filter for Call options only
calls = [x for x in chain if x.right == OptionRight.CALL]
# Filter for Out-of-the-Money (OTM) calls: Strike > Underlying Price
# This allows for some capital appreciation of the stock.
underlying_price = chain.underlying.price
otm_calls = [x for x in calls if x.strike > underlying_price]
# Sort by Expiry (closest first) and then by Strike (lowest first)
# We want the OTM call closest to the money to get decent premium,
# but still OTM.
sorted_contracts = sorted(otm_calls, key=lambda x: (x.expiry, x.strike))
if not sorted_contracts:
return
selected_contract = sorted_contracts[0]
# 3. Execute the Trade
# Step A: Ensure we have the underlying stock (100 shares per contract)
# If we don't have 100 shares, buy them.
current_shares = self.portfolio[self.equity_symbol].quantity
if current_shares < 100:
quantity_to_buy = 100 - current_shares
self.market_order(self.equity_symbol, quantity_to_buy)
self.log(f"Buying {quantity_to_buy} shares of {self.ticker} at {underlying_price}")
# Step B: Sell (Write) the Call Option
self.market_order(selected_contract.symbol, -1)
self.log(f"Selling Covered Call: {selected_contract.symbol} | Strike: {selected_contract.strike} | Expiry: {selected_contract.expiry}")
def on_order_event(self, order_event):
# Log filled orders for debugging
if order_event.status == OrderStatus.FILLED:
self.debug(f"Order Filled: {order_event.symbol} - {order_event.direction} @ {order_event.fill_price}")
def on_assignment_order_event(self, assignment_event):
# This event triggers if our short call is exercised and our stock is called away.
self.log(f"Assignment Event: {assignment_event.symbol} was assigned.")
Q&A: Understanding the Strategy
Q: Why do we set DataNormalizationMode.RAW?
A: When trading options in QuantConnect, the underlying equity price must match the strike prices in the option chain. Adjusted data (which accounts for splits and dividends historically) would cause a mismatch between the historical stock price and the fixed strike prices of the options.
Q: How does the universe_filter work?
A: This function narrows down the thousands of available option contracts to a manageable list.
.strikes(-2, 5): Selects strikes from 2 levels below to 5 levels above the current price..expiration(timedelta(30), timedelta(45)): Only selects contracts expiring between 30 and 45 days from the current date.
Q: What happens if the stock price crashes?
A: This is the primary risk of a Covered Call. You own the stock, so you participate in the downside. However, the premium received from selling the call option provides a small buffer (hedge) against the loss, making it slightly safer than just holding the stock alone.
Q: What happens if the stock price skyrockets?
A: Your profit is capped at the Strike Price plus the premium received. If SPY goes to the moon, your shares will be "called away" (sold) at the strike price. You miss out on gains above the strike, but you keep the max profit defined at entry.