From ced2df6e8c2dec8c6af161b0f8ff36df1a8ad48e Mon Sep 17 00:00:00 2001 From: Alexandre Catarino Date: Mon, 27 Jul 2026 16:55:50 +0100 Subject: [PATCH 1/2] Add margin-aware option strategy match selection OptionStrategyMatcher.MatchOnce greedily matched definitions in descending leg-count order, never consulting the objective function hook. Books of overlapping debit spreads were carved into ladders whose uncovered short leg is charged naked option margin, producing phantom margin deltas, inconsistent accept/reject decisions and TotalMarginUsed churn on fully covered, defined-risk books. MatchOnce now evaluates a second candidate solution that deprioritizes definitions leaving a short leg uncovered, and selects the best solution via the objective function. The new default objective function minimizes the quantity of uncovered short contracts, a deterministic proxy for the margin required to hold the positions. Ties preserve the previous grouping, so behavior only changes where the greedy carve left a short uncovered that another grouping of the same positions covers. Fixes #9638 Co-Authored-By: Claude Fable 5 --- .../IOptionStrategyMatchObjectiveFunction.cs | 6 +- .../StrategyMatcher/OptionStrategyMatcher.cs | 64 +++++++++++-- .../OptionStrategyMatcherOptions.cs | 4 +- ...ityOptionStrategyMatchObjectiveFunction.cs | 74 +++++++++++++++ ...ategyPositionGroupBuyingPowerModelTests.cs | 91 +++++++++++++++++++ .../OptionStrategyMatcherTests.cs | 87 ++++++++++++++++++ 6 files changed, 314 insertions(+), 12 deletions(-) create mode 100644 Common/Securities/Option/StrategyMatcher/UncoveredShortQuantityOptionStrategyMatchObjectiveFunction.cs diff --git a/Common/Securities/Option/StrategyMatcher/IOptionStrategyMatchObjectiveFunction.cs b/Common/Securities/Option/StrategyMatcher/IOptionStrategyMatchObjectiveFunction.cs index e5c37b92adee..3e3d82608c1d 100644 --- a/Common/Securities/Option/StrategyMatcher/IOptionStrategyMatchObjectiveFunction.cs +++ b/Common/Securities/Option/StrategyMatcher/IOptionStrategyMatchObjectiveFunction.cs @@ -21,9 +21,9 @@ namespace QuantConnect.Securities.Option.StrategyMatcher public interface IOptionStrategyMatchObjectiveFunction { /// - /// Evaluates the objective function for the provided match solution. Solution with the highest score will be selected - /// as the solution. NOTE: This part of the match has not been implemented as of 2020-11-06 as it's only evaluating the - /// first solution match (MatchOnce). + /// Evaluates the objective function for the provided match solution. The solution with the highest score will be + /// selected as the solution. By convention, solutions that can't be improved upon score zero, the maximum, which + /// allows the matcher to skip evaluating additional candidate solutions. /// decimal ComputeScore(OptionPositionCollection input, OptionStrategyMatch match, OptionPositionCollection unmatched); } diff --git a/Common/Securities/Option/StrategyMatcher/OptionStrategyMatcher.cs b/Common/Securities/Option/StrategyMatcher/OptionStrategyMatcher.cs index 4f11481f2001..7c6eafb61d39 100644 --- a/Common/Securities/Option/StrategyMatcher/OptionStrategyMatcher.cs +++ b/Common/Securities/Option/StrategyMatcher/OptionStrategyMatcher.cs @@ -13,7 +13,9 @@ * limitations under the License. */ +using System; using System.Collections.Generic; +using System.Linq; namespace QuantConnect.Securities.Option.StrategyMatcher { @@ -37,24 +39,45 @@ public OptionStrategyMatcher(OptionStrategyMatcherOptions options) Options = options; } - // TODO : Implement matching multiple permutations and using the objective function to select the best solution - /// /// Using the definitions provided in , attempts to match all . /// The resulting presents a single, valid solution for matching as many positions - /// as possible. + /// as possible. A fixed set of candidate solutions is evaluated and the one scoring highest against the configured + /// is selected, so short positions are grouped into + /// covered strategies instead of being charged naked option margin whenever the positions allow it. + /// On equal scores, the solution produced by the configured definition enumeration order is preserved. /// public OptionStrategyMatch MatchOnce(OptionPositionCollection positions) { - // these definitions are enumerated according to the configured IOptionStrategyDefinitionEnumerator + // the first candidate solution greedily matches definitions in the configured enumeration order, by + // default descending by leg count so more complex definitions get matching priority. it's evaluated + // first so that whenever the objective function scores another candidate equally this one is preserved + var bestMatch = Match(Options.Definitions, positions, out var unmatched); + var bestScore = Options.ObjectiveFunction.ComputeScore(positions, bestMatch, unmatched); + if (bestScore >= 0) + { + // by convention solutions that can't be improved upon score zero, see IOptionStrategyMatchObjectiveFunction + return bestMatch; + } + + // the second candidate deprioritizes definitions leaving a short leg uncovered within the strategy + // (naked calls/puts, ladders, short backspreads/straddles/strangles), so short positions are matched + // into covered strategies whenever another grouping of the same positions allows it. this avoids + // greedily carving, for instance, two overlapping bull call spreads into a bull call ladder, whose + // uncovered short leg is charged naked option margin, plus an unmatched long contract + var candidateMatch = Match(Options.Definitions.OrderBy(HasUncoveredShortLeg), positions, out unmatched); + var candidateScore = Options.ObjectiveFunction.ComputeScore(positions, candidateMatch, unmatched); + + return candidateScore > bestScore ? candidateMatch : bestMatch; + } + private OptionStrategyMatch Match(IEnumerable definitions, OptionPositionCollection positions, + out OptionPositionCollection unmatched) + { var strategies = new List(); - foreach (var definition in Options.Definitions) + foreach (var definition in definitions) { // simplest implementation here is to match one at a time, updating positions in between - // a better implementation would be to evaluate all possible matches and make decisions - // prioritizing positions that would require more margin if not matched - OptionStrategyDefinitionMatch match; while (definition.TryMatchOnce(Options, positions, out match)) { @@ -68,7 +91,32 @@ public OptionStrategyMatch MatchOnce(OptionPositionCollection positions) } } + unmatched = positions; return new OptionStrategyMatch(strategies); } + + /// + /// Determines whether the definition, matched at the unit level, leaves a short option leg which isn't + /// covered by long legs of the same right or by the underlying lots the definition requires + /// + private static bool HasUncoveredShortLeg(OptionStrategyDefinition definition) + { + var netCalls = 0; + var netPuts = 0; + foreach (var leg in definition.Legs) + { + if (leg.Right == OptionRight.Call) + { + netCalls += leg.Quantity; + } + else + { + netPuts += leg.Quantity; + } + } + + // long underlying lots cover short calls, short underlying lots cover short puts + return -netCalls > Math.Max(0, definition.UnderlyingLots) || -netPuts > Math.Max(0, -definition.UnderlyingLots); + } } } diff --git a/Common/Securities/Option/StrategyMatcher/OptionStrategyMatcherOptions.cs b/Common/Securities/Option/StrategyMatcher/OptionStrategyMatcherOptions.cs index 6e9a40f48b42..c44584a21521 100644 --- a/Common/Securities/Option/StrategyMatcher/OptionStrategyMatcherOptions.cs +++ b/Common/Securities/Option/StrategyMatcher/OptionStrategyMatcherOptions.cs @@ -93,7 +93,9 @@ public OptionStrategyMatcherOptions( if (objectiveFunction == null) { - objectiveFunction = new UnmatchedPositionCountOptionStrategyMatchObjectiveFunction(); + // by default we prefer solutions minimizing the uncovered short option quantity, + // a proxy for the margin required to hold the resulting position groups + objectiveFunction = new UncoveredShortQuantityOptionStrategyMatchObjectiveFunction(); } if (positionEnumerator == null) diff --git a/Common/Securities/Option/StrategyMatcher/UncoveredShortQuantityOptionStrategyMatchObjectiveFunction.cs b/Common/Securities/Option/StrategyMatcher/UncoveredShortQuantityOptionStrategyMatchObjectiveFunction.cs new file mode 100644 index 000000000000..e2ed2af358ee --- /dev/null +++ b/Common/Securities/Option/StrategyMatcher/UncoveredShortQuantityOptionStrategyMatchObjectiveFunction.cs @@ -0,0 +1,74 @@ +/* + * 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.Linq; + +namespace QuantConnect.Securities.Option.StrategyMatcher +{ + /// + /// Provides an implementation of that minimizes the total + /// quantity of short option contracts left uncovered, either within their matched strategy (such as the second + /// short leg of a ladder) or unmatched entirely. Uncovered shorts are charged naked option margin, typically an + /// order of magnitude larger than the margin of covered, risk-defined strategies, which makes this quantity a + /// cheap and deterministic proxy for the total margin required to hold the positions. + /// + public class UncoveredShortQuantityOptionStrategyMatchObjectiveFunction : IOptionStrategyMatchObjectiveFunction + { + /// + /// Computes the score as the negated total quantity of uncovered short option contracts, so the solution + /// covering the most short contracts wins and a solution without uncovered shorts scores zero, the maximum. + /// A short leg is covered when its strategy holds long options of the same right, or the underlying lots + /// with the offsetting sign, quantity for quantity. + /// + public decimal ComputeScore(OptionPositionCollection input, OptionStrategyMatch match, OptionPositionCollection unmatched) + { + var uncovered = 0m; + foreach (var strategy in match.Strategies) + { + var netCalls = 0m; + var netPuts = 0m; + foreach (var leg in strategy.OptionLegs) + { + if (leg.Right == OptionRight.Call) + { + netCalls += leg.Quantity; + } + else + { + netPuts += leg.Quantity; + } + } + + // at the matching level underlying legs are expressed in lots, + // long lots cover short calls and short lots cover short puts + var underlyingLots = strategy.UnderlyingLegs.Sum(leg => leg.Quantity); + uncovered += Math.Max(0, -netCalls - Math.Max(0, underlyingLots)); + uncovered += Math.Max(0, -netPuts - Math.Max(0, -underlyingLots)); + } + + foreach (var position in unmatched) + { + if (position.Quantity < 0 && position.Symbol.SecurityType.IsOption()) + { + // unmatched short options fall through to stand-alone groups charged naked option margin + uncovered -= position.Quantity; + } + } + + return -uncovered; + } + } +} diff --git a/Tests/Common/Securities/OptionStrategyPositionGroupBuyingPowerModelTests.cs b/Tests/Common/Securities/OptionStrategyPositionGroupBuyingPowerModelTests.cs index 0f81f7bb1881..06a08ff224f8 100644 --- a/Tests/Common/Securities/OptionStrategyPositionGroupBuyingPowerModelTests.cs +++ b/Tests/Common/Securities/OptionStrategyPositionGroupBuyingPowerModelTests.cs @@ -670,6 +670,97 @@ public void HasSufficientBuyingPowerForReducingStrategyOrder() Assert.IsTrue(hasSufficientBuyingPowerResult.IsSufficient); } + [Test] + public void FullyCoveredOverlappingDebitSpreadsBookRequiresNoMaintenanceMargin() + { + SetUpOverlappingBullCallSpreads(); + + Assert.AreEqual(2, _portfolio.Positions.Groups.Count); + Assert.IsTrue(_portfolio.Positions.Groups.All(group => + group.BuyingPowerModel.ToString() == OptionStrategyDefinitions.BullCallSpread.Name), + string.Join(", ", _portfolio.Positions.Groups.Select(group => group.BuyingPowerModel.ToString()))); + + // every short call is covered by a long call at a lower strike, so no margin is required beyond the premium already paid. + // the greedy leg-count-descending matching used to carve this book into a bull call ladder plus an unmatched long, + // charging naked call margin (premium + 20% of the underlying value) for the ladder's uncovered short leg + Assert.AreEqual(0, _portfolio.TotalMarginUsed); + } + + [Test] + public void OverlappingDebitSpreadOrderRequiresOnlyPremium() + { + var (_, call600, _, call605) = SetUpOverlappingBullCallSpreads(holdSecondSpread: false); + + // enough cash for the new spread's ~$295 net debit, far below the ~$12k naked call margin the + // ladder re-grouping of the combined book used to charge for this defined-risk order + _algorithm.SetCash(2000); + + var groupOrderManager = new GroupOrderManager(1, 2, 1); + var orders = new List + { + Order.CreateOrder(new SubmitOrderRequest(OrderType.ComboMarket, SecurityType.Option, call600.Symbol, + 1m.GetOrderLegGroupQuantity(groupOrderManager), 0, 0, _algorithm.Time, "", groupOrderManager: groupOrderManager)), + Order.CreateOrder(new SubmitOrderRequest(OrderType.ComboMarket, SecurityType.Option, call605.Symbol, + (-1m).GetOrderLegGroupQuantity(groupOrderManager), 0, 0, _algorithm.Time, "", groupOrderManager: groupOrderManager)) + }; + + Assert.IsTrue(_portfolio.Positions.TryCreatePositionGroup(orders, out var positionGroup)); + + var result = positionGroup.BuyingPowerModel.HasSufficientBuyingPowerForOrder( + new HasSufficientPositionGroupBuyingPowerForOrderParameters(_portfolio, positionGroup, orders)); + + Assert.IsTrue(result.IsSufficient, result.Reason); + } + + [Test] + public void LongOnlyOrderAgainstCoveredSpreadBookIsNotChargedShortMargin() + { + SetUpOverlappingBullCallSpreads(); + + var call610 = _algorithm.AddOptionContract(Symbols.CreateOptionSymbol("SPY", OptionRight.Call, 610, new DateTime(2025, 2, 21))); + call610.SetMarketPrice(new Tick { Value = 2.28m }); + + // enough cash for the long call's $228 premium, its maximum risk. re-grouping artifacts used to + // charge this long-only order the naked margin of a short leg it doesn't introduce + _algorithm.SetCash(2000); + + var order = Order.CreateOrder(new SubmitOrderRequest(OrderType.Market, SecurityType.Option, call610.Symbol, 1, 0, 0, + _algorithm.Time, "")); + var result = _portfolio.HasSufficientBuyingPowerForOrder(new List { order }); + + Assert.IsTrue(result.IsSufficient, result.Reason); + } + + /// + /// Sets up a book of two overlapping SPY bull call spreads with interleaved strikes and the same expiration, + /// long 598/short 603 and long 600/short 605, optionally holding only the first spread + /// + private (Option call598, Option call600, Option call603, Option call605) SetUpOverlappingBullCallSpreads(bool holdSecondSpread = true) + { + _equity.SetMarketPrice(new Tick { Value = 600.40m }); + + var expiry = new DateTime(2025, 2, 21); + var call598 = _algorithm.AddOptionContract(Symbols.CreateOptionSymbol("SPY", OptionRight.Call, 598, expiry)); + call598.SetMarketPrice(new Tick { Value = 8.11m }); + var call600 = _algorithm.AddOptionContract(Symbols.CreateOptionSymbol("SPY", OptionRight.Call, 600, expiry)); + call600.SetMarketPrice(new Tick { Value = 6.72m }); + var call603 = _algorithm.AddOptionContract(Symbols.CreateOptionSymbol("SPY", OptionRight.Call, 603, expiry)); + call603.SetMarketPrice(new Tick { Value = 4.85m }); + var call605 = _algorithm.AddOptionContract(Symbols.CreateOptionSymbol("SPY", OptionRight.Call, 605, expiry)); + call605.SetMarketPrice(new Tick { Value = 3.77m }); + + call598.Holdings.SetHoldings(call598.Price, 1); + call603.Holdings.SetHoldings(call603.Price, -1); + + if (holdSecondSpread) + { + call600.Holdings.SetHoldings(call600.Price, 1); + call605.Holdings.SetHoldings(call605.Price, -1); + } + + return (call598, call600, call603, call605); + } + // Increasing short position [TestCase(-10, -11)] // Decreasing short position diff --git a/Tests/Common/Securities/Options/StrategyMatcher/OptionStrategyMatcherTests.cs b/Tests/Common/Securities/Options/StrategyMatcher/OptionStrategyMatcherTests.cs index 305856790395..40fda4119dae 100644 --- a/Tests/Common/Securities/Options/StrategyMatcher/OptionStrategyMatcherTests.cs +++ b/Tests/Common/Securities/Options/StrategyMatcher/OptionStrategyMatcherTests.cs @@ -85,5 +85,92 @@ public void MatchesAgainstFullPositionCollection() } } } + + [Test] + public void MatchesOverlappingDebitSpreadsAsSpreadsInsteadOfLadder() + { + // two overlapping bull call spreads with interleaved strikes, same expiration. + // a leg-count-greedy match carves this book into a bull call ladder, whose second short leg is + // charged naked call margin, plus an unmatched long. the correct, margin-free solution is two spreads + var positions = OptionPositionCollection.Empty.AddRange( + Position(Call[598]), + Position(Call[600]), + Position(Call[603], -1), + Position(Call[605], -1) + ); + + var matcher = new OptionStrategyMatcher(OptionStrategyMatcherOptions.ForDefinitions(AllDefinitions)); + var match = matcher.MatchOnce(positions); + + Assert.AreEqual(2, match.Strategies.Count); + Assert.IsTrue(match.Strategies.All(strategy => strategy.Name == BullCallSpread.Name), + string.Join(", ", match.Strategies.Select(strategy => strategy.Name))); + // all four contracts must be consumed, either spread pairing is acceptable + Assert.AreEqual(4, match.Strategies.Sum(strategy => strategy.OptionLegs.Count)); + } + + [Test] + public void MatchLeavesNoShortContractUncoveredWhenFullCoverageExists() + { + // every short strike has a long at a lower strike available to cover it, same expiration: + // pairing shorts in ascending order against lower longs covers all of them, so no solution + // should leave a short contract uncovered (inside a ladder) or unmatched + var positions = OptionPositionCollection.Empty.AddRange( + Position(Call[598], 3), Position(Call[600], 2), Position(Call[604], 3), Position(Call[608], 2), + Position(Call[603], -3), Position(Call[605], -2), Position(Call[609], -2), Position(Call[613], -1) + ); + + var matcher = new OptionStrategyMatcher(OptionStrategyMatcherOptions.ForDefinitions(AllDefinitions)); + var match = matcher.MatchOnce(positions); + + var matchedShortQuantity = 0; + foreach (var strategy in match.Strategies) + { + // no strategy is allowed to hold net short calls, which would be charged naked call margin + Assert.GreaterOrEqual(strategy.OptionLegs.Sum(leg => leg.Quantity), 0, + $"{strategy.Name}: {string.Join("|", strategy.OptionLegs.Select(leg => new OptionPosition(leg.Symbol, leg.Quantity)))}"); + + matchedShortQuantity -= strategy.OptionLegs.Where(leg => leg.Quantity < 0).Sum(leg => leg.Quantity); + } + + // all 8 short contracts are matched into strategies covering them + Assert.AreEqual(8, matchedShortQuantity); + } + + [Test] + public void MatchesTrueButterflyBookAsButterfly() + { + // a true butterfly book must not be decomposed into a bull call spread plus a bear call spread, + // which would require margin for the bear spread's strike width while the butterfly requires none + var positions = OptionPositionCollection.Empty.AddRange( + Position(Call[595]), + Position(Call[600], -2), + Position(Call[605]) + ); + + var matcher = new OptionStrategyMatcher(OptionStrategyMatcherOptions.ForDefinitions(AllDefinitions)); + var match = matcher.MatchOnce(positions); + + Assert.AreEqual(1, match.Strategies.Count); + Assert.AreEqual(ButterflyCall.Name, match.Strategies.Single().Name); + } + + [Test] + public void MatchesLadderBookAsLadderWhenNoBetterSolutionExists() + { + // an actual ladder book has one genuinely uncovered short either way it's grouped, + // so on equal scores the original leg-count-greedy solution is preserved + var positions = OptionPositionCollection.Empty.AddRange( + Position(Call[595]), + Position(Call[600], -1), + Position(Call[605], -1) + ); + + var matcher = new OptionStrategyMatcher(OptionStrategyMatcherOptions.ForDefinitions(AllDefinitions)); + var match = matcher.MatchOnce(positions); + + Assert.AreEqual(1, match.Strategies.Count); + Assert.AreEqual(BullCallLadder.Name, match.Strategies.Single().Name); + } } } From ba9805c9342bb5a86f9559b69d4f316c89dd79ec Mon Sep 17 00:00:00 2001 From: Martin Molinero Date: Thu, 30 Jul 2026 17:42:43 -0300 Subject: [PATCH 2/2] Cache strategy definition ordering and skip redundant match pass Materialize the definition enumerations once per matcher options instead of re-sorting them on every MatchOnce call, and only evaluate the second candidate solution when some short contract can actually be covered by a long of the same right or by the underlying lots held. A book of naked shorts, by far the most common one reaching that point, now runs a single matching pass. Co-Authored-By: Claude Opus 5 (1M context) --- .../StrategyMatcher/OptionStrategyMatcher.cs | 40 ++++++++++++------- .../OptionStrategyMatcherOptions.cs | 35 +++++++++++++++- ...ityOptionStrategyMatchObjectiveFunction.cs | 18 ++++++--- 3 files changed, 71 insertions(+), 22 deletions(-) diff --git a/Common/Securities/Option/StrategyMatcher/OptionStrategyMatcher.cs b/Common/Securities/Option/StrategyMatcher/OptionStrategyMatcher.cs index 7c6eafb61d39..2826f82667f2 100644 --- a/Common/Securities/Option/StrategyMatcher/OptionStrategyMatcher.cs +++ b/Common/Securities/Option/StrategyMatcher/OptionStrategyMatcher.cs @@ -13,9 +13,7 @@ * limitations under the License. */ -using System; using System.Collections.Generic; -using System.Linq; namespace QuantConnect.Securities.Option.StrategyMatcher { @@ -54,9 +52,11 @@ public OptionStrategyMatch MatchOnce(OptionPositionCollection positions) // first so that whenever the objective function scores another candidate equally this one is preserved var bestMatch = Match(Options.Definitions, positions, out var unmatched); var bestScore = Options.ObjectiveFunction.ComputeScore(positions, bestMatch, unmatched); - if (bestScore >= 0) + if (bestScore >= 0 || !CanCoverAnyShort(positions)) { - // by convention solutions that can't be improved upon score zero, see IOptionStrategyMatchObjectiveFunction + // by convention solutions that can't be improved upon score zero, see IOptionStrategyMatchObjectiveFunction. + // re-ordering the definitions can only pay off when some short contract can actually be covered, so a book + // of naked shorts, by far the most common one reaching this point, skips the second matching pass return bestMatch; } @@ -65,7 +65,7 @@ public OptionStrategyMatch MatchOnce(OptionPositionCollection positions) // into covered strategies whenever another grouping of the same positions allows it. this avoids // greedily carving, for instance, two overlapping bull call spreads into a bull call ladder, whose // uncovered short leg is charged naked option margin, plus an unmatched long contract - var candidateMatch = Match(Options.Definitions.OrderBy(HasUncoveredShortLeg), positions, out unmatched); + var candidateMatch = Match(Options.CoveredShortsFirstDefinitions, positions, out unmatched); var candidateScore = Options.ObjectiveFunction.ComputeScore(positions, candidateMatch, unmatched); return candidateScore > bestScore ? candidateMatch : bestMatch; @@ -96,27 +96,37 @@ private OptionStrategyMatch Match(IEnumerable definiti } /// - /// Determines whether the definition, matched at the unit level, leaves a short option leg which isn't - /// covered by long legs of the same right or by the underlying lots the definition requires + /// Determines whether any short contract in the collection could be covered by a long contract of the same + /// right or by the underlying lots held, a precondition for a different grouping to reduce uncovered shorts /// - private static bool HasUncoveredShortLeg(OptionStrategyDefinition definition) + private static bool CanCoverAnyShort(OptionPositionCollection positions) { - var netCalls = 0; - var netPuts = 0; - foreach (var leg in definition.Legs) + var hasLongCall = false; + var hasShortCall = false; + var hasLongPut = false; + var hasShortPut = false; + foreach (var position in positions) { - if (leg.Right == OptionRight.Call) + if (position.IsUnderlying) { - netCalls += leg.Quantity; + continue; + } + + if (position.Right == OptionRight.Call) + { + hasLongCall |= position.Quantity > 0; + hasShortCall |= position.Quantity < 0; } else { - netPuts += leg.Quantity; + hasLongPut |= position.Quantity > 0; + hasShortPut |= position.Quantity < 0; } } // long underlying lots cover short calls, short underlying lots cover short puts - return -netCalls > Math.Max(0, definition.UnderlyingLots) || -netPuts > Math.Max(0, -definition.UnderlyingLots); + return hasShortCall && (hasLongCall || positions.UnderlyingQuantity > 0) + || hasShortPut && (hasLongPut || positions.UnderlyingQuantity < 0); } } } diff --git a/Common/Securities/Option/StrategyMatcher/OptionStrategyMatcherOptions.cs b/Common/Securities/Option/StrategyMatcher/OptionStrategyMatcherOptions.cs index c44584a21521..0acfedaf235e 100644 --- a/Common/Securities/Option/StrategyMatcher/OptionStrategyMatcherOptions.cs +++ b/Common/Securities/Option/StrategyMatcher/OptionStrategyMatcherOptions.cs @@ -55,13 +55,21 @@ public class OptionStrategyMatcherOptions /// The definitions to be used for matching. /// public IEnumerable Definitions - => _definitionEnumerator.Enumerate(_definitions); + => _enumeratedDefinitions ??= _definitionEnumerator.Enumerate(_definitions).ToList(); + + /// + /// The definitions to be used for matching, deprioritizing those leaving a short option leg uncovered + /// + public IEnumerable CoveredShortsFirstDefinitions + => _coveredShortsFirstDefinitions ??= Definitions.OrderBy(HasUncoveredShortLeg).ToList(); /// /// Objective function used to compare different match solutions for a given set of positions/definitions /// public IOptionStrategyMatchObjectiveFunction ObjectiveFunction { get; } + private List _enumeratedDefinitions; + private List _coveredShortsFirstDefinitions; private readonly IReadOnlyList _definitions; private readonly IOptionPositionCollectionEnumerator _positionEnumerator; private readonly IOptionStrategyDefinitionEnumerator _definitionEnumerator; @@ -122,6 +130,31 @@ public int GetMaximumLegMatches(int legIndex) return MaximumCountPerLeg[legIndex]; } + /// + /// Determines whether the definition, matched at the unit level, leaves a short option leg which isn't + /// covered by long legs of the same right or by the underlying lots the definition requires. Only ever + /// evaluated while building , which is cached + /// + private static bool HasUncoveredShortLeg(OptionStrategyDefinition definition) + { + var netCalls = 0; + var netPuts = 0; + foreach (var leg in definition.Legs) + { + if (leg.Right == OptionRight.Call) + { + netCalls += leg.Quantity; + } + else + { + netPuts += leg.Quantity; + } + } + + // long underlying lots cover short calls, short underlying lots cover short puts + return -netCalls > Math.Max(0, definition.UnderlyingLots) || -netPuts > Math.Max(0, -definition.UnderlyingLots); + } + /// /// Enumerates the specified according to the configured /// diff --git a/Common/Securities/Option/StrategyMatcher/UncoveredShortQuantityOptionStrategyMatchObjectiveFunction.cs b/Common/Securities/Option/StrategyMatcher/UncoveredShortQuantityOptionStrategyMatchObjectiveFunction.cs index e2ed2af358ee..e339e9a311a5 100644 --- a/Common/Securities/Option/StrategyMatcher/UncoveredShortQuantityOptionStrategyMatchObjectiveFunction.cs +++ b/Common/Securities/Option/StrategyMatcher/UncoveredShortQuantityOptionStrategyMatchObjectiveFunction.cs @@ -52,16 +52,12 @@ public decimal ComputeScore(OptionPositionCollection input, OptionStrategyMatch } } - // at the matching level underlying legs are expressed in lots, - // long lots cover short calls and short lots cover short puts - var underlyingLots = strategy.UnderlyingLegs.Sum(leg => leg.Quantity); - uncovered += Math.Max(0, -netCalls - Math.Max(0, underlyingLots)); - uncovered += Math.Max(0, -netPuts - Math.Max(0, -underlyingLots)); + uncovered += GetUncoveredQuantity(netCalls, netPuts, strategy.UnderlyingLegs.Sum(leg => leg.Quantity)); } foreach (var position in unmatched) { - if (position.Quantity < 0 && position.Symbol.SecurityType.IsOption()) + if (position.Quantity < 0 && !position.IsUnderlying) { // unmatched short options fall through to stand-alone groups charged naked option margin uncovered -= position.Quantity; @@ -70,5 +66,15 @@ public decimal ComputeScore(OptionPositionCollection input, OptionStrategyMatch return -uncovered; } + + /// + /// At the matching level underlying legs are expressed in lots, + /// long lots cover short calls and short lots cover short puts + /// + private static decimal GetUncoveredQuantity(decimal netCalls, decimal netPuts, decimal underlyingLots) + { + return Math.Max(0, -netCalls - Math.Max(0, underlyingLots)) + + Math.Max(0, -netPuts - Math.Max(0, -underlyingLots)); + } } }