From f57b62fb72895d735316a43cf2a81791bdf6b429 Mon Sep 17 00:00:00 2001 From: Jhonathan Abreu Date: Tue, 11 Aug 2026 19:58:07 -0400 Subject: [PATCH 1/2] Fall back to per-leg margin when option strategy margin cannot be computed Legging into multi-leg option positions with sequential market orders hard-crashed algorithms with "Sequence contains no matching element" when a margin call probed a degenerate trial position group, and matched strategies without margin modeling (backspreads) threw NotImplementedException. OptionStrategyPositionGroupBuyingPowerModel now falls back to margining each leg individually instead of throwing, logging the condition once. Also adds an OptionLegData (quantity, right, strike, expiration) constructor so strategy legs can be built from Python with either date or datetime expiry, and a one-time hint suggesting combo orders when market orders leg into option positions. --- ...StrategiesMarginCallRegressionAlgorithm.cs | 204 ++++++++++++++++++ ...StrategiesMarginCallRegressionAlgorithm.py | 107 +++++++++ Algorithm/QCAlgorithm.Trading.cs | 16 ++ Common/Securities/Option/OptionStrategy.cs | 30 ++- ...onStrategyPositionGroupBuyingPowerModel.cs | 127 ++++++++++- ...ategyPositionGroupBuyingPowerModelTests.cs | 65 ++++++ .../Options/OptionStrategiesTests.cs | 47 ++++ 7 files changed, 587 insertions(+), 9 deletions(-) create mode 100644 Algorithm.CSharp/LeggedInOptionStrategiesMarginCallRegressionAlgorithm.cs create mode 100644 Algorithm.Python/LeggedInOptionStrategiesMarginCallRegressionAlgorithm.py diff --git a/Algorithm.CSharp/LeggedInOptionStrategiesMarginCallRegressionAlgorithm.cs b/Algorithm.CSharp/LeggedInOptionStrategiesMarginCallRegressionAlgorithm.cs new file mode 100644 index 000000000000..896e65e3c973 --- /dev/null +++ b/Algorithm.CSharp/LeggedInOptionStrategiesMarginCallRegressionAlgorithm.cs @@ -0,0 +1,204 @@ +/* + * QUANTCONNECT.COM - Democratizing Finance, Empowering Individuals. + * Lean Algorithmic Trading Engine v2.0. Copyright 2014 QuantConnect Corporation. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. +*/ + +using System; +using System.Collections.Generic; +using System.Linq; +using QuantConnect.Data; +using QuantConnect.Data.Market; +using QuantConnect.Interfaces; +using QuantConnect.Orders; +using QuantConnect.Securities.Option.StrategyMatcher; + +namespace QuantConnect.Algorithm.CSharp +{ + /// + /// Regression algorithm legging into multiple single-lot option strategy position groups with sequential + /// market orders and then going through a margin call that requires a partial reduction of the groups. + /// The margin call order quantity calculation probes degenerate (zero-quantity) trial groups, which used to + /// crash the algorithm with "Sequence contains no matching element" in OptionStrategyPositionGroupBuyingPowerModel. + /// + public class LeggedInOptionStrategiesMarginCallRegressionAlgorithm : QCAlgorithm, IRegressionAlgorithmDefinition + { + private Symbol _optionSymbol; + private bool _legged; + private bool _cashDropped; + private int _onMarginCallCount; + + public override void Initialize() + { + SetStartDate(2015, 12, 24); + SetEndDate(2015, 12, 24); + SetCash(200000); + + var equity = AddEquity("GOOG", leverage: 4); + var option = AddOption(equity.Symbol); + _optionSymbol = option.Symbol; + + option.SetFilter(u => u.StandardsOnly().Strikes(-2, +2).Expiration(0, 180)); + } + + public override void OnData(Slice slice) + { + if (!_legged) + { + OptionChain chain; + if (IsMarketOpen(_optionSymbol) && slice.OptionChains.TryGetValue(_optionSymbol, out chain)) + { + var contractsByExpiry = chain.GroupBy(x => x.Expiry).OrderBy(x => x.Key).ToList(); + + // A put spread at the nearest expiry: long the lowest strike put, short the next one + var puts = contractsByExpiry[0].Where(contract => contract.Right == OptionRight.Put) + .OrderBy(contract => contract.Strike) + .ToList(); + var longPut = puts[0]; + var shortPut = puts.First(contract => contract.Strike > longPut.Strike); + + // And a call spread at another expiry so two separate strategy groups are resolved + var calls = contractsByExpiry + .Skip(1) + .Select(x => x.Where(contract => contract.Right == OptionRight.Call).OrderBy(contract => contract.Strike).ToList()) + .First(x => x.Count > 1); + var shortCall = calls[0]; + var longCall = calls.First(contract => contract.Strike > shortCall.Strike); + + // Leg into the strategies with individual market orders instead of combo orders + MarketOrder(shortCall.Symbol, -1); + MarketOrder(longCall.Symbol, +1); + MarketOrder(shortPut.Symbol, -1); + MarketOrder(longPut.Symbol, +1); + _legged = true; + + AssertOptionStrategyIsPresent(OptionStrategyDefinitions.BearCallSpread.Name); + AssertOptionStrategyIsPresent(OptionStrategyDefinitions.BullPutSpread.Name); + } + return; + } + + if (!_cashDropped && Portfolio.Invested) + { + // Simulate a drawdown: equity drops below the margin used by the strategy groups so that the + // margin call model requests a partial reduction of the single-lot position groups + var cash = Portfolio.CashBook[Currencies.USD].Amount; + Portfolio.CashBook[Currencies.USD].SetAmount(cash - Portfolio.TotalPortfolioValue + 0.6m * Portfolio.TotalMarginUsed); + _cashDropped = true; + } + } + + public override void OnMarginCall(List requests) + { + _onMarginCallCount++; + + foreach (var request in requests) + { + var holdingsQuantity = Securities[request.Symbol].Holdings.Quantity; + if (request.Quantity != -holdingsQuantity) + { + throw new RegressionTestException($@"Expected margin call order for {request.Symbol} to fully liquidate the {holdingsQuantity + } holdings, but its quantity was {request.Quantity}"); + } + } + } + + public override void OnEndOfAlgorithm() + { + if (_onMarginCallCount != 1) + { + throw new RegressionTestException($"OnMarginCall was called {_onMarginCallCount} times, expected 1"); + } + + var orders = Transactions.GetOrders().ToList(); + if (orders.Count <= 4) + { + throw new RegressionTestException( + $"Expected margin call orders in addition to the 4 strategy leg entries, but found {orders.Count} orders in total"); + } + + if (orders.Any(order => !order.Status.IsFill())) + { + throw new RegressionTestException("All orders should be filled"); + } + } + + private void AssertOptionStrategyIsPresent(string name) + { + if (Portfolio.Positions.Groups.Count(group => + group.BuyingPowerModel is Securities.Option.OptionStrategyPositionGroupBuyingPowerModel model && model.ToString() == name) != 1) + { + throw new RegressionTestException($"Option strategy: '{name}' was not found!"); + } + } + + /// + /// This is used by the regression test system to indicate if the open source Lean repository has the required data to run this algorithm. + /// + public bool CanRunLocally { get; } = true; + + /// + /// This is used by the regression test system to indicate which languages this algorithm is written in. + /// + public List Languages { get; } = new() { Language.CSharp, Language.Python }; + + /// + /// Data Points count of all timeslices of algorithm + /// + public long DataPoints => 15023; + + /// + /// Data Points count of the algorithm history + /// + public int AlgorithmHistoryDataPoints => 0; + + /// + /// Final status of the algorithm + /// + public AlgorithmStatus AlgorithmStatus => AlgorithmStatus.Completed; + + /// + /// This is used by the regression test system to indicate what the expected statistics are from running the algorithm + /// + public Dictionary ExpectedStatistics => new Dictionary + { + {"Total Orders", "6"}, + {"Average Win", "0%"}, + {"Average Loss", "0%"}, + {"Compounding Annual Return", "0%"}, + {"Drawdown", "0%"}, + {"Expectancy", "0"}, + {"Start Equity", "200000"}, + {"End Equity", "313"}, + {"Net Profit", "0%"}, + {"Sharpe Ratio", "0"}, + {"Sortino Ratio", "0"}, + {"Probabilistic Sharpe Ratio", "0%"}, + {"Loss Rate", "0%"}, + {"Win Rate", "0%"}, + {"Profit-Loss Ratio", "0"}, + {"Alpha", "0"}, + {"Beta", "0"}, + {"Annual Standard Deviation", "0"}, + {"Annual Variance", "0"}, + {"Information Ratio", "0"}, + {"Tracking Error", "0"}, + {"Treynor Ratio", "0"}, + {"Total Fees", "$6.00"}, + {"Estimated Strategy Capacity", "$250000.00"}, + {"Lowest Capacity Asset", "GOOCV W87G1Y7EJGW6|GOOCV VP83T1ZUHROL"}, + {"Portfolio Turnover", "5146.96%"}, + {"Drawdown Recovery", "0"}, + {"OrderListHash", "60f3e2ec37bcb6c5ccdcce8fbb14fe22"} + }; + } +} diff --git a/Algorithm.Python/LeggedInOptionStrategiesMarginCallRegressionAlgorithm.py b/Algorithm.Python/LeggedInOptionStrategiesMarginCallRegressionAlgorithm.py new file mode 100644 index 000000000000..c498117c7e30 --- /dev/null +++ b/Algorithm.Python/LeggedInOptionStrategiesMarginCallRegressionAlgorithm.py @@ -0,0 +1,107 @@ +# QUANTCONNECT.COM - Democratizing Finance, Empowering Individuals. +# Lean Algorithmic Trading Engine v2.0. Copyright 2014 QuantConnect Corporation. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +from AlgorithmImports import * + +### +### Regression algorithm legging into multiple single-lot option strategy position groups with sequential +### market orders and then going through a margin call that requires a partial reduction of the groups. +### The margin call order quantity calculation probes degenerate (zero-quantity) trial groups, which used to +### crash the algorithm with "Sequence contains no matching element" in OptionStrategyPositionGroupBuyingPowerModel. +### +class LeggedInOptionStrategiesMarginCallRegressionAlgorithm(QCAlgorithm): + def initialize(self): + self.set_start_date(2015, 12, 24) + self.set_end_date(2015, 12, 24) + self.set_cash(200000) + + equity = self.add_equity("GOOG", leverage=4) + option = self.add_option(equity.symbol) + self._option_symbol = option.symbol + option.set_filter(lambda u: u.standards_only().strikes(-2, +2).expiration(0, 180)) + + self._legged = False + self._cash_dropped = False + self._on_margin_call_count = 0 + + def on_data(self, slice): + if not self._legged: + chain = slice.option_chains.get(self._option_symbol) + if not self.is_market_open(self._option_symbol) or not chain: + return + + contracts_by_expiry = {} + for contract in chain: + contracts_by_expiry.setdefault(contract.expiry, []).append(contract) + expiries = sorted(contracts_by_expiry.keys()) + + # A put spread at the nearest expiry: long the lowest strike put, short the next one + puts = sorted([x for x in contracts_by_expiry[expiries[0]] if x.right == OptionRight.PUT], + key=lambda x: x.strike) + long_put = puts[0] + short_put = next(x for x in puts if x.strike > long_put.strike) + + # And a call spread at another expiry so two separate strategy groups are resolved + calls = next(c for c in + (sorted([x for x in contracts_by_expiry[expiry] if x.right == OptionRight.CALL], key=lambda x: x.strike) + for expiry in expiries[1:]) + if len(c) > 1) + short_call = calls[0] + long_call = next(x for x in calls if x.strike > short_call.strike) + + # Leg into the strategies with individual market orders instead of combo orders + self.market_order(short_call.symbol, -1) + self.market_order(long_call.symbol, +1) + self.market_order(short_put.symbol, -1) + self.market_order(long_put.symbol, +1) + self._legged = True + + self.assert_option_strategy_is_present("Bear Call Spread") + self.assert_option_strategy_is_present("Bull Put Spread") + return + + if not self._cash_dropped and self.portfolio.invested: + # Simulate a drawdown: equity drops below the margin used by the strategy groups so that the + # margin call model requests a partial reduction of the single-lot position groups + cash = self.portfolio.cash_book[Currencies.USD].amount + self.portfolio.cash_book[Currencies.USD].set_amount( + cash - self.portfolio.total_portfolio_value + 0.6 * self.portfolio.total_margin_used) + self._cash_dropped = True + + def on_margin_call(self, requests): + self._on_margin_call_count += 1 + + for request in requests: + holdings_quantity = self.securities[request.symbol].holdings.quantity + if request.quantity != -holdings_quantity: + raise Exception(f"Expected margin call order for {request.symbol} to fully liquidate the " + f"{holdings_quantity} holdings, but its quantity was {request.quantity}") + + return requests + + def on_end_of_algorithm(self): + if self._on_margin_call_count != 1: + raise Exception(f"OnMarginCall was called {self._on_margin_call_count} times, expected 1") + + orders = list(self.transactions.get_orders()) + if len(orders) <= 4: + raise Exception(f"Expected margin call orders in addition to the 4 strategy leg entries, " + f"but found {len(orders)} orders in total") + + if any(x.status != OrderStatus.FILLED for x in orders): + raise Exception("All orders should be filled") + + def assert_option_strategy_is_present(self, name): + if sum(1 for group in self.portfolio.positions.groups + if str(group.buying_power_model) == name) != 1: + raise Exception(f"Option strategy: '{name}' was not found!") diff --git a/Algorithm/QCAlgorithm.Trading.cs b/Algorithm/QCAlgorithm.Trading.cs index ddba02f0249c..864f994014fb 100644 --- a/Algorithm/QCAlgorithm.Trading.cs +++ b/Algorithm/QCAlgorithm.Trading.cs @@ -36,6 +36,7 @@ public partial class QCAlgorithm private bool _isGtdTfiForMooAndMocOrdersValidationWarningSent; private bool _isOptionsOrderOnStockSplitWarningSent; private bool _liquidateSymbolNotFoundWarningSent; + private bool _isSequentialOptionLegOrdersWarningSent; /// /// Transaction Manager - Process transaction fills and order management. @@ -280,6 +281,21 @@ public OrderTicket MarketOrder(Symbol symbol, decimal quantity, bool asynchronou } } + // Legging into multi-leg option positions with sequential market orders works but exposes the user to + // execution risk between fills and to naked margin on the intermediate positions. Hint at combo orders once + if (!_isSequentialOptionLegOrdersWarningSent && security.Type.IsOption()) + { + var canonical = security.Symbol.Canonical; + if (Portfolio.Positions.Groups.Any(group => group.Positions.Any(position => position.Symbol != security.Symbol + && position.Symbol.SecurityType.IsOption() && position.Symbol.Canonical == canonical))) + { + Debug("Warning: detected market orders on individual option contracts while already holding other contracts of the " + + "same option chain. To enter a multi-leg option position atomically and get option strategy margin benefits, " + + "consider using a combo order (ComboMarketOrder) or an OptionStrategies helper with Buy/Sell instead."); + _isSequentialOptionLegOrdersWarningSent = true; + } + } + var request = CreateSubmitOrderRequest(OrderType.Market, security, quantity, tag, orderProperties ?? DefaultOrderProperties?.Clone(), asynchronous); //Add the order and create a new order Id. diff --git a/Common/Securities/Option/OptionStrategy.cs b/Common/Securities/Option/OptionStrategy.cs index 96fb513df383..fe9efa149340 100644 --- a/Common/Securities/Option/OptionStrategy.cs +++ b/Common/Securities/Option/OptionStrategy.cs @@ -61,7 +61,7 @@ public OptionStrategy(string name, Symbol canonicalSymbol, List o { Name = name; CanonicalOption = canonicalSymbol; - Underlying = canonicalSymbol.Underlying; + Underlying = canonicalSymbol?.Underlying; OptionLegs = optionLegs ?? new List(); UnderlyingLegs = underlyingLegs ?? new List(); @@ -128,7 +128,8 @@ public static OptionStrategy Create(string name, IEnumerable legs) { optionLegs.Add(optionLeg); - if (canonicalSymbol == null) + // legs created from strike/expiration data don't have a symbol until the strategy is traded + if (canonicalSymbol == null && optionLeg.Symbol != null) { canonicalSymbol = optionLeg.Symbol.Canonical; } @@ -162,6 +163,31 @@ public class OptionLegData : Leg /// public decimal Strike { get; set; } + /// + /// Creates a new instance of + /// + public OptionLegData() + { + } + + /// + /// Creates a new instance of from the specified parameters. + /// The leg symbol is created from the strategy's canonical option symbol when the strategy is traded + /// + /// The quantity of the leg + /// The option right of the leg + /// The strike price of the leg + /// The expiration date of the leg + /// Optional order limit price of the leg + public OptionLegData(int quantity, OptionRight right, decimal strike, DateTime expiration, decimal? orderPrice = null) + { + Quantity = quantity; + Right = right; + Strike = strike; + Expiration = expiration; + OrderPrice = orderPrice; + } + /// /// Creates a new instance of from the specified parameters /// diff --git a/Common/Securities/Option/OptionStrategyPositionGroupBuyingPowerModel.cs b/Common/Securities/Option/OptionStrategyPositionGroupBuyingPowerModel.cs index c5f0d1f41622..791eb1068a06 100644 --- a/Common/Securities/Option/OptionStrategyPositionGroupBuyingPowerModel.cs +++ b/Common/Securities/Option/OptionStrategyPositionGroupBuyingPowerModel.cs @@ -15,9 +15,11 @@ using System; using System.Linq; +using QuantConnect.Logging; using QuantConnect.Orders.Fees; using QuantConnect.Securities.Positions; using QuantConnect.Securities.Option.StrategyMatcher; +using System.Collections.Concurrent; using System.Collections.Generic; using QuantConnect.Orders; @@ -31,6 +33,9 @@ namespace QuantConnect.Securities.Option /// public class OptionStrategyPositionGroupBuyingPowerModel : PositionGroupBuyingPowerModel { + // one entry per strategy name and caller so the per-leg margin fallback is logged only once per case + private static readonly ConcurrentDictionary _perLegMarginFallbacksLogged = new(); + private readonly OptionStrategy _optionStrategy; /// @@ -60,7 +65,29 @@ public override MaintenanceMargin GetMaintenanceMargin(PositionGroupMaintenanceM // we could be liquidating a position return new MaintenanceMargin(0); } - else if (_optionStrategy.Name == OptionStrategyDefinitions.ProtectivePut.Name || _optionStrategy.Name == OptionStrategyDefinitions.ProtectiveCall.Name) + + try + { + return GetOptionStrategyMaintenanceMargin(parameters); + } + catch (InvalidOperationException exception) + { + // A position group whose legs don't fit the matched strategy's expected shape must not crash the algorithm: + // legging into spreads with sequential market orders hard-crashed production deployments with + // "Sequence contains no matching element" when a margin call probed a degenerate trial group + // (GH #9612 guarded the zero-quantity probes; this guards any remaining degenerate shape). + // Fall back to margining each leg individually, a conservative estimate that ignores leg offsets. + LogPerLegMarginFallback(nameof(GetMaintenanceMargin), exception.Message, parameters.PositionGroup); + return new MaintenanceMargin(GetPerLegMaintenanceMargin(parameters.PositionGroup, parameters.Portfolio)); + } + } + + /// + /// Gets the maintenance margin for the strategy modeled by this instance + /// + private MaintenanceMargin GetOptionStrategyMaintenanceMargin(PositionGroupMaintenanceMarginParameters parameters) + { + if (_optionStrategy.Name == OptionStrategyDefinitions.ProtectivePut.Name || _optionStrategy.Name == OptionStrategyDefinitions.ProtectiveCall.Name) { // Minimum (((10% * Call/Put Strike Price) + Call/Put Out of the Money Amount), Short Stock/Long Maintenance Requirement) var optionPosition = parameters.PositionGroup.Positions.FirstOrDefault(position => position.Symbol.SecurityType.IsOption()); @@ -284,7 +311,11 @@ public override MaintenanceMargin GetMaintenanceMargin(PositionGroupMaintenanceM return GetPutLadderMargin(parameters, true); } - throw new NotImplementedException($"Option strategy {_optionStrategy.Name} margin modeling has yet to be implemented"); + // A strategy the matcher can produce but which has no margin modeling (e.g. the backspreads): + // margin each leg individually instead of crashing the algorithm + LogPerLegMarginFallback(nameof(GetMaintenanceMargin), + $"Option strategy {_optionStrategy.Name} margin modeling has yet to be implemented", parameters.PositionGroup); + return new MaintenanceMargin(GetPerLegMaintenanceMargin(parameters.PositionGroup, parameters.Portfolio)); } /// @@ -299,6 +330,28 @@ public override InitialMargin GetInitialMarginRequirement(PositionGroupInitialMa return OptionInitialMargin.Zero; } + try + { + return GetOptionStrategyInitialMargin(parameters); + } + catch (InvalidOperationException exception) + { + // A position group whose legs don't fit the matched strategy's expected shape must not crash the algorithm: + // legging into spreads with sequential market orders hard-crashed production deployments with + // "Sequence contains no matching element" when a margin call probed a degenerate trial group + // (GH #9612 guarded the zero-quantity probes; this guards any remaining degenerate shape). + // Fall back to margining each leg individually, a conservative estimate that ignores leg offsets. + LogPerLegMarginFallback(nameof(GetInitialMarginRequirement), exception.Message, parameters.PositionGroup); + return new OptionInitialMargin(GetPerLegInitialMargin(parameters.PositionGroup, parameters.Portfolio), + GetPositionGroupPremium(parameters.PositionGroup, parameters.Portfolio)); + } + } + + /// + /// Gets the initial margin required for the strategy modeled by this instance + /// + private InitialMargin GetOptionStrategyInitialMargin(PositionGroupInitialMarginParameters parameters) + { var result = 0m; if (_optionStrategy == null) @@ -423,18 +476,30 @@ public override InitialMargin GetInitialMarginRequirement(PositionGroupInitialMa } else { - throw new NotImplementedException($"Option strategy {_optionStrategy.Name} margin modeling has yet to be implemented"); + // A strategy the matcher can produce but which has no margin modeling (e.g. the backspreads): + // margin each leg individually instead of crashing the algorithm + LogPerLegMarginFallback(nameof(GetInitialMarginRequirement), + $"Option strategy {_optionStrategy.Name} margin modeling has yet to be implemented", parameters.PositionGroup); + result = GetPerLegInitialMargin(parameters.PositionGroup, parameters.Portfolio); } - // Add premium to initial margin only when it is positive (the user must pay the premium) + return new OptionInitialMargin(result, GetPositionGroupPremium(parameters.PositionGroup, parameters.Portfolio)); + } + + /// + /// Gets the premium of the option positions in the group, which is added to the initial margin + /// only when it is positive (the user must pay the premium) + /// + private static decimal GetPositionGroupPremium(IPositionGroup positionGroup, SecurityPortfolioManager portfolio) + { var premium = 0m; - foreach (var position in parameters.PositionGroup.Positions.Where(position => position.Symbol.SecurityType.IsOption())) + foreach (var position in positionGroup.Positions.Where(position => position.Symbol.SecurityType.IsOption())) { - var option = (Option)parameters.Portfolio.Securities[position.Symbol]; + var option = (Option)portfolio.Securities[position.Symbol]; premium += option.Holdings.GetQuantityValue(position.Quantity).InAccountCurrency; } - return new OptionInitialMargin(result, premium); + return premium; } /// @@ -667,5 +732,53 @@ private static decimal GetPutLadderMargin(PositionGroupMaintenanceMarginParamete return new MaintenanceMargin(Math.Abs(margin)); } } + + /// + /// Returns the sum of each leg's maintenance margin as if it was held outside of the group. + /// Used as a safe, conservative fallback when the strategy-specific margin cannot be computed + /// + private static decimal GetPerLegMaintenanceMargin(IPositionGroup positionGroup, SecurityPortfolioManager portfolio) + { + var margin = 0m; + foreach (var position in positionGroup.Positions) + { + var security = portfolio.Securities[position.Symbol]; + margin += Math.Abs(security.BuyingPowerModel.GetMaintenanceMargin( + MaintenanceMarginParameters.ForQuantityAtCurrentPrice(security, position.Quantity))); + } + + return margin; + } + + /// + /// Returns the sum of each leg's initial margin (without premium) as if it was held outside of the group. + /// Used as a safe, conservative fallback when the strategy-specific margin cannot be computed + /// + private static decimal GetPerLegInitialMargin(IPositionGroup positionGroup, SecurityPortfolioManager portfolio) + { + var margin = 0m; + foreach (var position in positionGroup.Positions) + { + var security = portfolio.Securities[position.Symbol]; + var initialMargin = security.BuyingPowerModel.GetInitialMarginRequirement(new InitialMarginParameters(security, position.Quantity)); + var optionInitialMargin = initialMargin as OptionInitialMargin; + margin += Math.Abs(optionInitialMargin?.ValueWithoutPremium ?? initialMargin); + } + + return margin; + } + + /// + /// Logs that the strategy margin could not be computed for a position group and each leg will be margined individually. + /// Logged only once per strategy and caller to avoid flooding, since these models are re-created on every group resolution + /// + private void LogPerLegMarginFallback(string caller, string reason, IPositionGroup positionGroup) + { + if (_perLegMarginFallbacksLogged.TryAdd($"{_optionStrategy?.Name}:{caller}", 0)) + { + Log.Error($"OptionStrategyPositionGroupBuyingPowerModel.{caller}(): unable to compute the strategy margin for the '{_optionStrategy?.Name}' " + + $"position group {positionGroup.Key}: {reason}. Falling back to margining each leg individually."); + } + } } } diff --git a/Tests/Common/Securities/OptionStrategyPositionGroupBuyingPowerModelTests.cs b/Tests/Common/Securities/OptionStrategyPositionGroupBuyingPowerModelTests.cs index 06a08ff224f8..98fb8129db7c 100644 --- a/Tests/Common/Securities/OptionStrategyPositionGroupBuyingPowerModelTests.cs +++ b/Tests/Common/Securities/OptionStrategyPositionGroupBuyingPowerModelTests.cs @@ -1151,6 +1151,71 @@ public void FullyLiquidatesSingleLotGroupWhenMarginCallRequiresPartialReduction( ComputeAndAssertQuantityForDeltaBuyingPower(positionGroup, -1, -usedMargin / 2); } + [Test] + public void FallsBackToPerLegMarginForDegeneratePositionGroup() + { + var positionGroup = SetUpOptionStrategy(OptionStrategyDefinitions.BullCallSpread, 1); + + // A group whose legs don't fit the matched strategy's expected shape, like a bull call spread group + // missing its long leg, used to hard-crash margin computations with + // "InvalidOperationException: Sequence contains no matching element" + var shortLeg = positionGroup.Positions.Single(position => position.Quantity < 0); + var degenerateGroup = new PositionGroup(positionGroup.BuyingPowerModel, 1, shortLeg); + + var security = _portfolio.Securities[shortLeg.Symbol]; + var expectedMaintenanceMargin = Math.Abs(security.BuyingPowerModel.GetMaintenanceMargin( + MaintenanceMarginParameters.ForQuantityAtCurrentPrice(security, shortLeg.Quantity))); + var expectedInitialMargin = Math.Abs(((OptionInitialMargin)security.BuyingPowerModel.GetInitialMarginRequirement( + new InitialMarginParameters(security, shortLeg.Quantity))).ValueWithoutPremium); + + var maintenanceMargin = 0m; + var initialMargin = (OptionInitialMargin)null; + Assert.DoesNotThrow(() => maintenanceMargin = degenerateGroup.BuyingPowerModel.GetMaintenanceMargin( + new PositionGroupMaintenanceMarginParameters(_portfolio, degenerateGroup))); + Assert.DoesNotThrow(() => initialMargin = (OptionInitialMargin)degenerateGroup.BuyingPowerModel.GetInitialMarginRequirement( + new PositionGroupInitialMarginParameters(_portfolio, degenerateGroup))); + + Assert.AreEqual(expectedMaintenanceMargin, maintenanceMargin); + Assert.AreEqual(expectedInitialMargin, initialMargin.ValueWithoutPremium); + } + + [Test] + public void FallsBackToPerLegMarginForStrategiesWithoutMarginModeling() + { + var positionGroup = SetUpOptionStrategy(OptionStrategyDefinitions.BullCallSpread, 1); + var orderedLegs = positionGroup.Positions.OrderBy(position => position.Symbol.ID.StrikePrice).ToList(); + var lowerStrikeSymbol = orderedLegs[0].Symbol; + var higherStrikeSymbol = orderedLegs[1].Symbol; + + // A strategy the matcher can produce but which has no specific margin modeling, like the backspreads, + // used to hard-crash margin computations with NotImplementedException + var callBackspread = OptionStrategies.CallBackspread(lowerStrikeSymbol.Canonical, + lowerStrikeSymbol.ID.StrikePrice, higherStrikeSymbol.ID.StrikePrice, lowerStrikeSymbol.ID.Date); + var backspreadGroup = new PositionGroup(new OptionStrategyPositionGroupBuyingPowerModel(callBackspread), 1, + new Position(lowerStrikeSymbol, -1, 1), new Position(higherStrikeSymbol, 2, 2)); + + var expectedMaintenanceMargin = 0m; + var expectedInitialMargin = 0m; + foreach (var position in backspreadGroup.Positions) + { + var security = _portfolio.Securities[position.Symbol]; + expectedMaintenanceMargin += Math.Abs(security.BuyingPowerModel.GetMaintenanceMargin( + MaintenanceMarginParameters.ForQuantityAtCurrentPrice(security, position.Quantity))); + expectedInitialMargin += Math.Abs(((OptionInitialMargin)security.BuyingPowerModel.GetInitialMarginRequirement( + new InitialMarginParameters(security, position.Quantity))).ValueWithoutPremium); + } + + var maintenanceMargin = 0m; + var initialMargin = (OptionInitialMargin)null; + Assert.DoesNotThrow(() => maintenanceMargin = backspreadGroup.BuyingPowerModel.GetMaintenanceMargin( + new PositionGroupMaintenanceMarginParameters(_portfolio, backspreadGroup))); + Assert.DoesNotThrow(() => initialMargin = (OptionInitialMargin)backspreadGroup.BuyingPowerModel.GetInitialMarginRequirement( + new PositionGroupInitialMarginParameters(_portfolio, backspreadGroup))); + + Assert.AreEqual(expectedMaintenanceMargin, maintenanceMargin); + Assert.AreEqual(expectedInitialMargin, initialMargin.ValueWithoutPremium); + } + /// /// TODO: Revisit the explicit test cases when we can take into account premium for strategies with zero margin. /// diff --git a/Tests/Common/Securities/Options/OptionStrategiesTests.cs b/Tests/Common/Securities/Options/OptionStrategiesTests.cs index 19942d1552b5..ecc10718f3be 100644 --- a/Tests/Common/Securities/Options/OptionStrategiesTests.cs +++ b/Tests/Common/Securities/Options/OptionStrategiesTests.cs @@ -17,6 +17,7 @@ using System.Collections.Generic; using System.Linq; using NUnit.Framework; +using Python.Runtime; using QuantConnect.Securities.Option; using QuantConnect.Securities.Option.StrategyMatcher; @@ -1628,5 +1629,51 @@ public void SetsOptionLegsSymbols(Symbol canonicalSymbol, Symbol contractSymbol) var leg = strategy.OptionLegs.Single(); Assert.AreEqual(contractSymbol, leg.Symbol); } + + [Test] + public void CreatesOptionLegDataFromStrikeAndExpiration() + { + var expiration = new DateTime(2023, 08, 18); + var leg = new OptionStrategy.OptionLegData(-1, OptionRight.Put, 4000m, expiration); + + Assert.AreEqual(-1, leg.Quantity); + Assert.AreEqual(OptionRight.Put, leg.Right); + Assert.AreEqual(4000m, leg.Strike); + Assert.AreEqual(expiration, leg.Expiration); + Assert.IsNull(leg.OrderPrice); + Assert.IsNull(leg.Symbol); + + // the leg symbol is created from the canonical option symbol when used in a strategy + var strategy = new OptionStrategy("Test Strategy", Symbols.SPY_Option_Chain, new List { leg }); + var expectedSymbol = Symbol.CreateOption(Symbols.SPY, Market.USA, OptionStyle.American, OptionRight.Put, 4000m, expiration); + Assert.AreEqual(expectedSymbol, strategy.OptionLegs.Single().Symbol); + } + + [TestCase("date")] + [TestCase("datetime")] + public void CreatesOptionLegDataFromPython(string expiryType) + { + // Production deployments trying to build strategy legs from Python failed with + // "Trying to dynamically access a method that does not exist" because OptionLegData had no + // (quantity, right, strike, expiration) constructor. Both date and datetime must be accepted for expiry + using (Py.GIL()) + { + var testModule = PyModule.FromString("testModule", + @" +from AlgorithmImports import * +from datetime import date, datetime + +def create_leg(expiry_type): + expiry = date(2023, 8, 18) if expiry_type == 'date' else datetime(2023, 8, 18) + return OptionStrategy.OptionLegData(-1, OptionRight.PUT, 4000, expiry) +"); + var leg = testModule.GetAttr("create_leg").Invoke(expiryType.ToPython()).As(); + + Assert.AreEqual(-1, leg.Quantity); + Assert.AreEqual(OptionRight.Put, leg.Right); + Assert.AreEqual(4000m, leg.Strike); + Assert.AreEqual(new DateTime(2023, 08, 18), leg.Expiration); + } + } } } From a4381116abdd19b8839028489c21f2afe527a9fc Mon Sep 17 00:00:00 2001 From: Jhonathan Abreu Date: Wed, 12 Aug 2026 18:38:14 -0400 Subject: [PATCH 2/2] Generalize code comments --- .../Option/OptionStrategyPositionGroupBuyingPowerModel.cs | 4 ++-- Tests/Common/Securities/Options/OptionStrategiesTests.cs | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/Common/Securities/Option/OptionStrategyPositionGroupBuyingPowerModel.cs b/Common/Securities/Option/OptionStrategyPositionGroupBuyingPowerModel.cs index 791eb1068a06..65d9766ece6d 100644 --- a/Common/Securities/Option/OptionStrategyPositionGroupBuyingPowerModel.cs +++ b/Common/Securities/Option/OptionStrategyPositionGroupBuyingPowerModel.cs @@ -73,7 +73,7 @@ public override MaintenanceMargin GetMaintenanceMargin(PositionGroupMaintenanceM catch (InvalidOperationException exception) { // A position group whose legs don't fit the matched strategy's expected shape must not crash the algorithm: - // legging into spreads with sequential market orders hard-crashed production deployments with + // legging into spreads with sequential market orders could hard-crash the algorithm with // "Sequence contains no matching element" when a margin call probed a degenerate trial group // (GH #9612 guarded the zero-quantity probes; this guards any remaining degenerate shape). // Fall back to margining each leg individually, a conservative estimate that ignores leg offsets. @@ -337,7 +337,7 @@ public override InitialMargin GetInitialMarginRequirement(PositionGroupInitialMa catch (InvalidOperationException exception) { // A position group whose legs don't fit the matched strategy's expected shape must not crash the algorithm: - // legging into spreads with sequential market orders hard-crashed production deployments with + // legging into spreads with sequential market orders could hard-crash the algorithm with // "Sequence contains no matching element" when a margin call probed a degenerate trial group // (GH #9612 guarded the zero-quantity probes; this guards any remaining degenerate shape). // Fall back to margining each leg individually, a conservative estimate that ignores leg offsets. diff --git a/Tests/Common/Securities/Options/OptionStrategiesTests.cs b/Tests/Common/Securities/Options/OptionStrategiesTests.cs index ecc10718f3be..efedccffec78 100644 --- a/Tests/Common/Securities/Options/OptionStrategiesTests.cs +++ b/Tests/Common/Securities/Options/OptionStrategiesTests.cs @@ -1653,7 +1653,7 @@ public void CreatesOptionLegDataFromStrikeAndExpiration() [TestCase("datetime")] public void CreatesOptionLegDataFromPython(string expiryType) { - // Production deployments trying to build strategy legs from Python failed with + // Building strategy legs from Python used to fail with // "Trying to dynamically access a method that does not exist" because OptionLegData had no // (quantity, right, strike, expiration) constructor. Both date and datetime must be accepted for expiry using (Py.GIL())