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
64 changes: 56 additions & 8 deletions Common/Securities/Option/StrategyMatcher/OptionStrategyMatcher.cs
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,9 @@
* limitations under the License.
*/

using System;
using System.Collections.Generic;
using System.Linq;

namespace QuantConnect.Securities.Option.StrategyMatcher
{
Expand All @@ -37,24 +39,45 @@ 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)
{
// by convention solutions that can't be improved upon score zero, see IOptionStrategyMatchObjectiveFunction
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.Definitions.OrderBy(HasUncoveredShortLeg), 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,32 @@ public OptionStrategyMatch MatchOnce(OptionPositionCollection positions)
}
}

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

/// <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
/// </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);
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -93,7 +93,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 Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,74 @@
/*
* 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;
}
}

// at the matching level underlying legs are expressed in lots,
// long lots cover short calls and short lots cover short puts
var underlyingLots = strategy.UnderlyingLegs.Sum(leg => leg.Quantity);
uncovered += Math.Max(0, -netCalls - Math.Max(0, underlyingLots));
uncovered += Math.Max(0, -netPuts - Math.Max(0, -underlyingLots));
}

foreach (var position in unmatched)
{
if (position.Quantity < 0 && position.Symbol.SecurityType.IsOption())
{
// unmatched short options fall through to stand-alone groups charged naked option margin
uncovered -= position.Quantity;
}
}

return -uncovered;
}
}
}
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
Original file line number Diff line number Diff line change
Expand Up @@ -85,5 +85,92 @@ public void MatchesAgainstFullPositionCollection()
}
}
}

[Test]
public void MatchesOverlappingDebitSpreadsAsSpreadsInsteadOfLadder()
{
// two overlapping bull call spreads with interleaved strikes, same expiration.
// a leg-count-greedy match carves this book into a bull call ladder, whose second short leg is
// charged naked call margin, plus an unmatched long. the correct, margin-free solution is two spreads
var positions = OptionPositionCollection.Empty.AddRange(
Position(Call[598]),
Position(Call[600]),
Position(Call[603], -1),
Position(Call[605], -1)
);

var matcher = new OptionStrategyMatcher(OptionStrategyMatcherOptions.ForDefinitions(AllDefinitions));
var match = matcher.MatchOnce(positions);

Assert.AreEqual(2, match.Strategies.Count);
Assert.IsTrue(match.Strategies.All(strategy => strategy.Name == BullCallSpread.Name),
string.Join(", ", match.Strategies.Select(strategy => strategy.Name)));
// all four contracts must be consumed, either spread pairing is acceptable
Assert.AreEqual(4, match.Strategies.Sum(strategy => strategy.OptionLegs.Count));
}

[Test]
public void MatchLeavesNoShortContractUncoveredWhenFullCoverageExists()
{
// every short strike has a long at a lower strike available to cover it, same expiration:
// pairing shorts in ascending order against lower longs covers all of them, so no solution
// should leave a short contract uncovered (inside a ladder) or unmatched
var positions = OptionPositionCollection.Empty.AddRange(
Position(Call[598], 3), Position(Call[600], 2), Position(Call[604], 3), Position(Call[608], 2),
Position(Call[603], -3), Position(Call[605], -2), Position(Call[609], -2), Position(Call[613], -1)
);

var matcher = new OptionStrategyMatcher(OptionStrategyMatcherOptions.ForDefinitions(AllDefinitions));
var match = matcher.MatchOnce(positions);

var matchedShortQuantity = 0;
foreach (var strategy in match.Strategies)
{
// no strategy is allowed to hold net short calls, which would be charged naked call margin
Assert.GreaterOrEqual(strategy.OptionLegs.Sum(leg => leg.Quantity), 0,
$"{strategy.Name}: {string.Join("|", strategy.OptionLegs.Select(leg => new OptionPosition(leg.Symbol, leg.Quantity)))}");

matchedShortQuantity -= strategy.OptionLegs.Where(leg => leg.Quantity < 0).Sum(leg => leg.Quantity);
}

// all 8 short contracts are matched into strategies covering them
Assert.AreEqual(8, matchedShortQuantity);
}

[Test]
public void MatchesTrueButterflyBookAsButterfly()
{
// a true butterfly book must not be decomposed into a bull call spread plus a bear call spread,
// which would require margin for the bear spread's strike width while the butterfly requires none
var positions = OptionPositionCollection.Empty.AddRange(
Position(Call[595]),
Position(Call[600], -2),
Position(Call[605])
);

var matcher = new OptionStrategyMatcher(OptionStrategyMatcherOptions.ForDefinitions(AllDefinitions));
var match = matcher.MatchOnce(positions);

Assert.AreEqual(1, match.Strategies.Count);
Assert.AreEqual(ButterflyCall.Name, match.Strategies.Single().Name);
}

[Test]
public void MatchesLadderBookAsLadderWhenNoBetterSolutionExists()
{
// an actual ladder book has one genuinely uncovered short either way it's grouped,
// so on equal scores the original leg-count-greedy solution is preserved
var positions = OptionPositionCollection.Empty.AddRange(
Position(Call[595]),
Position(Call[600], -1),
Position(Call[605], -1)
);

var matcher = new OptionStrategyMatcher(OptionStrategyMatcherOptions.ForDefinitions(AllDefinitions));
var match = matcher.MatchOnce(positions);

Assert.AreEqual(1, match.Strategies.Count);
Assert.AreEqual(BullCallLadder.Name, match.Strategies.Single().Name);
}
}
}
Loading