Skip to content
Draft
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
@@ -0,0 +1,204 @@
/*
* 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;
using System.Linq;
using QuantConnect.Data;
using QuantConnect.Data.Market;
using QuantConnect.Interfaces;
using QuantConnect.Orders;
using QuantConnect.Securities.Option.StrategyMatcher;

namespace QuantConnect.Algorithm.CSharp
{
/// <summary>
/// Regression algorithm legging into multiple single-lot option strategy position groups with sequential
/// market orders and then going through a margin call that requires a partial reduction of the groups.
/// The margin call order quantity calculation probes degenerate (zero-quantity) trial groups, which used to
/// crash the algorithm with "Sequence contains no matching element" in OptionStrategyPositionGroupBuyingPowerModel.
/// </summary>
public class LeggedInOptionStrategiesMarginCallRegressionAlgorithm : QCAlgorithm, IRegressionAlgorithmDefinition
{
private Symbol _optionSymbol;
private bool _legged;
private bool _cashDropped;
private int _onMarginCallCount;

public override void Initialize()
{
SetStartDate(2015, 12, 24);
SetEndDate(2015, 12, 24);
SetCash(200000);

var equity = AddEquity("GOOG", leverage: 4);
var option = AddOption(equity.Symbol);
_optionSymbol = option.Symbol;

option.SetFilter(u => u.StandardsOnly().Strikes(-2, +2).Expiration(0, 180));
}

public override void OnData(Slice slice)
{
if (!_legged)
{
OptionChain chain;
if (IsMarketOpen(_optionSymbol) && slice.OptionChains.TryGetValue(_optionSymbol, out chain))
{
var contractsByExpiry = chain.GroupBy(x => x.Expiry).OrderBy(x => x.Key).ToList();

// A put spread at the nearest expiry: long the lowest strike put, short the next one
var puts = contractsByExpiry[0].Where(contract => contract.Right == OptionRight.Put)
.OrderBy(contract => contract.Strike)
.ToList();
var longPut = puts[0];
var shortPut = puts.First(contract => contract.Strike > longPut.Strike);

// And a call spread at another expiry so two separate strategy groups are resolved
var calls = contractsByExpiry
.Skip(1)
.Select(x => x.Where(contract => contract.Right == OptionRight.Call).OrderBy(contract => contract.Strike).ToList())
.First(x => x.Count > 1);
var shortCall = calls[0];
var longCall = calls.First(contract => contract.Strike > shortCall.Strike);

// Leg into the strategies with individual market orders instead of combo orders
MarketOrder(shortCall.Symbol, -1);
MarketOrder(longCall.Symbol, +1);
MarketOrder(shortPut.Symbol, -1);
MarketOrder(longPut.Symbol, +1);
_legged = true;

AssertOptionStrategyIsPresent(OptionStrategyDefinitions.BearCallSpread.Name);
AssertOptionStrategyIsPresent(OptionStrategyDefinitions.BullPutSpread.Name);
}
return;
}

if (!_cashDropped && Portfolio.Invested)
{
// Simulate a drawdown: equity drops below the margin used by the strategy groups so that the
// margin call model requests a partial reduction of the single-lot position groups
var cash = Portfolio.CashBook[Currencies.USD].Amount;
Portfolio.CashBook[Currencies.USD].SetAmount(cash - Portfolio.TotalPortfolioValue + 0.6m * Portfolio.TotalMarginUsed);
_cashDropped = true;
}
}

public override void OnMarginCall(List<SubmitOrderRequest> requests)
{
_onMarginCallCount++;

foreach (var request in requests)
{
var holdingsQuantity = Securities[request.Symbol].Holdings.Quantity;
if (request.Quantity != -holdingsQuantity)
{
throw new RegressionTestException($@"Expected margin call order for {request.Symbol} to fully liquidate the {holdingsQuantity
} holdings, but its quantity was {request.Quantity}");
}
}
}

public override void OnEndOfAlgorithm()
{
if (_onMarginCallCount != 1)
{
throw new RegressionTestException($"OnMarginCall was called {_onMarginCallCount} times, expected 1");
}

var orders = Transactions.GetOrders().ToList();
if (orders.Count <= 4)
{
throw new RegressionTestException(
$"Expected margin call orders in addition to the 4 strategy leg entries, but found {orders.Count} orders in total");
}

if (orders.Any(order => !order.Status.IsFill()))
{
throw new RegressionTestException("All orders should be filled");
}
}

private void AssertOptionStrategyIsPresent(string name)
{
if (Portfolio.Positions.Groups.Count(group =>
group.BuyingPowerModel is Securities.Option.OptionStrategyPositionGroupBuyingPowerModel model && model.ToString() == name) != 1)
{
throw new RegressionTestException($"Option strategy: '{name}' was not found!");
}
}

/// <summary>
/// This is used by the regression test system to indicate if the open source Lean repository has the required data to run this algorithm.
/// </summary>
public bool CanRunLocally { get; } = true;

/// <summary>
/// This is used by the regression test system to indicate which languages this algorithm is written in.
/// </summary>
public List<Language> Languages { get; } = new() { Language.CSharp, Language.Python };

/// <summary>
/// Data Points count of all timeslices of algorithm
/// </summary>
public long DataPoints => 15023;

/// <summary>
/// Data Points count of the algorithm history
/// </summary>
public int AlgorithmHistoryDataPoints => 0;

/// <summary>
/// Final status of the algorithm
/// </summary>
public AlgorithmStatus AlgorithmStatus => AlgorithmStatus.Completed;

/// <summary>
/// This is used by the regression test system to indicate what the expected statistics are from running the algorithm
/// </summary>
public Dictionary<string, string> ExpectedStatistics => new Dictionary<string, string>
{
{"Total Orders", "6"},
{"Average Win", "0%"},
{"Average Loss", "0%"},
{"Compounding Annual Return", "0%"},
{"Drawdown", "0%"},
{"Expectancy", "0"},
{"Start Equity", "200000"},
{"End Equity", "313"},
{"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", "$6.00"},
{"Estimated Strategy Capacity", "$250000.00"},
{"Lowest Capacity Asset", "GOOCV W87G1Y7EJGW6|GOOCV VP83T1ZUHROL"},
{"Portfolio Turnover", "5146.96%"},
{"Drawdown Recovery", "0"},
{"OrderListHash", "60f3e2ec37bcb6c5ccdcce8fbb14fe22"}
};
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,107 @@
# 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.

from AlgorithmImports import *

### <summary>
### Regression algorithm legging into multiple single-lot option strategy position groups with sequential
### market orders and then going through a margin call that requires a partial reduction of the groups.
### The margin call order quantity calculation probes degenerate (zero-quantity) trial groups, which used to
### crash the algorithm with "Sequence contains no matching element" in OptionStrategyPositionGroupBuyingPowerModel.
### </summary>
class LeggedInOptionStrategiesMarginCallRegressionAlgorithm(QCAlgorithm):
def initialize(self):
self.set_start_date(2015, 12, 24)
self.set_end_date(2015, 12, 24)
self.set_cash(200000)

equity = self.add_equity("GOOG", leverage=4)
option = self.add_option(equity.symbol)
self._option_symbol = option.symbol
option.set_filter(lambda u: u.standards_only().strikes(-2, +2).expiration(0, 180))

self._legged = False
self._cash_dropped = False
self._on_margin_call_count = 0

def on_data(self, slice):
if not self._legged:
chain = slice.option_chains.get(self._option_symbol)
if not self.is_market_open(self._option_symbol) or not chain:
return

contracts_by_expiry = {}
for contract in chain:
contracts_by_expiry.setdefault(contract.expiry, []).append(contract)
expiries = sorted(contracts_by_expiry.keys())

# A put spread at the nearest expiry: long the lowest strike put, short the next one
puts = sorted([x for x in contracts_by_expiry[expiries[0]] if x.right == OptionRight.PUT],
key=lambda x: x.strike)
long_put = puts[0]
short_put = next(x for x in puts if x.strike > long_put.strike)

# And a call spread at another expiry so two separate strategy groups are resolved
calls = next(c for c in
(sorted([x for x in contracts_by_expiry[expiry] if x.right == OptionRight.CALL], key=lambda x: x.strike)
for expiry in expiries[1:])
if len(c) > 1)
short_call = calls[0]
long_call = next(x for x in calls if x.strike > short_call.strike)

# Leg into the strategies with individual market orders instead of combo orders
self.market_order(short_call.symbol, -1)
self.market_order(long_call.symbol, +1)
self.market_order(short_put.symbol, -1)
self.market_order(long_put.symbol, +1)
self._legged = True

self.assert_option_strategy_is_present("Bear Call Spread")
self.assert_option_strategy_is_present("Bull Put Spread")
return

if not self._cash_dropped and self.portfolio.invested:
# Simulate a drawdown: equity drops below the margin used by the strategy groups so that the
# margin call model requests a partial reduction of the single-lot position groups
cash = self.portfolio.cash_book[Currencies.USD].amount
self.portfolio.cash_book[Currencies.USD].set_amount(
cash - self.portfolio.total_portfolio_value + 0.6 * self.portfolio.total_margin_used)
self._cash_dropped = True

def on_margin_call(self, requests):
self._on_margin_call_count += 1

for request in requests:
holdings_quantity = self.securities[request.symbol].holdings.quantity
if request.quantity != -holdings_quantity:
raise Exception(f"Expected margin call order for {request.symbol} to fully liquidate the "
f"{holdings_quantity} holdings, but its quantity was {request.quantity}")

return requests

def on_end_of_algorithm(self):
if self._on_margin_call_count != 1:
raise Exception(f"OnMarginCall was called {self._on_margin_call_count} times, expected 1")

orders = list(self.transactions.get_orders())
if len(orders) <= 4:
raise Exception(f"Expected margin call orders in addition to the 4 strategy leg entries, "
f"but found {len(orders)} orders in total")

if any(x.status != OrderStatus.FILLED for x in orders):
raise Exception("All orders should be filled")

def assert_option_strategy_is_present(self, name):
if sum(1 for group in self.portfolio.positions.groups
if str(group.buying_power_model) == name) != 1:
raise Exception(f"Option strategy: '{name}' was not found!")
16 changes: 16 additions & 0 deletions Algorithm/QCAlgorithm.Trading.cs
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,7 @@ public partial class QCAlgorithm
private bool _isGtdTfiForMooAndMocOrdersValidationWarningSent;
private bool _isOptionsOrderOnStockSplitWarningSent;
private bool _liquidateSymbolNotFoundWarningSent;
private bool _isSequentialOptionLegOrdersWarningSent;

/// <summary>
/// Transaction Manager - Process transaction fills and order management.
Expand Down Expand Up @@ -280,6 +281,21 @@ public OrderTicket MarketOrder(Symbol symbol, decimal quantity, bool asynchronou
}
}

// Legging into multi-leg option positions with sequential market orders works but exposes the user to
// execution risk between fills and to naked margin on the intermediate positions. Hint at combo orders once
if (!_isSequentialOptionLegOrdersWarningSent && security.Type.IsOption())
{
var canonical = security.Symbol.Canonical;
if (Portfolio.Positions.Groups.Any(group => group.Positions.Any(position => position.Symbol != security.Symbol
&& position.Symbol.SecurityType.IsOption() && position.Symbol.Canonical == canonical)))
{
Debug("Warning: detected market orders on individual option contracts while already holding other contracts of the " +
"same option chain. To enter a multi-leg option position atomically and get option strategy margin benefits, " +
"consider using a combo order (ComboMarketOrder) or an OptionStrategies helper with Buy/Sell instead.");
_isSequentialOptionLegOrdersWarningSent = true;
}
}

var request = CreateSubmitOrderRequest(OrderType.Market, security, quantity, tag, orderProperties ?? DefaultOrderProperties?.Clone(), asynchronous);

//Add the order and create a new order Id.
Expand Down
30 changes: 28 additions & 2 deletions Common/Securities/Option/OptionStrategy.cs
Original file line number Diff line number Diff line change
Expand Up @@ -61,7 +61,7 @@ public OptionStrategy(string name, Symbol canonicalSymbol, List<OptionLegData> o
{
Name = name;
CanonicalOption = canonicalSymbol;
Underlying = canonicalSymbol.Underlying;
Underlying = canonicalSymbol?.Underlying;
OptionLegs = optionLegs ?? new List<OptionLegData>();
UnderlyingLegs = underlyingLegs ?? new List<UnderlyingLegData>();

Expand Down Expand Up @@ -128,7 +128,8 @@ public static OptionStrategy Create(string name, IEnumerable<Leg> legs)
{
optionLegs.Add(optionLeg);

if (canonicalSymbol == null)
// legs created from strike/expiration data don't have a symbol until the strategy is traded
if (canonicalSymbol == null && optionLeg.Symbol != null)
{
canonicalSymbol = optionLeg.Symbol.Canonical;
}
Expand Down Expand Up @@ -162,6 +163,31 @@ public class OptionLegData : Leg
/// </summary>
public decimal Strike { get; set; }

/// <summary>
/// Creates a new instance of <see cref="OptionLegData"/>
/// </summary>
public OptionLegData()
{
}

/// <summary>
/// Creates a new instance of <see cref="OptionLegData"/> from the specified parameters.
/// The leg symbol is created from the strategy's canonical option symbol when the strategy is traded
/// </summary>
/// <param name="quantity">The quantity of the leg</param>
/// <param name="right">The option right of the leg</param>
/// <param name="strike">The strike price of the leg</param>
/// <param name="expiration">The expiration date of the leg</param>
/// <param name="orderPrice">Optional order limit price of the leg</param>
public OptionLegData(int quantity, OptionRight right, decimal strike, DateTime expiration, decimal? orderPrice = null)
{
Quantity = quantity;
Right = right;
Strike = strike;
Expiration = expiration;
OrderPrice = orderPrice;
}

/// <summary>
/// Creates a new instance of <see cref="OptionLegData"/> from the specified parameters
/// </summary>
Expand Down
Loading
Loading