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..2826f82667f2 100644
--- a/Common/Securities/Option/StrategyMatcher/OptionStrategyMatcher.cs
+++ b/Common/Securities/Option/StrategyMatcher/OptionStrategyMatcher.cs
@@ -37,24 +37,47 @@ 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 || !CanCoverAnyShort(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
+ 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.CoveredShortsFirstDefinitions, 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,42 @@ public OptionStrategyMatch MatchOnce(OptionPositionCollection positions)
}
}
+ unmatched = positions;
return new OptionStrategyMatch(strategies);
}
+
+ ///
+ /// 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 CanCoverAnyShort(OptionPositionCollection positions)
+ {
+ var hasLongCall = false;
+ var hasShortCall = false;
+ var hasLongPut = false;
+ var hasShortPut = false;
+ foreach (var position in positions)
+ {
+ if (position.IsUnderlying)
+ {
+ continue;
+ }
+
+ if (position.Right == OptionRight.Call)
+ {
+ hasLongCall |= position.Quantity > 0;
+ hasShortCall |= position.Quantity < 0;
+ }
+ else
+ {
+ hasLongPut |= position.Quantity > 0;
+ hasShortPut |= position.Quantity < 0;
+ }
+ }
+
+ // long underlying lots cover short calls, short underlying lots cover short puts
+ 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 6e9a40f48b42..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;
@@ -93,7 +101,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)
@@ -120,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
new file mode 100644
index 000000000000..e339e9a311a5
--- /dev/null
+++ b/Common/Securities/Option/StrategyMatcher/UncoveredShortQuantityOptionStrategyMatchObjectiveFunction.cs
@@ -0,0 +1,80 @@
+/*
+ * 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;
+ }
+ }
+
+ uncovered += GetUncoveredQuantity(netCalls, netPuts, strategy.UnderlyingLegs.Sum(leg => leg.Quantity));
+ }
+
+ foreach (var position in unmatched)
+ {
+ if (position.Quantity < 0 && !position.IsUnderlying)
+ {
+ // unmatched short options fall through to stand-alone groups charged naked option margin
+ uncovered -= position.Quantity;
+ }
+ }
+
+ 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));
+ }
+ }
+}
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);
+ }
}
}