From 503297a67bd5ce588ddfe5244280b3d61d973413 Mon Sep 17 00:00:00 2001 From: mkzung <103102868+mkzung@users.noreply.github.com> Date: Fri, 6 Nov 2026 04:00:17 +0000 Subject: [PATCH] Keep covered credit spreads from leaving naked shorts --- .../StrategyMatcher/OptionStrategyMatcher.cs | 7 ++---- .../OptionStrategyMatcherOptions.cs | 13 +++++----- ...ategyPositionGroupBuyingPowerModelTests.cs | 22 +++++++++++++++++ .../OptionStrategyMatcherTests.cs | 24 +++++++++++++++++++ 4 files changed, 54 insertions(+), 12 deletions(-) diff --git a/Common/Securities/Option/StrategyMatcher/OptionStrategyMatcher.cs b/Common/Securities/Option/StrategyMatcher/OptionStrategyMatcher.cs index e622f31d3432..e9902972a686 100644 --- a/Common/Securities/Option/StrategyMatcher/OptionStrategyMatcher.cs +++ b/Common/Securities/Option/StrategyMatcher/OptionStrategyMatcher.cs @@ -67,11 +67,8 @@ public OptionStrategyMatch MatchOnce(OptionPositionCollection positions) 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 + // the second candidate favors definitions with balanced option legs after underlying coverage. + // surplus longs within a strategy can consume the cover needed by shorts elsewhere in the book var candidateMatch = Match(Options.CoveredShortsFirstDefinitions, positions, out unmatched); var candidateScore = Options.ObjectiveFunction.ComputeScore(positions, candidateMatch, unmatched); diff --git a/Common/Securities/Option/StrategyMatcher/OptionStrategyMatcherOptions.cs b/Common/Securities/Option/StrategyMatcher/OptionStrategyMatcherOptions.cs index 65f5617b0c34..8fbb0d577b1d 100644 --- a/Common/Securities/Option/StrategyMatcher/OptionStrategyMatcherOptions.cs +++ b/Common/Securities/Option/StrategyMatcher/OptionStrategyMatcherOptions.cs @@ -63,10 +63,10 @@ public IEnumerable Definitions => _enumeratedDefinitions ??= _definitionEnumerator.Enumerate(_definitions).ToList(); /// - /// The definitions to be used for matching, deprioritizing those leaving a short option leg uncovered + /// The definitions to be used for matching, favoring balanced option legs after accounting for underlying lots /// public IEnumerable CoveredShortsFirstDefinitions - => _coveredShortsFirstDefinitions ??= Definitions.OrderBy(HasUncoveredShortLeg).ToList(); + => _coveredShortsFirstDefinitions ??= Definitions.OrderBy(HasUnbalancedLegs).ToList(); /// /// Objective function used to compare different match solutions for a given set of positions/definitions @@ -136,11 +136,10 @@ public int GetMaximumLegMatches(int 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 + /// Determines whether the definition leaves uncovered shorts or surplus longs of either right, + /// after accounting for the underlying lots required to cover short legs /// - private static bool HasUncoveredShortLeg(OptionStrategyDefinition definition) + private static bool HasUnbalancedLegs(OptionStrategyDefinition definition) { var netCalls = 0; var netPuts = 0; @@ -157,7 +156,7 @@ private static bool HasUncoveredShortLeg(OptionStrategyDefinition definition) } // 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 -netCalls != Math.Max(0, definition.UnderlyingLots) || -netPuts != Math.Max(0, -definition.UnderlyingLots); } /// diff --git a/Tests/Common/Securities/OptionStrategyPositionGroupBuyingPowerModelTests.cs b/Tests/Common/Securities/OptionStrategyPositionGroupBuyingPowerModelTests.cs index 4b45b6a15ead..c25e836fc030 100644 --- a/Tests/Common/Securities/OptionStrategyPositionGroupBuyingPowerModelTests.cs +++ b/Tests/Common/Securities/OptionStrategyPositionGroupBuyingPowerModelTests.cs @@ -686,6 +686,28 @@ public void FullyCoveredOverlappingDebitSpreadsBookRequiresNoMaintenanceMargin() Assert.AreEqual(0, _portfolio.TotalMarginUsed); } + [TestCase(OptionRight.Put)] + [TestCase(OptionRight.Call)] + public void CoveredCreditSpreadsRequireOnlyTheirCombinedStrikeWidths(OptionRight right) + { + _equity.SetMarketPrice(new Tick { Value = 500 }); + var expiry = new DateTime(2025, 2, 21); + var direction = right == OptionRight.Put ? -1 : 1; + foreach (var offset in new[] { 0, 5 }) + { + var shortOption = _algorithm.AddOptionContract(Symbols.CreateOptionSymbol("SPY", right, + 500 + direction * offset, expiry)); + var longOption = _algorithm.AddOptionContract(Symbols.CreateOptionSymbol("SPY", right, + 500 + direction * (offset + 30), expiry)); + shortOption.SetMarketPrice(new Tick { Value = 5 }); + longOption.SetMarketPrice(new Tick { Value = 2 }); + shortOption.Holdings.SetHoldings(shortOption.Price, -1); + longOption.Holdings.SetHoldings(longOption.Price, 1); + } + + Assert.AreEqual(6000, _portfolio.TotalMarginUsed); + } + [Test] public void OverlappingDebitSpreadOrderRequiresOnlyPremium() { diff --git a/Tests/Common/Securities/Options/StrategyMatcher/OptionStrategyMatcherTests.cs b/Tests/Common/Securities/Options/StrategyMatcher/OptionStrategyMatcherTests.cs index 94587eb88026..b67c68819296 100644 --- a/Tests/Common/Securities/Options/StrategyMatcher/OptionStrategyMatcherTests.cs +++ b/Tests/Common/Securities/Options/StrategyMatcher/OptionStrategyMatcherTests.cs @@ -110,6 +110,30 @@ public void MatchesOverlappingDebitSpreadsAsSpreadsInsteadOfLadder() Assert.AreEqual(4, match.Strategies.Sum(strategy => strategy.OptionLegs.Count)); } + [TestCase(OptionRight.Put, false)] + [TestCase(OptionRight.Put, true)] + [TestCase(OptionRight.Call, false)] + [TestCase(OptionRight.Call, true)] + public void MatchesCoveredCreditSpreadsWithoutLeavingANakedShort(OptionRight right, bool overlapping) + { + var options = right == OptionRight.Put ? Put : Call; + var direction = right == OptionRight.Put ? -1 : 1; + var secondShort = overlapping ? 5 : 35; + var positions = OptionPositionCollection.Empty.AddRange( + Position(options[1000], -1), + Position(options[1000 + direction * 30]), + Position(options[1000 + direction * secondShort], -1), + Position(options[1000 + direction * (secondShort + 30)]) + ); + 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.OptionLegs.Count == 2 && + strategy.OptionLegs.Sum(leg => leg.Quantity) == 0), + string.Join(", ", match.Strategies.Select(strategy => strategy.Name))); + Assert.AreEqual(4, match.Strategies.Sum(strategy => strategy.OptionLegs.Count)); + } + [Test] public void MatchLeavesNoShortContractUncoveredWhenFullCoverageExists() {