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; + } + } } }