From ced2df6e8c2dec8c6af161b0f8ff36df1a8ad48e Mon Sep 17 00:00:00 2001 From: Alexandre Catarino Date: Mon, 27 Jul 2026 16:55:50 +0100 Subject: [PATCH 1/6] 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/6] 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)); + } } } From 908a4feb4d157fa78f0d707cec782f566f70d069 Mon Sep 17 00:00:00 2001 From: Alexandre Catarino Date: Sat, 1 Aug 2026 20:20:09 +0100 Subject: [PATCH 3/6] Bound credit-side short coverage and add overlapping spreads regression The uncovered short proxy treated any same-right long as covering a short leg. A long on the credit side (higher strike for calls, lower for puts) caps the risk at the strike width, which for a distant long can exceed the naked short margin, so preferring it could raise the margin required instead of lowering it. Coverage from the debit side stays free, while credit-side coverage only counts within 10% of the short strike, the price-free stand-in for the naked short margin floor of the option margin model. Beyond that width the short counts as uncovered, the candidate solutions tie and the previous grouping is preserved, so the selection can only ever lower the margin required to hold the positions. Also adds a regression algorithm for the reported defect: two overlapping bull call debit spreads with interleaved strikes resolve into two margin free spreads instead of a bull call ladder charging naked call margin plus an unmatched long. Co-Authored-By: Claude Fable 5 --- ...ppingBullCallSpreadsRegressionAlgorithm.cs | 142 ++++++++++++++++++ ...ityOptionStrategyMatchObjectiveFunction.cs | 142 +++++++++++++++--- .../OptionStrategyMatcherTests.cs | 42 ++++++ 3 files changed, 304 insertions(+), 22 deletions(-) create mode 100644 Algorithm.CSharp/OptionEquityOverlappingBullCallSpreadsRegressionAlgorithm.cs diff --git a/Algorithm.CSharp/OptionEquityOverlappingBullCallSpreadsRegressionAlgorithm.cs b/Algorithm.CSharp/OptionEquityOverlappingBullCallSpreadsRegressionAlgorithm.cs new file mode 100644 index 000000000000..5b51001de455 --- /dev/null +++ b/Algorithm.CSharp/OptionEquityOverlappingBullCallSpreadsRegressionAlgorithm.cs @@ -0,0 +1,142 @@ +/* + * 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; +using QuantConnect.Data; +using QuantConnect.Data.Market; +using System.Collections.Generic; +using QuantConnect.Securities.Option; +using QuantConnect.Securities.Positions; +using QuantConnect.Securities.Option.StrategyMatcher; + +namespace QuantConnect.Algorithm.CSharp +{ + /// + /// Regression algorithm asserting that a book of two overlapping bull call debit spreads, with interleaved + /// strikes and the same expiration, is grouped as two margin-free bull call spreads. The greedy leg-count + /// descending matching used to carve this book into a bull call ladder plus an unmatched long, charging + /// naked call margin for the ladder's uncovered short leg on a fully covered, defined-risk book + /// + public class OptionEquityOverlappingBullCallSpreadsRegressionAlgorithm : OptionEquityBaseStrategyRegressionAlgorithm + { + /// + /// OnData event is the primary entry point for your algorithm. Each new data point will be pumped in here. + /// + /// Slice object keyed by symbol containing the stock data + public override void OnData(Slice slice) + { + if (!Portfolio.Invested) + { + OptionChain chain; + if (IsMarketOpen(_optionSymbol) && slice.OptionChains.TryGetValue(_optionSymbol, out chain)) + { + var callContracts = chain + .Where(contract => contract.Right == OptionRight.Call); + var expiry = callContracts.Min(x => x.Expiry); + var contracts = callContracts.Where(x => x.Expiry == expiry) + .DistinctBy(x => x.Strike) + .OrderBy(x => x.Strike) + .ToList(); + if (contracts.Count < 4) return; + + var initialMargin = Portfolio.MarginRemaining; + + // first debit spread: long the lowest strike, short the second highest + MarketOrder(contracts[0].Symbol, 1); + MarketOrder(contracts[2].Symbol, -1); + + AssertOptionStrategyIsPresent(OptionStrategyDefinitions.BullCallSpread.Name, 1); + + // second debit spread, overlapping the first: long the second lowest strike, short the highest + MarketOrder(contracts[1].Symbol, 1); + MarketOrder(contracts[3].Symbol, -1); + var freeMarginPostTrade = Portfolio.MarginRemaining; + + // every short call is covered by a long call at a lower strike: the book must resolve into two + // margin-free bull call spreads, not a bull call ladder charging naked call margin plus an orphan long + var bullCallSpreadsCount = Portfolio.Positions.Groups.Count(group => + group.BuyingPowerModel is OptionStrategyPositionGroupBuyingPowerModel + && group.BuyingPowerModel.ToString() == OptionStrategyDefinitions.BullCallSpread.Name); + if (bullCallSpreadsCount != 2) + { + throw new RegressionTestException($"Expected two Bull Call Spread groups, found {bullCallSpreadsCount}: " + + string.Join(", ", Portfolio.Positions.Groups.Select(group => group.BuyingPowerModel.ToString()))); + } + + var expectedMarginUsage = 0m; + if (expectedMarginUsage != Portfolio.TotalMarginUsed) + { + throw new RegressionTestException($"Unexpected margin used!: {Portfolio.TotalMarginUsed}"); + } + + // we paid the ask and value using the assets price + var priceSpreadDifference = GetPriceSpreadDifference(contracts[0].Symbol, contracts[1].Symbol, + contracts[2].Symbol, contracts[3].Symbol); + if (initialMargin != (freeMarginPostTrade + expectedMarginUsage + _paidFees - priceSpreadDifference)) + { + throw new RegressionTestException("Unexpected margin remaining!"); + } + } + } + } + + /// + /// Data Points count of all timeslices of algorithm + /// + public override long DataPoints => 15023; + + /// + /// Data Points count of the algorithm history + /// + public override int AlgorithmHistoryDataPoints => 0; + + /// + /// This is used by the regression test system to indicate what the expected statistics are from running the algorithm + /// + public override Dictionary ExpectedStatistics => new Dictionary + { + {"Total Orders", "4"}, + {"Average Win", "0%"}, + {"Average Loss", "0%"}, + {"Compounding Annual Return", "0%"}, + {"Drawdown", "0%"}, + {"Expectancy", "0"}, + {"Start Equity", "200000"}, + {"End Equity", "199756"}, + {"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", "$4.00"}, + {"Estimated Strategy Capacity", "$65000.00"}, + {"Lowest Capacity Asset", "GOOCV W78ZERHAT67A|GOOCV VP83T1ZUHROL"}, + {"Portfolio Turnover", "2.85%"}, + {"Drawdown Recovery", "0"}, + {"OrderListHash", "4f2d6ca65efe107133bf6baff5fe5512"} + }; + } +} diff --git a/Common/Securities/Option/StrategyMatcher/UncoveredShortQuantityOptionStrategyMatchObjectiveFunction.cs b/Common/Securities/Option/StrategyMatcher/UncoveredShortQuantityOptionStrategyMatchObjectiveFunction.cs index e339e9a311a5..2f5cf0cf4ad7 100644 --- a/Common/Securities/Option/StrategyMatcher/UncoveredShortQuantityOptionStrategyMatchObjectiveFunction.cs +++ b/Common/Securities/Option/StrategyMatcher/UncoveredShortQuantityOptionStrategyMatchObjectiveFunction.cs @@ -14,6 +14,7 @@ */ using System; +using System.Collections.Generic; using System.Linq; namespace QuantConnect.Securities.Option.StrategyMatcher @@ -27,32 +28,32 @@ namespace QuantConnect.Securities.Option.StrategyMatcher /// public class UncoveredShortQuantityOptionStrategyMatchObjectiveFunction : IOptionStrategyMatchObjectiveFunction { + /// + /// Naked short equity option margin has a floor of 10% of the underlying value (see ). + /// The matcher holds no security prices, so the short leg's strike stands in for the underlying price: a long + /// covering a short from the credit side (higher strike for calls, lower strike for puts) is margined at the + /// strike width, so a width beyond this fraction of the short strike likely requires more margin than leaving + /// the short naked, and such a short is counted as uncovered instead + /// + private const decimal MaximumCreditCoverWidthFactor = 0.1m; + /// /// 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. + /// A short leg is covered when its strategy holds, quantity for quantity, long options of the same right on + /// the debit side (margin free) or within on the credit side, + /// or the underlying lots with the offsetting sign /// 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; - } - } - - uncovered += GetUncoveredQuantity(netCalls, netPuts, strategy.UnderlyingLegs.Sum(leg => 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 += GetUncoveredQuantity(strategy.OptionLegs, OptionRight.Call, Math.Max(0, underlyingLots)); + uncovered += GetUncoveredQuantity(strategy.OptionLegs, OptionRight.Put, Math.Max(0, -underlyingLots)); } foreach (var position in unmatched) @@ -68,13 +69,110 @@ 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 + /// Determines the quantity of short contracts of the given right which the strategy's own long legs and + /// underlying lots don't cover at a margin below the naked short margin proxy /// - private static decimal GetUncoveredQuantity(decimal netCalls, decimal netPuts, decimal underlyingLots) + private static decimal GetUncoveredQuantity(IEnumerable optionLegs, OptionRight right, + decimal underlyingCover) + { + List shorts = null; + List longs = null; + foreach (var leg in optionLegs) + { + if (leg.Right != right || leg.Quantity == 0) + { + continue; + } + + if (leg.Quantity < 0) + { + (shorts ??= new List()).Add(new StrikeQuantity(leg.Strike, -leg.Quantity)); + } + else + { + (longs ??= new List()).Add(new StrikeQuantity(leg.Strike, leg.Quantity)); + } + } + + if (shorts == null) + { + return 0; + } + + // debit-side longs cover for free: at or below the short strike for calls, at or above for puts. sorting + // ascending for calls (descending for puts) makes each short's set of debit-side longs contain the sets + // of the shorts before it, so covering shorts in order never wastes a long another short needed. it also + // leaves credit-side longs enumerated nearest first, minimizing the width of credit-side covers below + var sign = right == OptionRight.Call ? 1 : -1; + shorts.Sort((left, other) => sign * left.Strike.CompareTo(other.Strike)); + longs?.Sort((left, other) => sign * left.Strike.CompareTo(other.Strike)); + + foreach (var shortLeg in shorts) + { + if (longs != null) + { + foreach (var longLeg in longs) + { + if (shortLeg.Quantity == 0) + { + break; + } + + if (sign * (shortLeg.Strike - longLeg.Strike) >= 0) + { + Cover(shortLeg, longLeg); + } + } + } + + var lots = Math.Min(shortLeg.Quantity, underlyingCover); + shortLeg.Quantity -= lots; + underlyingCover -= lots; + } + + var uncovered = 0m; + foreach (var shortLeg in shorts) + { + if (longs != null) + { + foreach (var longLeg in longs) + { + if (shortLeg.Quantity == 0) + { + break; + } + + // a credit-side long caps the risk at the strike width, worth it only below the naked margin proxy + if (sign * (longLeg.Strike - shortLeg.Strike) <= MaximumCreditCoverWidthFactor * shortLeg.Strike) + { + Cover(shortLeg, longLeg); + } + } + } + + uncovered += shortLeg.Quantity; + } + + return uncovered; + } + + private static void Cover(StrikeQuantity shortLeg, StrikeQuantity longLeg) { - return Math.Max(0, -netCalls - Math.Max(0, underlyingLots)) - + Math.Max(0, -netPuts - Math.Max(0, -underlyingLots)); + var quantity = Math.Min(shortLeg.Quantity, longLeg.Quantity); + shortLeg.Quantity -= quantity; + longLeg.Quantity -= quantity; + } + + private sealed class StrikeQuantity + { + public decimal Strike { get; } + public decimal Quantity { get; set; } + + public StrikeQuantity(decimal strike, decimal quantity) + { + Strike = strike; + Quantity = quantity; + } } } } diff --git a/Tests/Common/Securities/Options/StrategyMatcher/OptionStrategyMatcherTests.cs b/Tests/Common/Securities/Options/StrategyMatcher/OptionStrategyMatcherTests.cs index 40fda4119dae..09f2992f63f0 100644 --- a/Tests/Common/Securities/Options/StrategyMatcher/OptionStrategyMatcherTests.cs +++ b/Tests/Common/Securities/Options/StrategyMatcher/OptionStrategyMatcherTests.cs @@ -155,6 +155,48 @@ public void MatchesTrueButterflyBookAsButterfly() Assert.AreEqual(ButterflyCall.Name, match.Strategies.Single().Name); } + [Test] + public void DoesNotCoverShortCallWithDistantLongWhenNakedMarginIsCheaper() + { + // covering the ladder's uncovered 600 short with the distant 700 long would carve a 150-wide bear + // call spread, margined at the strike width, costing more than the naked short margin (~20% of the + // underlying value). the ladder carve must be preserved instead + var positions = OptionPositionCollection.Empty.AddRange( + Position(Call[500]), + Position(Call[550], -1), + Position(Call[600], -1), + Position(Call[700]) + ); + + var matcher = new OptionStrategyMatcher(OptionStrategyMatcherOptions.ForDefinitions(AllDefinitions)); + var match = matcher.MatchOnce(positions); + + var strategyNames = string.Join(", ", match.Strategies.Select(strategy => strategy.Name)); + Assert.IsTrue(match.Strategies.Any(strategy => strategy.Name == BullCallLadder.Name), strategyNames); + Assert.IsFalse(match.Strategies.Any(strategy => strategy.Name == BearCallSpread.Name), strategyNames); + } + + [Test] + public void DoesNotCoverShortPutWithDistantLongWhenNakedMarginIsCheaper() + { + // covering the ladder's uncovered 550 short with the distant 100 long would carve a 450-wide bull + // put spread, margined at the strike width, costing more than the naked short margin (~20% of the + // underlying value). the ladder carve must be preserved instead + var positions = OptionPositionCollection.Empty.AddRange( + Position(Put[650]), + Position(Put[600], -1), + Position(Put[550], -1), + Position(Put[100]) + ); + + var matcher = new OptionStrategyMatcher(OptionStrategyMatcherOptions.ForDefinitions(AllDefinitions)); + var match = matcher.MatchOnce(positions); + + var strategyNames = string.Join(", ", match.Strategies.Select(strategy => strategy.Name)); + Assert.IsTrue(match.Strategies.Any(strategy => strategy.Name == BearPutLadder.Name), strategyNames); + Assert.IsFalse(match.Strategies.Any(strategy => strategy.Name == BullPutSpread.Name), strategyNames); + } + [Test] public void MatchesLadderBookAsLadderWhenNoBetterSolutionExists() { From 3af7e6b958fe3372553416b6d8073d904b0a399f Mon Sep 17 00:00:00 2001 From: Alexandre Catarino Date: Sat, 1 Aug 2026 21:54:59 +0100 Subject: [PATCH 4/6] Skip provably useless match passes and drop scoring allocations Matching again cannot help once the first solution already leaves no more shorts uncovered than the positions can possibly cover, since a long contract covers at most its own quantity of shorts of the same right, and so does an underlying lot. Checking that bound generalizes the naked shorts precondition it replaces and removes the second pass from books holding fewer longs than shorts, such as a plain ladder, which measured 2.2x slower than a single pass before and is now level with it. The credit side width test also subsumes the debit side one, whose width is never positive, so coverage collapses into a single predicate and one pass over the legs. Strategies with a single short leg, which is every spread, butterfly, condor, backspread and covered call, now take a fast path that needs neither ordering nor allocation, and the remaining ladders and short butterflies sort a small array in place instead of allocating lists, objects and sort closures per score. Co-Authored-By: Claude Fable 5 --- .../StrategyMatcher/OptionStrategyMatcher.cs | 31 ++-- ...ityOptionStrategyMatchObjectiveFunction.cs | 164 +++++++++++------- 2 files changed, 115 insertions(+), 80 deletions(-) diff --git a/Common/Securities/Option/StrategyMatcher/OptionStrategyMatcher.cs b/Common/Securities/Option/StrategyMatcher/OptionStrategyMatcher.cs index 2826f82667f2..7d5d1caec9d3 100644 --- a/Common/Securities/Option/StrategyMatcher/OptionStrategyMatcher.cs +++ b/Common/Securities/Option/StrategyMatcher/OptionStrategyMatcher.cs @@ -13,6 +13,7 @@ * limitations under the License. */ +using System; using System.Collections.Generic; namespace QuantConnect.Securities.Option.StrategyMatcher @@ -52,11 +53,12 @@ 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 || !CanCoverAnyShort(positions)) + if (bestScore >= 0 || -bestScore <= GetMinimumUncoveredQuantity(positions)) { // 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 + // matching again is also pointless once the first solution leaves no more short contracts uncovered than + // the positions themselves can possibly cover, which is the case for a book of naked shorts, for a book + // holding fewer longs than shorts, and generally whenever the first solution is already optimal return bestMatch; } @@ -96,15 +98,14 @@ private OptionStrategyMatch Match(IEnumerable definiti } /// - /// 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 + /// Determines the smallest quantity of short contracts any grouping of these positions can leave uncovered. + /// A long contract covers at most its own quantity of shorts of the same right, and so does an underlying lot, + /// which bounds how much a different grouping could possibly improve on the solution already found /// - private static bool CanCoverAnyShort(OptionPositionCollection positions) + private static decimal GetMinimumUncoveredQuantity(OptionPositionCollection positions) { - var hasLongCall = false; - var hasShortCall = false; - var hasLongPut = false; - var hasShortPut = false; + var calls = 0m; + var puts = 0m; foreach (var position in positions) { if (position.IsUnderlying) @@ -114,19 +115,17 @@ private static bool CanCoverAnyShort(OptionPositionCollection positions) if (position.Right == OptionRight.Call) { - hasLongCall |= position.Quantity > 0; - hasShortCall |= position.Quantity < 0; + calls += position.Quantity; } else { - hasLongPut |= position.Quantity > 0; - hasShortPut |= position.Quantity < 0; + puts += position.Quantity; } } // long underlying lots cover short calls, short underlying lots cover short puts - return hasShortCall && (hasLongCall || positions.UnderlyingQuantity > 0) - || hasShortPut && (hasLongPut || positions.UnderlyingQuantity < 0); + return Math.Max(0, -calls - Math.Max(0, positions.UnderlyingQuantity)) + + Math.Max(0, -puts - Math.Max(0, -positions.UnderlyingQuantity)); } } } diff --git a/Common/Securities/Option/StrategyMatcher/UncoveredShortQuantityOptionStrategyMatchObjectiveFunction.cs b/Common/Securities/Option/StrategyMatcher/UncoveredShortQuantityOptionStrategyMatchObjectiveFunction.cs index 2f5cf0cf4ad7..03c8f699a440 100644 --- a/Common/Securities/Option/StrategyMatcher/UncoveredShortQuantityOptionStrategyMatchObjectiveFunction.cs +++ b/Common/Securities/Option/StrategyMatcher/UncoveredShortQuantityOptionStrategyMatchObjectiveFunction.cs @@ -15,7 +15,6 @@ using System; using System.Collections.Generic; -using System.Linq; namespace QuantConnect.Securities.Option.StrategyMatcher { @@ -40,9 +39,9 @@ public class UncoveredShortQuantityOptionStrategyMatchObjectiveFunction : IOptio /// /// 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, quantity for quantity, long options of the same right on - /// the debit side (margin free) or within on the credit side, - /// or the underlying lots with the offsetting sign + /// A short leg is covered when its strategy holds, quantity for quantity, the underlying lots with the + /// offsetting sign or long options of the same right whose strike is on the debit side of the short strike + /// or within of it on the credit side /// public decimal ComputeScore(OptionPositionCollection input, OptionStrategyMatch match, OptionPositionCollection unmatched) { @@ -51,7 +50,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); + var underlyingLots = 0m; + for (var i = 0; i < strategy.UnderlyingLegs.Count; i++) + { + underlyingLots += strategy.UnderlyingLegs[i].Quantity; + } + uncovered += GetUncoveredQuantity(strategy.OptionLegs, OptionRight.Call, Math.Max(0, underlyingLots)); uncovered += GetUncoveredQuantity(strategy.OptionLegs, OptionRight.Put, Math.Max(0, -underlyingLots)); } @@ -72,13 +76,14 @@ public decimal ComputeScore(OptionPositionCollection input, OptionStrategyMatch /// Determines the quantity of short contracts of the given right which the strategy's own long legs and /// underlying lots don't cover at a margin below the naked short margin proxy /// - private static decimal GetUncoveredQuantity(IEnumerable optionLegs, OptionRight right, - decimal underlyingCover) + private static decimal GetUncoveredQuantity(List legs, OptionRight right, decimal underlyingCover) { - List shorts = null; - List longs = null; - foreach (var leg in optionLegs) + var shortLegCount = 0; + var longLegCount = 0; + var shortQuantity = 0m; + for (var i = 0; i < legs.Count; i++) { + var leg = legs[i]; if (leg.Right != right || leg.Quantity == 0) { continue; @@ -86,93 +91,124 @@ private static decimal GetUncoveredQuantity(IEnumerable()).Add(new StrikeQuantity(leg.Strike, -leg.Quantity)); + shortLegCount++; + shortQuantity -= leg.Quantity; } else { - (longs ??= new List()).Add(new StrikeQuantity(leg.Strike, leg.Quantity)); + longLegCount++; } } - if (shorts == null) + if (shortLegCount == 0) { + // nothing short of this right, the most common case by far return 0; } - // debit-side longs cover for free: at or below the short strike for calls, at or above for puts. sorting - // ascending for calls (descending for puts) makes each short's set of debit-side longs contain the sets - // of the shorts before it, so covering shorts in order never wastes a long another short needed. it also - // leaves credit-side longs enumerated nearest first, minimizing the width of credit-side covers below + if (longLegCount == 0) + { + // no long of this right to pair with, only the underlying lots can cover + return Math.Max(0, shortQuantity - underlyingCover); + } + + // calls are covered by lower strikes and puts by higher ones var sign = right == OptionRight.Call ? 1 : -1; - shorts.Sort((left, other) => sign * left.Strike.CompareTo(other.Strike)); - longs?.Sort((left, other) => sign * left.Strike.CompareTo(other.Strike)); - foreach (var shortLeg in shorts) + if (shortLegCount == 1) { - if (longs != null) + // a single short leg takes from every long leg allowed to cover it, no ordering required + var shortStrike = 0m; + for (var i = 0; i < legs.Count; i++) { - foreach (var longLeg in longs) + if (legs[i].Right == right && legs[i].Quantity < 0) { - if (shortLeg.Quantity == 0) - { - break; - } - - if (sign * (shortLeg.Strike - longLeg.Strike) >= 0) - { - Cover(shortLeg, longLeg); - } + shortStrike = legs[i].Strike; + break; } } - var lots = Math.Min(shortLeg.Quantity, underlyingCover); - shortLeg.Quantity -= lots; - underlyingCover -= lots; + var cover = underlyingCover; + for (var i = 0; i < legs.Count; i++) + { + var leg = legs[i]; + if (leg.Right == right && leg.Quantity > 0 && Covers(sign, shortStrike, leg.Strike)) + { + cover += leg.Quantity; + } + } + + return Math.Max(0, shortQuantity - cover); + } + + // several short legs of the same right, which only ladders and short butterflies produce. the set of long + // legs allowed to cover a short grows with the short's strike for calls, and shrinks for puts, so the sets + // are nested: taking from the shorts in that order never spends a long leg that a later short needed + var shortStrikes = new decimal[shortLegCount]; + var shortQuantities = new decimal[shortLegCount]; + var longStrikes = new decimal[longLegCount]; + var longQuantities = new decimal[longLegCount]; + var shorts = 0; + var longs = 0; + for (var i = 0; i < legs.Count; i++) + { + var leg = legs[i]; + if (leg.Right != right || leg.Quantity == 0) + { + continue; + } + + if (leg.Quantity < 0) + { + // insertion sort: ascending strike for calls, descending for puts + var index = shorts++; + while (index > 0 && sign * (shortStrikes[index - 1] - leg.Strike) > 0) + { + shortStrikes[index] = shortStrikes[index - 1]; + shortQuantities[index] = shortQuantities[index - 1]; + index--; + } + shortStrikes[index] = leg.Strike; + shortQuantities[index] = -leg.Quantity; + } + else + { + longStrikes[longs] = leg.Strike; + longQuantities[longs++] = leg.Quantity; + } } var uncovered = 0m; - foreach (var shortLeg in shorts) + for (var i = 0; i < shortLegCount; i++) { - if (longs != null) + var remaining = shortQuantities[i]; + for (var j = 0; j < longLegCount && remaining > 0; j++) { - foreach (var longLeg in longs) + if (longQuantities[j] > 0 && Covers(sign, shortStrikes[i], longStrikes[j])) { - if (shortLeg.Quantity == 0) - { - break; - } - - // a credit-side long caps the risk at the strike width, worth it only below the naked margin proxy - if (sign * (longLeg.Strike - shortLeg.Strike) <= MaximumCreditCoverWidthFactor * shortLeg.Strike) - { - Cover(shortLeg, longLeg); - } + var quantity = Math.Min(remaining, longQuantities[j]); + remaining -= quantity; + longQuantities[j] -= quantity; } } - uncovered += shortLeg.Quantity; + var lots = Math.Min(remaining, underlyingCover); + remaining -= lots; + underlyingCover -= lots; + uncovered += remaining; } return uncovered; } - private static void Cover(StrikeQuantity shortLeg, StrikeQuantity longLeg) - { - var quantity = Math.Min(shortLeg.Quantity, longLeg.Quantity); - shortLeg.Quantity -= quantity; - longLeg.Quantity -= quantity; - } - - private sealed class StrikeQuantity + /// + /// Determines whether a long leg covers a short leg of the same right at a margin below the naked short margin + /// proxy. This holds for every long on the debit side of the short strike, where the width is not positive and + /// the strategy requires no margin at all, and up to beyond it + /// + private static bool Covers(int sign, decimal shortStrike, decimal longStrike) { - public decimal Strike { get; } - public decimal Quantity { get; set; } - - public StrikeQuantity(decimal strike, decimal quantity) - { - Strike = strike; - Quantity = quantity; - } + return sign * (longStrike - shortStrike) <= MaximumCreditCoverWidthFactor * shortStrike; } } } From 51029185a2651fab0acbde9eafb2d925f8939b93 Mon Sep 17 00:00:00 2001 From: Alexandre Catarino Date: Sun, 2 Aug 2026 00:41:29 +0100 Subject: [PATCH 5/6] Require a covering long to outlive the short it covers The coverage proxy compared strikes only, so a short calendar spread, long the near expiration and short the far one at the same strike, read as fully covered on a zero strike width. The margin models disagree: once the long expires the short is naked for the rest of its life, and short calendar spreads are charged the stand-alone naked short margin while ordinary calendar spreads, whose long outlives the short, require none. Requiring the covering long to expire no earlier than the short makes the proxy mirror that distinction exactly, and leaves same expiry books untouched. The skip added for provably useless second passes reads the score as a quantity of uncovered contracts, which only the default objective function guarantees, so a custom one now always gets both candidates. Also documents that the definition ordering is cached, freezing the first output of a user supplied enumerator, and drops the stale claim that nothing in the options type is consulted by the matcher. Co-Authored-By: Claude Fable 5 --- .../StrategyMatcher/OptionStrategyMatcher.cs | 6 +- .../OptionStrategyMatcherOptions.cs | 19 +++-- ...ityOptionStrategyMatchObjectiveFunction.cs | 30 +++++-- .../OptionStrategyMatcherTests.cs | 84 +++++++++++++++++++ 4 files changed, 122 insertions(+), 17 deletions(-) diff --git a/Common/Securities/Option/StrategyMatcher/OptionStrategyMatcher.cs b/Common/Securities/Option/StrategyMatcher/OptionStrategyMatcher.cs index 7d5d1caec9d3..26b60322a252 100644 --- a/Common/Securities/Option/StrategyMatcher/OptionStrategyMatcher.cs +++ b/Common/Securities/Option/StrategyMatcher/OptionStrategyMatcher.cs @@ -53,7 +53,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 || -bestScore <= GetMinimumUncoveredQuantity(positions)) + if (bestScore >= 0 + // the bound below reads the score as a negated quantity of uncovered short contracts, which only the + // default objective function guarantees, so a custom one always gets to evaluate both candidates + || Options.ObjectiveFunction is UncoveredShortQuantityOptionStrategyMatchObjectiveFunction + && -bestScore <= GetMinimumUncoveredQuantity(positions)) { // by convention solutions that can't be improved upon score zero, see IOptionStrategyMatchObjectiveFunction. // matching again is also pointless once the first solution leaves no more short contracts uncovered than diff --git a/Common/Securities/Option/StrategyMatcher/OptionStrategyMatcherOptions.cs b/Common/Securities/Option/StrategyMatcher/OptionStrategyMatcherOptions.cs index 0acfedaf235e..65f5617b0c34 100644 --- a/Common/Securities/Option/StrategyMatcher/OptionStrategyMatcherOptions.cs +++ b/Common/Securities/Option/StrategyMatcher/OptionStrategyMatcherOptions.cs @@ -23,14 +23,15 @@ namespace QuantConnect.Securities.Option.StrategyMatcher /// Defines options that influence how the matcher operates. /// /// - /// Many properties in this type are not implemented in the matcher but are provided to document - /// the types of things that can be added to the matcher in the future as necessary. Some of the + /// Some properties in this type are not implemented in the matcher but are provided to document the + /// types of things that can be added to it in the future as necessary. + /// and are not consulted anywhere: the matcher evaluates a fixed + /// set of candidate solutions, which keeps its result independent of how long matching takes. Further /// features contemplated in this class would require updating the various matching/filtering/slicing - /// functions to accept these options, or a particular property. This is the case for the enumerators - /// which would be used to prioritize which positions to try and match first. A great implementation - /// of the would be to yield positions with the - /// highest margin requirements first. At time of writing, the goal is to achieve a workable rev0, - /// and we can later improve the efficiency/optimization of the matching process. + /// functions to accept these options, or a particular property. This is the case for the position + /// enumerator, which would be used to prioritize which positions to try and match first: a great + /// implementation of the would be to yield positions + /// with the highest margin requirements first. /// public class OptionStrategyMatcherOptions { @@ -54,6 +55,10 @@ public class OptionStrategyMatcherOptions /// /// The definitions to be used for matching. /// + /// + /// The configured is consulted once and its output is + /// cached, so an enumerator yielding a different order on each call has only its first order honored + /// public IEnumerable Definitions => _enumeratedDefinitions ??= _definitionEnumerator.Enumerate(_definitions).ToList(); diff --git a/Common/Securities/Option/StrategyMatcher/UncoveredShortQuantityOptionStrategyMatchObjectiveFunction.cs b/Common/Securities/Option/StrategyMatcher/UncoveredShortQuantityOptionStrategyMatchObjectiveFunction.cs index 03c8f699a440..07b487671acd 100644 --- a/Common/Securities/Option/StrategyMatcher/UncoveredShortQuantityOptionStrategyMatchObjectiveFunction.cs +++ b/Common/Securities/Option/StrategyMatcher/UncoveredShortQuantityOptionStrategyMatchObjectiveFunction.cs @@ -40,8 +40,8 @@ public class UncoveredShortQuantityOptionStrategyMatchObjectiveFunction : IOptio /// 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, quantity for quantity, the underlying lots with the - /// offsetting sign or long options of the same right whose strike is on the debit side of the short strike - /// or within of it on the credit side + /// offsetting sign or long options of the same right which outlive it and whose strike is on the debit side + /// of the short strike or within of it on the credit side /// public decimal ComputeScore(OptionPositionCollection input, OptionStrategyMatch match, OptionPositionCollection unmatched) { @@ -119,11 +119,13 @@ private static decimal GetUncoveredQuantity(List l { // a single short leg takes from every long leg allowed to cover it, no ordering required var shortStrike = 0m; + var shortExpiration = DateTime.MinValue; for (var i = 0; i < legs.Count; i++) { if (legs[i].Right == right && legs[i].Quantity < 0) { shortStrike = legs[i].Strike; + shortExpiration = legs[i].Expiration; break; } } @@ -132,7 +134,8 @@ private static decimal GetUncoveredQuantity(List l for (var i = 0; i < legs.Count; i++) { var leg = legs[i]; - if (leg.Right == right && leg.Quantity > 0 && Covers(sign, shortStrike, leg.Strike)) + if (leg.Right == right && leg.Quantity > 0 + && Covers(sign, shortStrike, shortExpiration, leg.Strike, leg.Expiration)) { cover += leg.Quantity; } @@ -146,8 +149,10 @@ private static decimal GetUncoveredQuantity(List l // are nested: taking from the shorts in that order never spends a long leg that a later short needed var shortStrikes = new decimal[shortLegCount]; var shortQuantities = new decimal[shortLegCount]; + var shortExpirations = new DateTime[shortLegCount]; var longStrikes = new decimal[longLegCount]; var longQuantities = new decimal[longLegCount]; + var longExpirations = new DateTime[longLegCount]; var shorts = 0; var longs = 0; for (var i = 0; i < legs.Count; i++) @@ -166,15 +171,18 @@ private static decimal GetUncoveredQuantity(List l { shortStrikes[index] = shortStrikes[index - 1]; shortQuantities[index] = shortQuantities[index - 1]; + shortExpirations[index] = shortExpirations[index - 1]; index--; } shortStrikes[index] = leg.Strike; shortQuantities[index] = -leg.Quantity; + shortExpirations[index] = leg.Expiration; } else { longStrikes[longs] = leg.Strike; - longQuantities[longs++] = leg.Quantity; + longQuantities[longs] = leg.Quantity; + longExpirations[longs++] = leg.Expiration; } } @@ -184,7 +192,8 @@ private static decimal GetUncoveredQuantity(List l var remaining = shortQuantities[i]; for (var j = 0; j < longLegCount && remaining > 0; j++) { - if (longQuantities[j] > 0 && Covers(sign, shortStrikes[i], longStrikes[j])) + if (longQuantities[j] > 0 + && Covers(sign, shortStrikes[i], shortExpirations[i], longStrikes[j], longExpirations[j])) { var quantity = Math.Min(remaining, longQuantities[j]); remaining -= quantity; @@ -203,12 +212,15 @@ private static decimal GetUncoveredQuantity(List l /// /// Determines whether a long leg covers a short leg of the same right at a margin below the naked short margin - /// proxy. This holds for every long on the debit side of the short strike, where the width is not positive and - /// the strategy requires no margin at all, and up to beyond it + /// proxy. The long must outlive the short, since a long expiring first leaves the short naked for the rest of + /// its life and the margin models charge those groups, the short calendar spreads, the naked short margin. It + /// must also sit on the debit side of the short strike, where the width is not positive and the strategy + /// requires no margin at all, or up to beyond it /// - private static bool Covers(int sign, decimal shortStrike, decimal longStrike) + private static bool Covers(int sign, decimal shortStrike, DateTime shortExpiration, decimal longStrike, DateTime longExpiration) { - return sign * (longStrike - shortStrike) <= MaximumCreditCoverWidthFactor * shortStrike; + return longExpiration >= shortExpiration + && sign * (longStrike - shortStrike) <= MaximumCreditCoverWidthFactor * shortStrike; } } } diff --git a/Tests/Common/Securities/Options/StrategyMatcher/OptionStrategyMatcherTests.cs b/Tests/Common/Securities/Options/StrategyMatcher/OptionStrategyMatcherTests.cs index 09f2992f63f0..4d015257ac5b 100644 --- a/Tests/Common/Securities/Options/StrategyMatcher/OptionStrategyMatcherTests.cs +++ b/Tests/Common/Securities/Options/StrategyMatcher/OptionStrategyMatcherTests.cs @@ -14,6 +14,7 @@ */ using System; +using System.Collections.Generic; using System.Linq; using NUnit.Framework; using QuantConnect.Securities.Option.StrategyMatcher; @@ -197,6 +198,89 @@ public void DoesNotCoverShortPutWithDistantLongWhenNakedMarginIsCheaper() Assert.IsFalse(match.Strategies.Any(strategy => strategy.Name == BullPutSpread.Name), strategyNames); } + [TestCase(OptionRight.Call)] + [TestCase(OptionRight.Put)] + public void ShortCalendarSpreadLeavesItsShortLegUncovered(OptionRight right) + { + // long the near expiration and short the far one at the same strike: once the long expires the short + // is naked for the rest of its life, which is why the margin model charges short calendar spreads the + // stand-alone naked short margin. the score must reflect that instead of reading the width as zero + var definition = right == OptionRight.Call ? ShortCallCalendarSpread : ShortPutCalendarSpread; + var positions = OptionPositionCollection.Empty.AddRange( + Position(Contract[right, 600m, 0], 1), + Position(Contract[right, 600m, 1], -1) + ); + + Assert.AreEqual(-1, ScoreSingleMatch(definition, positions)); + } + + [TestCase(OptionRight.Call)] + [TestCase(OptionRight.Put)] + public void CalendarSpreadCoversItsShortLeg(OptionRight right) + { + // short the near expiration and long the far one: the long outlives the short, the strategy requires + // no margin at all, and the score must not confuse it with the short calendar spread above + var definition = right == OptionRight.Call ? CallCalendarSpread : PutCalendarSpread; + var positions = OptionPositionCollection.Empty.AddRange( + Position(Contract[right, 600m, 0], -1), + Position(Contract[right, 600m, 1], 1) + ); + + Assert.AreEqual(0, ScoreSingleMatch(definition, positions)); + } + + [Test] + public void UnderlyingLotsCoverShortCalls() + { + // the underlying lots held by a covered call cover its short leg, so nothing is left uncovered + var positions = OptionPositionCollection.Empty.AddRange( + Position(Underlying, 100), + Position(Call[600m], -1) + ); + + Assert.AreEqual(0, ScoreSingleMatch(CoveredCall, positions)); + } + + [Test] + public void CustomObjectiveFunctionEvaluatesEveryCandidate() + { + // a book of naked shorts, where the default objective function knows no grouping can cover anything and + // skips the second candidate. a custom objective function makes no promise about what its score means, + // so it must be given both candidates to choose between + var positions = OptionPositionCollection.Empty.AddRange( + Position(Call[600m], -1), + Position(Call[605m], -1) + ); + + var objectiveFunction = new CountingObjectiveFunction(); + var matcher = new OptionStrategyMatcher(OptionStrategyMatcherOptions.ForDefinitions(AllDefinitions) + .WithObjectiveFunction(objectiveFunction)); + matcher.MatchOnce(positions); + + Assert.AreEqual(2, objectiveFunction.Count); + } + + private static decimal ScoreSingleMatch(OptionStrategyDefinition definition, OptionPositionCollection positions) + { + var options = OptionStrategyMatcherOptions.ForDefinitions(definition); + Assert.IsTrue(definition.TryMatchOnce(options, positions, out var match), $"{definition.Name} did not match"); + + var strategies = new List { match.CreateStrategy() }; + return options.ObjectiveFunction.ComputeScore(positions, new OptionStrategyMatch(strategies), + OptionPositionCollection.Empty); + } + + private class CountingObjectiveFunction : IOptionStrategyMatchObjectiveFunction + { + public int Count { get; private set; } + + public decimal ComputeScore(OptionPositionCollection input, OptionStrategyMatch match, OptionPositionCollection unmatched) + { + Count++; + return -1m; + } + } + [Test] public void MatchesLadderBookAsLadderWhenNoBetterSolutionExists() { From 9f50a9f3252fc26132e799ccc0c7f2242c4e9db0 Mon Sep 17 00:00:00 2001 From: Alexandre Catarino Date: Sun, 2 Aug 2026 02:13:43 +0100 Subject: [PATCH 6/6] Apply the uncovered short bound to the default objective function only A function deriving from the default one is free to score by different rules, so taking its score for a quantity of uncovered contracts could skip a second candidate it would have preferred. Match the type exactly instead, which leaves derived functions always evaluating both. Also documents that the legacy objective function scores are not bounded above by zero, so configuring it ends candidate evaluation and preserves the single matching pass, and describes the regression algorithm strikes by their order in the chain rather than as the highest ones, which only held for a chain of exactly four strikes. Co-Authored-By: Claude Fable 5 --- ...ppingBullCallSpreadsRegressionAlgorithm.cs | 4 +- .../StrategyMatcher/OptionStrategyMatcher.cs | 5 +- ...untOptionStrategyMatchObjectiveFunction.cs | 6 ++ .../OptionStrategyMatcherTests.cs | 59 ++++++++++++++----- 4 files changed, 56 insertions(+), 18 deletions(-) diff --git a/Algorithm.CSharp/OptionEquityOverlappingBullCallSpreadsRegressionAlgorithm.cs b/Algorithm.CSharp/OptionEquityOverlappingBullCallSpreadsRegressionAlgorithm.cs index 5b51001de455..db4832ed5f8e 100644 --- a/Algorithm.CSharp/OptionEquityOverlappingBullCallSpreadsRegressionAlgorithm.cs +++ b/Algorithm.CSharp/OptionEquityOverlappingBullCallSpreadsRegressionAlgorithm.cs @@ -55,13 +55,13 @@ public override void OnData(Slice slice) var initialMargin = Portfolio.MarginRemaining; - // first debit spread: long the lowest strike, short the second highest + // first debit spread: long the lowest strike, short the third lowest MarketOrder(contracts[0].Symbol, 1); MarketOrder(contracts[2].Symbol, -1); AssertOptionStrategyIsPresent(OptionStrategyDefinitions.BullCallSpread.Name, 1); - // second debit spread, overlapping the first: long the second lowest strike, short the highest + // second debit spread, its strikes interleaved with the first: long the second lowest, short the fourth lowest MarketOrder(contracts[1].Symbol, 1); MarketOrder(contracts[3].Symbol, -1); var freeMarginPostTrade = Portfolio.MarginRemaining; diff --git a/Common/Securities/Option/StrategyMatcher/OptionStrategyMatcher.cs b/Common/Securities/Option/StrategyMatcher/OptionStrategyMatcher.cs index 26b60322a252..e622f31d3432 100644 --- a/Common/Securities/Option/StrategyMatcher/OptionStrategyMatcher.cs +++ b/Common/Securities/Option/StrategyMatcher/OptionStrategyMatcher.cs @@ -55,8 +55,9 @@ public OptionStrategyMatch MatchOnce(OptionPositionCollection positions) var bestScore = Options.ObjectiveFunction.ComputeScore(positions, bestMatch, unmatched); if (bestScore >= 0 // the bound below reads the score as a negated quantity of uncovered short contracts, which only the - // default objective function guarantees, so a custom one always gets to evaluate both candidates - || Options.ObjectiveFunction is UncoveredShortQuantityOptionStrategyMatchObjectiveFunction + // default objective function itself guarantees. any other one, including a derived function free to + // score by different rules, always gets to evaluate both candidates + || Options.ObjectiveFunction.GetType() == typeof(UncoveredShortQuantityOptionStrategyMatchObjectiveFunction) && -bestScore <= GetMinimumUncoveredQuantity(positions)) { // by convention solutions that can't be improved upon score zero, see IOptionStrategyMatchObjectiveFunction. diff --git a/Common/Securities/Option/StrategyMatcher/UnmatchedPositionCountOptionStrategyMatchObjectiveFunction.cs b/Common/Securities/Option/StrategyMatcher/UnmatchedPositionCountOptionStrategyMatchObjectiveFunction.cs index 30352edb6527..111b5396bbe2 100644 --- a/Common/Securities/Option/StrategyMatcher/UnmatchedPositionCountOptionStrategyMatchObjectiveFunction.cs +++ b/Common/Securities/Option/StrategyMatcher/UnmatchedPositionCountOptionStrategyMatchObjectiveFunction.cs @@ -22,6 +22,12 @@ namespace QuantConnect.Securities.Option.StrategyMatcher /// Provides an implementation of that evaluates the number of unmatched /// positions, in number of contracts, giving precedence to solutions that have fewer unmatched contracts. /// + /// + /// Unlike the rest of the implementations, these scores are not bounded above by zero: a mostly long book scores + /// positive even with contracts left unmatched. Since stops evaluating further + /// candidate solutions as soon as one scores zero or better, configuring this function effectively preserves the + /// single greedy matching pass performed before candidate solutions were compared. + /// public class UnmatchedPositionCountOptionStrategyMatchObjectiveFunction : IOptionStrategyMatchObjectiveFunction { /// diff --git a/Tests/Common/Securities/Options/StrategyMatcher/OptionStrategyMatcherTests.cs b/Tests/Common/Securities/Options/StrategyMatcher/OptionStrategyMatcherTests.cs index 4d015257ac5b..e4a1d1dee7a4 100644 --- a/Tests/Common/Securities/Options/StrategyMatcher/OptionStrategyMatcherTests.cs +++ b/Tests/Common/Securities/Options/StrategyMatcher/OptionStrategyMatcherTests.cs @@ -260,6 +260,42 @@ public void CustomObjectiveFunctionEvaluatesEveryCandidate() Assert.AreEqual(2, objectiveFunction.Count); } + [Test] + public void DerivedObjectiveFunctionEvaluatesEveryCandidate() + { + // deriving from the default objective function doesn't carry over what its scores mean, so the bound + // taking the score for a quantity of uncovered contracts must not be applied to a derived one either + var positions = OptionPositionCollection.Empty.AddRange( + Position(Call[600m], -1), + Position(Call[605m], -1) + ); + + var objectiveFunction = new CountingDerivedObjectiveFunction(); + var matcher = new OptionStrategyMatcher(OptionStrategyMatcherOptions.ForDefinitions(AllDefinitions) + .WithObjectiveFunction(objectiveFunction)); + matcher.MatchOnce(positions); + + Assert.AreEqual(2, objectiveFunction.Count); + } + + [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); + } + private static decimal ScoreSingleMatch(OptionStrategyDefinition definition, OptionPositionCollection positions) { var options = OptionStrategyMatcherOptions.ForDefinitions(definition); @@ -281,22 +317,17 @@ public decimal ComputeScore(OptionPositionCollection input, OptionStrategyMatch } } - [Test] - public void MatchesLadderBookAsLadderWhenNoBetterSolutionExists() + private class CountingDerivedObjectiveFunction : UncoveredShortQuantityOptionStrategyMatchObjectiveFunction, + IOptionStrategyMatchObjectiveFunction { - // 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); + public int Count { get; private set; } - Assert.AreEqual(1, match.Strategies.Count); - Assert.AreEqual(BullCallLadder.Name, match.Strategies.Single().Name); + decimal IOptionStrategyMatchObjectiveFunction.ComputeScore(OptionPositionCollection input, OptionStrategyMatch match, + OptionPositionCollection unmatched) + { + Count++; + return -1m; + } } } }