diff --git a/Algorithm.CSharp/OptionEquityOverlappingBullCallSpreadsRegressionAlgorithm.cs b/Algorithm.CSharp/OptionEquityOverlappingBullCallSpreadsRegressionAlgorithm.cs
new file mode 100644
index 000000000000..db4832ed5f8e
--- /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 third lowest
+ MarketOrder(contracts[0].Symbol, 1);
+ MarketOrder(contracts[2].Symbol, -1);
+
+ AssertOptionStrategyIsPresent(OptionStrategyDefinitions.BullCallSpread.Name, 1);
+
+ // 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;
+
+ // 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/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..e622f31d3432 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
@@ -37,24 +38,53 @@ 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
+ // the bound below reads the score as a negated quantity of uncovered short contracts, which only the
+ // 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.
+ // 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;
+ }
+
+ // 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 +98,39 @@ public OptionStrategyMatch MatchOnce(OptionPositionCollection positions)
}
}
+ unmatched = positions;
return new OptionStrategyMatch(strategies);
}
+
+ ///
+ /// 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 decimal GetMinimumUncoveredQuantity(OptionPositionCollection positions)
+ {
+ var calls = 0m;
+ var puts = 0m;
+ foreach (var position in positions)
+ {
+ if (position.IsUnderlying)
+ {
+ continue;
+ }
+
+ if (position.Right == OptionRight.Call)
+ {
+ calls += position.Quantity;
+ }
+ else
+ {
+ puts += position.Quantity;
+ }
+ }
+
+ // long underlying lots cover short calls, short underlying lots cover short puts
+ 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/OptionStrategyMatcherOptions.cs b/Common/Securities/Option/StrategyMatcher/OptionStrategyMatcherOptions.cs
index 6e9a40f48b42..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,14 +55,26 @@ 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
- => _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 +106,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 +135,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..07b487671acd
--- /dev/null
+++ b/Common/Securities/Option/StrategyMatcher/UncoveredShortQuantityOptionStrategyMatchObjectiveFunction.cs
@@ -0,0 +1,226 @@
+/*
+ * QUANTCONNECT.COM - Democratizing Finance, Empowering Individuals.
+ * Lean Algorithmic Trading Engine v2.0. Copyright 2014 QuantConnect Corporation.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+*/
+
+using System;
+using System.Collections.Generic;
+
+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
+ {
+ ///
+ /// 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, quantity for quantity, the underlying lots with the
+ /// 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)
+ {
+ var uncovered = 0m;
+ foreach (var strategy in match.Strategies)
+ {
+ // at the matching level underlying legs are expressed in lots,
+ // long lots cover short calls and short lots cover short puts
+ 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));
+ }
+
+ 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;
+ }
+
+ ///
+ /// 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(List legs, OptionRight right, decimal underlyingCover)
+ {
+ 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;
+ }
+
+ if (leg.Quantity < 0)
+ {
+ shortLegCount++;
+ shortQuantity -= leg.Quantity;
+ }
+ else
+ {
+ longLegCount++;
+ }
+ }
+
+ if (shortLegCount == 0)
+ {
+ // nothing short of this right, the most common case by far
+ return 0;
+ }
+
+ 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;
+
+ if (shortLegCount == 1)
+ {
+ // 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;
+ }
+ }
+
+ 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, shortExpiration, leg.Strike, leg.Expiration))
+ {
+ 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 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++)
+ {
+ 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];
+ 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;
+ longExpirations[longs++] = leg.Expiration;
+ }
+ }
+
+ var uncovered = 0m;
+ for (var i = 0; i < shortLegCount; i++)
+ {
+ var remaining = shortQuantities[i];
+ for (var j = 0; j < longLegCount && remaining > 0; j++)
+ {
+ if (longQuantities[j] > 0
+ && Covers(sign, shortStrikes[i], shortExpirations[i], longStrikes[j], longExpirations[j]))
+ {
+ var quantity = Math.Min(remaining, longQuantities[j]);
+ remaining -= quantity;
+ longQuantities[j] -= quantity;
+ }
+ }
+
+ var lots = Math.Min(remaining, underlyingCover);
+ remaining -= lots;
+ underlyingCover -= lots;
+ uncovered += remaining;
+ }
+
+ return uncovered;
+ }
+
+ ///
+ /// Determines whether a long leg covers a short leg of the same right at a margin below the naked short margin
+ /// 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, DateTime shortExpiration, decimal longStrike, DateTime longExpiration)
+ {
+ return longExpiration >= shortExpiration
+ && sign * (longStrike - shortStrike) <= MaximumCreditCoverWidthFactor * shortStrike;
+ }
+ }
+}
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/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..e4a1d1dee7a4 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;
@@ -85,5 +86,248 @@ 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 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);
+ }
+
+ [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);
+ }
+
+ [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);
+ 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;
+ }
+ }
+
+ private class CountingDerivedObjectiveFunction : UncoveredShortQuantityOptionStrategyMatchObjectiveFunction,
+ IOptionStrategyMatchObjectiveFunction
+ {
+ public int Count { get; private set; }
+
+ decimal IOptionStrategyMatchObjectiveFunction.ComputeScore(OptionPositionCollection input, OptionStrategyMatch match,
+ OptionPositionCollection unmatched)
+ {
+ Count++;
+ return -1m;
+ }
+ }
}
}