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); + } + } } }