Problem Description
Solution
Here is a complete, robust implementation of an Iron Condor strategy using the QuantConnect (Lean) Python API.
Strategy Overview
An Iron Condor is a neutral strategy defined by four legs with the same expiration date:
- Sell a Put (OTM)
- Buy a Put (Further OTM - Protection)
- Sell a Call (OTM)
- Buy a Call (Further OTM - Protection)
This algorithm enters a position when the portfolio is not invested, targeting options expiring in roughly 30-45 days. It selects strikes based on a fixed distance from the underlying price to ensure the "body" of the condor captures the current price action.
Python Implementation
# region imports
from AlgorithmImports import *
# endregion
class IronCondorStrategy(QCAlgorithm):
def initialize(self):
# 1. Set Backtest Params
self.set_start_date(2023, 1, 1)
self.set_end_date(2023, 12, 1)
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)
self.underlying_symbol = equity.symbol
# 3. Add Option and Set Filter
# We need a universe of options to select from.
option = self.add_option(self.ticker, Resolution.MINUTE)
self.option_symbol = option.symbol
# Filter:
# - Strikes: -20 to +20 relative to ATM
# - Expiry: 30 to 45 days out
option.set_filter(lambda universe: universe.include_weeklys()
.strikes(-20, 20)
.expiration(timedelta(30), timedelta(45)))
def on_data(self, slice: Slice):
# Avoid trading if we already have positions
if self.portfolio.invested:
return
# Try to get the option chain for our symbol
chain = slice.option_chains.get(self.option_symbol)
if not chain:
return
# 4. Select Expiration Date
# Sort contracts by expiry and select the furthest available date within our filter
expiry = sorted(chain, key=lambda x: x.expiry, reverse=True)[0].expiry
# Filter contracts for this specific expiry
contracts = [x for x in chain if x.expiry == expiry]
if not contracts:
return
# 5. Select Strikes
# Get the underlying price to determine OTM levels
underlying_price = chain.underlying.price
# Separate Puts and Calls
puts = sorted([x for x in contracts if x.right == OptionRight.PUT], key=lambda x: x.strike)
calls = sorted([x for x in contracts if x.right == OptionRight.CALL], key=lambda x: x.strike)
if not puts or not calls:
return
# Logic:
# Short Strikes: ~5 points OTM
# Long Strikes (Protection): ~10 points OTM (5 points width wings)
# Find Short Put (Strike < Price)
short_put = min(puts, key=lambda x: abs(x.strike - (underlying_price - 5)))
# Find Long Put (Strike < Short Put)
long_put = min(puts, key=lambda x: abs(x.strike - (underlying_price - 10)))
# Find Short Call (Strike > Price)
short_call = min(calls, key=lambda x: abs(x.strike - (underlying_price + 5)))
# Find Long Call (Strike > Short Call)
long_call = min(calls, key=lambda x: abs(x.strike - (underlying_price + 10)))
# Validation: Ensure strikes are distinct and ordered correctly for an Iron Condor
# Order: Long Put < Short Put < Price < Short Call < Long Call
if not (long_put.strike < short_put.strike < underlying_price < short_call.strike < long_call.strike):
self.debug(f"Strikes not aligned for Iron Condor at {self.time}. Skipping.")
return
# 6. Execute Strategy
# QuantConnect provides a helper 'OptionStrategies.IronCondor' to bundle the legs
iron_condor = OptionStrategies.iron_condor(
self.option_symbol,
long_put.strike,
short_put.strike,
short_call.strike,
long_call.strike,
expiry
)
self.buy(iron_condor, 1)
self.debug(f"Opened Iron Condor at {self.time}: "
f"Long Put {long_put.strike} | Short Put {short_put.strike} | "
f"Short Call {short_call.strike} | Long Call {long_call.strike}")
def on_order_event(self, order_event):
# Log fills for debugging
if order_event.status == OrderStatus.FILLED:
self.log(f"Order Filled: {order_event}")
Key Implementation Details
-
Universe Filtering (
set_filter):- We restrict the data feed to options expiring between 30 and 45 days. This is the "sweet spot" for Theta decay strategies like Iron Condors.
- We restrict strikes to +/- 20 levels from the current price to reduce memory usage and processing time.
-
Strike Selection:
- The code separates Puts and Calls.
- It targets a Short Strike roughly $5 out-of-the-money (OTM) and a Long Strike roughly $10 OTM.
- This creates "wings" that are $5 wide.
- Note: In a production environment, you might use Delta (e.g., Sell 20 Delta, Buy 10 Delta) instead of fixed price distances, but fixed distance is safer for structural understanding.
-
OptionStrategies.iron_condor:- Instead of manually sending 4 separate
MarketOrderorLimitOrdercommands, we use the QuantConnect helper. - This ensures the system understands these 4 legs belong together, which helps with margin calculation and execution logic.
- Instead of manually sending 4 separate
-
Exit Logic:
- In this basic example, the algorithm holds the position until expiration (or until the options are exercised/assigned).
- The
if self.portfolio.invested: returnline prevents opening new positions while one is active. Once the options expire and disappear from the portfolio, the algorithm will look for a new setup.
Common Questions & Answers
Q: How do I change the width of the wings?
A: Modify the subtraction/addition logic in the strike selection section. For example, change underlying_price - 10 to underlying_price - 15 to make the wings wider (increasing risk but also increasing probability of profit).
Q: How can I add a profit taker (e.g., close at 50% profit)?
A: You would need to track the Ticket returned by self.Buy. In OnData, check the current unrealized profit of the strategy. If it exceeds 50% of the credit received, use self.Liquidate() to close the position.
Q: Why use DataNormalizationMode.RAW?
A: When trading options, it is critical to use Raw data for the underlying equity. Adjusted data (which accounts for splits and dividends retroactively) will cause the strike prices of historical options to misalign with the underlying price data.
Q: Can I use Delta for selection instead of price?
A: Yes. You would need to ensure the option chain contains Greeks (which is standard in QC backtests). You would replace the min(key=lambda x: abs(x.strike...)) logic with min(key=lambda x: abs(x.greeks.delta - 0.20)) to find the 20 Delta contract.