Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -21,9 +21,9 @@ namespace QuantConnect.Securities.Option.StrategyMatcher
public interface IOptionStrategyMatchObjectiveFunction
{
/// <summary>
/// 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.
/// </summary>
decimal ComputeScore(OptionPositionCollection input, OptionStrategyMatch match, OptionPositionCollection unmatched);
}
Expand Down
74 changes: 66 additions & 8 deletions Common/Securities/Option/StrategyMatcher/OptionStrategyMatcher.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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

/// <summary>
/// Using the definitions provided in <see cref="Options"/>, attempts to match all <paramref name="positions"/>.
/// The resulting <see cref="OptionStrategyMatch"/> 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
/// <see cref="OptionStrategyMatcherOptions.ObjectiveFunction"/> 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.
/// </summary>
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<OptionStrategyDefinition> definitions, OptionPositionCollection positions,
out OptionPositionCollection unmatched)
{
var strategies = new List<OptionStrategy>();
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))
{
Expand All @@ -68,7 +91,42 @@ public OptionStrategyMatch MatchOnce(OptionPositionCollection positions)
}
}

unmatched = positions;
return new OptionStrategyMatch(strategies);
}

/// <summary>
/// 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
/// </summary>
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);
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -55,13 +55,21 @@ public class OptionStrategyMatcherOptions
/// The definitions to be used for matching.
/// </summary>
public IEnumerable<OptionStrategyDefinition> Definitions
=> _definitionEnumerator.Enumerate(_definitions);
=> _enumeratedDefinitions ??= _definitionEnumerator.Enumerate(_definitions).ToList();

/// <summary>
/// The definitions to be used for matching, deprioritizing those leaving a short option leg uncovered
/// </summary>
public IEnumerable<OptionStrategyDefinition> CoveredShortsFirstDefinitions
=> _coveredShortsFirstDefinitions ??= Definitions.OrderBy(HasUncoveredShortLeg).ToList();

/// <summary>
/// Objective function used to compare different match solutions for a given set of positions/definitions
/// </summary>
public IOptionStrategyMatchObjectiveFunction ObjectiveFunction { get; }

private List<OptionStrategyDefinition> _enumeratedDefinitions;
private List<OptionStrategyDefinition> _coveredShortsFirstDefinitions;
private readonly IReadOnlyList<OptionStrategyDefinition> _definitions;
private readonly IOptionPositionCollectionEnumerator _positionEnumerator;
private readonly IOptionStrategyDefinitionEnumerator _definitionEnumerator;
Expand Down Expand Up @@ -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)
Expand All @@ -120,6 +130,31 @@ public int GetMaximumLegMatches(int legIndex)
return MaximumCountPerLeg[legIndex];
}

/// <summary>
/// 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 <see cref="CoveredShortsFirstDefinitions"/>, which is cached
/// </summary>
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);
}

/// <summary>
/// Enumerates the specified <paramref name="positions"/> according to the configured
/// <see cref="IOptionPositionCollectionEnumerator"/>
Expand Down
Original file line number Diff line number Diff line change
@@ -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
{
/// <summary>
/// Provides an implementation of <see cref="IOptionStrategyMatchObjectiveFunction"/> 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.
/// </summary>
public class UncoveredShortQuantityOptionStrategyMatchObjectiveFunction : IOptionStrategyMatchObjectiveFunction
{
/// <summary>
/// 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.
/// </summary>
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;
}

/// <summary>
/// At the matching level underlying legs are expressed in lots,
/// long lots cover short calls and short lots cover short puts
/// </summary>
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));
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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>
{
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> { order });

Assert.IsTrue(result.IsSufficient, result.Reason);
}

/// <summary>
/// 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
/// </summary>
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
Expand Down
Loading
Loading