The issue is that Combo Leg Limit order is not filled with same parameters in same situation where Limit orders for individual legs are filled. Why?
Debug code 1: Individual legs Limit Order (This fills both orders in backtest)
from AlgorithmImports import *
from datetime import datetime, timedelta
class AlabChainCacheHistoryDebug(QCAlgorithm):
"""
Minimal debug algorithm to compare, at specific times:
- OptionContract.BidPrice from slice.OptionChains (chain contracts)
- Securities[contract].BidPrice / AskPrice (security cache)
- History(QuoteBar, contract, ...) snapshot
Target window: 2025-01-06 10:02–10:04 (America/New_York).
"""
def Initialize(self) -> None:
# Cover the original entry (Jan 2) and exit (Jan 6) window
self.SetStartDate(2025, 1, 2)
self.SetEndDate(2025, 1, 6)
self.SetCash(100000)
# Ensure algorithm time is US/Eastern to match your earlier logs
self.SetTimeZone("America/New_York")
# Subscriptions
self.alab = self.AddEquity("ALAB", Resolution.Minute).Symbol
# Canonical option subscription so we get OptionChains in the Slice
self.alab_opt = self.AddOption("ALAB", Resolution.Minute)
self.alab_opt.SetFilter(-50, 50, timedelta(days=0), timedelta(days=60))
self.alab_opt_symbol = self.alab_opt.Symbol
# Specific contracts
self.call_osi = "ALAB 250117C00150000"
self.put_osi = "ALAB 250117P00115000"
self.call_symbol = SymbolRepresentation.ParseOptionTickerOSI(
self.call_osi, SecurityType.Option, OptionStyle.American, Market.USA
)
self.put_symbol = SymbolRepresentation.ParseOptionTickerOSI(
self.put_osi, SecurityType.Option, OptionStyle.American, Market.USA
)
# Subscribe to the individual contracts too (so Securities cache has direct contract data)
self.AddOptionContract(self.call_symbol, Resolution.Minute)
self.AddOptionContract(self.put_symbol, Resolution.Minute)
# Reduce noise / synthetic bars
self.Securities[self.alab].SetDataNormalizationMode(DataNormalizationMode.Raw)
self.Securities[self.call_symbol].SetDataNormalizationMode(DataNormalizationMode.Raw)
self.Securities[self.put_symbol].SetDataNormalizationMode(DataNormalizationMode.Raw)
self.Settings.FillForward = False
self._printed = set()
# State for entry/exit sequence
self._legs_bought = False
self._legs_sold = False
def OnData(self, slice: Slice) -> None:
# 1) Always compute chain and cache quotes so we can reuse them for different dates
chain = slice.OptionChains.get(self.alab_opt_symbol)
call_chain_bid = None
put_chain_bid = None
if chain is not None:
contracts_iter = getattr(chain, "Contracts", None)
if contracts_iter is not None and hasattr(contracts_iter, "values"):
contracts_iter = contracts_iter.values()
else:
contracts_iter = chain
try:
contracts_list = list(contracts_iter)
except Exception:
contracts_list = []
call_contract = next((c for c in contracts_list if getattr(c, "Symbol", None) == self.call_symbol), None)
put_contract = next((c for c in contracts_list if getattr(c, "Symbol", None) == self.put_symbol), None)
call_chain_bid = getattr(call_contract, "BidPrice", None) if call_contract is not None else None
put_chain_bid = getattr(put_contract, "BidPrice", None) if put_contract is not None else None
# 2) Security cache bids/asks (what fill models typically reference)
call_sec = self.Securities.get(self.call_symbol)
put_sec = self.Securities.get(self.put_symbol)
call_cache_bid = float(call_sec.BidPrice) if call_sec is not None and call_sec.BidPrice is not None else None
call_cache_ask = float(call_sec.AskPrice) if call_sec is not None and call_sec.AskPrice is not None else None
put_cache_bid = float(put_sec.BidPrice) if put_sec is not None and put_sec.BidPrice is not None else None
put_cache_ask = float(put_sec.AskPrice) if put_sec is not None and put_sec.AskPrice is not None else None
# 3) On Jan 6, 10:02–10:04, log the same window diagnostics as before
if (
self.Time.year == 2025
and self.Time.month == 1
and self.Time.day == 6
and self.Time.hour == 10
and self.Time.minute in (2, 3, 4)
):
key = self.Time
if key not in self._printed:
self._printed.add(key)
self.Debug(
"ALAB dbg {} chain_bid(call,put)=({}, {}) cache_bidask(call)=({},{}) cache_bidask(put)=({},{}) chain_in_slice={}".format(
self.Time,
(float(call_chain_bid) if call_chain_bid is not None else None),
(float(put_chain_bid) if put_chain_bid is not None else None),
call_cache_bid,
call_cache_ask,
put_cache_bid,
put_cache_ask,
chain is not None,
)
)
try:
h_call = self.History(QuoteBar, self.call_symbol, 3, Resolution.Minute)
h_put = self.History(QuoteBar, self.put_symbol, 3, Resolution.Minute)
# In python, History returns a pandas DataFrame in QC; we print a compact tail.
if h_call is not None and hasattr(h_call, "tail"):
self.Debug("ALAB dbg hist call tail:\n{}".format(h_call.tail(3)))
else:
self.Debug("ALAB dbg hist call: {}".format(h_call))
if h_put is not None and hasattr(h_put, "tail"):
self.Debug("ALAB dbg hist put tail:\n{}".format(h_put.tail(3)))
else:
self.Debug("ALAB dbg hist put: {}".format(h_put))
except Exception as e:
self.Debug("ALAB dbg history error: {}".format(e))
# 4) On Jan 2, 15:32, open one contract per leg with per-leg limit BUY at the current ask
if (
self.Time.year == 2025
and self.Time.month == 1
and self.Time.day == 2
and self.Time.hour == 15
and self.Time.minute == 32
and not self._legs_bought
):
if (
call_cache_ask is not None
and put_cache_ask is not None
and call_cache_ask > 0
and put_cache_ask > 0
):
try:
self.Debug(
"ALAB dbg submitting entry per-leg buys at {} call_ask={} put_ask={}".format(
self.Time, call_cache_ask, put_cache_ask
)
)
# Buy 1 call and 1 put at the current ask
self.LimitOrder(
self.call_symbol,
1,
call_cache_ask,
tag="ALAB dbg entry call leg",
)
self.LimitOrder(
self.put_symbol,
1,
put_cache_ask,
tag="ALAB dbg entry put leg",
)
self._legs_bought = True
except Exception as e:
self.Debug("ALAB dbg entry per-leg submit error: {}".format(e))
# 5) On Jan 6, 10:03, close the legs with per-leg limit SELL at the current bid (Exit-style)
if (
self.Time.year == 2025
and self.Time.month == 1
and self.Time.day == 6
and self.Time.hour == 10
and self.Time.minute == 3
and self._legs_bought
and not self._legs_sold
):
if (
call_cache_bid is not None
and put_cache_bid is not None
and call_cache_bid > 0
and put_cache_bid > 0
):
try:
self.Debug(
"ALAB dbg submitting exit per-leg sells at {} call_bid={} put_bid={}".format(
self.Time, call_cache_bid, put_cache_bid
)
)
self.LimitOrder(
self.call_symbol,
-1,
call_cache_bid,
tag="ALAB dbg exit call leg",
)
self.LimitOrder(
self.put_symbol,
-1,
put_cache_bid,
tag="ALAB dbg exit put leg",
)
self._legs_sold = True
except Exception as e:
self.Debug("ALAB dbg exit per-leg submit error: {}".format(e))
Orders log:
Debug code 2: Combo Leg Limit Order (Order remains in Submitted state in backtest, does not fill)
from AlgorithmImports import *
from datetime import datetime, timedelta
class AlabChainCacheHistoryDebug(QCAlgorithm):
"""
Minimal debug algorithm to compare, at specific times:
- OptionContract.BidPrice from slice.OptionChains (chain contracts)
- Securities[contract].BidPrice / AskPrice (security cache)
- History(QuoteBar, contract, ...) snapshot
Target window: 2025-01-06 10:02–10:04 (America/New_York).
"""
def Initialize(self) -> None:
# Cover the original entry (Jan 2) and exit (Jan 6) window
self.SetStartDate(2025, 1, 2)
self.SetEndDate(2025, 1, 6)
self.SetCash(100000)
# Ensure algorithm time is US/Eastern to match your earlier logs
self.SetTimeZone("America/New_York")
# Subscriptions
self.alab = self.AddEquity("ALAB", Resolution.Minute).Symbol
# Canonical option subscription so we get OptionChains in the Slice
self.alab_opt = self.AddOption("ALAB", Resolution.Minute)
self.alab_opt.SetFilter(-50, 50, timedelta(days=0), timedelta(days=60))
self.alab_opt_symbol = self.alab_opt.Symbol
# Specific contracts
self.call_osi = "ALAB 250117C00150000"
self.put_osi = "ALAB 250117P00115000"
self.call_symbol = SymbolRepresentation.ParseOptionTickerOSI(
self.call_osi, SecurityType.Option, OptionStyle.American, Market.USA
)
self.put_symbol = SymbolRepresentation.ParseOptionTickerOSI(
self.put_osi, SecurityType.Option, OptionStyle.American, Market.USA
)
# Subscribe to the individual contracts too (so Securities cache has direct contract data)
self.AddOptionContract(self.call_symbol, Resolution.Minute)
self.AddOptionContract(self.put_symbol, Resolution.Minute)
# Reduce noise / synthetic bars
self.Securities[self.alab].SetDataNormalizationMode(DataNormalizationMode.Raw)
self.Securities[self.call_symbol].SetDataNormalizationMode(DataNormalizationMode.Raw)
self.Securities[self.put_symbol].SetDataNormalizationMode(DataNormalizationMode.Raw)
self.Settings.FillForward = False
self._printed = set()
# State for entry/exit sequence
self._legs_bought = False
self._legs_sold = False
def OnData(self, slice: Slice) -> None:
# 1) Always compute chain and cache quotes so we can reuse them for different dates
chain = slice.OptionChains.get(self.alab_opt_symbol)
call_chain_bid = None
put_chain_bid = None
if chain is not None:
contracts_iter = getattr(chain, "Contracts", None)
if contracts_iter is not None and hasattr(contracts_iter, "values"):
contracts_iter = contracts_iter.values()
else:
contracts_iter = chain
try:
contracts_list = list(contracts_iter)
except Exception:
contracts_list = []
call_contract = next((c for c in contracts_list if getattr(c, "Symbol", None) == self.call_symbol), None)
put_contract = next((c for c in contracts_list if getattr(c, "Symbol", None) == self.put_symbol), None)
call_chain_bid = getattr(call_contract, "BidPrice", None) if call_contract is not None else None
put_chain_bid = getattr(put_contract, "BidPrice", None) if put_contract is not None else None
# 2) Security cache bids/asks (what fill models typically reference)
call_sec = self.Securities.get(self.call_symbol)
put_sec = self.Securities.get(self.put_symbol)
call_cache_bid = float(call_sec.BidPrice) if call_sec is not None and call_sec.BidPrice is not None else None
call_cache_ask = float(call_sec.AskPrice) if call_sec is not None and call_sec.AskPrice is not None else None
put_cache_bid = float(put_sec.BidPrice) if put_sec is not None and put_sec.BidPrice is not None else None
put_cache_ask = float(put_sec.AskPrice) if put_sec is not None and put_sec.AskPrice is not None else None
# 3) On Jan 6, 10:02–10:04, log the same window diagnostics as before
if (
self.Time.year == 2025
and self.Time.month == 1
and self.Time.day == 6
and self.Time.hour == 10
and self.Time.minute in (2, 3, 4)
):
key = self.Time
if key not in self._printed:
self._printed.add(key)
self.Debug(
"ALAB dbg {} chain_bid(call,put)=({}, {}) cache_bidask(call)=({},{}) cache_bidask(put)=({},{}) chain_in_slice={}".format(
self.Time,
(float(call_chain_bid) if call_chain_bid is not None else None),
(float(put_chain_bid) if put_chain_bid is not None else None),
call_cache_bid,
call_cache_ask,
put_cache_bid,
put_cache_ask,
chain is not None,
)
)
try:
h_call = self.History(QuoteBar, self.call_symbol, 3, Resolution.Minute)
h_put = self.History(QuoteBar, self.put_symbol, 3, Resolution.Minute)
# In python, History returns a pandas DataFrame in QC; we print a compact tail.
if h_call is not None and hasattr(h_call, "tail"):
self.Debug("ALAB dbg hist call tail:\n{}".format(h_call.tail(3)))
else:
self.Debug("ALAB dbg hist call: {}".format(h_call))
if h_put is not None and hasattr(h_put, "tail"):
self.Debug("ALAB dbg hist put tail:\n{}".format(h_put.tail(3)))
else:
self.Debug("ALAB dbg hist put: {}".format(h_put))
except Exception as e:
self.Debug("ALAB dbg history error: {}".format(e))
# 4) On Jan 2, 15:32, open the pair with a ComboLegLimitOrder BUY at the current asks
if (
self.Time.year == 2025
and self.Time.month == 1
and self.Time.day == 2
and self.Time.hour == 15
and self.Time.minute == 32
and not self._legs_bought
):
if (
call_cache_ask is not None
and put_cache_ask is not None
and call_cache_ask > 0
and put_cache_ask > 0
):
try:
self.Debug(
"ALAB dbg submitting ENTRY combo at {} call_ask={} put_ask={}".format(
self.Time, call_cache_ask, put_cache_ask
)
)
# Buy 1 call and 1 put as a combo at the current asks
entry_legs = [
Leg.Create(self.call_symbol, 1, call_cache_ask),
Leg.Create(self.put_symbol, 1, put_cache_ask),
]
self.ComboLegLimitOrder(entry_legs, 1, tag="ALAB dbg ENTRY combo")
self._legs_bought = True
except Exception as e:
self.Debug("ALAB dbg ENTRY combo submit error: {}".format(e))
# 5) On Jan 6, 10:03, close the pair with a ComboLegLimitOrder SELL at the current bids (Exit-style)
if (
self.Time.year == 2025
and self.Time.month == 1
and self.Time.day == 6
and self.Time.hour == 10
and self.Time.minute == 3
and self._legs_bought
and not self._legs_sold
):
if (
call_cache_bid is not None
and put_cache_bid is not None
and call_cache_bid > 0
and put_cache_bid > 0
):
try:
self.Debug(
"ALAB dbg submitting EXIT combo at {} call_bid={} put_bid={}".format(
self.Time, call_cache_bid, put_cache_bid
)
)
exit_legs = [
Leg.Create(self.call_symbol, -1, call_cache_bid),
Leg.Create(self.put_symbol, -1, put_cache_bid),
]
self.ComboLegLimitOrder(exit_legs, 1, tag="ALAB dbg EXIT combo")
self._legs_sold = True
except Exception as e:
self.Debug("ALAB dbg EXIT combo submit error: {}".format(e))
Orders log:
Xon Offtree
Ok, I believe I found the answer to this myself. It's related to LEAN ImmediateFillModel, which is explained here:
https://www.quantconnect.com/docs/v2/writing-algorithms/reality-modeling/trade-fills/supported-models/immediate-model
The reason is that with Combo Leg Limit Order both legs need to satisfy Ask low price < limit price for buy orders and Bid high price > limit price for sell orders simultaneously (in practice, within the same bar), whereas with Limit Order each leg needs to satisfy the condition individually.
Xon Offtree
The material on this website is provided for informational purposes only and does not constitute an offer to sell, a solicitation to buy, or a recommendation or endorsement for any security or strategy, nor does it constitute an offer to provide investment advisory services by QuantConnect. In addition, the material offers no opinion with respect to the suitability of any security or specific investment. QuantConnect makes no guarantees as to the accuracy or completeness of the views expressed in the website. The views are subject to change, and may have become unreliable for various reasons, including changes in market conditions or economic circumstances. All investments involve risk, including loss of principal. You should consult with an investment professional before making any investment decisions.
To unlock posting to the community forums please complete at least 30% of Boot Camp.
You can continue your Boot Camp training progress from the terminal. We hope to see you in the community soon!