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..65d9766ece6d 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 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.
+ 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 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.
+ 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..efedccffec78 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)
+ {
+ // 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())
+ {
+ 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);
+ }
+ }
}
}