diff --git a/Algorithm.CSharp/BracketOrderLimitEntryRegressionAlgorithm.cs b/Algorithm.CSharp/BracketOrderLimitEntryRegressionAlgorithm.cs new file mode 100644 index 000000000000..a4b0f17c3b95 --- /dev/null +++ b/Algorithm.CSharp/BracketOrderLimitEntryRegressionAlgorithm.cs @@ -0,0 +1,234 @@ +/* + * 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.Interfaces; +using QuantConnect.Orders; + +namespace QuantConnect.Algorithm.CSharp +{ + /// + /// Regression algorithm asserting the behavior of a bracket order (OTOCO) built through the generic api (an entry which triggers a one cancels other) + /// using a limit entry order: the take profit and the stop loss are held, they can't fill, until the entry order fills + /// + public class BracketOrderLimitEntryRegressionAlgorithm : QCAlgorithm, IRegressionAlgorithmDefinition + { + private Symbol _symbol; + private SubmitOrderRequest _entry; + private SubmitOrderRequest _takeProfit; + private SubmitOrderRequest _stopLoss; + private DateTime? _entryFillTime; + + /// + /// Initialise the data and resolution required, as well as the cash and start-end dates for your algorithm. All algorithms must initialized. + /// + public override void Initialize() + { + SetStartDate(2013, 10, 07); + SetEndDate(2013, 10, 11); + SetCash(100000); + + _symbol = AddEquity("SPY", Resolution.Minute).Symbol; + } + + /// + /// 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 (_entry == null) + { + var price = Securities[_symbol].Price; + + // the take profit and stop loss are held until the entry fills + _entry = OrderFactory.LimitOrder(_symbol, 100, Math.Round(price * 0.999m, 2), tag: "Entry"); + _takeProfit = OrderFactory.LimitOrder(_symbol, -100, Math.Round(price * 1.004m, 2), tag: "Take profit"); + _stopLoss = OrderFactory.StopMarketOrder(_symbol, -100, Math.Round(price * 0.99m, 2), tag: "Stop loss"); + _entry.Triggers(OrderFactory.OneCancelsOther(_takeProfit, _stopLoss)); + + // composed but not submitted yet: the contingency is already set, the set id is not + if (_entry.OrderId > 0 || Ticket(_entry) != null || _entry.Contingency.Id != 0 || _entry.Contingency.Links.Single().Role != ContingencyRole.Parent + || _takeProfit.Contingency.Links.Count != 2 || _stopLoss.Contingency.Links.Count != 2 || _stopLoss.Contingency.Links[1].Type != ContingencyType.OneCancelsOther) + { + throw new RegressionTestException("Unexpected order request state before being submitted"); + } + + var tickets = Order(_entry); + + if (tickets.Count != 3 || tickets[0] != Ticket(_entry) || tickets[1] != Ticket(_takeProfit) || tickets[2] != Ticket(_stopLoss) + || _entry.OrderId <= 0 || _takeProfit.OrderId <= 0 || _stopLoss.OrderId <= 0 + || tickets[1].Contingency.Links.Single(link => link.Role == null).Type != ContingencyType.OneCancelsOther) + { + throw new RegressionTestException("Unexpected order tickets"); + } + + // an order request can only be submitted once + try + { + Order(_entry); + throw new RegressionTestException("Expected an exception when submitting an order request twice"); + } + catch (ArgumentException) + { + } + } + + if (Ticket(_entry).Status != OrderStatus.Filled) + { + foreach (var child in new[] { Ticket(_takeProfit), Ticket(_stopLoss) }) + { + if (!child.Contingency.IsWaitingForTrigger || child.Status != OrderStatus.Submitted || child.QuantityFilled != 0) + { + throw new RegressionTestException($"Expected the child order to be held waiting for the entry to fill: {child}"); + } + } + + // held orders are not accounted as open quantity + var openQuantity = Transactions.GetOpenOrdersRemainingQuantity(_symbol); + if (openQuantity != 100) + { + throw new RegressionTestException($"Expected the open orders remaining quantity to be 100 but was {openQuantity}"); + } + } + } + + /// + /// Order event handler + /// + public override void OnOrderEvent(OrderEvent orderEvent) + { + if (orderEvent.Status != OrderStatus.Filled) + { + return; + } + + if (orderEvent.OrderId == Ticket(_entry).OrderId) + { + _entryFillTime = orderEvent.UtcTime; + } + else + { + var triggeredTime = orderEvent.Ticket.Contingency.Links.Single(x => x.Role == ContingencyRole.Child).TriggeredTime; + if (!_entryFillTime.HasValue || triggeredTime != _entryFillTime || orderEvent.UtcTime <= triggeredTime) + { + throw new RegressionTestException($"Expected the exit order to fill after being triggered by the entry fill at {_entryFillTime}: {orderEvent}"); + } + } + } + + private OrderTicket Ticket(SubmitOrderRequest request) + { + return Transactions.GetOrderTicket(request.OrderId); + } + + /// + /// End of algorithm run event handler + /// + public override void OnEndOfAlgorithm() + { + if (_entryFillTime == null) + { + throw new RegressionTestException("Expected the entry order to be filled"); + } + + var exits = new[] { Ticket(_takeProfit), Ticket(_stopLoss) }; + if (exits.Count(x => x.Status == OrderStatus.Filled) != 1 || exits.Count(x => x.Status == OrderStatus.Canceled) != 1) + { + throw new RegressionTestException($"Expected one exit to fill and the other to be canceled: {string.Join(" | ", exits.Select(x => x.ToString()))}"); + } + + if (exits.Any(x => x.Contingency.IsWaitingForTrigger || x.Contingency.Links.Single(link => link.Role == ContingencyRole.Child).TriggeredTime != _entryFillTime)) + { + throw new RegressionTestException("Expected both exits to be triggered at the entry fill time"); + } + + if (Portfolio.Invested || Transactions.GetOpenOrders().Count != 0) + { + throw new RegressionTestException("Expected the position to be closed and no open orders"); + } + + // the orders keep their contingencies + var order = Transactions.GetOrderById(Ticket(_stopLoss).OrderId); + if (order.Contingency?.Count != 3 || order.Contingency.Links.Count != 2 || order.IsWaitingForTrigger()) + { + throw new RegressionTestException("Unexpected order contingencies"); + } + } + + /// + /// This is used by the regression test system to indicate if the open source Lean repository has the required data to run this algorithm. + /// + public bool CanRunLocally { get; } = true; + + /// + /// This is used by the regression test system to indicate which languages this algorithm is written in. + /// + public List Languages { get; } = new() { Language.CSharp, Language.Python }; + + /// + /// Data Points count of all timeslices of algorithm + /// + public long DataPoints => 3943; + + /// + /// Data Points count of the algorithm history + /// + public int AlgorithmHistoryDataPoints => 0; + + /// + /// Final status of the algorithm + /// + public AlgorithmStatus AlgorithmStatus => AlgorithmStatus.Completed; + + /// + /// This is used by the regression test system to indicate what the expected statistics are from running the algorithm + /// + public Dictionary ExpectedStatistics => new Dictionary + { + {"Total Orders", "3"}, + {"Average Win", "0.07%"}, + {"Average Loss", "0%"}, + {"Compounding Annual Return", "5.626%"}, + {"Drawdown", "0.000%"}, + {"Expectancy", "0"}, + {"Start Equity", "100000"}, + {"End Equity", "100070"}, + {"Net Profit", "0.070%"}, + {"Sharpe Ratio", "0"}, + {"Sortino Ratio", "0"}, + {"Probabilistic Sharpe Ratio", "0%"}, + {"Loss Rate", "0%"}, + {"Win Rate", "100%"}, + {"Profit-Loss Ratio", "0"}, + {"Alpha", "0"}, + {"Beta", "0"}, + {"Annual Standard Deviation", "0"}, + {"Annual Variance", "0"}, + {"Information Ratio", "-8.91"}, + {"Tracking Error", "0.223"}, + {"Treynor Ratio", "0"}, + {"Total Fees", "$2.00"}, + {"Estimated Strategy Capacity", "$24000000.00"}, + {"Lowest Capacity Asset", "SPY R735QTJ8XC9X"}, + {"Portfolio Turnover", "5.80%"}, + {"Drawdown Recovery", "0"}, + {"OrderListHash", "52e1a35402ecc7e967322fe561f176d8"} + }; + } +} diff --git a/Algorithm.CSharp/BracketOrderRegressionAlgorithm.cs b/Algorithm.CSharp/BracketOrderRegressionAlgorithm.cs new file mode 100644 index 000000000000..187265917e38 --- /dev/null +++ b/Algorithm.CSharp/BracketOrderRegressionAlgorithm.cs @@ -0,0 +1,210 @@ +/* + * 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.Collections.Generic; +using System.Linq; +using QuantConnect.Data; +using QuantConnect.Interfaces; +using QuantConnect.Orders; + +namespace QuantConnect.Algorithm.CSharp +{ + /// + /// Regression algorithm asserting the behavior of the helper method (OTOCO): + /// a market entry order which once filled triggers a take profit and a stop loss order, the first one to fill cancels the other + /// + public class BracketOrderRegressionAlgorithm : QCAlgorithm, IRegressionAlgorithmDefinition + { + private Symbol _symbol; + private List _tickets; + private readonly List _orderEvents = new(); + + /// + /// Initialise the data and resolution required, as well as the cash and start-end dates for your algorithm. All algorithms must initialized. + /// + public override void Initialize() + { + SetStartDate(2013, 10, 07); + SetEndDate(2013, 10, 11); + SetCash(100000); + + _symbol = AddEquity("SPY", Resolution.Minute).Symbol; + } + + /// + /// 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 (_tickets != null) + { + return; + } + + var price = Securities[_symbol].Price; + _tickets = BracketOrder(_symbol, 100, takeProfitPrice: price * 1.005m, stopLossPrice: price * 0.995m, tag: "Bracket"); + + if (_tickets.Count != 3) + { + throw new RegressionTestException($"Expected 3 order tickets, but got {_tickets.Count}"); + } + + var entry = _tickets[0]; + var takeProfit = _tickets[1]; + var stopLoss = _tickets[2]; + if (entry.OrderType != OrderType.Market || entry.Status != OrderStatus.Filled) + { + throw new RegressionTestException($"Expected the market entry order to be filled: {entry}"); + } + if (takeProfit.OrderType != OrderType.Limit || takeProfit.Quantity != -100 || stopLoss.OrderType != OrderType.StopMarket || stopLoss.Quantity != -100) + { + throw new RegressionTestException("Unexpected take profit and stop loss orders"); + } + + foreach (var ticket in _tickets) + { + if (ticket.Contingency == null || ticket.Contingency.Count != 3 + || !ticket.Contingency.OrderIds.SetEquals(_tickets.Select(x => x.OrderId))) + { + throw new RegressionTestException($"Unexpected contingency for order {ticket.OrderId}"); + } + } + + var parent = entry.Contingency.Links.Single(); + if (parent.Type != ContingencyType.OneTriggersOther || parent.Role != ContingencyRole.Parent) + { + throw new RegressionTestException($"Unexpected entry contingencies: {string.Join(",", entry.Contingency.Links)}"); + } + + foreach (var child in new[] { takeProfit, stopLoss }) + { + // the entry already filled so they should of been triggered and be working + if (child.Contingency.IsWaitingForTrigger || child.Status != OrderStatus.Submitted || child.Contingency.Links.Count != 2 + || !child.Contingency.Links.Any(link => link.Type == ContingencyType.OneTriggersOther && link.Role == ContingencyRole.Child && link.Id == parent.Id + && link.Triggered && link.TriggeredTime == UtcTime) + || !child.Contingency.Links.Any(link => link.Type == ContingencyType.OneCancelsOther && link.Role == null)) + { + throw new RegressionTestException($"Unexpected child order state: {child}. Contingencies: {string.Join(",", child.Contingency.Links)}"); + } + } + } + + /// + /// Order event handler + /// + public override void OnOrderEvent(OrderEvent orderEvent) + { + _orderEvents.Add(orderEvent); + } + + /// + /// End of algorithm run event handler + /// + public override void OnEndOfAlgorithm() + { + if (_tickets == null) + { + throw new RegressionTestException("The bracket order was never submitted"); + } + + var exits = _tickets.Skip(1).ToList(); + var filled = exits.Where(x => x.Status == OrderStatus.Filled).ToList(); + var canceled = exits.Where(x => x.Status == OrderStatus.Canceled).ToList(); + if (filled.Count != 1 || canceled.Count != 1) + { + throw new RegressionTestException($"Expected one exit to fill and the other to be canceled: {string.Join(" | ", exits)}"); + } + + if (Portfolio.Invested) + { + throw new RegressionTestException("Expected the position to be closed by the bracket exit"); + } + + // the sibling is canceled right after the fill + var fillIndex = _orderEvents.FindIndex(x => x.OrderId == filled[0].OrderId && x.Status == OrderStatus.Filled); + var cancelEvent = _orderEvents[fillIndex + 1]; + if (cancelEvent.OrderId != canceled[0].OrderId || cancelEvent.Status != OrderStatus.Canceled || cancelEvent.UtcTime != _orderEvents[fillIndex].UtcTime) + { + throw new RegressionTestException($"Expected the sibling to be canceled right after the fill, but was: {cancelEvent}"); + } + + if (Transactions.GetOpenOrders().Count != 0) + { + throw new RegressionTestException("Unexpected open orders"); + } + } + + /// + /// This is used by the regression test system to indicate if the open source Lean repository has the required data to run this algorithm. + /// + public bool CanRunLocally { get; } = true; + + /// + /// This is used by the regression test system to indicate which languages this algorithm is written in. + /// + public List Languages { get; } = new() { Language.CSharp, Language.Python }; + + /// + /// Data Points count of all timeslices of algorithm + /// + public long DataPoints => 3943; + + /// + /// Data Points count of the algorithm history + /// + public int AlgorithmHistoryDataPoints => 0; + + /// + /// Final status of the algorithm + /// + public AlgorithmStatus AlgorithmStatus => AlgorithmStatus.Completed; + + /// + /// This is used by the regression test system to indicate what the expected statistics are from running the algorithm + /// + public Dictionary ExpectedStatistics => new Dictionary + { + {"Total Orders", "3"}, + {"Average Win", "0.07%"}, + {"Average Loss", "0%"}, + {"Compounding Annual Return", "5.586%"}, + {"Drawdown", "0.000%"}, + {"Expectancy", "0"}, + {"Start Equity", "100000"}, + {"End Equity", "100069.52"}, + {"Net Profit", "0.070%"}, + {"Sharpe Ratio", "0"}, + {"Sortino Ratio", "0"}, + {"Probabilistic Sharpe Ratio", "0%"}, + {"Loss Rate", "0%"}, + {"Win Rate", "100%"}, + {"Profit-Loss Ratio", "0"}, + {"Alpha", "0"}, + {"Beta", "0"}, + {"Annual Standard Deviation", "0"}, + {"Annual Variance", "0"}, + {"Information Ratio", "-8.91"}, + {"Tracking Error", "0.223"}, + {"Treynor Ratio", "0"}, + {"Total Fees", "$2.00"}, + {"Estimated Strategy Capacity", "$31000000.00"}, + {"Lowest Capacity Asset", "SPY R735QTJ8XC9X"}, + {"Portfolio Turnover", "5.80%"}, + {"Drawdown Recovery", "0"}, + {"OrderListHash", "4ab291d7e7df4d2d944da910653113b6"} + }; + } +} diff --git a/Algorithm.CSharp/ContingentComboOrderRegressionAlgorithm.cs b/Algorithm.CSharp/ContingentComboOrderRegressionAlgorithm.cs new file mode 100644 index 000000000000..5fafe430bb4b --- /dev/null +++ b/Algorithm.CSharp/ContingentComboOrderRegressionAlgorithm.cs @@ -0,0 +1,210 @@ +/* + * 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.Collections.Generic; +using System.Linq; +using QuantConnect.Data; +using QuantConnect.Data.Market; +using QuantConnect.Interfaces; +using QuantConnect.Orders; + +namespace QuantConnect.Algorithm.CSharp +{ + /// + /// Regression algorithm asserting the behavior of contingent combo orders: a combo market order which once all its legs fill + /// triggers two combo limit orders related through a one cancels other contingency. Each combo order is handled as a single unit: + /// when one of the combo limit orders fills all the legs of the other one are canceled. + /// + public class ContingentComboOrderRegressionAlgorithm : QCAlgorithm, IRegressionAlgorithmDefinition + { + private Symbol _optionSymbol; + private List _parent; + private List _farExit; + private List _marketableExit; + private List _parentTickets; + private List _farExitTickets; + private List _marketableExitTickets; + private int _step; + + /// + /// Initialise the data and resolution required, as well as the cash and start-end dates for your algorithm. All algorithms must initialized. + /// + public override void Initialize() + { + SetStartDate(2015, 12, 24); + SetEndDate(2015, 12, 24); + SetCash(100000); + + var equity = AddEquity("GOOG", leverage: 4, fillForward: true); + var option = AddOption(equity.Symbol, fillForward: true); + _optionSymbol = option.Symbol; + + option.SetFilter(u => u.StandardsOnly().Strikes(-2, +2).Expiration(0, 180)); + } + + /// + /// 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 (_parent == null) + { + if (!IsMarketOpen(_optionSymbol) || !slice.OptionChains.TryGetValue(_optionSymbol, out var chain)) + { + return; + } + + var callContracts = chain.Where(contract => contract.Right == OptionRight.Call) + .GroupBy(x => x.Expiry) + .OrderBy(grouping => grouping.Key) + .First() + .OrderBy(x => x.Strike) + .ToList(); + if (callContracts.Count < 3) + { + return; + } + + var legs = new List + { + Leg.Create(callContracts[0].Symbol, 1), + Leg.Create(callContracts[1].Symbol, -2), + Leg.Create(callContracts[2].Symbol, 1), + }; + var currentPrice = legs.Sum(leg => leg.Quantity * Securities[leg.Symbol].Close); + + // selling the combo: the first one is too expensive so it won't fill, the second one is marketable + _farExit = OrderFactory.ComboLimitOrder(legs, -2, currentPrice + 3m, tag: "Far exit"); + _marketableExit = OrderFactory.ComboLimitOrder(legs, -2, currentPrice - 1.5m, tag: "Marketable exit"); + _parent = OrderFactory.ComboMarketOrder(legs, 2, tag: "Parent"); + // the legs of a combo order are a single unit, they trigger together + var tickets = OneTriggersOtherOrder(_parent, OrderFactory.OneCancelsOther(_farExit.Concat(_marketableExit))); + _parentTickets = tickets.Take(3).ToList(); + _farExitTickets = tickets.Skip(3).Take(3).ToList(); + _marketableExitTickets = tickets.Skip(6).ToList(); + + if (tickets.Count != 9 || _parent.Any(leg => leg.Contingency.Count != 9) || _farExitTickets.Count != 3 || _marketableExitTickets.Count != 3 + || tickets.Any(x => x.Contingency.Count != 9) + || tickets.Select(x => x.SubmitRequest.GroupOrderManager.Id).Distinct().Count() != 3) + { + throw new RegressionTestException("Unexpected order tickets"); + } + + // the combo market order filled, all its legs, so the exits were triggered + if (_parentTickets.Any(x => x.Status != OrderStatus.Filled) + || _farExitTickets.Concat(_marketableExitTickets).Any(x => x.Contingency.IsWaitingForTrigger || x.Status != OrderStatus.Submitted)) + { + throw new RegressionTestException("Expected the parent combo order to be filled and the exits to be triggered"); + } + + // each leg holds the contingencies of its combo order + if (_parentTickets.Any(x => x.Contingency.Links.Single().Role != ContingencyRole.Parent) + || _farExitTickets.Concat(_marketableExitTickets).Any(x => x.Contingency.Links.Count != 2 + || x.Contingency.Links.Count(link => link.Role == ContingencyRole.Child && link.Triggered) != 1 + || x.Contingency.Links.Count(link => link.Role == null && link.Type == ContingencyType.OneCancelsOther) != 1)) + { + throw new RegressionTestException("Unexpected contingencies"); + } + return; + } + + if (++_step == 2) + { + // the marketable combo filled, all its legs, so all the legs of the other combo were canceled + if (_marketableExitTickets.Any(x => x.Status != OrderStatus.Filled) || _farExitTickets.Any(x => x.Status != OrderStatus.Canceled)) + { + throw new RegressionTestException("Expected the marketable exit to be filled and the far exit to be canceled"); + } + + if (Portfolio.Invested || Transactions.GetOpenOrders().Count != 0) + { + throw new RegressionTestException("Expected no position nor open orders"); + } + } + } + + /// + /// End of algorithm run event handler + /// + public override void OnEndOfAlgorithm() + { + if (_step < 2) + { + throw new RegressionTestException("Expected the contingent combo orders to be submitted and asserted"); + } + } + + /// + /// This is used by the regression test system to indicate if the open source Lean repository has the required data to run this algorithm. + /// + public bool CanRunLocally { get; } = true; + + /// + /// This is used by the regression test system to indicate which languages this algorithm is written in. + /// + public List Languages { get; } = new() { Language.CSharp, Language.Python }; + + /// + /// Data Points count of all timeslices of algorithm + /// + public long DataPoints => 15023; + + /// + /// Data Points count of the algorithm history + /// + public int AlgorithmHistoryDataPoints => 0; + + /// + /// Final status of the algorithm + /// + public AlgorithmStatus AlgorithmStatus => AlgorithmStatus.Completed; + + /// + /// This is used by the regression test system to indicate what the expected statistics are from running the algorithm + /// + public Dictionary ExpectedStatistics => new Dictionary + { + {"Total Orders", "9"}, + {"Average Win", "0%"}, + {"Average Loss", "0%"}, + {"Compounding Annual Return", "0%"}, + {"Drawdown", "0%"}, + {"Expectancy", "0"}, + {"Start Equity", "100000"}, + {"End Equity", "99311.8"}, + {"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", "$8.20"}, + {"Estimated Strategy Capacity", "$12000.00"}, + {"Lowest Capacity Asset", "GOOCV W78ZERHAT67A|GOOCV VP83T1ZUHROL"}, + {"Portfolio Turnover", "24.33%"}, + {"Drawdown Recovery", "0"}, + {"OrderListHash", "ecd9865c9fd95b98a8abb3fca6ddf42e"} + }; + } +} diff --git a/Algorithm.CSharp/ContingentOrderCancelRegressionAlgorithm.cs b/Algorithm.CSharp/ContingentOrderCancelRegressionAlgorithm.cs new file mode 100644 index 000000000000..cc20fd165af5 --- /dev/null +++ b/Algorithm.CSharp/ContingentOrderCancelRegressionAlgorithm.cs @@ -0,0 +1,227 @@ +/* + * 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.Interfaces; +using QuantConnect.Orders; + +namespace QuantConnect.Algorithm.CSharp +{ + /// + /// Regression algorithm asserting the behavior of canceling contingent orders: + /// - canceling a parent order cancels the orders it would of triggered, including the ones those would trigger in turn + /// - canceling a member of a one cancels other contingency cancels its siblings too, the contingency is canceled as a whole + /// like brokerages do, whether the members are working or still held waiting for their parent + /// + public class ContingentOrderCancelRegressionAlgorithm : QCAlgorithm, IRegressionAlgorithmDefinition + { + private Symbol _symbol; + private int _step; + private List _tickets; + + /// + /// Initialise the data and resolution required, as well as the cash and start-end dates for your algorithm. All algorithms must initialized. + /// + public override void Initialize() + { + SetStartDate(2013, 10, 07); + SetEndDate(2013, 10, 07); + SetCash(100000); + + _symbol = AddEquity("SPY", Resolution.Minute).Symbol; + } + + /// + /// 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) + { + var price = Securities[_symbol].Price; + // far from the market price, won't fill + var entryPrice = Math.Round(price * 0.9m, 2); + switch (_step) + { + case 0: + // a bracket whose take profit triggers another order in turn + var takeProfit = OrderFactory.LimitOrder(_symbol, -100, price * 1.1m).Triggers(OrderFactory.MarketOrder(_symbol, 10)); + var stopLoss = OrderFactory.StopMarketOrder(_symbol, -100, price * 0.8m); + var entry = OrderFactory.LimitOrder(_symbol, 100, entryPrice).Triggers(OrderFactory.OneCancelsOther(takeProfit, stopLoss)); + _tickets = Order(entry); + if (_tickets.Count != 4 || _tickets.Skip(1).Any(x => !x.Contingency.IsWaitingForTrigger)) + { + throw new RegressionTestException("Unexpected order tickets"); + } + break; + + case 1: + // canceling the parent cancels all the orders it would trigger + var response = _tickets[0].Cancel("Canceling the parent"); + if (!response.IsSuccess) + { + throw new RegressionTestException($"Expected the cancel request to succeed: {response}"); + } + break; + + case 2: + AssertCanceled(_tickets, _tickets); + // tickets are: entry, take profit, the order triggered by the take profit and the stop loss + var parentId = _tickets[0].OrderId; + var takeProfitId = _tickets[1].OrderId; + if (new[] { _tickets[1], _tickets[3] }.Any(x => !x.OrderEvents.Last().Message.Contains($"Contingent parent order {parentId} was canceled", StringComparison.InvariantCulture)) + || !_tickets[2].OrderEvents.Last().Message.Contains($"Contingent parent order {takeProfitId} was canceled", StringComparison.InvariantCulture)) + { + throw new RegressionTestException("Unexpected cancel event message"); + } + + _tickets = Order(OrderFactory.LimitOrder(_symbol, 100, entryPrice).Bracket(price * 1.1m, price * 0.8m)); + break; + + case 3: + // canceling a held take profit cancels its sibling stop loss too, the parent keeps working + _tickets[1].Cancel("Canceling the held take profit"); + break; + + case 4: + AssertCanceled(_tickets, _tickets.Skip(1)); + if (!_tickets[2].OrderEvents.Last().Message.Contains($"Contingent sibling order {_tickets[1].OrderId} was canceled", StringComparison.InvariantCulture)) + { + throw new RegressionTestException("Unexpected cancel event message for the sibling stop loss"); + } + _tickets[0].Cancel(); + break; + + case 5: + AssertCanceled(_tickets, _tickets); + + MarketOrder(_symbol, 100); + _tickets = OneCancelsOtherOrder(new List + { + OrderFactory.LimitOrder(_symbol, -100, Math.Round(price * 1.1m, 2)), + OrderFactory.StopMarketOrder(_symbol, -100, Math.Round(price * 0.9m, 2)) + }); + break; + + case 6: + // canceling a member cancels its siblings + _tickets[0].Cancel("Canceling a sibling"); + break; + + case 7: + AssertCanceled(_tickets, _tickets); + + // liquidate + Liquidate(); + break; + + case 8: + AssertCanceled(_tickets, _tickets); + if (Portfolio.Invested || Transactions.GetOpenOrders().Count != 0) + { + throw new RegressionTestException("Expected no position nor open orders"); + } + break; + } + _step++; + } + + private static void AssertCanceled(List tickets, IEnumerable expectedCanceled) + { + var canceled = expectedCanceled.Select(x => x.OrderId).ToHashSet(); + foreach (var ticket in tickets) + { + var expectedStatus = canceled.Contains(ticket.OrderId) ? OrderStatus.Canceled : OrderStatus.Submitted; + if (ticket.Status != expectedStatus) + { + throw new RegressionTestException($"Expected order {ticket.OrderId} status to be {expectedStatus} but was {ticket.Status}"); + } + } + } + + /// + /// End of algorithm run event handler + /// + public override void OnEndOfAlgorithm() + { + if (_step < 9) + { + throw new RegressionTestException($"Unexpected step count {_step}"); + } + } + + /// + /// This is used by the regression test system to indicate if the open source Lean repository has the required data to run this algorithm. + /// + public bool CanRunLocally { get; } = true; + + /// + /// This is used by the regression test system to indicate which languages this algorithm is written in. + /// + public List Languages { get; } = new() { Language.CSharp, Language.Python }; + + /// + /// Data Points count of all timeslices of algorithm + /// + public long DataPoints => 795; + + /// + /// Data Points count of the algorithm history + /// + public int AlgorithmHistoryDataPoints => 0; + + /// + /// Final status of the algorithm + /// + public AlgorithmStatus AlgorithmStatus => AlgorithmStatus.Completed; + + /// + /// This is used by the regression test system to indicate what the expected statistics are from running the algorithm + /// + public Dictionary ExpectedStatistics => new Dictionary + { + {"Total Orders", "11"}, + {"Average Win", "0%"}, + {"Average Loss", "0%"}, + {"Compounding Annual Return", "0%"}, + {"Drawdown", "0%"}, + {"Expectancy", "0"}, + {"Start Equity", "100000"}, + {"End Equity", "99991.95"}, + {"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", "$2.00"}, + {"Estimated Strategy Capacity", "$21000000.00"}, + {"Lowest Capacity Asset", "SPY R735QTJ8XC9X"}, + {"Portfolio Turnover", "28.97%"}, + {"Drawdown Recovery", "0"}, + {"OrderListHash", "b6bb48fdab4a83d8c32b3f700695b4e9"} + }; + } +} diff --git a/Algorithm.CSharp/ContingentOrderUpdateRegressionAlgorithm.cs b/Algorithm.CSharp/ContingentOrderUpdateRegressionAlgorithm.cs new file mode 100644 index 000000000000..7019ff97abc0 --- /dev/null +++ b/Algorithm.CSharp/ContingentOrderUpdateRegressionAlgorithm.cs @@ -0,0 +1,195 @@ +/* + * 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.Interfaces; +using QuantConnect.Orders; + +namespace QuantConnect.Algorithm.CSharp +{ + /// + /// Regression algorithm asserting the behavior of updating contingent orders: orders held waiting for their parent to fill + /// can be updated, as well as the parent and the orders already working. An order held with a marketable price does not fill + /// until it's triggered, and once triggered it requires new data to fill, just like any other order. + /// + public class ContingentOrderUpdateRegressionAlgorithm : QCAlgorithm, IRegressionAlgorithmDefinition + { + private Symbol _symbol; + private int _step; + private OrderTicket _entry; + private OrderTicket _takeProfit; + private OrderTicket _stopLoss; + + /// + /// Initialise the data and resolution required, as well as the cash and start-end dates for your algorithm. All algorithms must initialized. + /// + public override void Initialize() + { + SetStartDate(2013, 10, 07); + SetEndDate(2013, 10, 07); + SetCash(100000); + + _symbol = AddEquity("SPY", Resolution.Minute).Symbol; + } + + /// + /// 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) + { + var price = Securities[_symbol].Price; + switch (_step) + { + case 0: + // the entry is far from the market price, won't fill + var tickets = BracketOrder(_symbol, 100, takeProfitPrice: Math.Round(price * 1.1m, 2), stopLossPrice: Math.Round(price * 0.8m, 2), + limitPrice: Math.Round(price * 0.9m, 2)); + _entry = tickets[0]; + _takeProfit = tickets[1]; + _stopLoss = tickets[2]; + break; + + case 1: + // update the held orders: the take profit gets a marketable price, below the market price, it would fill if it was working + AssertSuccess(_takeProfit.UpdateLimitPrice(Math.Round(price * 0.95m, 2), "Updated take profit")); + AssertSuccess(_stopLoss.Update(new UpdateOrderFields { StopPrice = Math.Round(price * 0.85m, 2), Quantity = -100, Tag = "Updated stop loss" })); + break; + + case 2: + case 3: + if (_takeProfit.Status != OrderStatus.UpdateSubmitted || !_takeProfit.Contingency.IsWaitingForTrigger || _takeProfit.QuantityFilled != 0 + || _takeProfit.Tag != "Updated take profit" || _takeProfit.Get(OrderField.LimitPrice) >= price + || _stopLoss.Status != OrderStatus.UpdateSubmitted || !_stopLoss.Contingency.IsWaitingForTrigger || _stopLoss.Tag != "Updated stop loss") + { + throw new RegressionTestException($"Expected the held orders to be updated but not filled: {_takeProfit} | {_stopLoss}"); + } + + if (_step == 3) + { + // update the entry so it fills + AssertSuccess(_entry.UpdateLimitPrice(Math.Round(price * 1.01m, 2), "Updated entry")); + } + break; + + case 4: + // the updated entry filled right away triggering its children, which require new data to fill: just like any other order + // they don't fill with the data from the time they start working. So the marketable take profit filled with the next data, + // canceling the stop loss + if (_takeProfit.Status != OrderStatus.Filled || _stopLoss.Status != OrderStatus.Canceled || Portfolio.Invested) + { + throw new RegressionTestException($"Expected the take profit to be filled and the stop loss canceled: {_takeProfit} | {_stopLoss}"); + } + + var entryFillTime = _entry.OrderEvents.Single(x => x.Status == OrderStatus.Filled).UtcTime; + var takeProfitFillTime = _takeProfit.OrderEvents.Single(x => x.Status == OrderStatus.Filled).UtcTime; + if (takeProfitFillTime != entryFillTime.AddMinutes(1)) + { + throw new RegressionTestException($"Expected the take profit to fill the minute after the entry, entry: {entryFillTime} take profit: {takeProfitFillTime}"); + } + + // closed orders can't be updated + if (_stopLoss.UpdateStopPrice(1).IsSuccess) + { + throw new RegressionTestException("Expected the update of a canceled order to fail"); + } + break; + } + _step++; + } + + private static void AssertSuccess(OrderResponse response) + { + if (!response.IsSuccess) + { + throw new RegressionTestException($"Expected the order request to succeed: {response}"); + } + } + + /// + /// End of algorithm run event handler + /// + public override void OnEndOfAlgorithm() + { + if (_step < 5) + { + throw new RegressionTestException($"Unexpected step count {_step}"); + } + } + + /// + /// This is used by the regression test system to indicate if the open source Lean repository has the required data to run this algorithm. + /// + public bool CanRunLocally { get; } = true; + + /// + /// This is used by the regression test system to indicate which languages this algorithm is written in. + /// + public List Languages { get; } = new() { Language.CSharp, Language.Python }; + + /// + /// Data Points count of all timeslices of algorithm + /// + public long DataPoints => 795; + + /// + /// Data Points count of the algorithm history + /// + public int AlgorithmHistoryDataPoints => 0; + + /// + /// Final status of the algorithm + /// + public AlgorithmStatus AlgorithmStatus => AlgorithmStatus.Completed; + + /// + /// This is used by the regression test system to indicate what the expected statistics are from running the algorithm + /// + public Dictionary ExpectedStatistics => new Dictionary + { + {"Total Orders", "3"}, + {"Average Win", "0%"}, + {"Average Loss", "0%"}, + {"Compounding Annual Return", "0%"}, + {"Drawdown", "0%"}, + {"Expectancy", "0"}, + {"Start Equity", "100000"}, + {"End Equity", "100009.24"}, + {"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", "$2.00"}, + {"Estimated Strategy Capacity", "$16000000.00"}, + {"Lowest Capacity Asset", "SPY R735QTJ8XC9X"}, + {"Portfolio Turnover", "28.94%"}, + {"Drawdown Recovery", "0"}, + {"OrderListHash", "ce48af81e6d765f281d9ef34d6054056"} + }; + } +} diff --git a/Algorithm.CSharp/ContingentTrailingStopOrderRegressionAlgorithm.cs b/Algorithm.CSharp/ContingentTrailingStopOrderRegressionAlgorithm.cs new file mode 100644 index 000000000000..dfe5185f5b3d --- /dev/null +++ b/Algorithm.CSharp/ContingentTrailingStopOrderRegressionAlgorithm.cs @@ -0,0 +1,166 @@ +/* + * 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 QuantConnect.Data; +using QuantConnect.Interfaces; +using QuantConnect.Orders; + +namespace QuantConnect.Algorithm.CSharp +{ + /// + /// Regression algorithm asserting the behavior of a trailing stop order triggered by another order, through the generic + /// api: its stop price is set once it's triggered, from the market price at that time, from where it starts trailing + /// + public class ContingentTrailingStopOrderRegressionAlgorithm : QCAlgorithm, IRegressionAlgorithmDefinition + { + private const decimal TrailingPercentage = 0.005m; + private Symbol _symbol; + private SubmitOrderRequest _entry; + private SubmitOrderRequest _trailingStop; + private bool _assertedTriggeredStopPrice; + + /// + /// Initialise the data and resolution required, as well as the cash and start-end dates for your algorithm. All algorithms must initialized. + /// + public override void Initialize() + { + SetStartDate(2013, 10, 07); + SetEndDate(2013, 10, 11); + SetCash(100000); + + _symbol = AddEquity("SPY", Resolution.Minute).Symbol; + } + + /// + /// 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) + { + var price = Securities[_symbol].Price; + if (_entry == null) + { + _trailingStop = OrderFactory.TrailingStopOrder(_symbol, -100, TrailingPercentage, trailingAsPercentage: true, tag: "Trailing stop"); + _entry = OrderFactory.LimitOrder(_symbol, 100, Math.Round(price * 0.999m, 2), tag: "Entry").Triggers(_trailingStop); + Order(_entry); + } + + var stopPrice = Ticket(_trailingStop).Get(OrderField.StopPrice); + if (Ticket(_trailingStop).Contingency.IsWaitingForTrigger) + { + if (stopPrice != 0) + { + throw new RegressionTestException($"Expected the stop price of the held trailing stop order not to be set yet but was {stopPrice}"); + } + } + else if (!_assertedTriggeredStopPrice) + { + _assertedTriggeredStopPrice = true; + + // it was just triggered, the stop price is set from the current market price + var expectedStopPrice = price * (1 - TrailingPercentage); + if (Ticket(_entry).Status != OrderStatus.Filled || Math.Abs(stopPrice - expectedStopPrice) > 0.01m) + { + throw new RegressionTestException($"Expected the stop price to be {expectedStopPrice} but was {stopPrice}"); + } + } + } + + private OrderTicket Ticket(SubmitOrderRequest request) + { + return Transactions.GetOrderTicket(request.OrderId); + } + + /// + /// End of algorithm run event handler + /// + public override void OnEndOfAlgorithm() + { + if (!_assertedTriggeredStopPrice || Ticket(_trailingStop).Status != OrderStatus.Filled || Portfolio.Invested) + { + throw new RegressionTestException($"Expected the trailing stop order to be triggered and filled: {Ticket(_trailingStop)}"); + } + + // it trailed the market price up before filling + var entryFillPrice = Ticket(_entry).AverageFillPrice; + if (Ticket(_trailingStop).Get(OrderField.StopPrice) <= entryFillPrice * (1 - TrailingPercentage)) + { + throw new RegressionTestException("Expected the stop price to trail the market price"); + } + } + + /// + /// This is used by the regression test system to indicate if the open source Lean repository has the required data to run this algorithm. + /// + public bool CanRunLocally { get; } = true; + + /// + /// This is used by the regression test system to indicate which languages this algorithm is written in. + /// + public List Languages { get; } = new() { Language.CSharp, Language.Python }; + + /// + /// Data Points count of all timeslices of algorithm + /// + public long DataPoints => 3943; + + /// + /// Data Points count of the algorithm history + /// + public int AlgorithmHistoryDataPoints => 0; + + /// + /// Final status of the algorithm + /// + public AlgorithmStatus AlgorithmStatus => AlgorithmStatus.Completed; + + /// + /// This is used by the regression test system to indicate what the expected statistics are from running the algorithm + /// + public Dictionary ExpectedStatistics => new Dictionary + { + {"Total Orders", "2"}, + {"Average Win", "0.03%"}, + {"Average Loss", "0%"}, + {"Compounding Annual Return", "2.119%"}, + {"Drawdown", "0.100%"}, + {"Expectancy", "0"}, + {"Start Equity", "100000"}, + {"End Equity", "100026.81"}, + {"Net Profit", "0.027%"}, + {"Sharpe Ratio", "0"}, + {"Sortino Ratio", "0"}, + {"Probabilistic Sharpe Ratio", "0%"}, + {"Loss Rate", "0%"}, + {"Win Rate", "100%"}, + {"Profit-Loss Ratio", "0"}, + {"Alpha", "0"}, + {"Beta", "0"}, + {"Annual Standard Deviation", "0"}, + {"Annual Variance", "0"}, + {"Information Ratio", "-8.91"}, + {"Tracking Error", "0.223"}, + {"Treynor Ratio", "0"}, + {"Total Fees", "$2.00"}, + {"Estimated Strategy Capacity", "$37000000.00"}, + {"Lowest Capacity Asset", "SPY R735QTJ8XC9X"}, + {"Portfolio Turnover", "5.79%"}, + {"Drawdown Recovery", "0"}, + {"OrderListHash", "f19d8c82d90239b797ba96ed263d4426"} + }; + } +} diff --git a/Algorithm.CSharp/OneCancelsOtherOrderCashAccountRegressionAlgorithm.cs b/Algorithm.CSharp/OneCancelsOtherOrderCashAccountRegressionAlgorithm.cs new file mode 100644 index 000000000000..9ff57a1436cb --- /dev/null +++ b/Algorithm.CSharp/OneCancelsOtherOrderCashAccountRegressionAlgorithm.cs @@ -0,0 +1,162 @@ +/* + * 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.Brokerages; +using QuantConnect.Data; +using QuantConnect.Interfaces; +using QuantConnect.Orders; + +namespace QuantConnect.Algorithm.CSharp +{ + /// + /// Regression algorithm asserting contingent orders in a cash account: open orders reserve the cash they require, but the members + /// of a one cancels other contingency don't reserve it twice, since at most one of them will fill, nor do the orders held + /// waiting for their parent to fill. So we can submit a take profit and a stop loss for our whole position. + /// + public class OneCancelsOtherOrderCashAccountRegressionAlgorithm : QCAlgorithm, IRegressionAlgorithmDefinition + { + private Symbol _symbol; + private List _bracketTickets; + private List _exitTickets; + + /// + /// Initialise the data and resolution required, as well as the cash and start-end dates for your algorithm. All algorithms must initialized. + /// + public override void Initialize() + { + SetStartDate(2018, 4, 4); + SetEndDate(2018, 4, 4); + SetCash(10000); + SetCash("BTC", 1m); + + SetBrokerageModel(BrokerageName.Default, AccountType.Cash); + + _symbol = AddCrypto("BTCUSD", Resolution.Minute, Market.Coinbase).Symbol; + } + + /// + /// 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 (_exitTickets != null) + { + return; + } + + var price = Securities[_symbol].Price; + + // selling all the BTC we hold, each of them requires the whole position + _exitTickets = OneCancelsOtherOrder(new List + { + OrderFactory.LimitOrder(_symbol, -1, Math.Round(price * 1.002m, 2), tag: "Take Profit"), + OrderFactory.StopMarketOrder(_symbol, -1, Math.Round(price * 0.998m, 2), tag: "Stop Loss") + }); + + // using all our cash to buy more, once filled we sell what we bought + var quantity = Math.Round(9000 / price, 4); + _bracketTickets = BracketOrder(_symbol, quantity, takeProfitPrice: Math.Round(price * 1.5m, 2), stopLossPrice: Math.Round(price * 0.5m, 2), + limitPrice: Math.Round(price * 0.999m, 2)); + + foreach (var ticket in _exitTickets.Concat(_bracketTickets)) + { + if (ticket.Status != OrderStatus.Submitted) + { + throw new RegressionTestException($"Expected the order to be submitted: {ticket}. {ticket.SubmitRequest.Response}"); + } + } + } + + /// + /// End of algorithm run event handler + /// + public override void OnEndOfAlgorithm() + { + if (_exitTickets.Count(x => x.Status == OrderStatus.Filled) != 1 || _exitTickets.Count(x => x.Status == OrderStatus.Canceled) != 1) + { + throw new RegressionTestException($"Expected one exit to fill and the other to be canceled: {string.Join(" | ", _exitTickets)}"); + } + + if (_bracketTickets[0].Status != OrderStatus.Filled || _bracketTickets.Skip(1).Any(x => x.Contingency.IsWaitingForTrigger || x.Status == OrderStatus.Invalid)) + { + throw new RegressionTestException($"Expected the bracket entry to be filled and its exits triggered: {string.Join(" | ", _bracketTickets)}"); + } + } + + /// + /// This is used by the regression test system to indicate if the open source Lean repository has the required data to run this algorithm. + /// + public bool CanRunLocally { get; } = true; + + /// + /// This is used by the regression test system to indicate which languages this algorithm is written in. + /// + public List Languages { get; } = new() { Language.CSharp }; + + /// + /// Data Points count of all timeslices of algorithm + /// + public long DataPoints => 2897; + + /// + /// Data Points count of the algorithm history + /// + public int AlgorithmHistoryDataPoints => 10; + + /// + /// Final status of the algorithm + /// + public AlgorithmStatus AlgorithmStatus => AlgorithmStatus.Completed; + + /// + /// This is used by the regression test system to indicate what the expected statistics are from running the algorithm + /// + public Dictionary ExpectedStatistics => new Dictionary + { + {"Total Orders", "5"}, + {"Average Win", "0%"}, + {"Average Loss", "0%"}, + {"Compounding Annual Return", "0%"}, + {"Drawdown", "0%"}, + {"Expectancy", "0"}, + {"Start Equity", "17296.00"}, + {"End Equity", "16638.25"}, + {"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", "$0.00"}, + {"Estimated Strategy Capacity", "$43000.00"}, + {"Lowest Capacity Asset", "BTCUSD 2XR"}, + {"Portfolio Turnover", "97.76%"}, + {"Drawdown Recovery", "0"}, + {"OrderListHash", "d90a481c0d453bc43c7db8a13cedb04b"} + }; + } +} diff --git a/Algorithm.CSharp/OneCancelsOtherOrderRegressionAlgorithm.cs b/Algorithm.CSharp/OneCancelsOtherOrderRegressionAlgorithm.cs new file mode 100644 index 000000000000..fcbb316e26af --- /dev/null +++ b/Algorithm.CSharp/OneCancelsOtherOrderRegressionAlgorithm.cs @@ -0,0 +1,202 @@ +/* + * 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.Collections.Generic; +using System.Linq; +using QuantConnect.Data; +using QuantConnect.Interfaces; +using QuantConnect.Orders; + +namespace QuantConnect.Algorithm.CSharp +{ + /// + /// Regression algorithm asserting the behavior of the helper method (OCO/OCA): + /// a set of orders working at the same time where the first one to fill cancels the rest. We use it to exit an existing + /// position, each time it's closed we open it again and submit a new set of exit orders. + /// + public class OneCancelsOtherOrderRegressionAlgorithm : QCAlgorithm, IRegressionAlgorithmDefinition + { + private Symbol _symbol; + private List _tickets; + private int _completedSets; + private readonly HashSet _contingentOrderSetIds = new(); + + /// + /// Initialise the data and resolution required, as well as the cash and start-end dates for your algorithm. All algorithms must initialized. + /// + public override void Initialize() + { + SetStartDate(2013, 10, 07); + SetEndDate(2013, 10, 11); + SetCash(100000); + + _symbol = AddEquity("SPY", Resolution.Minute).Symbol; + } + + /// + /// 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 (_tickets != null) + { + if (_tickets.Any(x => x.Status.IsClosed())) + { + AssertCompletedSet(); + _tickets = null; + } + return; + } + + if (Portfolio.Invested || Transactions.GetOpenOrders().Count != 0) + { + throw new RegressionTestException("Expected no position nor open orders before submitting a new set of orders"); + } + + MarketOrder(_symbol, 100); + + var price = Securities[_symbol].Price; + _tickets = OneCancelsOtherOrder(new List + { + OrderFactory.LimitOrder(_symbol, -100, price * 1.003m, tag: "Take Profit"), + OrderFactory.StopMarketOrder(_symbol, -100, price * 0.997m, tag: "Stop Loss"), + OrderFactory.StopLimitOrder(_symbol, -100, price * 0.99m, price * 0.98m, tag: "Far Stop Loss") + }); + + if (_tickets.Count != 3) + { + throw new RegressionTestException($"Expected 3 order tickets, but got {_tickets.Count}"); + } + + foreach (var ticket in _tickets) + { + var contingency = ticket.Contingency.Links.Single(); + if (ticket.Contingency.IsWaitingForTrigger || ticket.Status != OrderStatus.Submitted || ticket.Contingency.Count != 3 + || contingency.Type != ContingencyType.OneCancelsOther || contingency.Role != null + || contingency.Id != _tickets[0].Contingency.Links[0].Id) + { + throw new RegressionTestException($"Unexpected order state: {ticket}. Contingencies: {string.Join(",", ticket.Contingency.Links)}"); + } + } + + if (!_contingentOrderSetIds.Add(_tickets[0].Contingency.Id)) + { + throw new RegressionTestException("Expected a new contingent order set id for each set of orders"); + } + + // at most one of them will fill + var openQuantity = Transactions.GetOpenOrdersRemainingQuantity(_symbol); + if (openQuantity != -100) + { + throw new RegressionTestException($"Expected the open orders remaining quantity to be -100 but was {openQuantity}"); + } + } + + private void AssertCompletedSet() + { + if (_tickets.Count(x => x.Status == OrderStatus.Filled) != 1 || _tickets.Count(x => x.Status == OrderStatus.Canceled) != 2) + { + throw new RegressionTestException($"Expected one order to fill and the others to be canceled: {string.Join(" | ", _tickets)}"); + } + + foreach (var canceled in _tickets.Where(x => x.Status == OrderStatus.Canceled)) + { + var cancelEvent = canceled.OrderEvents.Single(x => x.Status == OrderStatus.Canceled); + if (!cancelEvent.Message.Contains("Contingent sibling order", System.StringComparison.InvariantCulture)) + { + throw new RegressionTestException($"Unexpected cancel event message: {cancelEvent.Message}"); + } + } + + if (Portfolio.Invested) + { + throw new RegressionTestException("Expected the position to be closed"); + } + _completedSets++; + } + + /// + /// End of algorithm run event handler + /// + public override void OnEndOfAlgorithm() + { + if (_completedSets < 2) + { + throw new RegressionTestException($"Expected at least 2 completed sets of orders but got {_completedSets}"); + } + } + + /// + /// This is used by the regression test system to indicate if the open source Lean repository has the required data to run this algorithm. + /// + public bool CanRunLocally { get; } = true; + + /// + /// This is used by the regression test system to indicate which languages this algorithm is written in. + /// + public List Languages { get; } = new() { Language.CSharp, Language.Python }; + + /// + /// Data Points count of all timeslices of algorithm + /// + public long DataPoints => 3943; + + /// + /// Data Points count of the algorithm history + /// + public int AlgorithmHistoryDataPoints => 0; + + /// + /// Final status of the algorithm + /// + public AlgorithmStatus AlgorithmStatus => AlgorithmStatus.Completed; + + /// + /// This is used by the regression test system to indicate what the expected statistics are from running the algorithm + /// + public Dictionary ExpectedStatistics => new Dictionary + { + {"Total Orders", "84"}, + {"Average Win", "0.05%"}, + {"Average Loss", "-0.05%"}, + {"Compounding Annual Return", "14.732%"}, + {"Drawdown", "0.300%"}, + {"Expectancy", "0.166"}, + {"Start Equity", "100000"}, + {"End Equity", "100175.87"}, + {"Net Profit", "0.176%"}, + {"Sharpe Ratio", "3.916"}, + {"Sortino Ratio", "20.439"}, + {"Probabilistic Sharpe Ratio", "64.087%"}, + {"Loss Rate", "45%"}, + {"Win Rate", "55%"}, + {"Profit-Loss Ratio", "1.12"}, + {"Alpha", "-0.133"}, + {"Beta", "0.125"}, + {"Annual Standard Deviation", "0.029"}, + {"Annual Variance", "0.001"}, + {"Information Ratio", "-9.548"}, + {"Tracking Error", "0.195"}, + {"Treynor Ratio", "0.913"}, + {"Total Fees", "$41.00"}, + {"Estimated Strategy Capacity", "$29000000.00"}, + {"Lowest Capacity Asset", "SPY R735QTJ8XC9X"}, + {"Portfolio Turnover", "118.45%"}, + {"Drawdown Recovery", "2"}, + {"OrderListHash", "224828e3037b4636bab46ec66613ab34"} + }; + } +} diff --git a/Algorithm.CSharp/OneTriggersOtherOrderRegressionAlgorithm.cs b/Algorithm.CSharp/OneTriggersOtherOrderRegressionAlgorithm.cs new file mode 100644 index 000000000000..70d3c22fd81a --- /dev/null +++ b/Algorithm.CSharp/OneTriggersOtherOrderRegressionAlgorithm.cs @@ -0,0 +1,218 @@ +/* + * 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.Interfaces; +using QuantConnect.Orders; + +namespace QuantConnect.Algorithm.CSharp +{ + /// + /// Regression algorithm asserting the behavior of the helper method (OTO): + /// a parent order which once filled triggers multiple independent orders, for different symbols, one of which triggers another in turn (chain) + /// + public class OneTriggersOtherOrderRegressionAlgorithm : QCAlgorithm, IRegressionAlgorithmDefinition + { + private Symbol _spy; + private Symbol _ibm; + private Symbol _bac; + private SubmitOrderRequest _parent; + private SubmitOrderRequest _ibmChild; + private SubmitOrderRequest _bacGrandChild; + private SubmitOrderRequest _limitChild; + private List _tickets; + private readonly List _fillOrder = new(); + + /// + /// Initialise the data and resolution required, as well as the cash and start-end dates for your algorithm. All algorithms must initialized. + /// + public override void Initialize() + { + SetStartDate(2013, 10, 07); + SetEndDate(2013, 10, 11); + SetCash(100000); + + _spy = AddEquity("SPY", Resolution.Minute).Symbol; + _ibm = AddEquity("IBM", Resolution.Minute).Symbol; + _bac = AddEquity("BAC", Resolution.Minute).Symbol; + } + + /// + /// 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 (_parent == null) + { + if (!slice.ContainsKey(_spy) || !slice.ContainsKey(_ibm) || !slice.ContainsKey(_bac)) + { + return; + } + + var price = Securities[_spy].Price; + _parent = OrderFactory.LimitOrder(_spy, 100, Math.Round(price * 0.999m, 2), tag: "Parent"); + _bacGrandChild = OrderFactory.MarketOrder(_bac, 20, tag: "Grand child"); + _ibmChild = OrderFactory.MarketOrder(_ibm, 10, tag: "Child").Triggers(_bacGrandChild); + _limitChild = OrderFactory.LimitOrder(_spy, -50, Math.Round(price * 1.05m, 2), tag: "Independent child"); + + _tickets = OneTriggersOtherOrder(_parent, new List { _ibmChild, _limitChild }); + + var expectedTickets = new[] { Ticket(_parent), Ticket(_ibmChild), Ticket(_bacGrandChild), Ticket(_limitChild) }; + if (!_tickets.SequenceEqual(expectedTickets) || _tickets.Any(x => x.Contingency.Count != 4)) + { + throw new RegressionTestException("Unexpected order tickets"); + } + + // the IBM order is the child of a contingency and the parent of another one + var contingencies = Ticket(_ibmChild).Contingency.Links; + if (contingencies.Count != 2 || contingencies.Any(x => x.Type != ContingencyType.OneTriggersOther) + || contingencies.Single(x => x.Role == ContingencyRole.Child).Id != Ticket(_parent).Contingency.Links.Single().Id + || contingencies.Single(x => x.Role == ContingencyRole.Parent).Id != Ticket(_bacGrandChild).Contingency.Links.Single().Id + || Ticket(_limitChild).Contingency.Links.Single().Role != ContingencyRole.Child) + { + throw new RegressionTestException("Unexpected contingencies"); + } + } + + if (Ticket(_parent).Status != OrderStatus.Filled) + { + if (_tickets.Skip(1).Any(x => !x.Contingency.IsWaitingForTrigger || x.Status != OrderStatus.Submitted)) + { + throw new RegressionTestException("Expected all the orders to be held waiting for the parent to fill"); + } + } + else if (_tickets.Any(x => x.Contingency.IsWaitingForTrigger)) + { + throw new RegressionTestException("Expected all the orders to be triggered once the parent filled"); + } + } + + /// + /// Order event handler + /// + public override void OnOrderEvent(OrderEvent orderEvent) + { + if (orderEvent.Status == OrderStatus.Filled) + { + _fillOrder.Add(orderEvent.OrderId); + } + else if (orderEvent.Status == OrderStatus.Canceled) + { + throw new RegressionTestException($"Unexpected canceled order event, the triggered orders are independent: {orderEvent}"); + } + } + + private OrderTicket Ticket(SubmitOrderRequest request) + { + return Transactions.GetOrderTicket(request.OrderId); + } + + /// + /// End of algorithm run event handler + /// + public override void OnEndOfAlgorithm() + { + var expectedFillOrder = new[] { Ticket(_parent).OrderId, Ticket(_ibmChild).OrderId, Ticket(_bacGrandChild).OrderId }; + if (!_fillOrder.SequenceEqual(expectedFillOrder)) + { + throw new RegressionTestException($"Unexpected fill order: {string.Join(",", _fillOrder)}"); + } + + // market orders fill right away once triggered + var parentFillTime = Ticket(_parent).OrderEvents.Single(x => x.Status == OrderStatus.Filled).UtcTime; + if (Ticket(_ibmChild).OrderEvents.Single(x => x.Status == OrderStatus.Filled).UtcTime != parentFillTime + || Ticket(_bacGrandChild).OrderEvents.Single(x => x.Status == OrderStatus.Filled).UtcTime != parentFillTime) + { + throw new RegressionTestException("Expected the market orders to fill once triggered"); + } + + if (Portfolio[_spy].Quantity != 100 || Portfolio[_ibm].Quantity != 10 || Portfolio[_bac].Quantity != 20) + { + throw new RegressionTestException("Unexpected holdings"); + } + + // the independent limit order is still working + var openOrder = Transactions.GetOpenOrders().Single(); + if (openOrder.Id != Ticket(_limitChild).OrderId || openOrder.IsWaitingForTrigger() || openOrder.Status != OrderStatus.Submitted) + { + throw new RegressionTestException("Expected the independent limit order to be still working"); + } + } + + /// + /// This is used by the regression test system to indicate if the open source Lean repository has the required data to run this algorithm. + /// + public bool CanRunLocally { get; } = true; + + /// + /// This is used by the regression test system to indicate which languages this algorithm is written in. + /// + public List Languages { get; } = new() { Language.CSharp, Language.Python }; + + /// + /// Data Points count of all timeslices of algorithm + /// + public long DataPoints => 11743; + + /// + /// Data Points count of the algorithm history + /// + public int AlgorithmHistoryDataPoints => 0; + + /// + /// Final status of the algorithm + /// + public AlgorithmStatus AlgorithmStatus => AlgorithmStatus.Completed; + + /// + /// This is used by the regression test system to indicate what the expected statistics are from running the algorithm + /// + public Dictionary ExpectedStatistics => new Dictionary + { + {"Total Orders", "4"}, + {"Average Win", "0%"}, + {"Average Loss", "0%"}, + {"Compounding Annual Return", "25.744%"}, + {"Drawdown", "0.300%"}, + {"Expectancy", "0"}, + {"Start Equity", "100000"}, + {"End Equity", "100293.32"}, + {"Net Profit", "0.293%"}, + {"Sharpe Ratio", "5.352"}, + {"Sortino Ratio", "0"}, + {"Probabilistic Sharpe Ratio", "66.734%"}, + {"Loss Rate", "0%"}, + {"Win Rate", "0%"}, + {"Profit-Loss Ratio", "0"}, + {"Alpha", "-0.125"}, + {"Beta", "0.16"}, + {"Annual Standard Deviation", "0.036"}, + {"Annual Variance", "0.001"}, + {"Information Ratio", "-9.545"}, + {"Tracking Error", "0.187"}, + {"Treynor Ratio", "1.193"}, + {"Total Fees", "$3.00"}, + {"Estimated Strategy Capacity", "$510000000.00"}, + {"Lowest Capacity Asset", "NB R735QTJ8XC9X"}, + {"Portfolio Turnover", "3.21%"}, + {"Drawdown Recovery", "2"}, + {"OrderListHash", "b4f102bd24c3554af06b65aece16548e"} + }; + } +} diff --git a/Algorithm.CSharp/OneUpdatesOtherOrderRegressionAlgorithm.cs b/Algorithm.CSharp/OneUpdatesOtherOrderRegressionAlgorithm.cs new file mode 100644 index 000000000000..0737ee310f59 --- /dev/null +++ b/Algorithm.CSharp/OneUpdatesOtherOrderRegressionAlgorithm.cs @@ -0,0 +1,211 @@ +/* + * 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.Interfaces; +using QuantConnect.Orders; +using QuantConnect.Orders.Fills; +using QuantConnect.Securities; + +namespace QuantConnect.Algorithm.CSharp +{ + /// + /// Regression algorithm asserting the behavior of the helper method (OUO): + /// a partial fill of an order reduces the remaining quantity of its siblings proportionally, which are canceled once it completely fills. + /// A custom fill model is used to partially fill limit orders. + /// + public class OneUpdatesOtherOrderRegressionAlgorithm : QCAlgorithm, IRegressionAlgorithmDefinition + { + private Symbol _symbol; + private OrderTicket _takeProfit; + private OrderTicket _stopLoss; + private readonly List _stopLossQuantities = new(); + + /// + /// Initialise the data and resolution required, as well as the cash and start-end dates for your algorithm. All algorithms must initialized. + /// + public override void Initialize() + { + SetStartDate(2013, 10, 07); + SetEndDate(2013, 10, 11); + SetCash(100000); + + var equity = AddEquity("SPY", Resolution.Minute); + equity.SetFillModel(new PartialLimitFillModel()); + _symbol = equity.Symbol; + } + + /// + /// 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 (_takeProfit != null) + { + // the sibling quantity is reduced by the brokerage right after each partial fill + if (_stopLossQuantities.Count == 0 || _stopLossQuantities[^1] != _stopLoss.Quantity) + { + _stopLossQuantities.Add(_stopLoss.Quantity); + } + return; + } + + MarketOrder(_symbol, 100); + + var price = Securities[_symbol].Price; + var tickets = OneUpdatesOtherOrder(new List + { + OrderFactory.LimitOrder(_symbol, -100, Math.Round(price * 1.001m, 2), tag: "Take Profit"), + // twice the size so we can assert it's reduced proportionally + OrderFactory.StopMarketOrder(_symbol, -200, Math.Round(price * 0.9m, 2), tag: "Stop Loss") + }); + _takeProfit = tickets[0]; + _stopLoss = tickets[1]; + + if (tickets.Any(x => x.Contingency.Links.Single().Type != ContingencyType.OneUpdatesOther || x.Contingency.IsWaitingForTrigger)) + { + throw new RegressionTestException("Unexpected contingencies"); + } + } + + /// + /// End of algorithm run event handler + /// + public override void OnEndOfAlgorithm() + { + if (_takeProfit.Status != OrderStatus.Filled || _stopLoss.Status != OrderStatus.Canceled) + { + throw new RegressionTestException($"Expected the take profit to be filled and the stop loss canceled: {_takeProfit} | {_stopLoss}"); + } + + var partialFills = _takeProfit.OrderEvents.Count(x => x.Status == OrderStatus.PartiallyFilled); + if (partialFills != 2) + { + throw new RegressionTestException($"Expected 2 partial fills but got {partialFills}"); + } + + // 40 out of 100 filled => 200 * 60 / 100 = 120. Then 40 out of 60 remaining filled => 120 * 20 / 60 = 40 + var expectedQuantities = new[] { -200m, -120m, -40m }; + if (!_stopLossQuantities.SequenceEqual(expectedQuantities)) + { + throw new RegressionTestException($"Unexpected stop loss quantities: {string.Join(",", _stopLossQuantities)}"); + } + + if (Portfolio.Invested || Transactions.GetOpenOrders().Count != 0) + { + throw new RegressionTestException("Expected the position to be closed and no open orders"); + } + } + + /// + /// Fill model which fills limit orders in chunks of 40 shares + /// + private class PartialLimitFillModel : FillModel + { + private readonly Dictionary _absoluteRemainingByOrderId = new(); + + public override OrderEvent LimitFill(Security asset, LimitOrder order) + { + var fill = base.LimitFill(asset, order); + if (fill.Status != OrderStatus.Filled) + { + return fill; + } + + if (!_absoluteRemainingByOrderId.TryGetValue(order.Id, out var absoluteRemaining)) + { + absoluteRemaining = order.AbsoluteQuantity; + } + + if (absoluteRemaining <= 40) + { + fill.FillQuantity = Math.Sign(order.Quantity) * absoluteRemaining; + _absoluteRemainingByOrderId.Remove(order.Id); + } + else + { + fill.FillQuantity = Math.Sign(order.Quantity) * 40; + fill.Status = OrderStatus.PartiallyFilled; + _absoluteRemainingByOrderId[order.Id] = absoluteRemaining - 40; + } + return fill; + } + } + + /// + /// This is used by the regression test system to indicate if the open source Lean repository has the required data to run this algorithm. + /// + public bool CanRunLocally { get; } = true; + + /// + /// This is used by the regression test system to indicate which languages this algorithm is written in. + /// + public List Languages { get; } = new() { Language.CSharp }; + + /// + /// Data Points count of all timeslices of algorithm + /// + public long DataPoints => 3943; + + /// + /// Data Points count of the algorithm history + /// + public int AlgorithmHistoryDataPoints => 0; + + /// + /// Final status of the algorithm + /// + public AlgorithmStatus AlgorithmStatus => AlgorithmStatus.Completed; + + /// + /// This is used by the regression test system to indicate what the expected statistics are from running the algorithm + /// + public Dictionary ExpectedStatistics => new Dictionary + { + {"Total Orders", "3"}, + {"Average Win", "0.00%"}, + {"Average Loss", "0%"}, + {"Compounding Annual Return", "0.929%"}, + {"Drawdown", "0.000%"}, + {"Expectancy", "0"}, + {"Start Equity", "100000"}, + {"End Equity", "100011.83"}, + {"Net Profit", "0.012%"}, + {"Sharpe Ratio", "0"}, + {"Sortino Ratio", "0"}, + {"Probabilistic Sharpe Ratio", "0%"}, + {"Loss Rate", "0%"}, + {"Win Rate", "100%"}, + {"Profit-Loss Ratio", "0"}, + {"Alpha", "0"}, + {"Beta", "0"}, + {"Annual Standard Deviation", "0"}, + {"Annual Variance", "0"}, + {"Information Ratio", "-8.91"}, + {"Tracking Error", "0.223"}, + {"Treynor Ratio", "0"}, + {"Total Fees", "$2.00"}, + {"Estimated Strategy Capacity", "$16000000.00"}, + {"Lowest Capacity Asset", "SPY R735QTJ8XC9X"}, + {"Portfolio Turnover", "5.79%"}, + {"Drawdown Recovery", "0"}, + {"OrderListHash", "08b9360bf7365db81d43a86f81dfa919"} + }; + } +} diff --git a/Algorithm.Python/BracketOrderLimitEntryRegressionAlgorithm.py b/Algorithm.Python/BracketOrderLimitEntryRegressionAlgorithm.py new file mode 100644 index 000000000000..975572eb51b4 --- /dev/null +++ b/Algorithm.Python/BracketOrderLimitEntryRegressionAlgorithm.py @@ -0,0 +1,108 @@ +# 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 * + +### +### Regression algorithm asserting the behavior of a bracket order (OTOCO) built through the generic OrderFactory api (an entry which triggers a one cancels other) using +### a limit entry order: the take profit and the stop loss are held, they can't fill, until the entry order fills +### +class BracketOrderLimitEntryRegressionAlgorithm(QCAlgorithm): + + def initialize(self) -> None: + self.set_start_date(2013, 10, 7) + self.set_end_date(2013, 10, 11) + self.set_cash(100000) + + self._symbol = self.add_equity("SPY", Resolution.MINUTE).symbol + self._entry = None + self._take_profit = None + self._stop_loss = None + self._entry_fill_time = None + + def on_data(self, slice: Slice) -> None: + if self._entry is None: + price = self.securities[self._symbol].price + + # the take profit and stop loss are held until the entry fills + self._entry = self.order_factory.limit_order(self._symbol, 100, round(price * 0.999, 2), tag="Entry") + self._take_profit = self.order_factory.limit_order(self._symbol, -100, round(price * 1.004, 2), tag="Take profit") + self._stop_loss = self.order_factory.stop_market_order(self._symbol, -100, round(price * 0.99, 2), tag="Stop loss") + self._entry.triggers(self.order_factory.one_cancels_other(self._take_profit, self._stop_loss)) + + # composed but not submitted yet: the contingency is already set, the set id is not + entry_links = self._entry.contingency.links + stop_loss_links = self._stop_loss.contingency.links + if (self._entry.order_id > 0 or self._ticket(self._entry) is not None or self._entry.contingency.id != 0 or entry_links[0].role != ContingencyRole.PARENT + or len(self._take_profit.contingency.links) != 2 or len(stop_loss_links) != 2 or stop_loss_links[1].type != ContingencyType.ONE_CANCELS_OTHER): + raise RegressionTestException("Unexpected order request state before being submitted") + + tickets = self.order(self._entry) + + if (len(tickets) != 3 or tickets[0].order_id != self._entry.order_id or tickets[1].order_id != self._take_profit.order_id + or tickets[2].order_id != self._stop_loss.order_id or self._entry.order_id <= 0 + or next(x for x in tickets[1].contingency.links if x.role is None).type != ContingencyType.ONE_CANCELS_OTHER): + raise RegressionTestException("Unexpected order tickets") + + # an order request can only be submitted once + try: + self.order(self._entry) + raise RegressionTestException("Expected an exception when submitting an order request twice") + except ArgumentException: + pass + + if self._ticket(self._entry).status != OrderStatus.FILLED: + for child in [self._ticket(self._take_profit), self._ticket(self._stop_loss)]: + if not child.contingency.is_waiting_for_trigger or child.status != OrderStatus.SUBMITTED or child.quantity_filled != 0: + raise RegressionTestException(f"Expected the child order to be held waiting for the entry to fill: {child}") + + # held orders are not accounted as open quantity + open_quantity = self.transactions.get_open_orders_remaining_quantity(self._symbol) + if open_quantity != 100: + raise RegressionTestException(f"Expected the open orders remaining quantity to be 100 but was {open_quantity}") + + def _get_triggered_time(self, ticket: OrderTicket) -> datetime: + return next(x for x in ticket.contingency.links if x.role == ContingencyRole.CHILD).triggered_time + + def on_order_event(self, order_event: OrderEvent) -> None: + if order_event.status != OrderStatus.FILLED: + return + + if order_event.order_id == self._ticket(self._entry).order_id: + self._entry_fill_time = order_event.utc_time + else: + triggered_time = self._get_triggered_time(order_event.ticket) + if self._entry_fill_time is None or triggered_time != self._entry_fill_time or order_event.utc_time <= triggered_time: + raise RegressionTestException(f"Expected the exit order to fill after being triggered by the entry fill at {self._entry_fill_time}: {order_event}") + + def _ticket(self, request: SubmitOrderRequest) -> OrderTicket: + return self.transactions.get_order_ticket(request.order_id) + + def on_end_of_algorithm(self) -> None: + if self._entry_fill_time is None: + raise RegressionTestException("Expected the entry order to be filled") + + exits = [self._ticket(self._take_profit), self._ticket(self._stop_loss)] + if len([x for x in exits if x.status == OrderStatus.FILLED]) != 1 or len([x for x in exits if x.status == OrderStatus.CANCELED]) != 1: + raise RegressionTestException("Expected one exit to fill and the other to be canceled") + + if any(x.contingency.is_waiting_for_trigger or self._get_triggered_time(x) != self._entry_fill_time for x in exits): + raise RegressionTestException("Expected both exits to be triggered at the entry fill time") + + if self.portfolio.invested or len(self.transactions.get_open_orders()) != 0: + raise RegressionTestException("Expected the position to be closed and no open orders") + + # the orders keep their contingencies + order = self.transactions.get_order_by_id(self._ticket(self._stop_loss).order_id) + if order.contingency is None or order.contingency.count != 3 or len(order.contingency.links) != 2: + raise RegressionTestException("Unexpected order contingencies") diff --git a/Algorithm.Python/BracketOrderRegressionAlgorithm.py b/Algorithm.Python/BracketOrderRegressionAlgorithm.py new file mode 100644 index 000000000000..7877501d2165 --- /dev/null +++ b/Algorithm.Python/BracketOrderRegressionAlgorithm.py @@ -0,0 +1,93 @@ +# 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 * + +### +### Regression algorithm asserting the behavior of the bracket_order helper method (OTOCO): a market entry order +### which once filled triggers a take profit and a stop loss order, the first one to fill cancels the other +### +class BracketOrderRegressionAlgorithm(QCAlgorithm): + + def initialize(self) -> None: + self.set_start_date(2013, 10, 7) + self.set_end_date(2013, 10, 11) + self.set_cash(100000) + + self._symbol = self.add_equity("SPY", Resolution.MINUTE).symbol + self._tickets = None + self._order_events = [] + + def on_data(self, slice: Slice) -> None: + if self._tickets is not None: + return + + price = self.securities[self._symbol].price + self._tickets = self.bracket_order(self._symbol, 100, take_profit_price=price * 1.005, stop_loss_price=price * 0.995, tag="Bracket") + + if len(self._tickets) != 3: + raise RegressionTestException(f"Expected 3 order tickets, but got {len(self._tickets)}") + + entry, take_profit, stop_loss = self._tickets + if entry.order_type != OrderType.MARKET or entry.status != OrderStatus.FILLED: + raise RegressionTestException(f"Expected the market entry order to be filled: {entry}") + if (take_profit.order_type != OrderType.LIMIT or take_profit.quantity != -100 + or stop_loss.order_type != OrderType.STOP_MARKET or stop_loss.quantity != -100): + raise RegressionTestException("Unexpected take profit and stop loss orders") + + order_ids = sorted([x.order_id for x in self._tickets]) + for ticket in self._tickets: + contingency = ticket.contingency + if contingency is None or contingency.count != 3 or sorted(contingency.order_ids) != order_ids: + raise RegressionTestException(f"Unexpected contingency for order {ticket.order_id}") + + entry_contingencies = entry.contingency.links + parent = entry_contingencies[0] + if len(entry_contingencies) != 1 or parent.type != ContingencyType.ONE_TRIGGERS_OTHER or parent.role != ContingencyRole.PARENT: + raise RegressionTestException("Unexpected entry contingencies") + + for child in [take_profit, stop_loss]: + contingencies = child.contingency.links + # the entry already filled so they should of been triggered and be working + triggered = [c for c in contingencies if c.type == ContingencyType.ONE_TRIGGERS_OTHER and c.role == ContingencyRole.CHILD + and c.id == parent.id and c.triggered and c.triggered_time == self.utc_time] + member = [c for c in contingencies if c.type == ContingencyType.ONE_CANCELS_OTHER and c.role is None] + if (child.contingency.is_waiting_for_trigger or child.status != OrderStatus.SUBMITTED or len(contingencies) != 2 + or len(triggered) != 1 or len(member) != 1): + raise RegressionTestException(f"Unexpected child order state: {child}") + + def on_order_event(self, order_event: OrderEvent) -> None: + self._order_events.append(order_event) + + def on_end_of_algorithm(self) -> None: + if self._tickets is None: + raise RegressionTestException("The bracket order was never submitted") + + exits = self._tickets[1:] + filled = [x for x in exits if x.status == OrderStatus.FILLED] + canceled = [x for x in exits if x.status == OrderStatus.CANCELED] + if len(filled) != 1 or len(canceled) != 1: + raise RegressionTestException("Expected one exit to fill and the other to be canceled") + + if self.portfolio.invested: + raise RegressionTestException("Expected the position to be closed by the bracket exit") + + # the sibling is canceled right after the fill + fill_index = next(i for i, x in enumerate(self._order_events) if x.order_id == filled[0].order_id and x.status == OrderStatus.FILLED) + cancel_event = self._order_events[fill_index + 1] + if (cancel_event.order_id != canceled[0].order_id or cancel_event.status != OrderStatus.CANCELED + or cancel_event.utc_time != self._order_events[fill_index].utc_time): + raise RegressionTestException(f"Expected the sibling to be canceled right after the fill, but was: {cancel_event}") + + if len(self.transactions.get_open_orders()) != 0: + raise RegressionTestException("Unexpected open orders") diff --git a/Algorithm.Python/ContingentComboOrderRegressionAlgorithm.py b/Algorithm.Python/ContingentComboOrderRegressionAlgorithm.py new file mode 100644 index 000000000000..23df3fadc99b --- /dev/null +++ b/Algorithm.Python/ContingentComboOrderRegressionAlgorithm.py @@ -0,0 +1,106 @@ +# 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 * + +### +### Regression algorithm asserting the behavior of contingent combo orders: a combo market order which once all its legs fill +### triggers two combo limit orders related through a one cancels other contingency. Each combo order is handled as a single unit: +### when one of the combo limit orders fills all the legs of the other one are canceled. +### +class ContingentComboOrderRegressionAlgorithm(QCAlgorithm): + + def initialize(self) -> None: + self.set_start_date(2015, 12, 24) + self.set_end_date(2015, 12, 24) + self.set_cash(100000) + + equity = self.add_equity("GOOG", leverage=4, fill_forward=True) + option = self.add_option(equity.symbol, fill_forward=True) + self._option_symbol = option.symbol + + option.set_filter(lambda u: u.standards_only().strikes(-2, 2).expiration(0, 180)) + + self._parent = None + self._step = 0 + + def on_data(self, slice: Slice) -> None: + if self._parent is None: + chain = slice.option_chains.get(self._option_symbol) + if not self.is_market_open(self._option_symbol) or chain is None: + return + + calls = [contract for contract in chain if contract.right == OptionRight.CALL] + if not calls: + return + expiry = min(contract.expiry for contract in calls) + call_contracts = sorted([contract for contract in calls if contract.expiry == expiry], key=lambda contract: contract.strike) + if len(call_contracts) < 3: + return + + legs = [ + Leg.create(call_contracts[0].symbol, 1), + Leg.create(call_contracts[1].symbol, -2), + Leg.create(call_contracts[2].symbol, 1), + ] + current_price = sum(leg.quantity * self.securities[leg.symbol].close for leg in legs) + + # selling the combo: the first one is too expensive so it won't fill, the second one is marketable + self._far_exit = self.order_factory.combo_limit_order(legs, -2, current_price + 3, tag="Far exit") + self._marketable_exit = self.order_factory.combo_limit_order(legs, -2, current_price - 1.5, tag="Marketable exit") + self._parent = self.order_factory.combo_market_order(legs, 2, tag="Parent") + # the legs of a combo order are a single unit, they trigger together + tickets = self.one_triggers_other_order(self._parent, self.order_factory.one_cancels_other(self._far_exit + self._marketable_exit)) + + self._parent_tickets = [self._ticket(leg) for leg in self._parent] + self._far_exit_tickets = [self._ticket(leg) for leg in self._far_exit] + self._marketable_exit_tickets = [self._ticket(leg) for leg in self._marketable_exit] + + if (len(tickets) != 9 or [x.order_id for x in tickets] != [x.order_id for x in self._parent_tickets + self._far_exit_tickets + self._marketable_exit_tickets] + or any(leg.contingency.count != 9 for leg in self._parent) + or any(x.contingency.count != 9 for x in tickets) + or len({x.submit_request.group_order_manager.id for x in tickets}) != 3): + raise RegressionTestException("Unexpected order tickets") + + # the combo market order filled, all its legs, so the exits were triggered + if (any(x.status != OrderStatus.FILLED for x in self._parent_tickets) + or any(x.contingency.is_waiting_for_trigger or x.status != OrderStatus.SUBMITTED for x in self._far_exit_tickets + self._marketable_exit_tickets)): + raise RegressionTestException("Expected the parent combo order to be filled and the exits to be triggered") + + # each leg holds the contingencies of its combo order + if any(len(x.contingency.links) != 1 or x.contingency.links[0].role != ContingencyRole.PARENT for x in self._parent_tickets): + raise RegressionTestException("Unexpected contingencies") + for ticket in self._far_exit_tickets + self._marketable_exit_tickets: + links = ticket.contingency.links + if (len(links) != 2 + or sum(1 for link in links if link.role == ContingencyRole.CHILD and link.triggered) != 1 + or sum(1 for link in links if link.role is None and link.type == ContingencyType.ONE_CANCELS_OTHER) != 1): + raise RegressionTestException("Unexpected contingencies") + return + + self._step += 1 + if self._step == 2: + # the marketable combo filled, all its legs, so all the legs of the other combo were canceled + if (any(x.status != OrderStatus.FILLED for x in self._marketable_exit_tickets) + or any(x.status != OrderStatus.CANCELED for x in self._far_exit_tickets)): + raise RegressionTestException("Expected the marketable exit to be filled and the far exit to be canceled") + + if self.portfolio.invested or len(self.transactions.get_open_orders()) != 0: + raise RegressionTestException("Expected no position nor open orders") + + def _ticket(self, request: SubmitOrderRequest) -> OrderTicket: + return self.transactions.get_order_ticket(request.order_id) + + def on_end_of_algorithm(self) -> None: + if self._step < 2: + raise RegressionTestException("Expected the contingent combo orders to be submitted and asserted") diff --git a/Algorithm.Python/ContingentOrderCancelRegressionAlgorithm.py b/Algorithm.Python/ContingentOrderCancelRegressionAlgorithm.py new file mode 100644 index 000000000000..a7ce19b377bd --- /dev/null +++ b/Algorithm.Python/ContingentOrderCancelRegressionAlgorithm.py @@ -0,0 +1,109 @@ +# 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 * + +### +### Regression algorithm asserting the behavior of canceling contingent orders: +### - canceling a parent order cancels the orders it would of triggered, including the ones those would trigger in turn +### - canceling a member of a one cancels other contingency cancels its siblings too, the contingency is canceled as a whole +### like brokerages do, whether the members are working or still held waiting for their parent +### +class ContingentOrderCancelRegressionAlgorithm(QCAlgorithm): + + def initialize(self) -> None: + self.set_start_date(2013, 10, 7) + self.set_end_date(2013, 10, 7) + self.set_cash(100000) + + self._symbol = self.add_equity("SPY", Resolution.MINUTE).symbol + self._step = 0 + self._tickets = None + + def on_data(self, slice: Slice) -> None: + price = self.securities[self._symbol].price + # far from the market price, won't fill + entry_price = round(price * 0.9, 2) + + if self._step == 0: + # a bracket whose take profit triggers another order in turn + take_profit = self.order_factory.limit_order(self._symbol, -100, price * 1.1).triggers(self.order_factory.market_order(self._symbol, 10)) + stop_loss = self.order_factory.stop_market_order(self._symbol, -100, price * 0.8) + entry = self.order_factory.limit_order(self._symbol, 100, entry_price).triggers(self.order_factory.one_cancels_other(take_profit, stop_loss)) + self._tickets = self.order(entry) + if len(self._tickets) != 4 or any(not x.contingency.is_waiting_for_trigger for x in self._tickets[1:]): + raise RegressionTestException("Unexpected order tickets") + + elif self._step == 1: + # canceling the parent cancels all the orders it would trigger + response = self._tickets[0].cancel("Canceling the parent") + if not response.is_success: + raise RegressionTestException(f"Expected the cancel request to succeed: {response}") + + elif self._step == 2: + self._assert_canceled(self._tickets, self._tickets) + # tickets are: entry, take profit, the order triggered by the take profit and the stop loss + parent_id = self._tickets[0].order_id + take_profit_id = self._tickets[1].order_id + if (any(f"Contingent parent order {parent_id} was canceled" not in x.order_events[-1].message for x in [self._tickets[1], self._tickets[3]]) + or f"Contingent parent order {take_profit_id} was canceled" not in self._tickets[2].order_events[-1].message): + raise RegressionTestException("Unexpected cancel event message") + + self._tickets = self.order(self.order_factory.limit_order(self._symbol, 100, entry_price).bracket(price * 1.1, price * 0.8)) + + elif self._step == 3: + # canceling a held take profit cancels its sibling stop loss too, the parent keeps working + self._tickets[1].cancel("Canceling the held take profit") + + elif self._step == 4: + self._assert_canceled(self._tickets, self._tickets[1:]) + if f"Contingent sibling order {self._tickets[1].order_id} was canceled" not in self._tickets[2].order_events[-1].message: + raise RegressionTestException("Unexpected cancel event message for the sibling stop loss") + self._tickets[0].cancel() + + elif self._step == 5: + self._assert_canceled(self._tickets, self._tickets) + + self.market_order(self._symbol, 100) + self._tickets = self.one_cancels_other_order([ + self.order_factory.limit_order(self._symbol, -100, round(price * 1.1, 2)), + self.order_factory.stop_market_order(self._symbol, -100, round(price * 0.9, 2)) + ]) + + elif self._step == 6: + # canceling a member cancels its siblings + self._tickets[0].cancel("Canceling a sibling") + + elif self._step == 7: + self._assert_canceled(self._tickets, self._tickets) + + # liquidate + self.liquidate() + + elif self._step == 8: + self._assert_canceled(self._tickets, self._tickets) + if self.portfolio.invested or len(self.transactions.get_open_orders()) != 0: + raise RegressionTestException("Expected no position nor open orders") + + self._step += 1 + + def _assert_canceled(self, tickets: list[OrderTicket], expected_canceled: list[OrderTicket]) -> None: + canceled = [x.order_id for x in expected_canceled] + for ticket in tickets: + expected_status = OrderStatus.CANCELED if ticket.order_id in canceled else OrderStatus.SUBMITTED + if ticket.status != expected_status: + raise RegressionTestException(f"Expected order {ticket.order_id} status to be {expected_status} but was {ticket.status}") + + def on_end_of_algorithm(self) -> None: + if self._step < 9: + raise RegressionTestException(f"Unexpected step count {self._step}") diff --git a/Algorithm.Python/ContingentOrderUpdateRegressionAlgorithm.py b/Algorithm.Python/ContingentOrderUpdateRegressionAlgorithm.py new file mode 100644 index 000000000000..436a6c9a44cf --- /dev/null +++ b/Algorithm.Python/ContingentOrderUpdateRegressionAlgorithm.py @@ -0,0 +1,85 @@ +# 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 * + +### +### Regression algorithm asserting the behavior of updating contingent orders: orders held waiting for their parent to fill +### can be updated, as well as the parent and the orders already working. An order held with a marketable price does not fill +### until it's triggered, and once triggered it requires new data to fill, just like any other order. +### +class ContingentOrderUpdateRegressionAlgorithm(QCAlgorithm): + + def initialize(self) -> None: + self.set_start_date(2013, 10, 7) + self.set_end_date(2013, 10, 7) + self.set_cash(100000) + + self._symbol = self.add_equity("SPY", Resolution.MINUTE).symbol + self._step = 0 + + def on_data(self, slice: Slice) -> None: + price = self.securities[self._symbol].price + + if self._step == 0: + # the entry is far from the market price, won't fill + tickets = self.bracket_order(self._symbol, 100, take_profit_price=round(price * 1.1, 2), stop_loss_price=round(price * 0.8, 2), + limit_price=round(price * 0.9, 2)) + self._entry, self._take_profit, self._stop_loss = tickets + + elif self._step == 1: + # update the held orders: the take profit gets a marketable price, below the market price, it would fill if it was working + self._assert_success(self._take_profit.update_limit_price(round(price * 0.95, 2), "Updated take profit")) + update_fields = UpdateOrderFields() + update_fields.stop_price = round(price * 0.85, 2) + update_fields.quantity = -100 + update_fields.tag = "Updated stop loss" + self._assert_success(self._stop_loss.update(update_fields)) + + elif self._step == 2 or self._step == 3: + if (self._take_profit.status != OrderStatus.UPDATE_SUBMITTED or not self._take_profit.contingency.is_waiting_for_trigger + or self._take_profit.quantity_filled != 0 or self._take_profit.tag != "Updated take profit" + or self._take_profit.get(OrderField.LIMIT_PRICE) >= price + or self._stop_loss.status != OrderStatus.UPDATE_SUBMITTED or not self._stop_loss.contingency.is_waiting_for_trigger + or self._stop_loss.tag != "Updated stop loss"): + raise RegressionTestException(f"Expected the held orders to be updated but not filled: {self._take_profit} | {self._stop_loss}") + + if self._step == 3: + # update the entry so it fills + self._assert_success(self._entry.update_limit_price(round(price * 1.01, 2), "Updated entry")) + + elif self._step == 4: + # the updated entry filled right away triggering its children, which require new data to fill: just like any other order + # they don't fill with the data from the time they start working. So the marketable take profit filled with the next data, + # canceling the stop loss + if self._take_profit.status != OrderStatus.FILLED or self._stop_loss.status != OrderStatus.CANCELED or self.portfolio.invested: + raise RegressionTestException(f"Expected the take profit to be filled and the stop loss canceled: {self._take_profit} | {self._stop_loss}") + + entry_fill_time = next(x for x in self._entry.order_events if x.status == OrderStatus.FILLED).utc_time + take_profit_fill_time = next(x for x in self._take_profit.order_events if x.status == OrderStatus.FILLED).utc_time + if take_profit_fill_time != entry_fill_time + timedelta(minutes=1): + raise RegressionTestException(f"Expected the take profit to fill the minute after the entry, entry: {entry_fill_time} take profit: {take_profit_fill_time}") + + # closed orders can't be updated + if self._stop_loss.update_stop_price(1).is_success: + raise RegressionTestException("Expected the update of a canceled order to fail") + + self._step += 1 + + def _assert_success(self, response: OrderResponse) -> None: + if not response.is_success: + raise RegressionTestException(f"Expected the order request to succeed: {response}") + + def on_end_of_algorithm(self) -> None: + if self._step < 5: + raise RegressionTestException(f"Unexpected step count {self._step}") diff --git a/Algorithm.Python/ContingentTrailingStopOrderRegressionAlgorithm.py b/Algorithm.Python/ContingentTrailingStopOrderRegressionAlgorithm.py new file mode 100644 index 000000000000..c68775c59e05 --- /dev/null +++ b/Algorithm.Python/ContingentTrailingStopOrderRegressionAlgorithm.py @@ -0,0 +1,63 @@ +# 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 * + +### +### Regression algorithm asserting the behavior of a trailing stop order triggered by another order, through the generic OrderFactory api: +### its stop price is set once it's triggered, from the market price at that time, from where it starts trailing +### +class ContingentTrailingStopOrderRegressionAlgorithm(QCAlgorithm): + + _trailing_percentage = 0.005 + + def initialize(self) -> None: + self.set_start_date(2013, 10, 7) + self.set_end_date(2013, 10, 11) + self.set_cash(100000) + + self._symbol = self.add_equity("SPY", Resolution.MINUTE).symbol + self._entry = None + self._trailing_stop = None + self._asserted_triggered_stop_price = False + + def on_data(self, slice: Slice) -> None: + price = self.securities[self._symbol].price + if self._entry is None: + self._trailing_stop = self.order_factory.trailing_stop_order(self._symbol, -100, self._trailing_percentage, True, tag="Trailing stop") + self._entry = self.order_factory.limit_order(self._symbol, 100, round(price * 0.999, 2), tag="Entry").triggers(self._trailing_stop) + self.order(self._entry) + + stop_price = self._ticket(self._trailing_stop).get(OrderField.STOP_PRICE) + if self._ticket(self._trailing_stop).contingency.is_waiting_for_trigger: + if stop_price != 0: + raise RegressionTestException(f"Expected the stop price of the held trailing stop order not to be set yet but was {stop_price}") + elif not self._asserted_triggered_stop_price: + self._asserted_triggered_stop_price = True + + # it was just triggered, the stop price is set from the current market price + expected_stop_price = price * (1 - self._trailing_percentage) + if self._ticket(self._entry).status != OrderStatus.FILLED or abs(stop_price - expected_stop_price) > 0.01: + raise RegressionTestException(f"Expected the stop price to be {expected_stop_price} but was {stop_price}") + + def _ticket(self, request: SubmitOrderRequest) -> OrderTicket: + return self.transactions.get_order_ticket(request.order_id) + + def on_end_of_algorithm(self) -> None: + if not self._asserted_triggered_stop_price or self._ticket(self._trailing_stop).status != OrderStatus.FILLED or self.portfolio.invested: + raise RegressionTestException(f"Expected the trailing stop order to be triggered and filled: {self._ticket(self._trailing_stop)}") + + # it trailed the market price up before filling + entry_fill_price = self._ticket(self._entry).average_fill_price + if self._ticket(self._trailing_stop).get(OrderField.STOP_PRICE) <= entry_fill_price * (1 - self._trailing_percentage): + raise RegressionTestException("Expected the stop price to trail the market price") diff --git a/Algorithm.Python/OneCancelsOtherOrderRegressionAlgorithm.py b/Algorithm.Python/OneCancelsOtherOrderRegressionAlgorithm.py new file mode 100644 index 000000000000..e0c7ac4b4023 --- /dev/null +++ b/Algorithm.Python/OneCancelsOtherOrderRegressionAlgorithm.py @@ -0,0 +1,89 @@ +# 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 * + +### +### Regression algorithm asserting the behavior of the one_cancels_other_order helper method (OCO/OCA): a set of orders +### working at the same time where the first one to fill cancels the rest. We use it to exit an existing position, +### each time it's closed we open it again and submit a new set of exit orders. +### +class OneCancelsOtherOrderRegressionAlgorithm(QCAlgorithm): + + def initialize(self) -> None: + self.set_start_date(2013, 10, 7) + self.set_end_date(2013, 10, 11) + self.set_cash(100000) + + self._symbol = self.add_equity("SPY", Resolution.MINUTE).symbol + self._tickets = None + self._completed_sets = 0 + self._contingent_order_set_ids = set() + + def on_data(self, slice: Slice) -> None: + if self._tickets is not None: + if any(x.status in [OrderStatus.FILLED, OrderStatus.CANCELED, OrderStatus.INVALID] for x in self._tickets): + self._assert_completed_set() + self._tickets = None + return + + if self.portfolio.invested or len(self.transactions.get_open_orders()) != 0: + raise RegressionTestException("Expected no position nor open orders before submitting a new set of orders") + + self.market_order(self._symbol, 100) + + price = self.securities[self._symbol].price + self._tickets = self.one_cancels_other_order([ + self.order_factory.limit_order(self._symbol, -100, price * 1.003, tag="Take Profit"), + self.order_factory.stop_market_order(self._symbol, -100, price * 0.997, tag="Stop Loss"), + self.order_factory.stop_limit_order(self._symbol, -100, price * 0.99, price * 0.98, tag="Far Stop Loss") + ]) + + if len(self._tickets) != 3: + raise RegressionTestException(f"Expected 3 order tickets, but got {len(self._tickets)}") + + expected_contingency_id = self._tickets[0].contingency.links[0].id + for ticket in self._tickets: + contingencies = ticket.contingency.links + if (ticket.contingency.is_waiting_for_trigger or ticket.status != OrderStatus.SUBMITTED or ticket.contingency.count != 3 + or len(contingencies) != 1 or contingencies[0].type != ContingencyType.ONE_CANCELS_OTHER + or contingencies[0].role is not None or contingencies[0].id != expected_contingency_id): + raise RegressionTestException(f"Unexpected order state: {ticket}") + + set_id = self._tickets[0].contingency.id + if set_id in self._contingent_order_set_ids: + raise RegressionTestException("Expected a new contingent order set id for each set of orders") + self._contingent_order_set_ids.add(set_id) + + # at most one of them will fill + open_quantity = self.transactions.get_open_orders_remaining_quantity(self._symbol) + if open_quantity != -100: + raise RegressionTestException(f"Expected the open orders remaining quantity to be -100 but was {open_quantity}") + + def _assert_completed_set(self) -> None: + canceled = [x for x in self._tickets if x.status == OrderStatus.CANCELED] + if len([x for x in self._tickets if x.status == OrderStatus.FILLED]) != 1 or len(canceled) != 2: + raise RegressionTestException("Expected one order to fill and the others to be canceled") + + for ticket in canceled: + cancel_event = next(x for x in ticket.order_events if x.status == OrderStatus.CANCELED) + if "Contingent sibling order" not in cancel_event.message: + raise RegressionTestException(f"Unexpected cancel event message: {cancel_event.message}") + + if self.portfolio.invested: + raise RegressionTestException("Expected the position to be closed") + self._completed_sets += 1 + + def on_end_of_algorithm(self) -> None: + if self._completed_sets < 2: + raise RegressionTestException(f"Expected at least 2 completed sets of orders but got {self._completed_sets}") diff --git a/Algorithm.Python/OneTriggersOtherOrderRegressionAlgorithm.py b/Algorithm.Python/OneTriggersOtherOrderRegressionAlgorithm.py new file mode 100644 index 000000000000..3a31a22e1ed7 --- /dev/null +++ b/Algorithm.Python/OneTriggersOtherOrderRegressionAlgorithm.py @@ -0,0 +1,98 @@ +# 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 * + +### +### Regression algorithm asserting the behavior of the one_triggers_other_order helper method (OTO): a parent order which once filled +### triggers multiple independent orders, for different symbols, one of which triggers another in turn (chain) +### +class OneTriggersOtherOrderRegressionAlgorithm(QCAlgorithm): + + def initialize(self) -> None: + self.set_start_date(2013, 10, 7) + self.set_end_date(2013, 10, 11) + self.set_cash(100000) + + self._spy = self.add_equity("SPY", Resolution.MINUTE).symbol + self._ibm = self.add_equity("IBM", Resolution.MINUTE).symbol + self._bac = self.add_equity("BAC", Resolution.MINUTE).symbol + self._parent = None + self._tickets = None + self._fill_order = [] + + def on_data(self, slice: Slice) -> None: + if self._parent is None: + if not slice.contains_key(self._spy) or not slice.contains_key(self._ibm) or not slice.contains_key(self._bac): + return + + price = self.securities[self._spy].price + self._parent = self.order_factory.limit_order(self._spy, 100, round(price * 0.999, 2), tag="Parent") + self._bac_grand_child = self.order_factory.market_order(self._bac, 20, tag="Grand child") + self._ibm_child = self.order_factory.market_order(self._ibm, 10, tag="Child").triggers(self._bac_grand_child) + self._limit_child = self.order_factory.limit_order(self._spy, -50, round(price * 1.05, 2), tag="Independent child") + + self._tickets = self.one_triggers_other_order(self._parent, [self._ibm_child, self._limit_child]) + + expected_tickets = [self._ticket(self._parent), self._ticket(self._ibm_child), self._ticket(self._bac_grand_child), self._ticket(self._limit_child)] + if ([x.order_id for x in self._tickets] != [x.order_id for x in expected_tickets] + or any(x.contingency.count != 4 for x in self._tickets)): + raise RegressionTestException("Unexpected order tickets") + + # the IBM order is the child of a contingency and the parent of another one + contingencies = self._ticket(self._ibm_child).contingency.links + child = [x for x in contingencies if x.role == ContingencyRole.CHILD] + parent = [x for x in contingencies if x.role == ContingencyRole.PARENT] + if (len(contingencies) != 2 or any(x.type != ContingencyType.ONE_TRIGGERS_OTHER for x in contingencies) + or len(child) != 1 or child[0].id != self._ticket(self._parent).contingency.links[0].id + or len(parent) != 1 or parent[0].id != self._ticket(self._bac_grand_child).contingency.links[0].id + or self._ticket(self._limit_child).contingency.links[0].role != ContingencyRole.CHILD): + raise RegressionTestException("Unexpected contingencies") + + tickets = self._tickets + if self._ticket(self._parent).status != OrderStatus.FILLED: + if any(not x.contingency.is_waiting_for_trigger or x.status != OrderStatus.SUBMITTED for x in tickets[1:]): + raise RegressionTestException("Expected all the orders to be held waiting for the parent to fill") + elif any(x.contingency.is_waiting_for_trigger for x in tickets): + raise RegressionTestException("Expected all the orders to be triggered once the parent filled") + + def on_order_event(self, order_event: OrderEvent) -> None: + if order_event.status == OrderStatus.FILLED: + self._fill_order.append(order_event.order_id) + elif order_event.status == OrderStatus.CANCELED: + raise RegressionTestException(f"Unexpected canceled order event, the triggered orders are independent: {order_event}") + + def _get_fill_time(self, ticket: OrderTicket) -> datetime: + return next(x for x in ticket.order_events if x.status == OrderStatus.FILLED).utc_time + + def _ticket(self, request: SubmitOrderRequest) -> OrderTicket: + return self.transactions.get_order_ticket(request.order_id) + + def on_end_of_algorithm(self) -> None: + expected_fill_order = [self._ticket(self._parent).order_id, self._ticket(self._ibm_child).order_id, self._ticket(self._bac_grand_child).order_id] + if self._fill_order != expected_fill_order: + raise RegressionTestException(f"Unexpected fill order: {self._fill_order}") + + # market orders fill right away once triggered + parent_fill_time = self._get_fill_time(self._ticket(self._parent)) + if self._get_fill_time(self._ticket(self._ibm_child)) != parent_fill_time or self._get_fill_time(self._ticket(self._bac_grand_child)) != parent_fill_time: + raise RegressionTestException("Expected the market orders to fill once triggered") + + if self.portfolio[self._spy].quantity != 100 or self.portfolio[self._ibm].quantity != 10 or self.portfolio[self._bac].quantity != 20: + raise RegressionTestException("Unexpected holdings") + + # the independent limit order is still working + open_orders = self.transactions.get_open_orders() + if (len(open_orders) != 1 or open_orders[0].id != self._ticket(self._limit_child).order_id or self._ticket(self._limit_child).contingency.is_waiting_for_trigger + or open_orders[0].status != OrderStatus.SUBMITTED): + raise RegressionTestException("Expected the independent limit order to be still working") diff --git a/Algorithm/QCAlgorithm.Trading.ContingentOrders.cs b/Algorithm/QCAlgorithm.Trading.ContingentOrders.cs new file mode 100644 index 000000000000..12a54b7989e4 --- /dev/null +++ b/Algorithm/QCAlgorithm.Trading.ContingentOrders.cs @@ -0,0 +1,350 @@ +/* + * 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.Orders; +using QuantConnect.Interfaces; +using QuantConnect.Securities; +using System.Collections.Generic; + +namespace QuantConnect.Algorithm +{ + public partial class QCAlgorithm + { + /// + /// Creates order requests to be submitted later through , so they can be composed into contingent + /// orders before: orders which trigger other orders once filled (OTO), orders which cancel (OCO/OCA) or update (OUO) each other, + /// and any composition of them like brackets (OTOCO) + /// + [DocumentationAttribute(TradingAndOrders)] + public OrderFactory OrderFactory { get; private set; } + + /// + /// Submits the given order request, built through , along with the set of contingent orders composed on it + /// + /// The order request to submit, see + /// The tickets of all the submitted orders, parents before the orders they trigger, in the order they were composed + /// The orders triggered by another are held by the brokerage until then, see . + /// The whole set of contingent orders the request belongs to is submitted + [DocumentationAttribute(TradingAndOrders)] + public List Order(SubmitOrderRequest order) + { + return SubmitOrders(new[] { order }); + } + + /// + /// Submits the given order requests, built through , along with the sets of contingent orders composed on them: + /// the legs of a combo order, orders which cancel or update each other, each of them possibly triggering other orders once filled + /// + /// The order requests to submit, see + /// The tickets of all the submitted orders, parents first, in the order they were composed + [DocumentationAttribute(TradingAndOrders)] + public List Order(IEnumerable orders) + { + return SubmitOrders(orders); + } + + /// + /// Submits a bracket order (OTOCO): an entry order which once filled triggers a take profit limit order and a stop loss order of the + /// opposite quantity, which are held until then. Once the take profit or the stop loss fills the other one is canceled. + /// + /// The symbol to trade + /// The quantity of the entry order + /// The limit price of the take profit order + /// The stop price of the stop loss order + /// The limit price of the entry order, if not provided the entry is a market order + /// Send the order asynchronously (false). Otherwise we'll block until the market entry order fills + /// String tag for the orders (optional) + /// The order properties to use. Defaults to + /// The tickets of the entry, take profit and stop loss orders, in that order + /// For other entry or exit order types see and + [DocumentationAttribute(TradingAndOrders)] + public List BracketOrder(Symbol symbol, decimal quantity, decimal takeProfitPrice, decimal stopLossPrice, decimal? limitPrice = null, + bool asynchronous = false, string tag = "", IOrderProperties orderProperties = null) + { + var entry = limitPrice.HasValue + ? OrderFactory.LimitOrder(symbol, quantity, limitPrice.Value, asynchronous, tag, orderProperties) + : OrderFactory.MarketOrder(symbol, quantity, asynchronous, tag, orderProperties); + return SubmitOrders(new[] { entry.Bracket(takeProfitPrice, stopLossPrice) }); + } + + /// + /// Submits a set of orders where the first one to fill, even partially, cancels the rest (OCO/OCA) + /// + /// The order requests, all the legs for combo orders, which can trigger other orders in turn + /// The tickets of all the submitted orders + [DocumentationAttribute(TradingAndOrders)] + public List OneCancelsOtherOrder(IEnumerable orders) + { + return SubmitOrders(OrderFactory.OneCancelsOther(orders)); + } + + /// + /// Submits a set of orders where a partial fill of one of them reduces the remaining quantity of the rest proportionally, + /// which are canceled once it completely fills (OUO) + /// + /// The order requests, all the legs for combo orders, which can trigger other orders in turn + /// The tickets of all the submitted orders + [DocumentationAttribute(TradingAndOrders)] + public List OneUpdatesOtherOrder(IEnumerable orders) + { + return SubmitOrders(OrderFactory.OneUpdatesOther(orders)); + } + + /// + /// Submits an order which once completely filled triggers others (OTO), they are held until then and canceled if the parent is canceled + /// + /// The order request of the parent order + /// The order requests to trigger, all the legs for combo orders, independent of each other unless related + /// The tickets of all the submitted orders, the parent first + [DocumentationAttribute(TradingAndOrders)] + public List OneTriggersOtherOrder(SubmitOrderRequest parent, IEnumerable children) + { + return OneTriggersOtherOrder(new[] { parent }, children); + } + + /// + /// Submits a combo order which once all its legs fill triggers other orders (OTO), they are held until then and canceled if the parent is canceled + /// + /// The order requests of the legs of the parent combo order + /// The order requests to trigger, all the legs for combo orders, independent of each other unless related + /// The tickets of all the submitted orders, the parent legs first + [DocumentationAttribute(TradingAndOrders)] + public List OneTriggersOtherOrder(IEnumerable parent, IEnumerable children) + { + var legs = parent?.ToList(); + OrderContingency.Trigger(legs, children); + return SubmitOrders(legs); + } + + /// + /// Submits a single order request, along with its set of contingent orders if any, see + /// + private OrderTicket SubmitOrder(SubmitOrderRequest order) + { + if (order.Contingency != null) + { + return SubmitOrders(new[] { order })[0]; + } + + Action conversionWarning = null; + var response = PrepareRequest(order, ref conversionWarning); + if (response.IsError) + { + return OrderTicket.InvalidSubmitRequest(Transactions, order, response); + } + var ticket = Transactions.AddOrder(order); + if (order.Response.IsSuccess) + { + conversionWarning?.Invoke(); + } + WaitForOrderIfRequired(order, ticket); + return ticket; + } + + /// + /// Single entry point for submitting orders: single orders, combo orders and any set of contingent orders + /// + private List SubmitOrders(IEnumerable orders) + { + // the requests to submit, the sets of contingent orders as a whole: parents before the orders they trigger. + // We execute pre order checks for all requests before submitting, so that if anything fails we are not left with half submitted orders + var requests = new List(); + Action conversionWarning = null; + // the legs of the combo orders which are not part of a set of contingent orders, all of them are required + Dictionary comboLegs = null; + // the sets of contingent orders already added + HashSet> contingentSets = null; + foreach (var order in orders) + { + if (order.Contingency == null) + { + if (order.GroupOrderManager != null) + { + comboLegs ??= new(); + comboLegs[order.GroupOrderManager] = comboLegs.GetValueOrDefault(order.GroupOrderManager) + 1; + } + var response = PrepareRequest(order, ref conversionWarning); + if (response.IsError) + { + return new List { OrderTicket.InvalidSubmitRequest(Transactions, order, response) }; + } + requests.Add(order); + continue; + } + var setRequests = order.Contingency.Requests; + if (!(contingentSets ??= new()).Add(setRequests)) + { + // along with the rest of its set + continue; + } + for (var i = 0; i < setRequests.Count; i++) + { + var request = setRequests[i]; + var response = PrepareRequest(request, ref conversionWarning); + if (response.IsError) + { + return new List { OrderTicket.InvalidSubmitRequest(Transactions, request, response) }; + } + requests.Add(request); + } + } + if (comboLegs != null) + { + foreach (var (groupOrderManager, count) in comboLegs) + { + if (count != groupOrderManager.Count) + { + throw new ArgumentException($"Expected all the {groupOrderManager.Count} legs of the combo order, got {count}", nameof(orders)); + } + } + } + + // add the orders, creating their ids + var tickets = new List(requests.Count); + for (var i = 0; i < requests.Count; i++) + { + tickets.Add(Transactions.AddOrder(requests[i])); + } + if (requests.Count > 0 && requests[0].Response.IsSuccess) + { + conversionWarning?.Invoke(); + } + + for (var i = 0; i < requests.Count; i++) + { + WaitForOrderIfRequired(requests[i], tickets[i]); + } + return tickets; + } + + /// + /// Prepares a request for submission, converting the order type when required, and executes the pre order checks + /// + /// The request to prepare + /// The warnings to send once the orders are submitted, when a market order is converted + private OrderResponse PrepareRequest(SubmitOrderRequest request, ref Action conversionWarning) + { + if (request.OrderId > 0) + { + throw new ArgumentException($"The order was already submitted, it can only be submitted once: {request}"); + } + + var security = GetSecurityForOrder(request.Symbol); + // the security can have been renamed since the symbol was created + request.Symbol = security.Symbol; + var held = IsHeld(request); + if (request.Contingency?.Id == 0) + { + // we create a unique Id so the algorithm and the brokerage can relate the contingent orders with each other + request.Contingency.SetId(Transactions.GetIncrementContingentOrderSetId()); + } + if (request.GroupOrderManager != null) + { + if (request.GroupOrderManager.Id == 0) + { + // we create a unique Id so the algorithm and the brokerage can relate the combo orders with each other + request.GroupOrderManager.Id = Transactions.GetIncrementGroupOrderManagerId(); + } + } + else if (request.OrderType == OrderType.Market && !held) + { + conversionWarning += ConvertMarketOrderIfRequired(request, security); + } + else if (request.OrderType == OrderType.TrailingStop && request.StopPrice == 0 && !held) + { + // for held orders the brokerage will set it once it's triggered, from the market price at that time + request.StopPrice = Orders.TrailingStopOrder.CalculateStopPrice(security.Price, request.TrailingAmount, request.TrailingAsPercentage, + request.Quantity > 0 ? OrderDirection.Buy : OrderDirection.Sell); + } + + if (request.OrderType is OrderType.MarketOnOpen or OrderType.MarketOnClose) + { + InvalidateGoodTilDateTimeInForce(request.OrderProperties); + } + return PreOrderChecks(request); + } + + /// + /// Waits for the order to be processed, only for the orders which start working right away, not the ones held until another fills + /// + private void WaitForOrderIfRequired(SubmitOrderRequest request, OrderTicket ticket) + { + if (!request.Asynchronous && !IsHeld(request) && ticket.Status.IsOpen() + && request.OrderType is OrderType.Market or OrderType.OptionExercise or OrderType.ComboMarket) + { + Transactions.WaitForOrder(ticket.OrderId); + } + } + + /// + /// Whether the order is held by the brokerage until the order which triggers it fills + /// + private static bool IsHeld(SubmitOrderRequest request) + { + return request.Contingency?.IsWaitingForTrigger == true; + } + + /// + /// Converts a market order which would start working right away into a market on open/close order when required + /// + /// The warning to send once the converted order is submitted, null if it was not converted + private Action ConvertMarketOrderIfRequired(SubmitOrderRequest request, Security security) + { + // For futures and FOPs, market orders can be submitted on extended hours, so we let them through. + if (security.Type == SecurityType.Future || security.Type == SecurityType.FutureOption) + { + return null; + } + + // When the market is closed the order is converted to fill at the next open (MarketOnOpen), + // regardless of resolution. + if (!security.Exchange.ExchangeOpen) + { + request.OrderType = OrderType.MarketOnOpen; + return _isMarketOnOpenOrderWarningSent ? null : () => + { + if (!_isMarketOnOpenOrderWarningSent) + { + Debug("Warning: market orders submitted while the market is closed are automatically converted into MarketOnOpen orders to fill at the next market open."); + _isMarketOnOpenOrderWarningSent = true; + } + }; + } + + // The market is open: only a security subscribed solely to daily resolution needs conversion, since + // it has no fresh intraday price to fill against (it would otherwise fill at the stale previous + // close). It is filled at today's close (MarketOnClose), or at the next open (MarketOnOpen) if we are + // already within the MarketOnClose submission buffer. + // This is only done in backtesting. In live trading an open-market market order fills at the current + // market price, so we leave it as a regular market order. Markets that never close (e.g. crypto, + // forex) have no open/close to convert to, so they are left as a regular market order too. + if (!LiveMode && !security.Exchange.Hours.IsMarketAlwaysOpen && IsDailyResolutionOnly(security.Symbol)) + { + request.OrderType = IsWithinMarketOnCloseSubmissionBuffer(security) ? OrderType.MarketOnOpen : OrderType.MarketOnClose; + return _isDailyResolutionMarketOrderConversionWarningSent ? null : () => + { + if (!_isDailyResolutionMarketOrderConversionWarningSent) + { + Debug("Warning: market orders on daily resolution data sent during market hours are automatically converted into MarketOnClose orders (or MarketOnOpen near the close) to avoid filling at the stale previous close. Note: in live trading this conversion is not applied, as the order fills at the current market price."); + _isDailyResolutionMarketOrderConversionWarningSent = true; + } + }; + } + return null; + } + } +} diff --git a/Algorithm/QCAlgorithm.Trading.cs b/Algorithm/QCAlgorithm.Trading.cs index ddba02f0249c..65e758e660a0 100644 --- a/Algorithm/QCAlgorithm.Trading.cs +++ b/Algorithm/QCAlgorithm.Trading.cs @@ -240,58 +240,7 @@ public OrderTicket MarketOrder(Symbol symbol, double quantity, bool asynchronous [DocumentationAttribute(TradingAndOrders)] public OrderTicket MarketOrder(Symbol symbol, decimal quantity, bool asynchronous = false, string tag = "", IOrderProperties orderProperties = null) { - var security = GetSecurityForOrder(symbol); - - // For futures and FOPs, market orders can be submitted on extended hours, so we let them through. - if (security.Type != SecurityType.Future && security.Type != SecurityType.FutureOption) - { - // When the market is closed the order is converted to fill at the next open (MarketOnOpen), - // regardless of resolution. - if (!security.Exchange.ExchangeOpen) - { - var mooTicket = MarketOnOpenOrder(security.Symbol, quantity, asynchronous, tag, orderProperties); - if (!_isMarketOnOpenOrderWarningSent && mooTicket.SubmitRequest.Response.IsSuccess) - { - Debug("Warning: market orders submitted while the market is closed are automatically converted into MarketOnOpen orders to fill at the next market open."); - _isMarketOnOpenOrderWarningSent = true; - } - return mooTicket; - } - - // The market is open: only a security subscribed solely to daily resolution needs conversion, since - // it has no fresh intraday price to fill against (it would otherwise fill at the stale previous - // close). It is filled at today's close (MarketOnClose), or at the next open (MarketOnOpen) if we are - // already within the MarketOnClose submission buffer. - // This is only done in backtesting. In live trading an open-market market order fills at the current - // market price, so we leave it as a regular market order. Markets that never close (e.g. crypto, - // forex) have no open/close to convert to, so they are left as a regular market order too. - if (!LiveMode && !security.Exchange.Hours.IsMarketAlwaysOpen && IsDailyResolutionOnly(security.Symbol)) - { - var convertedTicket = IsWithinMarketOnCloseSubmissionBuffer(security) - ? MarketOnOpenOrder(security.Symbol, quantity, asynchronous, tag, orderProperties) - : MarketOnCloseOrder(security.Symbol, quantity, asynchronous, tag, orderProperties); - - if (!_isDailyResolutionMarketOrderConversionWarningSent && convertedTicket.SubmitRequest.Response.IsSuccess) - { - Debug("Warning: market orders on daily resolution data sent during market hours are automatically converted into MarketOnClose orders (or MarketOnOpen near the close) to avoid filling at the stale previous close. Note: in live trading this conversion is not applied, as the order fills at the current market price."); - _isDailyResolutionMarketOrderConversionWarningSent = true; - } - return convertedTicket; - } - } - - var request = CreateSubmitOrderRequest(OrderType.Market, security, quantity, tag, orderProperties ?? DefaultOrderProperties?.Clone(), asynchronous); - - //Add the order and create a new order Id. - var ticket = SubmitOrderRequest(request); - - // Wait for the order event to process, only if the exchange is open and the order is valid - if (ticket.Status != OrderStatus.Invalid && !asynchronous) - { - Transactions.WaitForOrder(ticket.OrderId); - } - - return ticket; + return SubmitOrder(OrderFactory.MarketOrder(symbol, quantity, asynchronous, tag, orderProperties)); } /// @@ -336,13 +285,7 @@ public OrderTicket MarketOnOpenOrder(Symbol symbol, int quantity, bool asynchron [DocumentationAttribute(TradingAndOrders)] public OrderTicket MarketOnOpenOrder(Symbol symbol, decimal quantity, bool asynchronous = false, string tag = "", IOrderProperties orderProperties = null) { - var properties = orderProperties ?? DefaultOrderProperties?.Clone(); - InvalidateGoodTilDateTimeInForce(properties); - - var security = GetSecurityForOrder(symbol); - var request = CreateSubmitOrderRequest(OrderType.MarketOnOpen, security, quantity, tag, properties, asynchronous); - - return SubmitOrderRequest(request); + return SubmitOrder(OrderFactory.MarketOnOpenOrder(symbol, quantity, asynchronous, tag, orderProperties)); } /// @@ -387,13 +330,7 @@ public OrderTicket MarketOnCloseOrder(Symbol symbol, double quantity, bool async [DocumentationAttribute(TradingAndOrders)] public OrderTicket MarketOnCloseOrder(Symbol symbol, decimal quantity, bool asynchronous = false, string tag = "", IOrderProperties orderProperties = null) { - var properties = orderProperties ?? DefaultOrderProperties?.Clone(); - InvalidateGoodTilDateTimeInForce(properties); - - var security = GetSecurityForOrder(symbol); - var request = CreateSubmitOrderRequest(OrderType.MarketOnClose, security, quantity, tag, properties, asynchronous); - - return SubmitOrderRequest(request); + return SubmitOrder(OrderFactory.MarketOnCloseOrder(symbol, quantity, asynchronous, tag, orderProperties)); } /// @@ -472,11 +409,7 @@ public OrderTicket LimitOrder(Symbol symbol, double quantity, decimal limitPrice [DocumentationAttribute(TradingAndOrders)] public OrderTicket LimitOrder(Symbol symbol, decimal quantity, decimal limitPrice, bool asynchronous = false, string tag = "", IOrderProperties orderProperties = null) { - var security = GetSecurityForOrder(symbol); - var request = CreateSubmitOrderRequest(OrderType.Limit, security, quantity, tag, - orderProperties ?? DefaultOrderProperties?.Clone(), asynchronous, limitPrice: limitPrice); - - return SubmitOrderRequest(request); + return SubmitOrder(OrderFactory.LimitOrder(symbol, quantity, limitPrice, asynchronous, tag, orderProperties)); } /// @@ -524,11 +457,7 @@ public OrderTicket StopMarketOrder(Symbol symbol, double quantity, decimal stopP [DocumentationAttribute(TradingAndOrders)] public OrderTicket StopMarketOrder(Symbol symbol, decimal quantity, decimal stopPrice, bool asynchronous = false, string tag = "", IOrderProperties orderProperties = null) { - var security = GetSecurityForOrder(symbol); - var request = CreateSubmitOrderRequest(OrderType.StopMarket, security, quantity, tag, - orderProperties ?? DefaultOrderProperties?.Clone(), asynchronous, stopPrice: stopPrice); - - return SubmitOrderRequest(request); + return SubmitOrder(OrderFactory.StopMarketOrder(symbol, quantity, stopPrice, asynchronous, tag, orderProperties)); } /// @@ -585,10 +514,7 @@ public OrderTicket TrailingStopOrder(Symbol symbol, double quantity, decimal tra public OrderTicket TrailingStopOrder(Symbol symbol, decimal quantity, decimal trailingAmount, bool trailingAsPercentage, bool asynchronous = false, string tag = "", IOrderProperties orderProperties = null) { - var security = GetSecurityForOrder(symbol); - var stopPrice = Orders.TrailingStopOrder.CalculateStopPrice(security.Price, trailingAmount, trailingAsPercentage, - quantity > 0 ? OrderDirection.Buy : OrderDirection.Sell); - return TrailingStopOrder(symbol, quantity, stopPrice, trailingAmount, trailingAsPercentage, asynchronous, tag, orderProperties); + return SubmitOrder(OrderFactory.TrailingStopOrder(symbol, quantity, trailingAmount, trailingAsPercentage, asynchronous, tag, orderProperties)); } /// @@ -645,19 +571,7 @@ public OrderTicket TrailingStopOrder(Symbol symbol, double quantity, decimal sto public OrderTicket TrailingStopOrder(Symbol symbol, decimal quantity, decimal stopPrice, decimal trailingAmount, bool trailingAsPercentage, bool asynchronous = false, string tag = "", IOrderProperties orderProperties = null) { - var security = GetSecurityForOrder(symbol); - var request = CreateSubmitOrderRequest( - OrderType.TrailingStop, - security, - quantity, - tag, - stopPrice: stopPrice, - trailingAmount: trailingAmount, - trailingAsPercentage: trailingAsPercentage, - properties: orderProperties ?? DefaultOrderProperties?.Clone(), - asynchronous: asynchronous); - - return SubmitOrderRequest(request); + return SubmitOrder(OrderFactory.TrailingStopOrder(symbol, quantity, stopPrice, trailingAmount, trailingAsPercentage, asynchronous, tag, orderProperties)); } /// @@ -711,11 +625,7 @@ public OrderTicket StopLimitOrder(Symbol symbol, double quantity, decimal stopPr public OrderTicket StopLimitOrder(Symbol symbol, decimal quantity, decimal stopPrice, decimal limitPrice, bool asynchronous = false, string tag = "", IOrderProperties orderProperties = null) { - var security = GetSecurityForOrder(symbol); - var request = CreateSubmitOrderRequest(OrderType.StopLimit, security, quantity, tag, stopPrice: stopPrice, - limitPrice: limitPrice, properties: orderProperties ?? DefaultOrderProperties?.Clone(), asynchronous: asynchronous); - - return SubmitOrderRequest(request); + return SubmitOrder(OrderFactory.StopLimitOrder(symbol, quantity, stopPrice, limitPrice, asynchronous, tag, orderProperties)); } /// @@ -769,12 +679,7 @@ public OrderTicket LimitIfTouchedOrder(Symbol symbol, double quantity, decimal t public OrderTicket LimitIfTouchedOrder(Symbol symbol, decimal quantity, decimal triggerPrice, decimal limitPrice, bool asynchronous = false, string tag = "", IOrderProperties orderProperties = null) { - var security = GetSecurityForOrder(symbol); - var request = CreateSubmitOrderRequest(OrderType.LimitIfTouched, security, quantity, tag, - triggerPrice: triggerPrice, limitPrice: limitPrice, properties: orderProperties ?? DefaultOrderProperties?.Clone(), - asynchronous: asynchronous); - - return SubmitOrderRequest(request); + return SubmitOrder(OrderFactory.LimitIfTouchedOrder(symbol, quantity, triggerPrice, limitPrice, asynchronous, tag, orderProperties)); } /// @@ -789,30 +694,7 @@ public OrderTicket LimitIfTouchedOrder(Symbol symbol, decimal quantity, decimal [DocumentationAttribute(TradingAndOrders)] public OrderTicket ExerciseOption(Symbol optionSymbol, int quantity, bool asynchronous = false, string tag = "", IOrderProperties orderProperties = null) { - var option = (Option)GetSecurityForOrder(optionSymbol); - - // SubmitOrderRequest.Quantity indicates the change in holdings quantity, therefore manual exercise quantities must be negative - // PreOrderChecksImpl confirms that we don't hold a short position, so we're lenient here and accept +/- quantity values - var request = CreateSubmitOrderRequest(OrderType.OptionExercise, option, -Math.Abs(quantity), tag, - orderProperties ?? DefaultOrderProperties?.Clone(), asynchronous); - - //Initialize the exercise order parameters - var preOrderCheckResponse = PreOrderChecks(request); - if (preOrderCheckResponse.IsError) - { - return OrderTicket.InvalidSubmitRequest(Transactions, request, preOrderCheckResponse); - } - - //Add the order and create a new order Id. - var ticket = Transactions.AddOrder(request); - - // Wait for the order event to process, only if the exchange is open - if (!asynchronous) - { - Transactions.WaitForOrder(ticket.OrderId); - } - - return ticket; + return SubmitOrder(OrderFactory.ExerciseOption(optionSymbol, quantity, asynchronous, tag, orderProperties)); } // Support for option strategies trading @@ -874,7 +756,7 @@ public List Order(OptionStrategy strategy, int quantity, bool async [DocumentationAttribute(TradingAndOrders)] public List ComboMarketOrder(List legs, int quantity, bool asynchronous = false, string tag = "", IOrderProperties orderProperties = null) { - return SubmitComboOrder(legs, quantity, 0, asynchronous, tag, orderProperties); + return SubmitOrders(OrderFactory.ComboMarketOrder(legs, quantity, asynchronous, tag, orderProperties)); } /// @@ -891,12 +773,7 @@ public List ComboMarketOrder(List legs, int quantity, bool asy public List ComboLegLimitOrder(List legs, int quantity, bool asynchronous = false, string tag = "", IOrderProperties orderProperties = null) { - if (legs.Any(x => x.OrderPrice == null || x.OrderPrice == 0)) - { - throw new ArgumentException("ComboLegLimitOrder requires a limit price for each leg"); - } - - return SubmitComboOrder(legs, quantity, 0, asynchronous, tag, orderProperties); + return SubmitOrders(OrderFactory.ComboLegLimitOrder(legs, quantity, asynchronous, tag, orderProperties)); } /// @@ -915,97 +792,12 @@ public List ComboLegLimitOrder(List legs, int quantity, bool a public List ComboLimitOrder(List legs, int quantity, decimal limitPrice, bool asynchronous = false, string tag = "", IOrderProperties orderProperties = null) { - if (limitPrice == 0) - { - throw new ArgumentException("ComboLimitOrder requires a limit price"); - } - - if (legs.Any(x => x.OrderPrice != null && x.OrderPrice != 0)) - { - throw new ArgumentException("ComboLimitOrder does not support limit prices for individual legs"); - } - - return SubmitComboOrder(legs, quantity, limitPrice, asynchronous, tag, orderProperties); + return SubmitOrders(OrderFactory.ComboLimitOrder(legs, quantity, limitPrice, asynchronous, tag, orderProperties)); } private List GenerateOptionStrategyOrders(OptionStrategy strategy, int strategyQuantity, bool asynchronous, string tag, IOrderProperties orderProperties) { - // Make sure the strategy is initialized, that is, canonical and leg symbols are set. - strategy.SetSymbols(); - - // setting up the tag text for all orders of one strategy - tag ??= $"{strategy.Name} ({strategyQuantity.ToStringInvariant()})"; - - var legs = strategy.UnderlyingLegs.Cast().Concat(strategy.OptionLegs).ToList(); - - return SubmitComboOrder(legs, strategyQuantity, 0, asynchronous, tag, orderProperties); - } - - private List SubmitComboOrder(List legs, decimal quantity, decimal limitPrice, bool asynchronous, string tag, IOrderProperties orderProperties) - { - CheckComboOrderSizing(legs, quantity); - - var orderType = OrderType.ComboMarket; - if (limitPrice != 0) - { - orderType = OrderType.ComboLimit; - } - - // we create a unique Id so the algorithm and the brokerage can relate the combo orders with each other - var groupOrderManager = new GroupOrderManager(Transactions.GetIncrementGroupOrderManagerId(), legs.Count, quantity, limitPrice); - - List orderTickets = new(capacity: legs.Count); - List submitRequests = new(capacity: legs.Count); - foreach (var leg in legs) - { - var security = GetSecurityForOrder(leg.Symbol); - - if (leg.OrderPrice.HasValue) - { - // limit price per leg! - limitPrice = leg.OrderPrice.Value; - orderType = OrderType.ComboLegLimit; - } - var request = CreateSubmitOrderRequest( - orderType, - security, - ((decimal)leg.Quantity).GetOrderLegGroupQuantity(groupOrderManager), - tag, - orderProperties ?? DefaultOrderProperties?.Clone(), - groupOrderManager: groupOrderManager, - limitPrice: limitPrice, - asynchronous: asynchronous); - - // we execture pre order checks for all requests before submitting, so that if anything fails we are not left with half submitted combo orders - var response = PreOrderChecks(request); - if (response.IsError) - { - orderTickets.Add(OrderTicket.InvalidSubmitRequest(Transactions, request, response)); - return orderTickets; - } - - submitRequests.Add(request); - } - - foreach (var request in submitRequests) - { - //Add the order and create a new order Id. - orderTickets.Add(Transactions.AddOrder(request)); - } - - // Wait for the order event to process, only if the exchange is open - if (!asynchronous && orderType == OrderType.ComboMarket) - { - foreach (var ticket in orderTickets) - { - if (ticket.Status.IsOpen()) - { - Transactions.WaitForOrder(ticket.OrderId); - } - } - } - - return orderTickets; + return SubmitOrders(OrderFactory.OptionStrategyOrder(strategy, strategyQuantity, asynchronous, tag, orderProperties)); } /// @@ -1705,29 +1497,6 @@ public bool IsMarketOpen(Symbol symbol) return symbol.IsMarketOpen(UtcTime, false); } - private SubmitOrderRequest CreateSubmitOrderRequest(OrderType orderType, Security security, decimal quantity, string tag, - IOrderProperties properties, bool asynchronous, decimal stopPrice = 0m, decimal limitPrice = 0m, decimal triggerPrice = 0m, decimal trailingAmount = 0m, - bool trailingAsPercentage = false, GroupOrderManager groupOrderManager = null) - { - return new SubmitOrderRequest(orderType, security.Type, security.Symbol, quantity, stopPrice, limitPrice, triggerPrice, trailingAmount, - trailingAsPercentage, UtcTime, tag, properties, groupOrderManager, asynchronous); - } - - private static void CheckComboOrderSizing(List legs, decimal quantity) - { - var greatestsCommonDivisor = Math.Abs(legs.Select(leg => leg.Quantity).GreatestCommonDivisor()); - - if (greatestsCommonDivisor != 1) - { - throw new ArgumentException( - "The global combo quantity should be used to increase or reduce the size of the order, " + - "while the leg quantities should be used to specify the ratio of the order. " + - "The combo order quantities should be reduced " + - $"from {quantity}x({string.Join(", ", legs.Select(leg => $"{leg.Quantity} {leg.Symbol}"))}) " + - $"to {quantity * greatestsCommonDivisor}x({string.Join(", ", legs.Select(leg => $"{leg.Quantity / greatestsCommonDivisor} {leg.Symbol}"))})."); - } - } - /// /// Resets the time-in-force to the default if the given one is a . /// This is required for MOO and MOC orders, for which GTD is not supported. diff --git a/Algorithm/QCAlgorithm.cs b/Algorithm/QCAlgorithm.cs index fc79bf006ed8..3719bf928139 100644 --- a/Algorithm/QCAlgorithm.cs +++ b/Algorithm/QCAlgorithm.cs @@ -208,6 +208,7 @@ public QCAlgorithm() Securities = new SecurityManager(_timeKeeper); Transactions = new SecurityTransactionManager(this, Securities); + OrderFactory = new OrderFactory(this); Portfolio = new SecurityPortfolioManager(Securities, Transactions, Settings, DefaultOrderProperties); SignalExport = new SignalExportManager(this); diff --git a/Brokerages/Backtesting/BacktestingBrokerage.cs b/Brokerages/Backtesting/BacktestingBrokerage.cs index 7d42168bcae4..16b593f589f1 100644 --- a/Brokerages/Backtesting/BacktestingBrokerage.cs +++ b/Brokerages/Backtesting/BacktestingBrokerage.cs @@ -46,6 +46,8 @@ public class BacktestingBrokerage : Brokerage private readonly ConcurrentDictionary _pending; private readonly object _needsScanLock = new object(); private readonly HashSet _pendingOptionAssignments = new HashSet(); + private readonly ContingentOrderProcessor _contingentOrderProcessor; + private readonly Func _contingentOrderProvider; /// /// This is the algorithm under test @@ -71,6 +73,9 @@ protected BacktestingBrokerage(IAlgorithm algorithm, string name) { Algorithm = algorithm; _pending = new ConcurrentDictionary(); + _contingentOrderProcessor = new ContingentOrderProcessor(orderId => Algorithm.Transactions.GetOrderTicket(orderId)?.QuantityFilled ?? 0, + algorithm?.Portfolio); + _contingentOrderProvider = orderId => TryGetOrder(orderId) ?? Algorithm.Transactions.GetOrderById(orderId); } /// @@ -204,29 +209,31 @@ public override bool CancelOrder(Order order) var result = true; foreach (var orderInGroup in orders) { - lock (_needsScanLock) - { - if (!_pending.TryRemove(orderInGroup.Id, out var _)) - { - // can't cancel something that isn't there, - // let's continue just in case some other order of the group has to be cancelled - result = false; - } - } - - AddBrokerageOrderId(orderInGroup); + // can't cancel something that isn't there, let's continue just in case some other order of the group has to be cancelled + result &= RemovePendingOrder(orderInGroup); // fire off the event that says this order has been canceled - var canceled = new OrderEvent(orderInGroup, - Algorithm.UtcTime, - OrderFee.Zero) - { Status = OrderStatus.Canceled }; - OnOrderEvent(canceled); + OnOrderEvent(new OrderEvent(orderInGroup, Algorithm.UtcTime, OrderFee.Zero) { Status = OrderStatus.Canceled }); } return result; } + /// + /// Removes the order from the pending ones, before it's canceled + /// + /// False if the order was not pending + private bool RemovePendingOrder(Order order) + { + bool removed; + lock (_needsScanLock) + { + removed = _pending.TryRemove(order.Id, out var _); + } + AddBrokerageOrderId(order); + return removed; + } + /// /// Scans all the outstanding orders and applies the algorithm model fills to generate the order events /// @@ -244,8 +251,12 @@ public virtual void Scan() var stillNeedsScan = false; - // process each pending order to produce fills/fire events - foreach (var kvp in _pending.OrderBySafe(x => x.Key)) + // process each pending order to produce fills/fire events, by id. When more than one member of the same OCO/OUO contingency + // could fill with the same data we can't know which one would of happen first, so we make the pessimistic assumption: + // stop orders, like the stop loss, go first and the rest of the members, like the take profit, are processed last + foreach (var kvp in _pending.SafeEnumeration().OrderBy(x => x.Value != null && !x.Value.Type.IsStopOrder() && x.Value.GetSiblingLink() != null + ? x.Key + (long)int.MaxValue + : x.Key)) { var order = kvp.Value; if (order == null) @@ -255,6 +266,12 @@ public virtual void Scan() continue; } + if (order.Contingency != null && !_pending.ContainsKey(kvp.Key)) + { + // removed as a consequence of a previous fill during this scan, like a contingent sibling (OCO) + continue; + } + if (order.Status.IsClosed()) { // this should never actually happen as we always remove closed orders as they happen @@ -276,6 +293,13 @@ public virtual void Scan() continue; } + if (!IsWorking(orders)) + { + // a contingent child held until its parent fills, or waiting for new data after being triggered + stillNeedsScan = true; + continue; + } + if(!orders.TryGetGroupOrdersSecurities(Algorithm.Portfolio, out var securities)) { Log.Error($"BacktestingBrokerage.Scan(): Unable to process orders: [{string.Join(",", orders.Select(o => o.Id))}] The security no longer exists. UtcTime: {Algorithm.UtcTime}"); @@ -490,6 +514,103 @@ protected override void OnOrderEvents(List orderEvents) _pendingOptionAssignments.Remove(orderEvents[i].Symbol); } base.OnOrderEvents(orderEvents); + + ProcessContingentOrders(orderEvents); + } + + /// + /// Determines whether all the given orders, the legs for a combo order, are working in the market + /// + private bool IsWorking(List orders) + { + for (var i = 0; i < orders.Count; i++) + { + if (!IsWorking(orders[i], Algorithm.UtcTime, Algorithm.Portfolio)) + { + return false; + } + } + return true; + } + + /// + /// Determines whether the order is working in the market at the given time, so it can fill + /// + /// + /// False for contingent child orders still held waiting for their parent to fill. Once triggered they can fill + /// right away if they are market orders, else they require new data: they shouldn't fill with prices from before being triggered + /// + internal static bool IsWorking(Order order, DateTime utcTime, ISecurityProvider securityProvider) + { + var child = order.GetContingencyLink(ContingencyRole.Child); + if (child == null) + { + return true; + } + if (!child.Triggered) + { + return false; + } + if (order.Type == OrderType.Market || order.Type == OrderType.ComboMarket) + { + return true; + } + + var triggeredTime = child.TriggeredTime ?? order.Time; + if (triggeredTime >= utcTime) + { + // just like any other order, it will be able to fill on the next bar + return false; + } + + var security = securityProvider?.GetSecurity(order.Symbol); + var lastData = security?.GetLastData(); + return lastData != null && lastData.EndTime.ConvertToUtc(security.Exchange.TimeZone) > triggeredTime; + } + + /// + /// Handles the lifecycle of contingent orders (OCO, OTO, OUO, brackets), a real brokerage would do it on its side: + /// triggers the held children once their parent fills, cancels or resizes the siblings of an order which filled, etc + /// + private void ProcessContingentOrders(List orderEvents) + { + var isContingent = false; + for (var i = 0; i < orderEvents.Count && !isContingent; i++) + { + // the ticket is set by the transaction handler, cheap way to skip the common case + isContingent = orderEvents[i].Ticket == null || orderEvents[i].Ticket.Contingency != null; + } + if (!isContingent) + { + return; + } + + List updates; + List cancels; + lock (_needsScanLock) + { + (updates, cancels) = _contingentOrderProcessor.Process(orderEvents, _contingentOrderProvider, Algorithm.UtcTime); + // the triggered orders can fill now + _needsScan |= updates != null; + } + + // the transaction handler applies them to the orders, which are the same instances the pending ones + for (var i = 0; i < updates?.Count; i++) + { + OnOrderUpdated(updates[i]); + } + if (cancels != null) + { + for (var i = 0; i < cancels.Count; i++) + { + if (_contingentOrderProvider(cancels[i].OrderId) is { } order) + { + RemovePendingOrder(order); + } + } + // together, so the processing of one of them doesn't cancel the others again. Will take care of their own contingent orders, if any + OnOrderEvents(cancels); + } } /// diff --git a/Brokerages/Brokerage.cs b/Brokerages/Brokerage.cs index 132575ea97fe..cbc17a7caa0b 100644 --- a/Brokerages/Brokerage.cs +++ b/Brokerages/Brokerage.cs @@ -202,6 +202,44 @@ protected virtual void OnOrderUpdated(OrderUpdateEvent e) } } + /// + /// Helper method for brokerages which support contingent orders (OCO, OTO, OUO, brackets): to be called after emitting fill order events, + /// it will notify through the children orders which were triggered by an order which completely filled, + /// all its legs for a combo order, so they are no longer held by the brokerage but working in the market + /// + /// The order events that were emitted + /// The order provider to use + protected void OnContingentOrdersTriggered(IReadOnlyList orderEvents, IOrderProvider orderProvider) + { + try + { + if (orderProvider == null || orderEvents == null) + { + return; + } + + // only fills trigger children, other events could add actions on the same orders, like canceling them + // the brokerage cancels and resizes the orders on its side, so only the triggered ones are notified + var (updates, _) = TriggeredContingentOrdersProcessor.Process(orderEvents.Where(orderEvent => orderEvent.Status == OrderStatus.Filled), + orderProvider.GetOrderById, DateTime.UtcNow); + if (updates == null) + { + return; + } + foreach (var update in updates) + { + if (update.ContingencyTriggered) + { + OnOrderUpdated(update); + } + } + } + catch (Exception err) + { + Log.Error(err); + } + } + /// /// Event invocator for the OrderIdChanged event /// @@ -471,6 +509,20 @@ protected virtual List GetCashBalance(Dictionary bro /// public virtual bool AccountInstantlyUpdated => false; + /// + /// Cache holding the legs of a combo order until all of them have been placed, so the brokerage can submit them together + /// + protected GroupOrderCacheManager GroupOrderCacheManager { get; } = new(); + + /// + /// Cache holding the orders of a set of contingent orders (OCO, OTO, OUO, brackets) until all of them have been placed, + /// so the brokerage can submit them together + /// + protected ContingentOrderCache ContingentOrderCache { get; } = new(); + + // only the orders to trigger are used: no filled quantities nor securities are required, it holds no state + private static readonly ContingentOrderProcessor TriggeredContingentOrdersProcessor = new(_ => 0, null); + /// /// Returns the brokerage account's base currency /// diff --git a/Brokerages/Properties/AssemblyInfo.cs b/Brokerages/Properties/AssemblyInfo.cs index 9eb5ddb9393f..ae9acef41877 100644 --- a/Brokerages/Properties/AssemblyInfo.cs +++ b/Brokerages/Properties/AssemblyInfo.cs @@ -1,4 +1,5 @@ using System.Reflection; +using System.Runtime.CompilerServices; using System.Runtime.InteropServices; // General Information about an assembly is controlled through the following @@ -14,4 +15,6 @@ [assembly: ComVisible(false)] // The following GUID is for the ID of the typelib if this project is exposed to COM -[assembly: Guid("044b87ad-d9f9-45c8-90b3-683de09ac42c")] \ No newline at end of file +[assembly: Guid("044b87ad-d9f9-45c8-90b3-683de09ac42c")] + +[assembly: InternalsVisibleTo("QuantConnect.Tests")] diff --git a/Common/Brokerages/AlpacaBrokerageModel.cs b/Common/Brokerages/AlpacaBrokerageModel.cs index b63ab4afcf5a..416966c1fdfc 100644 --- a/Common/Brokerages/AlpacaBrokerageModel.cs +++ b/Common/Brokerages/AlpacaBrokerageModel.cs @@ -34,6 +34,15 @@ public class AlpacaBrokerageModel : DefaultBrokerageModel /// private static readonly TimeOnly _mooWindowStart = new(19, 0, 0); + /// + /// The contingency types supported by the brokerage: bracket, oco and oto order classes + /// + private readonly HashSet _supportedContingencyTypes = new() + { + ContingencyType.OneCancelsOther, + ContingencyType.OneTriggersOther + }; + /// /// A dictionary that maps each supported to an array of supported by Alpaca brokerage. /// @@ -72,6 +81,57 @@ public override IFeeModel GetFeeModel(Security security) return new AlpacaFeeModel(); } + /// + /// Validates contingent orders, Alpaca supports these order classes, always for a single equity symbol: + /// - bracket: an entry order which triggers a take profit limit order and a stop loss order, where one cancels the other + /// - oto: an entry order which triggers a single take profit limit order or stop loss order + /// - oco: a take profit limit order and a stop loss order where one cancels the other, to exit an existing position + /// + private bool CanSubmitContingentOrder(Security security, Order order, out BrokerageMessageEvent message) + { + if (!this.ValidateContingentOrder(order, _supportedContingencyTypes, out message, supportsComboOrders: false, + supportsMultipleSymbols: false, supportsNesting: false, maximumOrderCount: 3)) + { + return false; + } + + var contingency = order.Contingency; + if (contingency == null) + { + return true; + } + + var isParent = order.GetContingencyLink(ContingencyRole.Parent) != null; + var isChild = order.GetContingencyLink(ContingencyRole.Child) != null; + var isMember = order.GetSiblingLink() != null; + if (security.Type != SecurityType.Equity) + { + message = this.UnsupportedContingentOrdersShape("only equities are supported."); + } + else if (isParent && isMember) + { + message = this.UnsupportedContingentOrdersShape("the entry order can not be part of a one cancels other contingency."); + } + else if (!isParent && order.Type != OrderType.Limit && order.Type != OrderType.StopMarket && order.Type != OrderType.StopLimit) + { + message = this.UnsupportedContingentOrdersShape("the exit orders have to be a limit order (take profit) or a stop market/limit order (stop loss)."); + } + else if (contingency.Count == 3 && !isParent && !(isChild && isMember)) + { + message = this.UnsupportedContingentOrdersShape("3 orders are only supported as a bracket: an entry order which triggers a take profit and a stop loss where one cancels the other."); + } + else if (isMember && contingency.OrderTypes.Count > 0 && (!contingency.OrderTypes.Contains(OrderType.Limit) + || !contingency.OrderTypes.Contains(OrderType.StopMarket) && !contingency.OrderTypes.Contains(OrderType.StopLimit))) + { + message = this.UnsupportedContingentOrdersShape("one cancels other requires a limit order (take profit) and a stop market/limit order (stop loss)."); + } + else if (isMember && !isChild && contingency.Directions.Count > 1) + { + message = this.UnsupportedContingentOrdersShape("one cancels other orders have to be for the same side."); + } + return message == null; + } + /// /// Returns true if the brokerage could accept this order. This takes into account /// order type, security type, and order size limits. @@ -115,6 +175,11 @@ public override bool CanSubmitOrder(Security security, Order order, out Brokerag return false; } + if (!CanSubmitContingentOrder(security, order, out message)) + { + return false; + } + if (!BrokerageExtensions.ValidateCrossZeroOrder(this, security, order, out message)) { return false; @@ -139,6 +204,13 @@ public override bool CanSubmitOrder(Security security, Order order, out Brokerag public override bool CanUpdateOrder(Security security, Order order, UpdateOrderRequest request, out BrokerageMessageEvent message) { message = null; + if (order.Contingency != null && request.Quantity.HasValue && request.Quantity.Value != order.Quantity) + { + // the legs of bracket, oco and oto orders are sized by the brokerage + message = new BrokerageMessageEvent(BrokerageMessageType.Warning, "NotSupported", + Messages.DefaultBrokerageModel.UnsupportedContingentOrdersQuantityUpdate(this)); + return false; + } return true; } diff --git a/Common/Brokerages/AxosClearingBrokerageModel.cs b/Common/Brokerages/AxosClearingBrokerageModel.cs index 41604e85505d..f3b44f867251 100644 --- a/Common/Brokerages/AxosClearingBrokerageModel.cs +++ b/Common/Brokerages/AxosClearingBrokerageModel.cs @@ -101,6 +101,11 @@ public override IBenchmark GetBenchmark(SecurityManager securities) /// True if the brokerage could process the order, false otherwise public override bool CanSubmitOrder(Security security, Order order, out BrokerageMessageEvent message) { + if (!this.ValidateContingentOrdersNotSupported(order, out message)) + { + return false; + } + message = null; // validate security type diff --git a/Common/Brokerages/BinanceBrokerageModel.cs b/Common/Brokerages/BinanceBrokerageModel.cs index bb4f826c846a..0e82496e94f7 100644 --- a/Common/Brokerages/BinanceBrokerageModel.cs +++ b/Common/Brokerages/BinanceBrokerageModel.cs @@ -28,6 +28,15 @@ namespace QuantConnect.Brokerages /// public class BinanceBrokerageModel : DefaultBrokerageModel { + /// + /// The contingency types supported by the brokerage: OCO, OTO and OTOCO order lists + /// + private readonly HashSet _supportedContingencyTypes = new() + { + ContingencyType.OneCancelsOther, + ContingencyType.OneTriggersOther + }; + private const decimal _defaultLeverage = 3; private const decimal _defaultFutureLeverage = 25; @@ -104,6 +113,52 @@ public override bool CanUpdateOrder(Security security, Order order, UpdateOrderR return false; } + /// + /// Validates contingent orders, Binance spot supports these order lists, always for a single symbol: + /// - OCO: a limit order and a stop limit order, for the same side, where one cancels the other + /// - OTO: a working limit order which triggers a single pending order once completely filled + /// - OTOCO: a working limit order which triggers a pending OCO + /// + private bool CanSubmitContingentOrder(Security security, Order order, out BrokerageMessageEvent message) + { + if (!this.ValidateContingentOrder(order, _supportedContingencyTypes, out message, supportsComboOrders: false, + supportsMultipleSymbols: false, supportsNesting: false, maximumOrderCount: 3)) + { + return false; + } + + var contingency = order.Contingency; + if (contingency == null) + { + return true; + } + + var isParent = order.GetContingencyLink(ContingencyRole.Parent) != null; + var isChild = order.GetContingencyLink(ContingencyRole.Child) != null; + var isMember = order.GetSiblingLink() != null; + if (security.Type != SecurityType.Crypto) + { + message = this.UnsupportedContingentOrdersShape("only spot crypto is supported."); + } + else if (isParent && (isMember || order.Type != OrderType.Limit)) + { + message = this.UnsupportedContingentOrdersShape("the working order which triggers others has to be a single limit order."); + } + else if (isMember && order.Type != OrderType.Limit && order.Type != OrderType.StopLimit) + { + message = this.UnsupportedContingentOrdersShape("one cancels other requires a limit order and a stop limit order."); + } + else if (contingency.Count == 3 && !isParent && !(isChild && isMember)) + { + message = this.UnsupportedContingentOrdersShape("3 orders are only supported as a working limit order which triggers two orders where one cancels the other."); + } + else if (isMember && contingency.Directions.Count > 1 && !isChild) + { + message = this.UnsupportedContingentOrdersShape("one cancels other orders have to be for the same side."); + } + return message == null; + } + /// /// Returns true if the brokerage could accept this order. This takes into account /// order type, security type, and order size limits. @@ -190,6 +245,11 @@ public override bool CanSubmitOrder(Security security, Order order, out Brokerag return false; } + + if (!CanSubmitContingentOrder(security, order, out message)) + { + return false; + } return base.CanSubmitOrder(security, order, out message); bool IsOrderSizeLargeEnough(decimal price) => diff --git a/Common/Brokerages/BinanceUSBrokerageModel.cs b/Common/Brokerages/BinanceUSBrokerageModel.cs index c68314e2187f..87931d73c504 100644 --- a/Common/Brokerages/BinanceUSBrokerageModel.cs +++ b/Common/Brokerages/BinanceUSBrokerageModel.cs @@ -13,6 +13,7 @@ * limitations under the License. */ +using QuantConnect.Orders; using QuantConnect.Securities; using System; using System.Collections.Generic; @@ -34,6 +35,23 @@ public class BinanceUSBrokerageModel : BinanceBrokerageModel /// protected override string MarketName => Market.BinanceUS; + /// + /// Returns true if the brokerage could accept this order. Binance US does not expose the order list endpoints, + /// so contingent orders are not supported + /// + /// The security of the order + /// The order to be processed + /// If this function returns false, a brokerage message detailing why the order may not be submitted + /// True if the brokerage could process the order, false otherwise + public override bool CanSubmitOrder(Security security, Order order, out BrokerageMessageEvent message) + { + if (!this.ValidateContingentOrdersNotSupported(order, out message)) + { + return false; + } + return base.CanSubmitOrder(security, order, out message); + } + /// /// Gets a map of the default markets to be used for each security type /// diff --git a/Common/Brokerages/BitfinexBrokerageModel.cs b/Common/Brokerages/BitfinexBrokerageModel.cs index 93be6705be9d..d4ade433537a 100644 --- a/Common/Brokerages/BitfinexBrokerageModel.cs +++ b/Common/Brokerages/BitfinexBrokerageModel.cs @@ -132,6 +132,11 @@ public override bool CanUpdateOrder(Security security, Order order, UpdateOrderR /// True if the brokerage could process the order, false otherwise public override bool CanSubmitOrder(Security security, Order order, out BrokerageMessageEvent message) { + if (!this.ValidateContingentOrdersNotSupported(order, out message)) + { + return false; + } + if (!IsValidOrderSize(security, order.Quantity, out message)) { return false; diff --git a/Common/Brokerages/BloombergFixBrokerageModel.cs b/Common/Brokerages/BloombergFixBrokerageModel.cs index 9ef1af4aa853..f717fad2c891 100644 --- a/Common/Brokerages/BloombergFixBrokerageModel.cs +++ b/Common/Brokerages/BloombergFixBrokerageModel.cs @@ -63,6 +63,11 @@ public BloombergFixBrokerageModel(AccountType accountType = AccountType.Margin) /// If this function returns false, a brokerage message detailing why the order may not be submitted public override bool CanSubmitOrder(Security security, Order order, out BrokerageMessageEvent message) { + if (!this.ValidateContingentOrdersNotSupported(order, out message)) + { + return false; + } + if (!_supportedSecurityTypes.Contains(security.Type)) { message = new BrokerageMessageEvent(BrokerageMessageType.Warning, "NotSupported", diff --git a/Common/Brokerages/BrokerageExtensions.cs b/Common/Brokerages/BrokerageExtensions.cs index 977ecb5e82d2..99e26d74d69a 100644 --- a/Common/Brokerages/BrokerageExtensions.cs +++ b/Common/Brokerages/BrokerageExtensions.cs @@ -38,6 +38,91 @@ public static class BrokerageExtensions OrderType.MarketOnClose }; + /// + /// Rejects contingent orders (OCO, OTO, OUO, brackets), for the brokerage models of brokerages which don't support them + /// + /// The brokerage model + /// The order to validate + /// If this function returns false, a brokerage message detailing why the order may not be submitted + /// False if the order is a contingent order + public static bool ValidateContingentOrdersNotSupported(this IBrokerageModel brokerageModel, Order order, out BrokerageMessageEvent message) + { + message = null; + if (order.Contingency == null) + { + return true; + } + + message = new BrokerageMessageEvent(BrokerageMessageType.Warning, "NotSupported", + Messages.DefaultBrokerageModel.UnsupportedContingentOrders(brokerageModel)); + return false; + } + + /// + /// Validates the contingencies of the given order are of a type supported by the brokerage + /// + /// The brokerage model + /// The order to validate + /// The contingency types supported by the brokerage + /// If this function returns false, a brokerage message detailing why the order may not be submitted + /// True if combo orders can be part of a set of contingent orders + /// True if the orders in the set can be for different symbols + /// True if an order triggered by another can trigger others in turn + /// The maximum number of orders in the set + /// True if the order is not a contingent order or all its contingencies are supported + public static bool ValidateContingentOrder(this IBrokerageModel brokerageModel, Order order, + IReadOnlySet supportedContingencyTypes, out BrokerageMessageEvent message, + bool supportsComboOrders = true, bool supportsMultipleSymbols = true, bool supportsNesting = true, int maximumOrderCount = int.MaxValue) + { + message = null; + var contingency = order.Contingency; + if (contingency == null) + { + return true; + } + + var isParent = false; + var isChild = false; + foreach (var link in contingency.Links) + { + if (!supportedContingencyTypes.Contains(link.Type)) + { + message = new BrokerageMessageEvent(BrokerageMessageType.Warning, "NotSupported", + Messages.DefaultBrokerageModel.UnsupportedContingencyType(brokerageModel, link.Type, supportedContingencyTypes)); + return false; + } + isParent |= link.Role == ContingencyRole.Parent; + isChild |= link.Role == ContingencyRole.Child; + } + + if (!supportsComboOrders && order.GroupOrderManager != null) + { + message = brokerageModel.UnsupportedContingentOrdersShape("combo orders are not supported."); + } + else if (!supportsMultipleSymbols && contingency.Symbols.Count > 1) + { + message = brokerageModel.UnsupportedContingentOrdersShape("all the orders have to be for the same symbol."); + } + else if (!supportsNesting && isParent && isChild) + { + message = brokerageModel.UnsupportedContingentOrdersShape("an order triggered by another can not trigger other orders in turn."); + } + else if (contingency.Count > maximumOrderCount) + { + message = brokerageModel.UnsupportedContingentOrdersShape($"the maximum number of orders is {maximumOrderCount.ToStringInvariant()}."); + } + return message == null; + } + + /// + /// Helper to create the message of a set of contingent orders with a shape not supported by the brokerage + /// + public static BrokerageMessageEvent UnsupportedContingentOrdersShape(this IBrokerageModel brokerageModel, string reason) + { + return new BrokerageMessageEvent(BrokerageMessageType.Warning, "NotSupported", + Messages.DefaultBrokerageModel.UnsupportedContingentOrdersShape(brokerageModel, reason)); + } + /// /// Determines if executing the specified order will cross the zero holdings threshold. /// diff --git a/Common/Brokerages/BybitBrokerageModel.cs b/Common/Brokerages/BybitBrokerageModel.cs index 2e5debeb1541..f1ede969ccde 100644 --- a/Common/Brokerages/BybitBrokerageModel.cs +++ b/Common/Brokerages/BybitBrokerageModel.cs @@ -159,6 +159,11 @@ public override bool CanUpdateOrder(Security security, Order order, UpdateOrderR /// True if the brokerage could process the order, false otherwise public override bool CanSubmitOrder(Security security, Order order, out BrokerageMessageEvent message) { + if (!this.ValidateContingentOrdersNotSupported(order, out message)) + { + return false; + } + if (security.Type != SecurityType.Crypto && security.Type != SecurityType.CryptoFuture && security.Type != SecurityType.Base) { message = new BrokerageMessageEvent(BrokerageMessageType.Warning, "NotSupported", diff --git a/Common/Brokerages/CharlesSchwabBrokerageModel.cs b/Common/Brokerages/CharlesSchwabBrokerageModel.cs index 7b256621f08f..e3e64b78fea5 100644 --- a/Common/Brokerages/CharlesSchwabBrokerageModel.cs +++ b/Common/Brokerages/CharlesSchwabBrokerageModel.cs @@ -37,6 +37,15 @@ public class CharlesSchwabBrokerageModel : DefaultBrokerageModel SecurityType.IndexOption }); + /// + /// The contingency types supported by the brokerage: OCO and TRIGGER order strategies + /// + private readonly HashSet _supportedContingencyTypes = new() + { + ContingencyType.OneCancelsOther, + ContingencyType.OneTriggersOther + }; + /// /// HashSet containing the order types supported by the operation in TradeStation. /// @@ -100,7 +109,33 @@ public override bool CanSubmitOrder(Security security, Order order, out Brokerag return false; } + // OCO and TRIGGER order strategies, which can be nested + if (!this.ValidateContingentOrder(order, _supportedContingencyTypes, out message, supportsComboOrders: false)) + { + return false; + } + return base.CanSubmitOrder(security, order, out message); } + + /// + /// Returns true if the brokerage would allow updating the order as specified by the request + /// + /// The security of the order + /// The order to be updated + /// The requested update to be made to the order + /// If this function returns false, a brokerage message detailing why the order may not be updated + /// True if the brokerage would allow updating the order, false otherwise + public override bool CanUpdateOrder(Security security, Order order, UpdateOrderRequest request, out BrokerageMessageEvent message) + { + if (order.Contingency != null) + { + // OCO and TRIGGER order strategies can only be replaced as a whole + message = new BrokerageMessageEvent(BrokerageMessageType.Warning, "NotSupported", + Messages.DefaultBrokerageModel.UnsupportedContingentOrdersUpdate(this)); + return false; + } + return base.CanUpdateOrder(security, order, request, out message); + } } } diff --git a/Common/Brokerages/ClearStreetBrokerageModel.cs b/Common/Brokerages/ClearStreetBrokerageModel.cs index 03f12acbab60..ddbc3100a8d6 100644 --- a/Common/Brokerages/ClearStreetBrokerageModel.cs +++ b/Common/Brokerages/ClearStreetBrokerageModel.cs @@ -54,6 +54,11 @@ public ClearStreetBrokerageModel(AccountType accountType = AccountType.Margin) /// True if the brokerage could process the order, false otherwise public override bool CanSubmitOrder(Security security, Order order, out BrokerageMessageEvent message) { + if (!this.ValidateContingentOrdersNotSupported(order, out message)) + { + return false; + } + if (!_supportOrderTypeBySecurityType.TryGetValue(security.Type, out var supportOrderTypes)) { message = new BrokerageMessageEvent(BrokerageMessageType.Warning, "NotSupported", diff --git a/Common/Brokerages/CoinbaseBrokerageModel.cs b/Common/Brokerages/CoinbaseBrokerageModel.cs index d6a91fe75ccd..20b8763e11c1 100644 --- a/Common/Brokerages/CoinbaseBrokerageModel.cs +++ b/Common/Brokerages/CoinbaseBrokerageModel.cs @@ -162,6 +162,11 @@ public override bool CanUpdateOrder(Security security, Order order, UpdateOrderR /// True if the brokerage could process the order, false otherwise public override bool CanSubmitOrder(Security security, Order order, out BrokerageMessageEvent message) { + if (!this.ValidateContingentOrdersNotSupported(order, out message)) + { + return false; + } + if(order == null || security == null) { var parameter = order == null ? nameof(order) : nameof(security); diff --git a/Common/Brokerages/ExanteBrokerageModel.cs b/Common/Brokerages/ExanteBrokerageModel.cs index bfd43dbbb843..2f7fa99e8eaa 100644 --- a/Common/Brokerages/ExanteBrokerageModel.cs +++ b/Common/Brokerages/ExanteBrokerageModel.cs @@ -61,6 +61,11 @@ public override IBenchmark GetBenchmark(SecurityManager securities) /// True if the brokerage could process the order, false otherwise public override bool CanSubmitOrder(Security security, Order order, out BrokerageMessageEvent message) { + if (!this.ValidateContingentOrdersNotSupported(order, out message)) + { + return false; + } + message = null; if (order == null) diff --git a/Common/Brokerages/EzeBrokerageModel.cs b/Common/Brokerages/EzeBrokerageModel.cs index a4419d0ed8c4..7c63289c9435 100644 --- a/Common/Brokerages/EzeBrokerageModel.cs +++ b/Common/Brokerages/EzeBrokerageModel.cs @@ -89,6 +89,11 @@ public override IFeeModel GetFeeModel(Security security) /// True if the brokerage could process the order, false otherwise public override bool CanSubmitOrder(Security security, Order order, out BrokerageMessageEvent message) { + if (!this.ValidateContingentOrdersNotSupported(order, out message)) + { + return false; + } + if (!_supportSecurityTypes.Contains(security.Type)) { message = new BrokerageMessageEvent(BrokerageMessageType.Warning, "NotSupported", diff --git a/Common/Brokerages/FTXBrokerageModel.cs b/Common/Brokerages/FTXBrokerageModel.cs index 1194dc6d232f..1218b59fae82 100644 --- a/Common/Brokerages/FTXBrokerageModel.cs +++ b/Common/Brokerages/FTXBrokerageModel.cs @@ -103,6 +103,11 @@ public override IBenchmark GetBenchmark(SecurityManager securities) /// True if the brokerage could process the order, false otherwise public override bool CanSubmitOrder(Security security, Order order, out BrokerageMessageEvent message) { + if (!this.ValidateContingentOrdersNotSupported(order, out message)) + { + return false; + } + if (!IsValidOrderSize(security, order.Quantity, out message)) { return false; diff --git a/Common/Brokerages/FxcmBrokerageModel.cs b/Common/Brokerages/FxcmBrokerageModel.cs index 213f481ed4b2..920cfc7d38d9 100644 --- a/Common/Brokerages/FxcmBrokerageModel.cs +++ b/Common/Brokerages/FxcmBrokerageModel.cs @@ -76,6 +76,11 @@ public FxcmBrokerageModel(AccountType accountType = AccountType.Margin) /// True if the brokerage could process the order, false otherwise public override bool CanSubmitOrder(Security security, Order order, out BrokerageMessageEvent message) { + if (!this.ValidateContingentOrdersNotSupported(order, out message)) + { + return false; + } + message = null; // validate security type diff --git a/Common/Brokerages/InteractiveBrokersBrokerageModel.cs b/Common/Brokerages/InteractiveBrokersBrokerageModel.cs index 80f193f6eafb..c1bdd82ae22d 100644 --- a/Common/Brokerages/InteractiveBrokersBrokerageModel.cs +++ b/Common/Brokerages/InteractiveBrokersBrokerageModel.cs @@ -70,6 +70,16 @@ public class InteractiveBrokersBrokerageModel : DefaultBrokerageModel typeof(GoodTilDateTimeInForce) }; + /// + /// Supported contingency types + /// + protected virtual HashSet SupportedContingencyTypes { get; } = new HashSet + { + ContingencyType.OneCancelsOther, + ContingencyType.OneTriggersOther, + ContingencyType.OneUpdatesOther + }; + /// /// Supported order types /// @@ -155,6 +165,18 @@ public override bool CanSubmitOrder(Security security, Order order, out Brokerag { message = null; + // contingent orders of any type and shape are supported, including combo orders: OCA groups and attached orders + if (!this.ValidateContingentOrder(order, SupportedContingencyTypes, out message)) + { + return false; + } + if (order.Type == OrderType.TrailingStop && order.Contingency != null + && order.Contingency.GetParentOrderTypes().Any(type => type != OrderType.Limit && type != OrderType.StopLimit)) + { + message = this.UnsupportedContingentOrdersShape("a trailing stop order can only be triggered by a limit or stop limit order."); + return false; + } + // validate order type if (!SupportedOrderTypes.Contains(order.Type)) { diff --git a/Common/Brokerages/InteractiveBrokersFixModel.cs b/Common/Brokerages/InteractiveBrokersFixModel.cs index 2157dc29aa6e..c34781964444 100644 --- a/Common/Brokerages/InteractiveBrokersFixModel.cs +++ b/Common/Brokerages/InteractiveBrokersFixModel.cs @@ -77,6 +77,11 @@ public InteractiveBrokersFixModel(AccountType accountType = AccountType.Margin) /// True if the brokerage could process the order, false otherwise public override bool CanSubmitOrder(Security security, Order order, out BrokerageMessageEvent message) { + if (!this.ValidateContingentOrdersNotSupported(order, out message)) + { + return false; + } + // only check supported combo order types if (order is ComboOrder && order.GroupOrderManager != null && SupportedOrderTypes.Contains(order.Type)) { diff --git a/Common/Brokerages/KrakenBrokerageModel.cs b/Common/Brokerages/KrakenBrokerageModel.cs index 98050bdb7bf6..9e0bb3be02fa 100644 --- a/Common/Brokerages/KrakenBrokerageModel.cs +++ b/Common/Brokerages/KrakenBrokerageModel.cs @@ -91,6 +91,11 @@ public KrakenBrokerageModel(AccountType accountType = AccountType.Cash) : base(a /// True if the brokerage could process the order, false otherwise public override bool CanSubmitOrder(Security security, Order order, out BrokerageMessageEvent message) { + if (!this.ValidateContingentOrdersNotSupported(order, out message)) + { + return false; + } + if (!IsValidOrderSize(security, order.Quantity, out message)) { return false; diff --git a/Common/Brokerages/OandaBrokerageModel.cs b/Common/Brokerages/OandaBrokerageModel.cs index e59507508066..7dec57a00df0 100644 --- a/Common/Brokerages/OandaBrokerageModel.cs +++ b/Common/Brokerages/OandaBrokerageModel.cs @@ -81,6 +81,11 @@ public OandaBrokerageModel(AccountType accountType = AccountType.Margin) /// True if the brokerage could process the order, false otherwise public override bool CanSubmitOrder(Security security, Order order, out BrokerageMessageEvent message) { + if (!this.ValidateContingentOrdersNotSupported(order, out message)) + { + return false; + } + message = null; // validate security type diff --git a/Common/Brokerages/PublicBrokerageModel.cs b/Common/Brokerages/PublicBrokerageModel.cs index 63b13a08f6e4..ad010b9d9cfa 100644 --- a/Common/Brokerages/PublicBrokerageModel.cs +++ b/Common/Brokerages/PublicBrokerageModel.cs @@ -80,6 +80,11 @@ public override IFeeModel GetFeeModel(Security security) /// True if the brokerage could process the order, false otherwise public override bool CanSubmitOrder(Security security, Order order, out BrokerageMessageEvent message) { + if (!this.ValidateContingentOrdersNotSupported(order, out message)) + { + return false; + } + message = default; if (!_supportSecurityTypes.Contains(security.Type)) diff --git a/Common/Brokerages/RBIBrokerageModel.cs b/Common/Brokerages/RBIBrokerageModel.cs index 958fa5079d5c..80d8e60bb287 100644 --- a/Common/Brokerages/RBIBrokerageModel.cs +++ b/Common/Brokerages/RBIBrokerageModel.cs @@ -57,6 +57,11 @@ public RBIBrokerageModel(AccountType accountType = AccountType.Margin) : base(ac /// True if the brokerage could process the order, false otherwise public override bool CanSubmitOrder(Security security, Order order, out BrokerageMessageEvent message) { + if (!this.ValidateContingentOrdersNotSupported(order, out message)) + { + return false; + } + if (!IsValidOrderSize(security, order.Quantity, out message)) { return false; diff --git a/Common/Brokerages/SamcoBrokerageModel.cs b/Common/Brokerages/SamcoBrokerageModel.cs index 5d5bb05a2422..02c1a5aadada 100644 --- a/Common/Brokerages/SamcoBrokerageModel.cs +++ b/Common/Brokerages/SamcoBrokerageModel.cs @@ -99,6 +99,11 @@ public override bool CanExecuteOrder(Security security, Order order) /// True if the brokerage could process the order, false otherwise public override bool CanSubmitOrder(Security security, Order order, out BrokerageMessageEvent message) { + if (!this.ValidateContingentOrdersNotSupported(order, out message)) + { + return false; + } + message = null; // validate security type diff --git a/Common/Brokerages/TDAmeritradeBrokerageModel.cs b/Common/Brokerages/TDAmeritradeBrokerageModel.cs index c033caeff720..d66e30e22826 100644 --- a/Common/Brokerages/TDAmeritradeBrokerageModel.cs +++ b/Common/Brokerages/TDAmeritradeBrokerageModel.cs @@ -58,6 +58,11 @@ public TDAmeritradeBrokerageModel(AccountType accountType = AccountType.Margin) /// True if the brokerage could process the order, false otherwise public override bool CanSubmitOrder(Security security, Order order, out BrokerageMessageEvent message) { + if (!this.ValidateContingentOrdersNotSupported(order, out message)) + { + return false; + } + if (!IsValidOrderSize(security, order.Quantity, out message)) { return false; diff --git a/Common/Brokerages/TastytradeBrokerageModel.cs b/Common/Brokerages/TastytradeBrokerageModel.cs index 8671a9d11e92..aa3bb85b2dfd 100644 --- a/Common/Brokerages/TastytradeBrokerageModel.cs +++ b/Common/Brokerages/TastytradeBrokerageModel.cs @@ -88,6 +88,11 @@ public override IFeeModel GetFeeModel(Security security) /// True if the brokerage could process the order, false otherwise public override bool CanSubmitOrder(Security security, Order order, out BrokerageMessageEvent message) { + if (!this.ValidateContingentOrdersNotSupported(order, out message)) + { + return false; + } + message = default; if (!_supportSecurityTypes.Contains(security.Type)) diff --git a/Common/Brokerages/TerminalLinkBrokerageModel.cs b/Common/Brokerages/TerminalLinkBrokerageModel.cs index 6bcc82d73930..ad506be8d9ce 100644 --- a/Common/Brokerages/TerminalLinkBrokerageModel.cs +++ b/Common/Brokerages/TerminalLinkBrokerageModel.cs @@ -58,6 +58,11 @@ public TerminalLinkBrokerageModel(AccountType accountType = AccountType.Margin) /// If this function returns false, a brokerage message detailing why the order may not be submitted public override bool CanSubmitOrder(Security security, Order order, out BrokerageMessageEvent message) { + if (!this.ValidateContingentOrdersNotSupported(order, out message)) + { + return false; + } + if (!_supportedSecurityTypes.Contains(security.Type)) { message = new BrokerageMessageEvent(BrokerageMessageType.Warning, "NotSupported", diff --git a/Common/Brokerages/TradeStationBrokerageModel.cs b/Common/Brokerages/TradeStationBrokerageModel.cs index 51e4fe5139e4..88babe4d5076 100644 --- a/Common/Brokerages/TradeStationBrokerageModel.cs +++ b/Common/Brokerages/TradeStationBrokerageModel.cs @@ -15,6 +15,7 @@ */ using System; +using System.Linq; using QuantConnect.Orders; using QuantConnect.Securities; using QuantConnect.Orders.Fees; @@ -55,6 +56,16 @@ public class TradeStationBrokerageModel : DefaultBrokerageModel SecurityType.IndexOption }; + /// + /// The contingency types supported by the brokerage: OCO and BRK (a fill reduces the rest) order groups and order sends order (OSO) + /// + private readonly HashSet _supportedContingencyTypes = new() + { + ContingencyType.OneCancelsOther, + ContingencyType.OneTriggersOther, + ContingencyType.OneUpdatesOther + }; + /// /// HashSet containing the order types supported by the operation in TradeStation. /// @@ -140,6 +151,27 @@ public override bool CanSubmitOrder(Security security, Order order, out Brokerag return false; } + // order groups (OCO, BRK) and order sends order (OSO) + if (!this.ValidateContingentOrder(order, _supportedContingencyTypes, out message, supportsComboOrders: false, supportsNesting: false)) + { + return false; + } + + if (order.GetSiblingLink()?.Type == ContingencyType.OneUpdatesOther) + { + // a bracket (BRK) group, where a fill reduces the other orders, requires the same symbol and a stop order + if (order.Contingency.Symbols.Count > 1) + { + message = this.UnsupportedContingentOrdersShape($"{ContingencyType.OneUpdatesOther} orders have to be for the same symbol."); + return false; + } + if (!order.Contingency.GetSiblingOrderTypes().Any(type => type is OrderType.StopMarket or OrderType.StopLimit or OrderType.TrailingStop)) + { + message = this.UnsupportedContingentOrdersShape($"{ContingencyType.OneUpdatesOther} orders require a stop order."); + return false; + } + } + if (!BrokerageExtensions.ValidateCrossZeroOrder(this, security, order, out message, NotSupportedCrossZeroOrderTypes)) { return false; diff --git a/Common/Brokerages/TradierBrokerageModel.cs b/Common/Brokerages/TradierBrokerageModel.cs index cfa3c71ab131..7266dd1a1e62 100644 --- a/Common/Brokerages/TradierBrokerageModel.cs +++ b/Common/Brokerages/TradierBrokerageModel.cs @@ -69,6 +69,11 @@ public TradierBrokerageModel(AccountType accountType = AccountType.Margin) /// True if the brokerage could process the order, false otherwise public override bool CanSubmitOrder(Security security, Order order, out BrokerageMessageEvent message) { + if (!this.ValidateContingentOrdersNotSupported(order, out message)) + { + return false; + } + message = null; if (!_supportedOrderTypes.Contains(order.Type)) diff --git a/Common/Brokerages/TradingTechnologiesBrokerageModel.cs b/Common/Brokerages/TradingTechnologiesBrokerageModel.cs index 82912eb470d8..74d30df8b9db 100644 --- a/Common/Brokerages/TradingTechnologiesBrokerageModel.cs +++ b/Common/Brokerages/TradingTechnologiesBrokerageModel.cs @@ -101,6 +101,11 @@ public override IFeeModel GetFeeModel(Security security) /// True if the brokerage could process the order, false otherwise public override bool CanSubmitOrder(Security security, Order order, out BrokerageMessageEvent message) { + if (!this.ValidateContingentOrdersNotSupported(order, out message)) + { + return false; + } + message = null; // validate security type diff --git a/Common/Brokerages/WebullBrokerageModel.cs b/Common/Brokerages/WebullBrokerageModel.cs index 4d1922860e8d..90c7e29d3216 100644 --- a/Common/Brokerages/WebullBrokerageModel.cs +++ b/Common/Brokerages/WebullBrokerageModel.cs @@ -100,6 +100,11 @@ public override IFeeModel GetFeeModel(Security security) /// True if the brokerage could process the order, false otherwise public override bool CanSubmitOrder(Security security, Order order, out BrokerageMessageEvent message) { + if (!this.ValidateContingentOrdersNotSupported(order, out message)) + { + return false; + } + message = default; if (!_supportedOrderTypesBySecurityType.TryGetValue(security.Type, out var supportedOrderTypes)) diff --git a/Common/Brokerages/WolverineBrokerageModel.cs b/Common/Brokerages/WolverineBrokerageModel.cs index 8b3b6e0416a2..1eb47a10934f 100644 --- a/Common/Brokerages/WolverineBrokerageModel.cs +++ b/Common/Brokerages/WolverineBrokerageModel.cs @@ -59,6 +59,11 @@ public WolverineBrokerageModel(AccountType accountType = AccountType.Margin) : b /// True if the brokerage could process the order, false otherwise public override bool CanSubmitOrder(Security security, Order order, out BrokerageMessageEvent message) { + if (!this.ValidateContingentOrdersNotSupported(order, out message)) + { + return false; + } + if (!IsValidOrderSize(security, order.Quantity, out message)) { return false; diff --git a/Common/Brokerages/ZerodhaBrokerageModel.cs b/Common/Brokerages/ZerodhaBrokerageModel.cs index 25cd5d380436..aff50ed745d9 100644 --- a/Common/Brokerages/ZerodhaBrokerageModel.cs +++ b/Common/Brokerages/ZerodhaBrokerageModel.cs @@ -98,6 +98,11 @@ public override bool CanExecuteOrder(Security security, Order order) /// True if the brokerage could process the order, false otherwise public override bool CanSubmitOrder(Security security, Order order, out BrokerageMessageEvent message) { + if (!this.ValidateContingentOrdersNotSupported(order, out message)) + { + return false; + } + message = null; // validate security type diff --git a/Common/Brokerages/dYdXBrokerageModel.cs b/Common/Brokerages/dYdXBrokerageModel.cs index d8b342aeddba..99ec92aab710 100644 --- a/Common/Brokerages/dYdXBrokerageModel.cs +++ b/Common/Brokerages/dYdXBrokerageModel.cs @@ -130,6 +130,11 @@ public override bool CanUpdateOrder(Security security, Order order, UpdateOrderR /// True if the brokerage could process the order, false otherwise public override bool CanSubmitOrder(Security security, Order order, out BrokerageMessageEvent message) { + if (!this.ValidateContingentOrdersNotSupported(order, out message)) + { + return false; + } + if (security.Type != SecurityType.CryptoFuture) { message = new BrokerageMessageEvent(BrokerageMessageType.Warning, "NotSupported", diff --git a/Common/Extensions.cs b/Common/Extensions.cs index be982b6c8f16..fbbff6d6bb90 100644 --- a/Common/Extensions.cs +++ b/Common/Extensions.cs @@ -2875,7 +2875,8 @@ public static OrderTicket ToOrderTicket(this Order order, SecurityTransactionMan order.Time, order.Tag, order.Properties, - order.GroupOrderManager); + order.GroupOrderManager, + contingency: order.Contingency); submitOrderRequest.SetOrderId(order.Id); var orderTicket = new OrderTicket(transactionManager, submitOrderRequest); diff --git a/Common/Interfaces/IBrokerage.cs b/Common/Interfaces/IBrokerage.cs index 4de0b189a626..59ffbad1b16b 100644 --- a/Common/Interfaces/IBrokerage.cs +++ b/Common/Interfaces/IBrokerage.cs @@ -157,5 +157,6 @@ public interface IBrokerage : IBrokerageCashSynchronizer, IDisposable /// Enables or disables concurrent processing of messages to and from the brokerage. /// bool ConcurrencyEnabled { get; set; } + } } diff --git a/Common/Messages/Messages.Brokerages.cs b/Common/Messages/Messages.Brokerages.cs index d6963943d4bf..98c7716186eb 100644 --- a/Common/Messages/Messages.Brokerages.cs +++ b/Common/Messages/Messages.Brokerages.cs @@ -110,6 +110,53 @@ public static string UnsupportedOrderType(IBrokerageModel brokerageModel, Orders return Invariant($"The {brokerageModel.GetType().Name} does not support {order.Type} order type. Only supports [{string.Join(',', supportedOrderTypes)}]"); } + /// + /// Returns a string message saying the given brokerage model does not support contingent orders + /// + [MethodImpl(MethodImplOptions.AggressiveInlining)] + public static string UnsupportedContingentOrders(IBrokerageModel brokerageModel) + { + return Invariant($"The {brokerageModel.GetType().Name} does not support contingent orders (OCO, OTO, OUO, brackets)."); + } + + /// + /// Returns a string message saying the contingency type of the given order is unsupported by the given brokerage model. + /// It also mentions the supported contingency types + /// + [MethodImpl(MethodImplOptions.AggressiveInlining)] + public static string UnsupportedContingencyType(IBrokerageModel brokerageModel, Orders.ContingencyType contingencyType, + IEnumerable supportedContingencyTypes) + { + return Invariant($"The {brokerageModel.GetType().Name} does not support {contingencyType} contingent orders. Only supports [{string.Join(',', supportedContingencyTypes)}]"); + } + + /// + /// Returns a string message saying the given brokerage model does not support updating contingent orders + /// + [MethodImpl(MethodImplOptions.AggressiveInlining)] + public static string UnsupportedContingentOrdersUpdate(IBrokerageModel brokerageModel) + { + return Invariant($"The {brokerageModel.GetType().Name} does not support updating contingent orders, please cancel and submit them again."); + } + + /// + /// Returns a string message saying the given brokerage model does not support updating the quantity of contingent orders + /// + [MethodImpl(MethodImplOptions.AggressiveInlining)] + public static string UnsupportedContingentOrdersQuantityUpdate(IBrokerageModel brokerageModel) + { + return Invariant($"The {brokerageModel.GetType().Name} does not support updating the quantity of contingent orders."); + } + + /// + /// Returns a string message saying the shape of the set of contingent orders is unsupported by the given brokerage model + /// + [MethodImpl(MethodImplOptions.AggressiveInlining)] + public static string UnsupportedContingentOrdersShape(IBrokerageModel brokerageModel, string reason) + { + return Invariant($"The {brokerageModel.GetType().Name} does not support this set of contingent orders: {reason}"); + } + /// /// Returns a string message saying the Time In Force of the given order is unsupported by the given brokerage /// model diff --git a/Common/Orders/ContingencyLink.cs b/Common/Orders/ContingencyLink.cs new file mode 100644 index 000000000000..d4b9e248b1dc --- /dev/null +++ b/Common/Orders/ContingencyLink.cs @@ -0,0 +1,109 @@ +/* + * 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 Newtonsoft.Json; + +namespace QuantConnect.Orders +{ + /// + /// Links an order to a contingency, that is, a relationship with other orders of the same + /// , and defines the role the order plays in it + /// + public class ContingencyLink + { + /// + /// The contingency id, unique within its set of contingent orders. + /// Orders sharing a contingency id are related through it + /// + [JsonProperty(PropertyName = "id")] + public int Id { get; } + + /// + /// The contingency type + /// + [JsonProperty(PropertyName = "type")] + public ContingencyType Type { get; } + + /// + /// The role of the order in this contingency, for a contingency. + /// Null for the other types, whose orders are all siblings + /// + [JsonProperty(PropertyName = "role", NullValueHandling = NullValueHandling.Ignore)] + public ContingencyRole? Role { get; } + + /// + /// For a , whether the parent filled and so the order was released to the market + /// + [JsonProperty(PropertyName = "triggered", DefaultValueHandling = DefaultValueHandling.Ignore)] + public bool Triggered { get; internal set; } + + /// + /// For a , the utc time at which the order was triggered, if any + /// + [JsonProperty(PropertyName = "triggeredTime", NullValueHandling = NullValueHandling.Ignore)] + public DateTime? TriggeredTime { get; internal set; } + + /// + /// Creates a new instance + /// + /// The contingency id, unique within its set of contingent orders + /// The contingency type + /// The role of the order in this contingency, required for only + /// For a child, whether it was already triggered + /// For a child, the utc time at which it was triggered + [JsonConstructor] + public ContingencyLink(int id, ContingencyType type, ContingencyRole? role = null, bool triggered = false, DateTime? triggeredTime = null) + { + if (!IsValidRole(type, role)) + { + throw new ArgumentException($"Invalid contingency role '{role?.ToString() ?? "null"}' for a '{type}' contingency"); + } + + Id = id; + Type = type; + Role = role; + Triggered = triggered; + TriggeredTime = triggeredTime; + } + + /// + /// Determines whether the role is valid for the contingency type: has + /// a parent and children, while the orders of the other types are all siblings, with no role + /// + public static bool IsValidRole(ContingencyType type, ContingencyRole? role) + { + return (type == ContingencyType.OneTriggersOther) == role.HasValue; + } + + /// + /// Creates a copy of this instance + /// + public ContingencyLink Clone() + { + return new ContingencyLink(Id, Type, Role, Triggered, TriggeredTime); + } + + /// + /// Returns a string that represents the current object + /// + public override string ToString() + { + var role = Role.HasValue ? $":{Role}" : string.Empty; + var state = Role == ContingencyRole.Child ? (Triggered ? ":Triggered" : ":Held") : string.Empty; + return $"{Type}:{Id}{role}{state}"; + } + } +} diff --git a/Common/Orders/ContingencyType.cs b/Common/Orders/ContingencyType.cs new file mode 100644 index 000000000000..b13b37145ff5 --- /dev/null +++ b/Common/Orders/ContingencyType.cs @@ -0,0 +1,57 @@ +/* + * 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. +*/ + +namespace QuantConnect.Orders +{ + /// + /// The type of relationship linking a set of contingent orders + /// + public enum ContingencyType + { + /// + /// One Cancels Other (OCO/OCA): once a member fills the remaining members are canceled (0) + /// + OneCancelsOther, + + /// + /// One Triggers Other (OTO): the children are held until the parent is completely filled (1) + /// + OneTriggersOther, + + /// + /// One Updates Other (OUO): a member fill reduces the quantity of the remaining members proportionally, + /// which are canceled once the member is completely filled (2) + /// + OneUpdatesOther + } + + /// + /// The role an order plays in a contingency, the only one with sides. + /// The orders of a or contingency + /// are all siblings, they have no role + /// + public enum ContingencyRole + { + /// + /// The parent, which triggers the children once completely filled (0) + /// + Parent, + + /// + /// A child, held until its parent fills (1) + /// + Child + } +} diff --git a/Common/Orders/ContingentOrderCache.cs b/Common/Orders/ContingentOrderCache.cs new file mode 100644 index 000000000000..26509eae07c6 --- /dev/null +++ b/Common/Orders/ContingentOrderCache.cs @@ -0,0 +1,65 @@ +/* + * 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.Linq; +using System.Collections.Generic; +using System.Collections.Concurrent; + +namespace QuantConnect.Orders +{ + /// + /// Provides a thread-safe service for caching the orders of a set of contingent orders (OCO, OTO, OUO, brackets) until all of them + /// have arrived, so that a brokerage can submit them together. Orders are placed one by one, see + /// + public class ContingentOrderCache + { + /// + /// The pending orders by their order id, the original instances so that the brokerage can set their brokerage ids + /// + private readonly ConcurrentDictionary _pendingOrders = new(); + + /// + /// Attempts to retrieve all the orders in the set of contingent orders from the cache + /// + /// Target order, which can be any of the orders of the set + /// All the orders in the set sorted by id: parents come before the orders they trigger + /// + /// True if all the orders of the set were successfully retrieved from the cache, which are removed from it. + /// Otherwise false, the target order is cached for future retrieval + /// + /// If the target order is not a contingent order, the resulting list will contain that single order alone + public bool TryGetContingentCachedOrders(Order order, out List orders) + { + if (!order.TryGetContingentOrders(TryGetOrder, out orders)) + { + // some order of the set is missing but cache the new one + _pendingOrders[order.Id] = order; + return false; + } + + for (var i = 0; i < orders.Count; i++) + { + _pendingOrders.TryRemove(orders[i].Id, out _); + } + return true; + } + + private Order TryGetOrder(int orderId) + { + _pendingOrders.TryGetValue(orderId, out var order); + return order; + } + } +} diff --git a/Common/Orders/ContingentOrderExtensions.cs b/Common/Orders/ContingentOrderExtensions.cs new file mode 100644 index 000000000000..cb0a408220d5 --- /dev/null +++ b/Common/Orders/ContingentOrderExtensions.cs @@ -0,0 +1,264 @@ +/* + * 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.Logging; +using System.Collections.Generic; + +namespace QuantConnect.Orders +{ + /// + /// Contingent orders (OCO, OTO, OUO, brackets) extension methods for easiest manipulation + /// + public static class ContingentOrderExtensions + { + /// + /// Determines whether the order is part of a set of contingent orders + /// + public static bool IsContingent(this Order order) + { + return order.Contingency != null && order.Contingency.Links.Count > 0; + } + + /// + /// Gets the first link of the order with the given role, null if none + /// + public static ContingencyLink GetContingencyLink(this Order order, ContingencyRole role) + { + return order.Contingency?.GetLink(role); + } + + /// + /// Gets the link of the order to its siblings, the other members of its + /// or contingency, null if none + /// + public static ContingencyLink GetSiblingLink(this Order order) + { + return order.Contingency?.GetLink(null); + } + + /// + /// Determines whether the order is a contingent child still held waiting for its parent to fill, + /// that is, the order is not working in the market yet + /// + public static bool IsWaitingForTrigger(this Order order) + { + return order.Contingency?.IsWaitingForTrigger == true; + } + + /// + /// Gets the utc time at which the contingent child order was triggered, null if not a child or not triggered yet + /// + public static DateTime? GetTriggeredTime(this Order order) + { + return order.GetContingencyLink(ContingencyRole.Child)?.TriggeredTime; + } + + /// + /// Gets the utc time from which the order is considered to be working in the market: + /// the time it was triggered for contingent child orders, else its creation time + /// + public static DateTime GetWorkingTime(this Order order) + { + return order.GetTriggeredTime() ?? order.Time; + } + + /// + /// Determines whether both orders are members of the same + /// or contingency, so at most one of them is expected to completely fill + /// + public static bool IsContingentSibling(this Order order, Order other) + { + return order.IsContingentSibling(order.GetSiblingLink(), other); + } + + /// + /// Determines whether the other order is a sibling of the given one, given its link to its siblings + /// + internal static bool IsContingentSibling(this Order order, ContingencyLink member, Order other) + { + return member != null && order.Id != other.Id && other.Contingency != null + && order.Contingency.Id == other.Contingency.Id && member.Id == other.GetSiblingLink()?.Id + // legs of the same combo are not siblings, they are a single unit + && !order.IsSameGroupOrder(other); + } + + /// + /// Determines whether both orders are legs of the same group (combo) order + /// + public static bool IsSameGroupOrder(this Order order, Order other) + { + return order.GroupOrderManager != null && other.GroupOrderManager != null + && order.GroupOrderManager.Id == other.GroupOrderManager.Id; + } + + /// + /// Gets all the orders in the set of contingent orders the given order belongs to + /// + /// Target order, which can be any of the orders in the set + /// Order provider to use to access the existing orders + /// List of orders in the set, sorted by id + /// False if any of the orders in the set is not yet found in the order provider. True otherwise + /// If the target order is not a contingent order, the resulting list will contain that single order alone + public static bool TryGetContingentOrders(this Order order, Func orderProvider, out List orders) + { + var contingency = order.Contingency; + if (contingency != null && contingency.OrderIds.Count != contingency.Count) + { + // this will happen while all the orders haven't arrived yet, we will retry + orders = null; + return false; + } + + orders = new List(contingency?.Count ?? 1) { order }; + if (contingency != null) + { + lock (contingency.OrderIds) + { + foreach (var otherOrderId in contingency.OrderIds) + { + if (otherOrderId == order.Id) + { + continue; + } + + var otherOrder = orderProvider(otherOrderId); + if (otherOrder == null) + { + // this will happen while all the orders haven't arrived yet, we will retry + return false; + } + orders.Add(otherOrder); + } + } + + if (contingency.Count != orders.Count) + { + if (Log.DebuggingEnabled) + { + Log.Debug($"ContingentOrderExtensions.TryGetContingentOrders(): missing orders of set {contingency.Id}." + + $" We have {orders.Count}/{contingency.Count} orders will skip"); + } + return false; + } + } + + orders.Sort((x, y) => x.Id.CompareTo(y.Id)); + return true; + } + + /// + /// Gets the orders of the set which exist in the given provider, without requiring all of them to be present + /// + /// Target order, which can be any of the orders in the set + /// Order provider to use to access the existing orders + /// The existing orders of the set, including the given one, sorted by id + public static List GetExistingContingentOrders(this Order order, Func orderProvider) + { + var contingency = order.Contingency; + var orders = new List(contingency?.Count ?? 1) { order }; + if (contingency != null) + { + lock (contingency.OrderIds) + { + foreach (var otherOrderId in contingency.OrderIds) + { + if (otherOrderId != order.Id) + { + var otherOrder = orderProvider(otherOrderId); + if (otherOrder != null) + { + orders.Add(otherOrder); + } + } + } + } + orders.Sort((x, y) => x.Id.CompareTo(y.Id)); + } + return orders; + } + + /// + /// Gets the children the given parent order triggers once filled + /// + /// The parent order + /// The orders in the set + public static IEnumerable GetContingentChildren(this Order order, IEnumerable contingentOrders) + { + var parent = order.GetContingencyLink(ContingencyRole.Parent); + if (parent == null) + { + return Enumerable.Empty(); + } + return contingentOrders.Where(other => other.Id != order.Id && other.GetContingencyLink(ContingencyRole.Child)?.Id == parent.Id); + } + + /// + /// Gets the parent orders of the given child, more than one when the parent is a combo order + /// + /// The child order + /// The orders in the set + public static IEnumerable GetContingentParents(this Order order, IEnumerable contingentOrders) + { + var child = order.GetContingencyLink(ContingencyRole.Child); + if (child == null) + { + return Enumerable.Empty(); + } + return contingentOrders.Where(other => other.Id != order.Id && other.GetContingencyLink(ContingencyRole.Parent)?.Id == child.Id); + } + + /// + /// Gets the sibling orders of the given one, the other members of its OCO/OUO contingency. + /// The legs of the same combo order are not siblings + /// + /// The member order + /// The orders in the set + public static IEnumerable GetContingentSiblings(this Order order, IEnumerable contingentOrders) + { + if (order.GetSiblingLink() == null) + { + return Enumerable.Empty(); + } + return contingentOrders.Where(other => order.IsContingentSibling(other)); + } + + /// + /// Gets all the descendants of the given order: its children, their children and so on + /// + /// The parent order + /// The orders in the set + public static List GetContingentDescendants(this Order order, IReadOnlyCollection contingentOrders) + { + var result = new List(); + var visited = new HashSet { order.Id }; + var pending = new Queue(); + pending.Enqueue(order); + while (pending.Count > 0) + { + foreach (var child in pending.Dequeue().GetContingentChildren(contingentOrders)) + { + if (visited.Add(child.Id)) + { + result.Add(child); + pending.Enqueue(child); + } + } + } + return result; + } + } +} diff --git a/Common/Orders/ContingentOrderProcessor.cs b/Common/Orders/ContingentOrderProcessor.cs new file mode 100644 index 000000000000..8193619abdc9 --- /dev/null +++ b/Common/Orders/ContingentOrderProcessor.cs @@ -0,0 +1,276 @@ +/* + * 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 QuantConnect.Securities; +using QuantConnect.Orders.Fees; +using System.Collections.Generic; + +namespace QuantConnect.Orders +{ + /// + /// Defines the lifecycle rules of contingent orders (OCO, OTO, OUO and their compositions, like brackets). + /// Given the order events that happened it determines which orders should be triggered, canceled or resized, + /// it's up to the caller, the one simulating the brokerage side, to apply these actions. + /// + /// + /// The rules are: + /// - : children are held until their parent, all its legs for a combo order, + /// is completely filled. If the parent is canceled, even if partially filled, or turns invalid its children are canceled. + /// - : the first fill of a member, even if partial, cancels its siblings. + /// - : a partial fill of a member reduces the remaining quantity of its siblings + /// proportionally, once it's completely filled its siblings are canceled. + /// The legs of a combo order are handled as a single unit. This type holds no state so it's thread safe. + /// + internal class ContingentOrderProcessor + { + private readonly Func _filledQuantityProvider; + private readonly ISecurityProvider _securityProvider; + + /// + /// Creates a new instance + /// + /// Provides the total filled quantity of an order by id + /// The security provider to use + public ContingentOrderProcessor(Func filledQuantityProvider, ISecurityProvider securityProvider) + { + _filledQuantityProvider = filledQuantityProvider; + _securityProvider = securityProvider; + } + + /// + /// Determines the actions to take on the contingent orders related to the orders of the given events + /// + /// The order events that happened, already applied + /// Provides access to the orders by id, null if it does not exist + /// The current utc time, for the events + /// The updates of the orders to trigger or resize, and the events of the orders to cancel. Null if none + public (List Updates, List Cancels) Process(IEnumerable orderEvents, Func orderProvider, + DateTime utcTime) + { + var actions = new Actions(utcTime, _securityProvider); + List contingentOrders = null; + foreach (var orderEvent in orderEvents) + { + if (!orderEvent.Status.IsClosed() && orderEvent.Status != OrderStatus.PartiallyFilled) + { + continue; + } + + var order = orderProvider(orderEvent.OrderId); + if (order == null || !order.IsContingent()) + { + continue; + } + + // the events of the same set, like the legs of a combo order, usually come together: the set is fetched once + if (contingentOrders == null || !ReferenceEquals(contingentOrders[0].Contingency?.OrderIds, order.Contingency.OrderIds)) + { + contingentOrders = order.GetExistingContingentOrders(orderProvider); + } + + if (orderEvent.Status == OrderStatus.Filled || orderEvent.Status == OrderStatus.PartiallyFilled) + { + ProcessFill(order, orderEvent, contingentOrders, actions); + continue; + } + + // the parent was canceled or turned invalid: it won't ever trigger its children. A member was canceled or turned + // invalid: the contingency is canceled as a whole, like brokerages do + ProcessHeldChildren(order, contingentOrders, orderEvent.Status, actions); + CancelSiblings(order, order.GetSiblingLink(), contingentOrders, orderEvent.Status, actions); + } + return (actions.Updates, actions.Cancels); + } + + /// + /// Triggers the children of the given parent still held waiting for it to fill, or cancels them if the parent was closed + /// + /// The parent order + /// The orders in the set + /// The status of the parent if it was closed without filling, null if it filled + /// The actions to add to + private static void ProcessHeldChildren(Order order, List contingentOrders, OrderStatus? parentClosedStatus, Actions actions) + { + var parent = order.GetContingencyLink(ContingencyRole.Parent); + if (parent == null) + { + return; + } + foreach (var other in contingentOrders) + { + var child = other.Id != order.Id && !other.Status.IsClosed() ? other.Contingency?.GetLink(ContingencyRole.Child) : null; + if (child == null || child.Id != parent.Id || child.Triggered) + { + continue; + } + if (parentClosedStatus == null) + { + actions.Trigger(other); + } + else + { + actions.Cancel(other, $"Contingent parent order {order.Id} was {parentClosedStatus.Value.ToString().ToLowerInvariant()}"); + } + } + } + + /// + /// Cancels the siblings of the given order which are still open + /// + /// The order which was filled or closed + /// The link of the order to its siblings + /// The orders in the set + /// The status of the order, the reason of the cancelation + /// The actions to add to + private static void CancelSiblings(Order order, ContingencyLink member, List contingentOrders, OrderStatus status, Actions actions) + { + if (member == null) + { + return; + } + foreach (var sibling in contingentOrders) + { + if (!sibling.Status.IsClosed() && order.IsContingentSibling(member, sibling)) + { + actions.Cancel(sibling, $"Contingent sibling order {order.Id} was {status.ToString().ToLowerInvariant()}"); + } + } + } + + private void ProcessFill(Order order, OrderEvent orderEvent, List contingentOrders, Actions actions) + { + var completelyFilled = orderEvent.Status == OrderStatus.Filled; + + var member = order.GetSiblingLink(); + if (member != null) + { + if (completelyFilled || member.Type == ContingencyType.OneCancelsOther) + { + CancelSiblings(order, member, contingentOrders, OrderStatus.Filled, actions); + } + else if (orderEvent.FillQuantity != 0) + { + // OUO partial fill: the remaining quantity of the siblings is reduced proportionally + var remainingAfter = Math.Abs(order.Quantity) - Math.Abs(_filledQuantityProvider(order.Id)); + var remainingBefore = remainingAfter + Math.Abs(orderEvent.FillQuantity); + if (remainingBefore > 0 && remainingAfter >= 0) + { + foreach (var sibling in contingentOrders) + { + if (sibling.Status.IsClosed() || !order.IsContingentSibling(member, sibling)) + { + continue; + } + var siblingFilled = Math.Abs(_filledQuantityProvider(sibling.Id)); + // multiply first so we don't lose precision + var siblingRemaining = (Math.Abs(sibling.Quantity) - siblingFilled) * remainingAfter / remainingBefore; + + var lotSize = _securityProvider?.GetSecurity(sibling.Symbol)?.SymbolProperties.LotSize ?? 0; + if (lotSize > 0) + { + siblingRemaining = Math.Round(siblingRemaining / lotSize) * lotSize; + } + + if (siblingRemaining <= 0) + { + actions.Cancel(sibling, $"Contingent sibling order {order.Id} was filled"); + } + else + { + var newQuantity = Math.Sign(sibling.Quantity) * (siblingFilled + siblingRemaining); + if (newQuantity != sibling.Quantity) + { + actions.UpdateQuantity(sibling, newQuantity); + } + } + } + } + } + } + + var parent = order.GetContingencyLink(ContingencyRole.Parent); + if (parent != null && completelyFilled) + { + // for combo orders all the legs have to be filled + foreach (var other in contingentOrders) + { + if (other.Id != order.Id && other.Status != OrderStatus.Filled && other.GetContingencyLink(ContingencyRole.Parent)?.Id == parent.Id) + { + return; + } + } + ProcessHeldChildren(order, contingentOrders, null, actions); + } + } + + /// + /// Builds the events of the actions to take, at most one per order + /// + private class Actions + { + private readonly DateTime _utcTime; + private readonly ISecurityProvider _securityProvider; + private HashSet _orderIds; + + public List Updates { get; private set; } + public List Cancels { get; private set; } + + public Actions(DateTime utcTime, ISecurityProvider securityProvider) + { + _utcTime = utcTime; + _securityProvider = securityProvider; + } + + /// + /// The held child is released to the market, a trailing stop starts trailing from the market price at this time + /// + public void Trigger(Order order) + { + if (Add(order)) + { + var update = new OrderUpdateEvent { OrderId = order.Id, ContingencyTriggered = true }; + if (order is TrailingStopOrder { StopPrice: 0 } trailingStop && _securityProvider?.GetSecurity(order.Symbol) is { } security) + { + update.TrailingStopPrice = TrailingStopOrder.CalculateStopPrice(security.Price, trailingStop.TrailingAmount, + trailingStop.TrailingAsPercentage, trailingStop.Direction); + } + (Updates ??= new()).Add(update); + } + } + + public void Cancel(Order order, string message) + { + if (Add(order)) + { + (Cancels ??= new()).Add(new OrderEvent(order, _utcTime, OrderFee.Zero, message) { Status = OrderStatus.Canceled }); + } + } + + public void UpdateQuantity(Order order, decimal quantity) + { + if (Add(order)) + { + (Updates ??= new()).Add(new OrderUpdateEvent { OrderId = order.Id, Quantity = quantity }); + } + } + + private bool Add(Order order) + { + return (_orderIds ??= new()).Add(order.Id); + } + } + } +} diff --git a/Common/Orders/Order.cs b/Common/Orders/Order.cs index 9f6ced0b4f64..b98c26895eb5 100644 --- a/Common/Orders/Order.cs +++ b/Common/Orders/Order.cs @@ -34,6 +34,7 @@ public abstract class Order private decimal _quantity; private decimal _price; private int _id; + private OrderContingency _contingency; /// /// Order ID. @@ -52,6 +53,7 @@ internal set GroupOrderManager.OrderIds.Add(_id); } } + RegisterContingentOrderId(); } } @@ -230,6 +232,22 @@ public bool IsMarketable [JsonProperty(PropertyName = "groupOrderManager", DefaultValueHandling = DefaultValueHandling.Ignore)] public GroupOrderManager GroupOrderManager { get; set; } + /// + /// The contingency of this order, if any: the set of contingent orders it belongs to (OCO, OTO, OUO, brackets) + /// and the links defining how it relates to the rest of the orders in the set + /// + [JsonProperty(PropertyName = "contingency", DefaultValueHandling = DefaultValueHandling.Ignore)] + public OrderContingency Contingency + { + get => _contingency; + set + { + _contingency = value; + _contingency?.SetOrder(this); + RegisterContingentOrderId(); + } + } + /// /// The adjustment mode used on the order fill price /// @@ -331,6 +349,20 @@ public virtual string GetDefaultTag() return string.Empty; } + /// + /// Registers this order id in its set of contingent orders, if any + /// + private void RegisterContingentOrderId() + { + if (_id != 0 && _contingency != null) + { + lock (_contingency.OrderIds) + { + _contingency.OrderIds.Add(_id); + } + } + } + /// /// Gets a new unique incremental id for this order /// @@ -388,6 +420,8 @@ protected void CopyTo(Order order) // The group order manager has to be set before the quantity, // since combo orders might need it to calculate the quantity in the Quantity setter. order.GroupOrderManager = GroupOrderManager; + // the set is shared, the links are cloned + order.Contingency = Contingency?.Clone(); order.Time = Time; order.LastFillTime = LastFillTime; order.LastUpdateTime = LastUpdateTime; @@ -412,12 +446,16 @@ protected void CopyTo(Order order) /// The that matches the request public static Order CreateOrder(SubmitOrderRequest request) { - return CreateOrder(request.OrderId, request.OrderType, request.Symbol, request.Quantity, request.Time, + var order = CreateOrder(request.OrderType, request.Symbol, request.Quantity, request.Time, request.Tag, request.OrderProperties, request.LimitPrice, request.StopPrice, request.TriggerPrice, request.TrailingAmount, request.TrailingAsPercentage, request.GroupOrderManager); + order.Contingency = request.Contingency?.Clone(); + order.Status = OrderStatus.New; + order.Id = request.OrderId; + return order; } - private static Order CreateOrder(int orderId, OrderType type, Symbol symbol, decimal quantity, DateTime time, + private static Order CreateOrder(OrderType type, Symbol symbol, decimal quantity, DateTime time, string tag, IOrderProperties properties, decimal limitPrice, decimal stopPrice, decimal triggerPrice, decimal trailingAmount, bool trailingAsPercentage, GroupOrderManager groupOrderManager) { @@ -475,8 +513,6 @@ private static Order CreateOrder(int orderId, OrderType type, Symbol symbol, dec default: throw new ArgumentOutOfRangeException(); } - order.Status = OrderStatus.New; - order.Id = orderId; return order; } } diff --git a/Common/Orders/OrderContingency.cs b/Common/Orders/OrderContingency.cs new file mode 100644 index 000000000000..46d303161836 --- /dev/null +++ b/Common/Orders/OrderContingency.cs @@ -0,0 +1,558 @@ +/* + * 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 Newtonsoft.Json; +using System.Collections.Generic; + +namespace QuantConnect.Orders +{ + /// + /// The contingency of an order: the set of contingent orders it belongs to (OCO, OTO, OUO and any composition of + /// them, like brackets) and the defining how this order relates to the rest of the set + /// + /// + /// The set state (, , ) is shared by all the orders of the set, + /// see , while the links, including their triggered state, belong to each order. + /// Unlike a the orders of the set are independent, only their lifecycle is related + /// + public class OrderContingency + { + private SharedState _set; + private readonly List _links; + // the order this contingency belongs to, null for an order request + private Order _order; + + /// + /// The unique id of the set of contingent orders this order belongs to + /// + [JsonProperty(PropertyName = "id")] + public int Id => _set.Id; + + /// + /// The total order count in the set of contingent orders + /// + [JsonProperty(PropertyName = "count")] + public int Count => _set.Count; + + /// + /// The ids of the orders in the set + /// + /// In live trading we process orders in dedicated threads so we need to be thread safe, access is synchronized locking this collection + [JsonProperty(PropertyName = "orderIds")] + public HashSet OrderIds => _set.OrderIds; + + /// + /// The different symbols of the orders in the set. Allows a brokerage model to validate a single order + /// knowing about the rest of the set. Only available at submission time + /// + [JsonIgnore] + public IReadOnlySet Symbols => _set.Symbols ??= _set.BuildSet(member => member.Symbol); + + /// + /// The different directions of the orders in the set. Allows a brokerage model to validate a single order + /// knowing about the rest of the set. Only available at submission time + /// + [JsonIgnore] + public IReadOnlySet Directions => _set.Directions ??= _set.BuildSet(member => member.Quantity > 0 ? OrderDirection.Buy : OrderDirection.Sell); + + /// + /// The different order types of the orders in the set. Allows a brokerage model to validate a single order + /// knowing about the rest of the set. Only available at submission time + /// + [JsonIgnore] + public IReadOnlySet OrderTypes => _set.OrderTypes ??= _set.BuildSet(member => member.OrderType); + + /// + /// The links of this order to the rest of the set: the role it plays in each contingency + /// + [JsonProperty(PropertyName = "links")] + public IReadOnlyList Links => _links; + + /// + /// True if this is a contingent child order still open and held, waiting for its parent order to fill + /// + [JsonIgnore] + public bool IsWaitingForTrigger => (_order == null || !_order.Status.IsClosed()) && GetLink(ContingencyRole.Child) is { Triggered: false }; + + /// + /// The order requests of the set before being submitted, in submission order: parents before the orders they trigger. + /// See + /// + internal IReadOnlyList Requests => _set.Requests ??= _set.BuildRequests(); + + /// + /// Creates the contingency of the first order of a new set of contingent orders, the rest are created through + /// + /// The unique id of the set of contingent orders + /// The total order count in the set + /// The links of this order to the rest of the set + public OrderContingency(int id, int count, IEnumerable links) + : this(new SharedState(id, count), links?.ToList() ?? new List()) + { + } + + /// + /// Creates the contingency of the first order of a new set of contingent orders, the rest are created through . + /// The set id is assigned once the orders are added into the algorithm + /// + /// The total order count in the set + /// The links of this order to the rest of the set + public OrderContingency(int count, IEnumerable links) + : this(0, count, links) + { + } + + /// + /// Creates a new instance from its serialized form, the set is not shared with any other instance + /// + [JsonConstructor] + private OrderContingency(int id, int count, IEnumerable orderIds, IEnumerable links) + : this(id, count, links) + { + if (orderIds != null) + { + _set.OrderIds.UnionWith(orderIds); + } + } + + private OrderContingency(SharedState set, List links) + { + _set = set; + _links = links; + } + + /// + /// Gets the first link with the given role: parent or child of a contingency, + /// or null for the link to the siblings of a / one + /// + internal ContingencyLink GetLink(ContingencyRole? role) + { + for (var i = 0; i < _links.Count; i++) + { + if (_links[i].Role == role) + { + return _links[i]; + } + } + return null; + } + + /// + /// Creates the contingency of another order of the same set of contingent orders: it shares the set with this instance, + /// with the given links of its own + /// + /// The links of the other order to the rest of the set + public OrderContingency WithLinks(IEnumerable links) + { + return new OrderContingency(_set, links?.ToList() ?? new List()); + } + + /// + /// Creates a copy of this instance: the set is shared, the links are cloned + /// + public OrderContingency Clone() + { + var links = new List(_links.Count); + for (var i = 0; i < _links.Count; i++) + { + links.Add(_links[i].Clone()); + } + return new OrderContingency(_set, links); + } + + /// + /// Returns a string that represents the current object + /// + public override string ToString() + { + return $"Set {Id.ToStringInvariant()} ({Count.ToStringInvariant()}): [{string.Join(",", _links)}]"; + } + + /// + /// Sets the unique id of the set of contingent orders, once the orders are added into the algorithm + /// + /// The unique id of the set + internal void SetId(int id) + { + _set.Id = id; + } + + /// + /// Sets the order this contingency belongs to + /// + internal void SetOrder(Order order) + { + _order = order; + } + + /// + /// The order types of the parents of this order, the orders it's waiting for, all the legs for a combo order + /// + internal IEnumerable GetParentOrderTypes() + { + return GetOrderTypes(GetLink(ContingencyRole.Child), ContingencyRole.Parent); + } + + /// + /// The order types of the members of the group of this order, like the ones where one cancels the other, including this order + /// + internal IEnumerable GetSiblingOrderTypes() + { + return GetOrderTypes(GetLink(null), null); + } + + /// + /// The order types of the orders of the set with a link of the given role to the given contingency + /// + private IEnumerable GetOrderTypes(ContingencyLink link, ContingencyRole? role) + { + if (link == null) + { + yield break; + } + foreach (var member in _set.Members) + { + var links = member.Contingency?.Links; + if (links == null) + { + continue; + } + for (var i = 0; i < links.Count; i++) + { + if (links[i].Role == role && links[i].Id == link.Id) + { + yield return member.OrderType; + break; + } + } + } + } + + /// + /// Relates the parent order to the orders it triggers once it completely fills (One Triggers Other) + /// + /// The parent order, all the legs for a combo order + /// The orders to trigger, all the legs for combo orders + internal static void Trigger(IEnumerable parent, IEnumerable children) + { + Link(ContingencyType.OneTriggersOther, (Members(parent), ContingencyRole.Parent), (Members(children), ContingencyRole.Child)); + } + + /// + /// Relates the orders to each other as siblings: One Cancels Other or One Updates Other + /// + /// The type of the relation + /// The orders to relate, all the legs for combo orders + internal static void Relate(ContingencyType type, IEnumerable members) + { + Link(type, (Members(members), null)); + } + + /// + /// Helper for brokerages to rebuild the contingencies of their open orders: relates the parent order to the orders it triggers once + /// it completely fills (One Triggers Other), joining them into a single set of contingent orders + /// + /// The parent order, all the legs for a combo order + /// The orders to trigger, all the legs for combo orders + public static void Trigger(IEnumerable parent, IEnumerable children) + { + Link(ContingencyType.OneTriggersOther, (Members(parent), ContingencyRole.Parent), (Members(children), ContingencyRole.Child)); + } + + /// + /// Helper for brokerages to rebuild the contingencies of their open orders: relates the orders to each other as siblings, + /// One Cancels Other or One Updates Other, joining them into a single set of contingent orders + /// + /// The type of the relation + /// The orders to relate, all the legs for combo orders + public static void Relate(ContingencyType type, IEnumerable members) + { + Link(type, (Members(members), null)); + } + + /// + /// Groups the orders into units, preserving their order: each order on its own except for the legs of a combo order which go together + /// + /// An order is missing or repeated, or some legs of a combo order are missing + public static List> GetUnits(IEnumerable orders) + { + return GetUnits(Members(orders)).Select(unit => unit.Select(member => (Order)member.Value).ToList()).ToList(); + } + + private static IEnumerable Members(IEnumerable requests) + { + return requests?.Select(request => new Member(request)); + } + + private static IEnumerable Members(IEnumerable orders) + { + return orders?.Select(order => new Member(order)); + } + + /// + /// Relates the orders of each side through a new contingency, joining them into a single set of contingent orders + /// + /// The type of the contingency + /// The orders playing each role in the contingency + private static void Link(ContingencyType type, params (IEnumerable Orders, ContingencyRole? Role)[] sides) + { + var units = new List>[sides.Length]; + for (var i = 0; i < sides.Length; i++) + { + var role = sides[i].Role; + units[i] = GetUnits(sides[i].Orders); + if (role == ContingencyRole.Parent ? units[i].Count != 1 : units[i].Count < (role == null ? 2 : 1)) + { + throw new ArgumentException($"Expected {(role == null ? "at least two orders to relate" : role == ContingencyRole.Parent ? "a single parent order" : "at least one order to trigger")}, all the legs for combo orders"); + } + // a parent can trigger orders more than once, the rest of the roles are played once + if (role != ContingencyRole.Parent) + { + foreach (var unit in units[i]) + { + if (unit[0].Contingency?.GetLink(role) != null) + { + throw new ArgumentException($"The orders are already {(role == null ? "related to other orders" : "triggered by another order")}"); + } + } + } + } + + // the first side joins first, so the contingency ids follow the composition order + var set = Join(null, units[0]); + var contingencyId = ++set.NextContingencyId; + for (var i = 0; i < sides.Length; i++) + { + var role = sides[i].Role; + Join(set, units[i]); + foreach (var unit in units[i]) + { + foreach (var leg in unit) + { + // the link to the parent goes first + var links = leg.Contingency._links; + links.Insert(role == ContingencyRole.Child ? 0 : links.Count, new ContingencyLink(contingencyId, type, role)); + } + } + } + } + + /// + /// Groups the orders into units, preserving their order: each order on its own except for the legs of a combo order which go together + /// + /// An order is missing or repeated, was already submitted, or some legs of a combo order are missing + private static List> GetUnits(IEnumerable orders) + { + var units = new List>(); + var seen = new HashSet(); + Dictionary> comboUnits = null; + foreach (var order in orders ?? Enumerable.Empty()) + { + if (order.Value == null) + { + throw new ArgumentException("Unexpected null order"); + } + if (order.Value is SubmitOrderRequest { OrderId: > 0 }) + { + throw new ArgumentException($"The order was already submitted, it can only be submitted once: {order}"); + } + if (!seen.Add(order.Value)) + { + throw new ArgumentException($"The order is present more than once: {order}"); + } + + if (order.GroupOrderManager == null) + { + units.Add(new List { order }); + continue; + } + comboUnits ??= new(); + if (!comboUnits.TryGetValue(order.GroupOrderManager, out var unit)) + { + comboUnits[order.GroupOrderManager] = unit = new List(); + units.Add(unit); + } + unit.Add(order); + } + + if (comboUnits != null) + { + foreach (var (groupOrderManager, legs) in comboUnits) + { + if (legs.Count != groupOrderManager.Count) + { + throw new ArgumentException($"Expected all the {groupOrderManager.Count} legs of the combo order, got {legs.Count}: {legs[0]}"); + } + } + } + return units; + } + + /// + /// Joins the units into the given set of contingent orders, if none the one of the first unit which belongs to a set or a new one. + /// The contingency ids of the sets which join remain unique, they are shifted + /// + private static SharedState Join(SharedState set, IEnumerable> units) + { + foreach (var unit in units) + { + var contingency = unit[0].Contingency; + if (contingency == null) + { + set ??= new SharedState(0, 0); + foreach (var leg in unit) + { + // an exercise is an instruction, not a working order which can be held, canceled or resized + if (leg.OrderType == OrderType.OptionExercise) + { + throw new ArgumentException($"Option exercise orders can not be part of a set of contingent orders: {leg}"); + } + leg.Contingency = new OrderContingency(set, new List()); + set.Members.Add(leg); + } + set.OnMembersChanged(); + } + else if (set == null) + { + set = contingency._set; + } + else if (!ReferenceEquals(set, contingency._set)) + { + var other = contingency._set; + var offset = set.NextContingencyId; + foreach (var member in other.Members) + { + var links = member.Contingency._links; + var shiftedLinks = new List(links.Count); + for (var i = 0; i < links.Count; i++) + { + shiftedLinks.Add(new ContingencyLink(links[i].Id + offset, links[i].Type, links[i].Role)); + } + member.Contingency = new OrderContingency(set, shiftedLinks); + set.Members.Add(member); + } + set.NextContingencyId += other.NextContingencyId; + set.OnMembersChanged(); + } + } + return set; + } + + /// + /// A member of a set of contingent orders: an order request before being submitted, or an order + /// + private readonly struct Member + { + private readonly SubmitOrderRequest _request; + private readonly Order _order; + + public Member(SubmitOrderRequest request) + { + _request = request; + } + + public Member(Order order) + { + _order = order; + } + + public object Value => _request ?? (object)_order; + public GroupOrderManager GroupOrderManager => _request != null ? _request.GroupOrderManager : _order.GroupOrderManager; + public OrderType OrderType => _request?.OrderType ?? _order.Type; + public Symbol Symbol => _request != null ? _request.Symbol : _order.Symbol; + public decimal Quantity => _request?.Quantity ?? _order.Quantity; + + public OrderContingency Contingency + { + get => _request != null ? _request.Contingency : _order.Contingency; + set + { + if (_request != null) + { + _request.Contingency = value; + } + else + { + _order.Contingency = value; + } + } + } + + public override string ToString() + { + return Value?.ToString(); + } + } + + /// + /// The state of a set of contingent orders, a single instance is shared by the contingencies of all the orders in the set + /// + private class SharedState + { + public int Id { get; set; } + public int Count { get; set; } + public HashSet OrderIds { get; } + public List Members { get; } = new(); + public int NextContingencyId { get; set; } + + // views of the members, built lazily on first use + public HashSet Symbols { get; set; } + public HashSet Directions { get; set; } + public HashSet OrderTypes { get; set; } + public List Requests { get; set; } + + public SharedState(int id, int count) + { + Id = id; + Count = count; + OrderIds = new(capacity: Math.Max(count, 0)); + } + + public void OnMembersChanged() + { + Count = Members.Count; + Symbols = null; + Directions = null; + OrderTypes = null; + Requests = null; + } + + public HashSet BuildSet(Func selector) + { + var result = new HashSet(); + foreach (var member in Members) + { + result.Add(selector(member)); + } + return result; + } + + public List BuildRequests() + { + var result = new List(Members.Count); + foreach (var member in Members) + { + if (member.Value is SubmitOrderRequest request) + { + result.Add(request); + } + } + return result; + } + } + } +} diff --git a/Common/Orders/OrderFactory.cs b/Common/Orders/OrderFactory.cs new file mode 100644 index 000000000000..41164d45afff --- /dev/null +++ b/Common/Orders/OrderFactory.cs @@ -0,0 +1,284 @@ +/* + * 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.Interfaces; +using System.Collections.Generic; + +namespace QuantConnect.Orders +{ + /// + /// Creates for an algorithm to be submitted later, so they can be composed into contingent orders before: + /// an order can trigger others once it fills (), which can in turn + /// cancel () or update () each other, + /// see . The order id and the contingency set id of the requests are assigned once they are submitted + /// + public class OrderFactory + { + private readonly IAlgorithm _algorithm; + + /// + /// Creates a new instance for the given algorithm, which provides the time and default order properties of the requests + /// + /// The algorithm instance + public OrderFactory(IAlgorithm algorithm) + { + _algorithm = algorithm; + } + + /// + /// Market order request + /// + public SubmitOrderRequest MarketOrder(Symbol symbol, decimal quantity, bool asynchronous = false, string tag = "", IOrderProperties orderProperties = null) + { + return Create(OrderType.Market, symbol, quantity, 0, 0, 0, 0, false, asynchronous, tag, orderProperties); + } + + /// + /// Market on open order request + /// + public SubmitOrderRequest MarketOnOpenOrder(Symbol symbol, decimal quantity, bool asynchronous = false, string tag = "", IOrderProperties orderProperties = null) + { + return Create(OrderType.MarketOnOpen, symbol, quantity, 0, 0, 0, 0, false, asynchronous, tag, orderProperties); + } + + /// + /// Market on close order request + /// + public SubmitOrderRequest MarketOnCloseOrder(Symbol symbol, decimal quantity, bool asynchronous = false, string tag = "", IOrderProperties orderProperties = null) + { + return Create(OrderType.MarketOnClose, symbol, quantity, 0, 0, 0, 0, false, asynchronous, tag, orderProperties); + } + + /// + /// Limit order request + /// + public SubmitOrderRequest LimitOrder(Symbol symbol, decimal quantity, decimal limitPrice, bool asynchronous = false, string tag = "", + IOrderProperties orderProperties = null) + { + return Create(OrderType.Limit, symbol, quantity, 0, limitPrice, 0, 0, false, asynchronous, tag, orderProperties); + } + + /// + /// Stop market order request + /// + public SubmitOrderRequest StopMarketOrder(Symbol symbol, decimal quantity, decimal stopPrice, bool asynchronous = false, string tag = "", + IOrderProperties orderProperties = null) + { + return Create(OrderType.StopMarket, symbol, quantity, stopPrice, 0, 0, 0, false, asynchronous, tag, orderProperties); + } + + /// + /// Stop limit order request + /// + public SubmitOrderRequest StopLimitOrder(Symbol symbol, decimal quantity, decimal stopPrice, decimal limitPrice, bool asynchronous = false, string tag = "", + IOrderProperties orderProperties = null) + { + return Create(OrderType.StopLimit, symbol, quantity, stopPrice, limitPrice, 0, 0, false, asynchronous, tag, orderProperties); + } + + /// + /// Trailing stop order request. The initial stop price is calculated based on the market price at the + /// time the order starts working: once submitted, or once triggered for an order triggered by another + /// + public SubmitOrderRequest TrailingStopOrder(Symbol symbol, decimal quantity, decimal trailingAmount, bool trailingAsPercentage, bool asynchronous = false, + string tag = "", IOrderProperties orderProperties = null) + { + return Create(OrderType.TrailingStop, symbol, quantity, 0, 0, 0, trailingAmount, trailingAsPercentage, asynchronous, tag, orderProperties); + } + + /// + /// Trailing stop order request with an initial stop price + /// + public SubmitOrderRequest TrailingStopOrder(Symbol symbol, decimal quantity, decimal stopPrice, decimal trailingAmount, bool trailingAsPercentage, + bool asynchronous = false, string tag = "", IOrderProperties orderProperties = null) + { + return Create(OrderType.TrailingStop, symbol, quantity, stopPrice, 0, 0, trailingAmount, trailingAsPercentage, asynchronous, tag, orderProperties); + } + + /// + /// Limit if touched order request + /// + public SubmitOrderRequest LimitIfTouchedOrder(Symbol symbol, decimal quantity, decimal triggerPrice, decimal limitPrice, bool asynchronous = false, + string tag = "", IOrderProperties orderProperties = null) + { + return Create(OrderType.LimitIfTouched, symbol, quantity, 0, limitPrice, triggerPrice, 0, false, asynchronous, tag, orderProperties); + } + + /// + /// Option exercise order request + /// + public SubmitOrderRequest ExerciseOption(Symbol optionSymbol, decimal quantity, bool asynchronous = false, string tag = "", IOrderProperties orderProperties = null) + { + if (optionSymbol != null && !optionSymbol.SecurityType.IsOption()) + { + throw new ArgumentException($"Only option contracts can be exercised: {optionSymbol}", nameof(optionSymbol)); + } + // the quantity indicates the change in holdings quantity, therefore manual exercise quantities must be negative + return Create(OrderType.OptionExercise, optionSymbol, -Math.Abs(quantity), 0, 0, 0, 0, false, asynchronous, tag, orderProperties); + } + + /// + /// Combo market order requests, one per leg. The legs are a single unit: composed and submitted together + /// + public List ComboMarketOrder(List legs, int quantity, bool asynchronous = false, string tag = "", IOrderProperties orderProperties = null) + { + if (legs.Any(leg => leg.OrderPrice != null && leg.OrderPrice != 0)) + { + throw new ArgumentException("ComboMarketOrder does not support limit prices for individual legs, please use ComboLegLimitOrder"); + } + return Combo(OrderType.ComboMarket, legs, quantity, 0, asynchronous, tag, orderProperties); + } + + /// + /// Combo limit order requests, one per leg, with a single limit price for the combo + /// + public List ComboLimitOrder(List legs, int quantity, decimal limitPrice, bool asynchronous = false, string tag = "", + IOrderProperties orderProperties = null) + { + if (limitPrice == 0) + { + throw new ArgumentException("ComboLimitOrder requires a limit price"); + } + + if (legs.Any(leg => leg.OrderPrice != null && leg.OrderPrice != 0)) + { + throw new ArgumentException("ComboLimitOrder does not support limit prices for individual legs"); + } + return Combo(OrderType.ComboLimit, legs, quantity, limitPrice, asynchronous, tag, orderProperties); + } + + /// + /// Combo leg limit order requests, one per leg, each leg with its own limit price + /// + public List ComboLegLimitOrder(List legs, int quantity, bool asynchronous = false, string tag = "", IOrderProperties orderProperties = null) + { + if (legs.Any(leg => leg.OrderPrice == null || leg.OrderPrice == 0)) + { + throw new ArgumentException("ComboLegLimitOrder requires a limit price for each leg"); + } + return Combo(OrderType.ComboLegLimit, legs, quantity, 0, asynchronous, tag, orderProperties); + } + + /// + /// Option strategy order requests, a combo market order of the strategy legs + /// + public List OptionStrategyOrder(Securities.Option.OptionStrategy strategy, int quantity, bool asynchronous = false, string tag = "", + IOrderProperties orderProperties = null) + { + // Make sure the strategy is initialized, that is, canonical and leg symbols are set. + strategy.SetSymbols(); + + // setting up the tag text for all orders of one strategy + tag ??= $"{strategy.Name} ({quantity.ToStringInvariant()})"; + + var legs = strategy.UnderlyingLegs.Cast().Concat(strategy.OptionLegs).ToList(); + return Combo(OrderType.ComboMarket, legs, quantity, 0, asynchronous, tag, orderProperties); + } + + /// + /// Relates the orders so that once one of them fills, even partially, the rest are canceled (One Cancels Other/All) + /// + /// The orders to relate + /// The same orders, so they can be submitted or triggered by another order + public List OneCancelsOther(params SubmitOrderRequest[] orders) + { + return OneCancelsOther((IEnumerable)orders); + } + + /// + /// Relates the orders so that once one of them fills, even partially, the rest are canceled (One Cancels Other/All) + /// + /// The orders to relate, including all the legs of combo orders + /// The same orders, so they can be submitted or triggered by another order + public List OneCancelsOther(IEnumerable orders) + { + var members = orders?.ToList(); + OrderContingency.Relate(ContingencyType.OneCancelsOther, members); + return members; + } + + /// + /// Relates the orders so that once one of them partially fills the remaining quantity of the rest is reduced proportionally, + /// and canceled once it completely fills (One Updates Other) + /// + /// The orders to relate + /// The same orders, so they can be submitted or triggered by another order + public List OneUpdatesOther(params SubmitOrderRequest[] orders) + { + return OneUpdatesOther((IEnumerable)orders); + } + + /// + /// Relates the orders so that once one of them partially fills the remaining quantity of the rest is reduced proportionally, + /// and canceled once it completely fills (One Updates Other) + /// + /// The orders to relate, including all the legs of combo orders + /// The same orders, so they can be submitted or triggered by another order + public List OneUpdatesOther(IEnumerable orders) + { + var members = orders?.ToList(); + OrderContingency.Relate(ContingencyType.OneUpdatesOther, members); + return members; + } + + private SubmitOrderRequest Create(OrderType type, Symbol symbol, decimal quantity, decimal stopPrice, decimal limitPrice, decimal triggerPrice, + decimal trailingAmount, bool trailingAsPercentage, bool asynchronous, string tag, IOrderProperties orderProperties) + { + return new SubmitOrderRequest(type, symbol.SecurityType, symbol, quantity, stopPrice, limitPrice, triggerPrice, trailingAmount, trailingAsPercentage, + _algorithm.UtcTime, tag, orderProperties ?? _algorithm.DefaultOrderProperties?.Clone(), asynchronous: asynchronous); + } + + private List Combo(OrderType type, List legs, decimal quantity, decimal limitPrice, bool asynchronous, string tag, + IOrderProperties orderProperties) + { + if (legs == null || legs.Count == 0 || legs.Any(leg => leg == null)) + { + throw new ArgumentException("Expected at least one leg", nameof(legs)); + } + + var greatestCommonDivisor = Math.Abs(legs.Select(leg => leg.Quantity).GreatestCommonDivisor()); + if (greatestCommonDivisor != 1) + { + throw new ArgumentException( + "The global combo quantity should be used to increase or reduce the size of the order, " + + "while the leg quantities should be used to specify the ratio of the order. " + + "The combo order quantities should be reduced " + + $"from {quantity}x({string.Join(", ", legs.Select(leg => $"{leg.Quantity} {leg.Symbol}"))}) " + + $"to {quantity * greatestCommonDivisor}x({string.Join(", ", legs.Select(leg => $"{leg.Quantity / greatestCommonDivisor} {leg.Symbol}"))})."); + } + + // the group id is set once submitted + var groupOrderManager = new GroupOrderManager(legs.Count, quantity, limitPrice); + var requests = new List(legs.Count); + foreach (var leg in legs) + { + var legType = type; + var legLimitPrice = limitPrice; + if (leg.OrderPrice.HasValue) + { + // limit price per leg + legLimitPrice = leg.OrderPrice.Value; + legType = OrderType.ComboLegLimit; + } + + requests.Add(new SubmitOrderRequest(legType, leg.Symbol.SecurityType, leg.Symbol, ((decimal)leg.Quantity).GetOrderLegGroupQuantity(groupOrderManager), + 0, legLimitPrice, 0, 0, false, _algorithm.UtcTime, tag, orderProperties ?? _algorithm.DefaultOrderProperties?.Clone(), groupOrderManager, asynchronous)); + } + return requests; + } + } +} diff --git a/Common/Orders/OrderJsonConverter.cs b/Common/Orders/OrderJsonConverter.cs index 88af09c592fa..4af4ace972d1 100644 --- a/Common/Orders/OrderJsonConverter.cs +++ b/Common/Orders/OrderJsonConverter.cs @@ -17,6 +17,7 @@ using System.Linq; using Newtonsoft.Json; using Newtonsoft.Json.Linq; +using System.Collections.Generic; using QuantConnect.Brokerages; using QuantConnect.Securities; @@ -180,6 +181,8 @@ public static Order CreateOrderFromJObject(JObject jObject) order.ContingentId = jsonContingentId.Value(); } + DeserializeContingency(jObject, order); + var timeInForce = jObject["Properties"]?["TimeInForce"] ?? jObject["TimeInForce"] ?? jObject["Duration"]; if (timeInForce == null) { @@ -387,6 +390,56 @@ private static GroupOrderManager DeserializeGroupOrderManager(JObject jObject) return result; } + /// + /// Deserializes the contingency of the order from the JSON object, if any, available for any order type + /// + private static void DeserializeContingency(JObject jObject, Order order) + { + var contingencyToken = jObject["Contingency"] ?? jObject["contingency"]; + if (contingencyToken == null || contingencyToken.Type != JTokenType.Object) + { + // not a contingent order, or an order serialized before they existed + return; + } + var linksToken = contingencyToken["Links"] ?? contingencyToken["links"]; + var count = (contingencyToken["Count"] ?? contingencyToken["count"])?.Value() ?? 0; + if (linksToken == null || linksToken.Type != JTokenType.Array || count < 1) + { + return; + } + + var links = new List(); + foreach (var token in linksToken) + { + if (token.Type != JTokenType.Object) + { + continue; + } + var type = (ContingencyType)((token["Type"] ?? token["type"])?.Value() ?? 0); + var roleToken = token["Role"] ?? token["role"]; + var role = roleToken != null && roleToken.Type != JTokenType.Null ? (ContingencyRole?)roleToken.Value() : null; + if (!ContingencyLink.IsValidRole(type, role)) + { + continue; + } + var triggeredTime = token["TriggeredTime"] ?? token["triggeredTime"]; + links.Add(new ContingencyLink( + (token["Id"] ?? token["id"])?.Value() ?? 0, + type, + role, + (token["Triggered"] ?? token["triggered"])?.Value() ?? false, + triggeredTime != null && triggeredTime.Type != JTokenType.Null ? triggeredTime.Value() : null)); + } + + var contingency = new OrderContingency((contingencyToken["Id"] ?? contingencyToken["id"])?.Value() ?? 0, count, links); + var orderIds = contingencyToken["OrderIds"] ?? contingencyToken["orderIds"]; + if (orderIds != null && orderIds.Type == JTokenType.Array) + { + contingency.OrderIds.UnionWith(orderIds.Values()); + } + order.Contingency = contingency; + } + /// /// Gets the decimal value of the given token, clamping it to the decimal range when the token holds /// a double too large or too small to be represented as a decimal. Values at the edge of the range, diff --git a/Common/Orders/OrderTicket.cs b/Common/Orders/OrderTicket.cs index f085c0f44646..1931335ed913 100644 --- a/Common/Orders/OrderTicket.cs +++ b/Common/Orders/OrderTicket.cs @@ -152,6 +152,15 @@ public string Tag get { return _order == null ? _submitRequest.Tag : _order.Tag; } } + /// + /// Gets the current contingency of this order: the set of contingent orders it belongs to (OCO, OTO, OUO, brackets) + /// and how it relates to them. Null if it's not a contingent order + /// + public OrderContingency Contingency + { + get { return _order == null ? _submitRequest.Contingency : _order.Contingency; } + } + /// /// Gets the that initiated this order /// diff --git a/Common/Orders/OrderUpdateEvent.cs b/Common/Orders/OrderUpdateEvent.cs index 2355c0a0f069..7666cba356ab 100644 --- a/Common/Orders/OrderUpdateEvent.cs +++ b/Common/Orders/OrderUpdateEvent.cs @@ -42,5 +42,17 @@ public class OrderUpdateEvent /// Time in UTC at which the stop was triggered for a , if any /// public DateTime? StopTriggeredTime { get; set; } + + /// + /// Flag indicating whether a contingent child order has been triggered, that is, its parent filled and + /// the order was released to the market. See + /// + public bool ContingencyTriggered { get; set; } + + /// + /// The updated order quantity, if any. Used when the brokerage resizes an order on its side, + /// like for the members of a contingency or the legs of a bracket order + /// + public decimal? Quantity { get; set; } } } diff --git a/Common/Orders/SubmitOrderRequest.cs b/Common/Orders/SubmitOrderRequest.cs index 136a0cd5d1ae..469acfb2d09b 100644 --- a/Common/Orders/SubmitOrderRequest.cs +++ b/Common/Orders/SubmitOrderRequest.cs @@ -14,12 +14,16 @@ */ using System; +using System.Linq; +using System.Collections.Generic; using QuantConnect.Interfaces; namespace QuantConnect.Orders { /// - /// Defines a request to submit a new order + /// Defines a request to submit a new order. Built through it is also the specification of an order which can + /// be composed with others before being submitted: an order can trigger others once it fills (), + /// which can in turn be related to each other (), see /// public class SubmitOrderRequest : OrderRequest { @@ -44,7 +48,7 @@ public SecurityType SecurityType /// public Symbol Symbol { - get; private set; + get; internal set; } /// @@ -52,7 +56,7 @@ public Symbol Symbol /// public OrderType OrderType { - get; private set; + get; internal set; } /// @@ -76,7 +80,7 @@ public decimal LimitPrice /// public decimal StopPrice { - get; private set; + get; internal set; } /// @@ -119,13 +123,23 @@ public GroupOrderManager GroupOrderManager get; private set; } + /// + /// Gets the contingency of this order: the set of contingent orders it belongs to and how it relates to them. + /// If null, the order is not a contingent order. Composed before being submitted through , + /// , and + /// + public OrderContingency Contingency + { + get; internal set; + } + /// /// Whether this request should be asynchronous, /// which means the ticket will be returned to the algorithm without waiting for submission /// public bool Asynchronous { - get; + get; private set; } /// @@ -147,6 +161,7 @@ public bool Asynchronous /// The manager for this combo order /// True if this request should be asynchronous, /// which means the ticket will be returned to the algorithm without waiting for submission + /// The contingency of this order, if any: the set of contingent orders it belongs to and how it relates to them public SubmitOrderRequest( OrderType orderType, SecurityType securityType, @@ -161,7 +176,8 @@ public SubmitOrderRequest( string tag, IOrderProperties properties = null, GroupOrderManager groupOrderManager = null, - bool asynchronous = false + bool asynchronous = false, + OrderContingency contingency = null ) : base(time, (int)OrderResponseErrorCode.UnableToFindOrder, tag) { @@ -177,6 +193,7 @@ public SubmitOrderRequest( TrailingAsPercentage = trailingAsPercentage; OrderProperties = properties; Asynchronous = asynchronous; + Contingency = contingency; } /// @@ -257,6 +274,57 @@ internal void SetOrderId(int orderId) OrderId = orderId; } + /// + /// Sets the orders this order will trigger once it is completely filled (One Triggers Other): they are held until then + /// and canceled if this order is canceled. The triggered orders are independent of each other, unless grouped through + /// or . + /// For the legs of a combo order see OneTriggersOtherOrder, they are triggered together once all the legs fill + /// + /// The orders to trigger, for a combo order all its legs + /// This instance + public SubmitOrderRequest Triggers(params SubmitOrderRequest[] orders) + { + return Triggers((IEnumerable)orders); + } + + /// + /// Sets the orders this order will trigger once it is completely filled (One Triggers Other), see + /// + /// The orders to trigger, for a combo order all its legs + /// This instance + public SubmitOrderRequest Triggers(IEnumerable orders) + { + OrderContingency.Trigger(new[] { this }, orders); + return this; + } + + /// + /// Brackets this order with a take profit limit order and a stop loss order, of the opposite quantity, which are held until + /// this order fills (One Triggers a One Cancels Other) + /// + /// The limit price of the take profit order + /// The stop price of the stop loss order + /// Optionally the limit price of the stop loss order, turning it into a stop limit order + /// How the take profit and stop loss relate: by default the first one to fill cancels the other. + /// Use so that a partial fill resizes the other + /// This instance + public SubmitOrderRequest Bracket(decimal takeProfitPrice, decimal stopLossPrice, decimal? stopLossLimitPrice = null, + ContingencyType contingencyType = ContingencyType.OneCancelsOther) + { + if (GroupOrderManager != null) + { + throw new InvalidOperationException($"{nameof(Bracket)} is not supported for combo orders, please use {nameof(Triggers)}"); + } + + // the exits take after this order, each with its own properties instance + var takeProfit = new SubmitOrderRequest(OrderType.Limit, SecurityType, Symbol, -Quantity, 0, takeProfitPrice, Time, Tag, OrderProperties?.Clone()); + var stopLoss = stopLossLimitPrice.HasValue + ? new SubmitOrderRequest(OrderType.StopLimit, SecurityType, Symbol, -Quantity, stopLossPrice, stopLossLimitPrice.Value, Time, Tag, OrderProperties?.Clone()) + : new SubmitOrderRequest(OrderType.StopMarket, SecurityType, Symbol, -Quantity, stopLossPrice, 0, Time, Tag, OrderProperties?.Clone()); + OrderContingency.Relate(contingencyType == ContingencyType.OneUpdatesOther ? ContingencyType.OneUpdatesOther : ContingencyType.OneCancelsOther, new[] { takeProfit, stopLoss }); + return Triggers(takeProfit, stopLoss); + } + /// /// Returns a string that represents the current object. /// diff --git a/Common/Orders/TimeInForces/DayTimeInForce.cs b/Common/Orders/TimeInForces/DayTimeInForce.cs index 7966f7092d41..4225b5a6ee2a 100644 --- a/Common/Orders/TimeInForces/DayTimeInForce.cs +++ b/Common/Orders/TimeInForces/DayTimeInForce.cs @@ -33,7 +33,9 @@ public override bool IsOrderExpired(Security security, Order order) { var exchangeHours = security.Exchange.Hours; - var orderTime = order.Time.ConvertFromUtc(exchangeHours.TimeZone); + // for contingent child orders the clock starts ticking once they are triggered, when their parent fills + var workingTime = order.GetWorkingTime(); + var orderTime = workingTime.ConvertFromUtc(exchangeHours.TimeZone); var time = security.LocalTime; bool expired; @@ -48,7 +50,7 @@ public override bool IsOrderExpired(Security security, Order order) var cutOffTimeZone = TimeZones.NewYork; var cutOffTimeSpan = TimeSpan.FromHours(17); - orderTime = order.Time.ConvertFromUtc(cutOffTimeZone); + orderTime = workingTime.ConvertFromUtc(cutOffTimeZone); var expiryTime = orderTime.Date.Add(cutOffTimeSpan); if (orderTime.TimeOfDay > cutOffTimeSpan) { diff --git a/Common/Properties/AssemblyInfo.cs b/Common/Properties/AssemblyInfo.cs index fa1e8eb689e8..ff21effa673a 100644 --- a/Common/Properties/AssemblyInfo.cs +++ b/Common/Properties/AssemblyInfo.cs @@ -17,6 +17,7 @@ // The following GUID is for the ID of the typelib if this project is exposed to COM [assembly: Guid("94687ba0-0b5f-43f7-a911-83b5a89651cf")] +[assembly: InternalsVisibleTo("QuantConnect.Algorithm")] [assembly: InternalsVisibleTo("QuantConnect.Algorithm.Framework")] [assembly: InternalsVisibleTo("QuantConnect.Brokerages")] [assembly: InternalsVisibleTo("QuantConnect.Lean.Engine")] diff --git a/Common/Securities/CashBuyingPowerModel.cs b/Common/Securities/CashBuyingPowerModel.cs index 92c44eca0eec..d9060482398a 100644 --- a/Common/Securities/CashBuyingPowerModel.cs +++ b/Common/Securities/CashBuyingPowerModel.cs @@ -423,6 +423,8 @@ private static decimal GetOpenOrdersReservedQuantity(SecurityPortfolioManager po } } + var isContingentMember = order.GetSiblingLink() != null; + // fetch open orders with matching symbol/side var openOrders = portfolio.Transactions.GetOpenOrders(x => { @@ -433,12 +435,17 @@ private static decimal GetOpenOrdersReservedQuantity(SecurityPortfolioManager po // don't count our current order x.Id != order.Id && // only count working orders - (x.Type == OrderType.Limit || x.Type == OrderType.StopMarket); + (x.Type == OrderType.Limit || x.Type == OrderType.StopMarket) && + // don't count contingent orders held waiting for their parent to fill, nor our contingent siblings + // (OCO/OUO) since at most one of us is expected to fill + (x.Contingency == null || !x.IsWaitingForTrigger() && !(isContingentMember && order.IsContingentSibling(x))); } ); // calculate reserved quantity for selected orders var openOrdersReservedQuantity = 0m; + // at most one of the members of a contingency (OCO/OUO) is expected to fill, so they reserve once: the biggest of them + Dictionary<(int, int), decimal> contingentSiblingsReservedQuantity = null; foreach (var openOrder in openOrders) { var orderSecurity = portfolio.Securities[openOrder.Symbol]; @@ -453,10 +460,30 @@ private static decimal GetOpenOrdersReservedQuantity(SecurityPortfolioManager po quantityInTargetCurrency *= GetOrderPrice(security, openOrder); } + var member = openOrder.Contingency != null ? openOrder.GetSiblingLink() : null; + if (member != null) + { + contingentSiblingsReservedQuantity ??= new(); + var key = (openOrder.Contingency.Id, member.Id); + if (!contingentSiblingsReservedQuantity.TryGetValue(key, out var existing) || quantityInTargetCurrency > existing) + { + contingentSiblingsReservedQuantity[key] = quantityInTargetCurrency; + } + continue; + } + openOrdersReservedQuantity += quantityInTargetCurrency; } } + if (contingentSiblingsReservedQuantity != null) + { + foreach (var reserved in contingentSiblingsReservedQuantity.Values) + { + openOrdersReservedQuantity += reserved; + } + } + return openOrdersReservedQuantity; } } diff --git a/Common/Securities/SecurityTransactionManager.cs b/Common/Securities/SecurityTransactionManager.cs index bad9422ba463..c281563902fc 100644 --- a/Common/Securities/SecurityTransactionManager.cs +++ b/Common/Securities/SecurityTransactionManager.cs @@ -40,6 +40,7 @@ private class TransactionRecordEntry private readonly IAlgorithm _algorithm; private int _orderId; private int _groupOrderManagerId; + private int _contingentOrderSetId; private readonly SecurityManager _securities; private TimeSpan _marketOrderFillTimeout = TimeSpan.MinValue; @@ -389,8 +390,48 @@ private IEnumerable GetOpenOrderTickets(Func fil /// Total quantity that hasn't been filled yet for all orders that were not filtered public decimal GetOpenOrdersRemainingQuantity(Func filter = null) { - return GetOpenOrderTickets(filter, memoize: false) - .Aggregate(0m, (d, t) => d + t.QuantityRemaining); + var result = 0m; + // for contingent orders (OCO/OUO) at most one of the siblings is expected to fill, we take the biggest per symbol + Dictionary<(int, int, Symbol), decimal> siblingsRemainingQuantity = null; + foreach (var ticket in GetOpenOrderTickets(filter, memoize: false)) + { + var contingency = ticket.Contingency; + if (contingency == null) + { + result += ticket.QuantityRemaining; + continue; + } + + if (contingency.IsWaitingForTrigger) + { + // held by the brokerage until its parent fills, it's not working yet + continue; + } + + var member = contingency.GetLink(null); + if (member == null) + { + result += ticket.QuantityRemaining; + continue; + } + + siblingsRemainingQuantity ??= new(); + var key = (contingency.Id, member.Id, ticket.Symbol); + var remaining = ticket.QuantityRemaining; + if (!siblingsRemainingQuantity.TryGetValue(key, out var existing) || Math.Abs(remaining) > Math.Abs(existing)) + { + siblingsRemainingQuantity[key] = remaining; + } + } + + if (siblingsRemainingQuantity != null) + { + foreach (var remaining in siblingsRemainingQuantity.Values) + { + result += remaining; + } + } + return result; } /// @@ -571,6 +612,15 @@ public int GetIncrementGroupOrderManagerId() return Interlocked.Increment(ref _groupOrderManagerId); } + /// + /// Get a new contingent order set id, and increment the internal counter. + /// + /// New unique int contingent order set id. + public int GetIncrementContingentOrderSetId() + { + return Interlocked.Increment(ref _contingentOrderSetId); + } + /// /// Sets the used for fetching orders for the algorithm /// diff --git a/Engine/TransactionHandlers/BrokerageTransactionHandler.cs b/Engine/TransactionHandlers/BrokerageTransactionHandler.cs index a17998018ba3..70ef75f7ef86 100644 --- a/Engine/TransactionHandlers/BrokerageTransactionHandler.cs +++ b/Engine/TransactionHandlers/BrokerageTransactionHandler.cs @@ -338,7 +338,7 @@ public OrderTicket AddOrder(SubmitOrderRequest request) var shortable = true; if (request.Quantity < 0) { - shortable = _algorithm.Shortable(request.Symbol, request.Quantity); + shortable = IsShortable(request); } if (!shortable) @@ -801,6 +801,18 @@ public void AddOpenOrder(Order order, IAlgorithm algorithm) order.GroupOrderManager.Id = algorithm.Transactions.GetIncrementGroupOrderManagerId(); } + if (order.Contingency != null) + { + // the set is shared by all the orders in it, we set its id once + lock (order.Contingency.OrderIds) + { + if (order.Contingency.Id == 0) + { + order.Contingency.SetId(algorithm.Transactions.GetIncrementContingentOrderSetId()); + } + } + } + var orderTicket = order.ToOrderTicket(algorithm.Transactions); SetPriceAdjustmentMode(order, algorithm); @@ -915,6 +927,19 @@ private OrderResponse HandleSubmitOrderRequest(SubmitOrderRequest request) return OrderResponse.Success(request); } + if (order.Contingency != null) + { + // the order is part of a set of contingent orders (OCO, OTO, OUO, brackets), which can hold combo orders too: + // they are validated and placed together once they have all arrived. The brokerage is responsible of handling + // their lifecycle: holding the children until their parent fills, canceling siblings, etc. + if (!order.TryGetContingentOrders(GetComboOrderLeg, out orders)) + { + // an order of the set is missing, we will be called again once it arrives + return OrderResponse.Success(request); + } + comboSecuritiesFound = orders.TryGetGroupOrdersSecurities(_algorithm.Portfolio, out securities); + } + if (orders.Any(o => o.Quantity == 0)) { var response = OrderResponse.ZeroQuantity(request); @@ -934,7 +959,9 @@ private OrderResponse HandleSubmitOrderRequest(SubmitOrderRequest request) } // check to see if we have enough money to place the order - if (!HasSufficientBuyingPowerForOrders(order, request, out var validationResult, orders, securities)) + if (order.Contingency == null + ? !HasSufficientBuyingPowerForOrders(order, request, out var validationResult, orders, securities) + : !HasSufficientBuyingPowerForContingentOrders(request, orders, securities, out validationResult)) { return validationResult; } @@ -983,6 +1010,50 @@ private OrderResponse HandleSubmitOrderRequest(SubmitOrderRequest request) return OrderResponse.Success(request); } + /// + /// Validates there is sufficient buying power for the orders of a set of contingent orders which start working right away. + /// Each of them is independent, the legs of a combo order being a single unit. Children are held by the brokerage until their parent fills + /// + private bool HasSufficientBuyingPowerForContingentOrders(SubmitOrderRequest request, List orders, Dictionary securities, + out OrderResponse response) + { + response = null; + HashSet validatedGroups = null; + foreach (var workingOrder in orders) + { + if (workingOrder.IsWaitingForTrigger()) + { + continue; + } + + List unit; + if (workingOrder.GroupOrderManager == null) + { + unit = new List(1) { workingOrder }; + } + else + { + validatedGroups ??= new(); + if (!validatedGroups.Add(workingOrder.GroupOrderManager.Id)) + { + continue; + } + workingOrder.TryGetGroupOrders(GetComboOrderLeg, out unit); + } + + var unitSecurities = new Dictionary(unit.Count); + foreach (var unitOrder in unit) + { + unitSecurities[unitOrder] = securities[unitOrder]; + } + if (!HasSufficientBuyingPowerForOrders(workingOrder, request, out response, unit, unitSecurities, invalidateOrders: orders)) + { + return false; + } + } + return true; + } + /// /// Handles a request to update order properties /// @@ -1029,8 +1100,9 @@ private OrderResponse HandleUpdateOrderRequest(UpdateOrderRequest request) return response; } - // If the order is not part of a ComboLegLimit update, validate sufficient buying power - if (order.GroupOrderManager == null) + // If the order is not part of a ComboLegLimit update, validate sufficient buying power. + // A contingent child waiting for its parent to fill isn't working yet, it's validated by the brokerage once triggered + if (order.GroupOrderManager == null && !order.IsWaitingForTrigger()) { var updatedOrder = order.Clone(); updatedOrder.ApplyUpdateOrderRequest(request); @@ -1140,7 +1212,9 @@ private OrderResponse HandleCancelOrderRequest(CancelOrderRequest request) /// Returns an error response if validation fails or an exception occurs. /// Returns null if validation passes. /// - private bool HasSufficientBuyingPowerForOrders(Order order, OrderRequest request, out OrderResponse response, List orders = null, Dictionary securities = null) + /// The orders to invalidate if the validation fails, the given orders by default + private bool HasSufficientBuyingPowerForOrders(Order order, OrderRequest request, out OrderResponse response, List orders = null, + Dictionary securities = null, List invalidateOrders = null) { response = null; HasSufficientBuyingPowerForOrderResult hasSufficientBuyingPowerResult; @@ -1152,7 +1226,14 @@ private bool HasSufficientBuyingPowerForOrders(Order order, OrderRequest request { Log.Error(err); _algorithm.Error($"Order Error: id: {order.Id.ToStringInvariant()}, Error executing margin models: {err.Message}"); - HandleOrderEvent(new OrderEvent(order, _algorithm.UtcTime, OrderFee.Zero, "Error executing margin models")); + if (invalidateOrders != null) + { + InvalidateOrders(invalidateOrders, "Error executing margin models"); + } + else + { + HandleOrderEvent(new OrderEvent(order, _algorithm.UtcTime, OrderFee.Zero, "Error executing margin models")); + } response = OrderResponse.Error(request, OrderResponseErrorCode.ProcessingError, "An error occurred while checking sufficient buying power for the orders."); return false; @@ -1173,7 +1254,7 @@ private bool HasSufficientBuyingPowerForOrders(Order order, OrderRequest request } else { - InvalidateOrders(orders, errorMessage); + InvalidateOrders(invalidateOrders ?? orders, errorMessage); response = OrderResponse.Error(request, OrderResponseErrorCode.InsufficientBuyingPower, errorMessage); } return false; @@ -1235,6 +1316,14 @@ private void HandleOrderEvents(List orderEvents) order.Status = orderEvent.Status; } + // a held order can not fill: the fill proves the brokerage released it, covers a missed or late trigger notification + var child = orderEvent.Status is OrderStatus.Filled or OrderStatus.PartiallyFilled ? order.Contingency?.GetLink(ContingencyRole.Child) : null; + if (child is { Triggered: false }) + { + child.TriggeredTime = _algorithm.UtcTime; + child.Triggered = true; + } + orderEvent.Id = order.GetNewId(); // set the modified time of the order to the fill's timestamp @@ -1434,13 +1523,47 @@ private void HandleOrderUpdated(OrderUpdateEvent e) return; } + // contingency updates can happen for any order type and don't carry the order type specific data, unless set + var isContingencyUpdate = e.ContingencyTriggered || e.Quantity.HasValue; + if (e.ContingencyTriggered) + { + var child = order.GetContingencyLink(ContingencyRole.Child); + if (child != null && !child.Triggered) + { + child.TriggeredTime = _algorithm.UtcTime; + child.Triggered = true; + } + } + + if (e.Quantity.HasValue && e.Quantity.Value != 0 && e.Quantity.Value != order.Quantity) + { + // the brokerage resized the order on its side (OUO sibling fill, bracket leg sizing), never go below what's already filled + var filledQuantity = _completeOrderTickets.TryGetValue(order.Id, out var ticket) ? ticket.QuantityFilled : 0; + if (Math.Abs(e.Quantity.Value) >= Math.Abs(filledQuantity) && Math.Sign(e.Quantity.Value) == Math.Sign(order.Quantity)) + { + order.Quantity = e.Quantity.Value; + } + else + { + Log.Error($"BrokerageTransactionHandler.HandleOrderUpdated(): ignoring invalid quantity update {e.Quantity.Value} for order id {order.Id}," + + $" quantity {order.Quantity} filled quantity {filledQuantity}"); + } + } + switch (order.Type) { case OrderType.TrailingStop: - ((TrailingStopOrder)order).StopPrice = e.TrailingStopPrice; + if (!isContingencyUpdate || e.TrailingStopPrice != 0) + { + ((TrailingStopOrder)order).StopPrice = e.TrailingStopPrice; + } break; case OrderType.StopLimit: + if (isContingencyUpdate) + { + break; + } var stopLimitOrder = (StopLimitOrder)order; if (e.StopTriggeredTime.HasValue) { @@ -2003,6 +2126,31 @@ private void SendWarningOnPriceChange(string priceType, decimal priceRound, deci } } + /// + /// Determines whether the requested short quantity is shortable. For contingent orders the open quantity of + /// the sibling orders is not taken into account, since at most one of them is expected to fill + /// + private bool IsShortable(SubmitOrderRequest request) + { + var contingency = request.Contingency; + var member = contingency?.GetLink(null); + if (member == null) + { + return _algorithm.Shortable(request.Symbol, request.Quantity); + } + + var security = _algorithm.Securities[request.Symbol]; + var shortableQuantity = security.ShortableProvider.ShortableQuantity(request.Symbol, security.LocalTime); + if (shortableQuantity == null) + { + return true; + } + + var openOrderQuantity = _algorithm.Transactions.GetOpenOrdersRemainingQuantity(ticket => ticket.Symbol == request.Symbol + && !(ticket.Contingency?.Id == contingency.Id && ticket.Contingency.GetLink(null)?.Id == member.Id)); + return security.Holdings.Quantity + openOrderQuantity - Math.Abs(request.Quantity) >= -shortableQuantity; + } + private string GetShortableErrorMessage(Symbol symbol, decimal quantity) { var shortableQuantity = _algorithm.ShortableQuantity(symbol); diff --git a/Engine/TransactionHandlers/OrderRequestProcessingPool.cs b/Engine/TransactionHandlers/OrderRequestProcessingPool.cs index 2fb067748582..8fbb9cb2e9f4 100644 --- a/Engine/TransactionHandlers/OrderRequestProcessingPool.cs +++ b/Engine/TransactionHandlers/OrderRequestProcessingPool.cs @@ -54,7 +54,7 @@ public class OrderRequestProcessingPool : IDisposable private readonly List _threads; // for each order (or combo group) being processed, the follow up requests waiting their turn in arrival order, // or null until a second request actually needs parking. while the key is here the order is already running - private readonly Dictionary<(bool IsGroup, int Id), Queue> _inFlight = new(); + private readonly Dictionary<(int Kind, int Id), Queue> _inFlight = new(); // guards the in flight map, the threads list and the growth/shutdown flags private readonly Lock _lock = new(); // maximum number of worker threads the pool can grow to on demand @@ -487,14 +487,19 @@ private void ProcessInOrder(WorkItem item) } /// - /// Builds the routing key that ties an order's requests together, the combo group when it has one, otherwise - /// the order itself. Order ids and group ids are separate counters that can share a value, so the flag keeps - /// a simple order and a combo group from colliding. + /// Builds the routing key that ties an order's requests together: the set of contingent orders when it's part of one, + /// which can hold combo orders too, else the combo group when it has one, otherwise the order itself. + /// Order ids, group ids and contingent ids are separate counters that can share a value, so the kind keeps them from colliding. /// - private static (bool IsGroup, int Id) GetRoutingKey(Order order) + private static (int Kind, int Id) GetRoutingKey(Order order) { + var contingent = order.Contingency; + if (contingent?.Id > 0) + { + return (2, contingent.Id); + } var group = order.GroupOrderManager; - return group?.Id > 0 ? (true, group.Id) : (false, order.Id); + return group?.Id > 0 ? (1, group.Id) : (0, order.Id); } /// @@ -503,9 +508,9 @@ private static (bool IsGroup, int Id) GetRoutingKey(Order order) private readonly struct WorkItem { public OrderRequest Request { get; } - public (bool IsGroup, int Id) Key { get; } + public (int Kind, int Id) Key { get; } - public WorkItem(OrderRequest request, (bool IsGroup, int Id) key) + public WorkItem(OrderRequest request, (int Kind, int Id) key) { Request = request; Key = key; diff --git a/Tests/Algorithm/AlgorithmOrderFactoryTests.cs b/Tests/Algorithm/AlgorithmOrderFactoryTests.cs new file mode 100644 index 000000000000..bbc7281415c2 --- /dev/null +++ b/Tests/Algorithm/AlgorithmOrderFactoryTests.cs @@ -0,0 +1,500 @@ +/* + * 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 NUnit.Framework; +using QuantConnect.Algorithm; +using QuantConnect.Data; +using QuantConnect.Data.Market; +using QuantConnect.Orders; +using QuantConnect.Orders.TimeInForces; +using QuantConnect.Securities; +using QuantConnect.Tests.Common.Securities; +using QuantConnect.Tests.Engine.DataFeeds; + +namespace QuantConnect.Tests.Algorithm +{ + [TestFixture] + public class AlgorithmOrderFactoryTests + { + private QCAlgorithm _algorithm; + private Symbol _spy; + private Symbol _aapl; + + [SetUp] + public void SetUp() + { + _algorithm = new QCAlgorithm(); + _algorithm.SubscriptionManager.SetDataManager(new DataManagerStub(_algorithm)); + _algorithm.SetCash(100000); + _algorithm.SetFinishedWarmingUp(); + _algorithm.SetLiveMode(false); + _algorithm.SetDateTime(new DateTime(2024, 1, 3, 16, 0, 0)); + _algorithm.Transactions.SetOrderProcessor(new FakeOrderProcessor()); + _algorithm.SetCurrentSlice(new Slice(DateTime.MinValue, Enumerable.Empty(), DateTime.MinValue)); + + _spy = AddEquity("SPY", 100); + _aapl = AddEquity("AAPL", 200); + } + + [Test] + public void PlainOrderIsNotContingent() + { + var order = _algorithm.OrderFactory.LimitOrder(_spy, 10, 99, tag: "tag"); + var tickets = _algorithm.Order(order); + + var request = tickets.Single().SubmitRequest; + Assert.AreSame(order, request); + Assert.AreSame(Ticket(order), tickets[0]); + Assert.IsTrue(order.OrderId > 0); + Assert.AreEqual(_algorithm.UtcTime, request.Time); + Assert.AreEqual(OrderType.Limit, request.OrderType); + Assert.AreEqual(99, request.LimitPrice); + Assert.AreEqual("tag", request.Tag); + Assert.IsNull(request.Contingency); + Assert.IsNull(tickets[0].Contingency); + } + + [Test] + public void ExistingOrderMethodsAreNotContingent() + { + var requests = new[] + { + _algorithm.LimitOrder(_spy, 10, 99).SubmitRequest, + _algorithm.StopMarketOrder(_spy, -10, 90).SubmitRequest, + _algorithm.StopLimitOrder(_spy, -10, 90, 89).SubmitRequest, + _algorithm.LimitIfTouchedOrder(_spy, -10, 110, 109).SubmitRequest, + _algorithm.TrailingStopOrder(_spy, -10, 0.1m, true).SubmitRequest, + _algorithm.MarketOrder(_spy, 10, asynchronous: true).SubmitRequest + }; + + Assert.IsTrue(requests.All(x => x.Contingency == null && x.GroupOrderManager == null)); + CollectionAssert.AreEqual(new[] { OrderType.Limit, OrderType.StopMarket, OrderType.StopLimit, OrderType.LimitIfTouched, OrderType.TrailingStop, OrderType.Market }, + requests.Select(x => x.OrderType)); + // the trailing stop price is calculated from the current price + Assert.AreEqual(90, requests[4].StopPrice); + } + + [TestCase(true)] + [TestCase(false)] + public void BracketOrder(bool limitEntry) + { + var properties = new OrderProperties { TimeInForce = TimeInForce.Day }; + var tickets = _algorithm.BracketOrder(_spy, 10, takeProfitPrice: 110, stopLossPrice: 90, limitPrice: limitEntry ? 99 : null, + asynchronous: true, tag: "bracket", orderProperties: properties); + + Assert.AreEqual(3, tickets.Count); + var requests = tickets.Select(x => x.SubmitRequest).ToList(); + CollectionAssert.AreEqual(new[] { limitEntry ? OrderType.Limit : OrderType.Market, OrderType.Limit, OrderType.StopMarket }, requests.Select(x => x.OrderType)); + CollectionAssert.AreEqual(new[] { 10m, -10m, -10m }, requests.Select(x => x.Quantity)); + Assert.AreEqual(110, requests[1].LimitPrice); + Assert.AreEqual(90, requests[2].StopPrice); + Assert.IsTrue(requests.All(x => x.Tag == "bracket" && x.OrderProperties.TimeInForce is DayTimeInForce)); + // each order gets it's own properties instance + Assert.AreEqual(3, requests.Select(x => x.OrderProperties).Distinct().Count()); + + AssertBracket(requests, ContingencyType.OneCancelsOther); + Assert.IsFalse(tickets[0].Contingency.IsWaitingForTrigger); + Assert.IsTrue(tickets[1].Contingency.IsWaitingForTrigger); + Assert.IsTrue(tickets[2].Contingency.IsWaitingForTrigger); + } + + [TestCase(ContingencyType.OneCancelsOther)] + [TestCase(ContingencyType.OneUpdatesOther)] + public void BracketThroughOrderFactory(ContingencyType contingencyType) + { + var entry = _algorithm.OrderFactory.StopLimitOrder(_spy, -10, 95, 94).Bracket(80, 105, stopLossLimitPrice: 106, contingencyType: contingencyType); + var tickets = _algorithm.Order(entry); + + var requests = tickets.Select(x => x.SubmitRequest).ToList(); + CollectionAssert.AreEqual(new[] { OrderType.StopLimit, OrderType.Limit, OrderType.StopLimit }, requests.Select(x => x.OrderType)); + CollectionAssert.AreEqual(new[] { -10m, 10m, 10m }, requests.Select(x => x.Quantity)); + Assert.AreEqual(80, requests[1].LimitPrice); + Assert.AreEqual(105, requests[2].StopPrice); + Assert.AreEqual(106, requests[2].LimitPrice); + AssertBracket(requests, contingencyType); + + // the submitted request is the ticket's, the whole set was submitted + Assert.AreSame(tickets[0], Ticket(entry)); + Assert.AreSame(entry, tickets[0].SubmitRequest); + CollectionAssert.AreEqual(tickets, entry.Contingency.Requests.Select(Ticket)); + Assert.IsTrue(tickets.All(ticket => ticket.SubmitRequest.Contingency.Id == entry.Contingency.Id)); + } + + [TestCase(true)] + [TestCase(false)] + public void OneCancelsOtherOrUpdatesOther(bool oneCancelsOther) + { + var orders = new List { _algorithm.OrderFactory.LimitOrder(_spy, -10, 110), _algorithm.OrderFactory.StopMarketOrder(_spy, -10, 90), _algorithm.OrderFactory.StopMarketOrder(_aapl, 5, 250) }; + var tickets = oneCancelsOther ? _algorithm.OneCancelsOtherOrder(orders) : _algorithm.OneUpdatesOtherOrder(orders); + + Assert.AreEqual(3, tickets.Count); + var contingency = tickets[0].SubmitRequest.Contingency; + Assert.AreEqual(3, contingency.Count); + CollectionAssert.AreEquivalent(new[] { _spy, _aapl }, contingency.Symbols); + CollectionAssert.AreEquivalent(new[] { OrderDirection.Buy, OrderDirection.Sell }, contingency.Directions); + CollectionAssert.AreEquivalent(new[] { OrderType.Limit, OrderType.StopMarket }, contingency.OrderTypes); + foreach (var ticket in tickets) + { + Assert.AreSame(contingency.OrderIds, ticket.SubmitRequest.Contingency.OrderIds); + var link = ticket.SubmitRequest.Contingency.Links.Single(); + Assert.AreEqual(1, link.Id); + Assert.AreEqual(oneCancelsOther ? ContingencyType.OneCancelsOther : ContingencyType.OneUpdatesOther, link.Type); + Assert.IsNull(link.Role); + Assert.IsFalse(ticket.Contingency.IsWaitingForTrigger); + } + } + + [Test] + public void EachSubmissionGetsANewManagerId() + { + var first = _algorithm.BracketOrder(_spy, 10, 110, 90, limitPrice: 99); + var second = _algorithm.BracketOrder(_spy, 10, 110, 90, limitPrice: 99); + + Assert.AreEqual(1, first[0].Contingency.Id); + Assert.AreEqual(2, second[0].Contingency.Id); + } + + [Test] + public void OneTriggersOtherChain() + { + // parent triggers two independent children, the first one triggers a one cancels other in turn + var takeProfit = _algorithm.OrderFactory.LimitOrder(_aapl, -5, 250); + var stopLoss = _algorithm.OrderFactory.StopMarketOrder(_aapl, -5, 150); + var firstChild = _algorithm.OrderFactory.MarketOrder(_aapl, 5).Triggers(_algorithm.OrderFactory.OneCancelsOther(takeProfit, stopLoss)); + var secondChild = _algorithm.OrderFactory.LimitOrder(_spy, -10, 120); + var parent = _algorithm.OrderFactory.LimitOrder(_spy, 10, 99); + + var tickets = _algorithm.OneTriggersOtherOrder(parent, new List { firstChild, secondChild }); + + // parents first, depth first + CollectionAssert.AreEqual(new[] { Ticket(parent), Ticket(firstChild), Ticket(takeProfit), Ticket(stopLoss), Ticket(secondChild) }, tickets); + Assert.IsTrue(tickets.All(x => x.Contingency.Count == 5)); + // each order has its own contingency, the set is shared + Assert.AreEqual(1, tickets.Select(x => x.Contingency.OrderIds).Distinct().Count()); + + AssertContingencies(parent, (1, ContingencyType.OneTriggersOther, ContingencyRole.Parent)); + AssertContingencies(firstChild, (1, ContingencyType.OneTriggersOther, ContingencyRole.Child), (2, ContingencyType.OneTriggersOther, ContingencyRole.Parent)); + AssertContingencies(takeProfit, (2, ContingencyType.OneTriggersOther, ContingencyRole.Child), (3, ContingencyType.OneCancelsOther, null)); + AssertContingencies(stopLoss, (2, ContingencyType.OneTriggersOther, ContingencyRole.Child), (3, ContingencyType.OneCancelsOther, null)); + AssertContingencies(secondChild, (1, ContingencyType.OneTriggersOther, ContingencyRole.Child)); + + Assert.IsFalse(Ticket(parent).Contingency.IsWaitingForTrigger); + Assert.IsTrue(tickets.Skip(1).All(x => x.Contingency.IsWaitingForTrigger)); + } + + [Test] + public void OneCancelsOtherEntriesEachWithItsOwnBracket() + { + var breakoutUp = _algorithm.OrderFactory.StopMarketOrder(_spy, 10, 105).Bracket(120, 100); + var breakoutDown = _algorithm.OrderFactory.StopMarketOrder(_spy, -10, 95).Bracket(80, 100); + + var tickets = _algorithm.Order(_algorithm.OrderFactory.OneCancelsOther(breakoutUp, breakoutDown)); + + Assert.AreEqual(6, tickets.Count); + Assert.IsTrue(tickets.All(x => x.Contingency.Count == 6)); + // the contingency ids follow the composition: each bracket first, then the one cancels other relating the entries + AssertContingencies(breakoutUp, (1, ContingencyType.OneTriggersOther, ContingencyRole.Parent), (5, ContingencyType.OneCancelsOther, null)); + AssertContingencies(breakoutDown, (3, ContingencyType.OneTriggersOther, ContingencyRole.Parent), (5, ContingencyType.OneCancelsOther, null)); + Assert.IsTrue(tickets.Skip(1).Take(2).All(ticket => ticket.Contingency.Links.Any(link => link.Id == 2 && link.Role == null))); + Assert.IsTrue(tickets.Skip(4).All(ticket => ticket.Contingency.Links.Any(link => link.Id == 4 && link.Role == null))); + Assert.IsFalse(Ticket(breakoutUp).Contingency.IsWaitingForTrigger); + Assert.IsFalse(Ticket(breakoutDown).Contingency.IsWaitingForTrigger); + Assert.AreEqual(4, tickets.Count(x => x.Contingency.IsWaitingForTrigger)); + } + + [Test] + public void ComboOrdersInAContingency() + { + var legs = new List { Leg.Create(_spy, 1), Leg.Create(_aapl, -1) }; + var exit = _algorithm.OrderFactory.ComboLimitOrder(legs, -2, 50); + var parent = _algorithm.OrderFactory.ComboMarketOrder(legs, 2, asynchronous: true); + // the legs are a single unit, they trigger together: all of them are required + Assert.Throws(() => parent[1].Triggers(exit)); + var tickets = _algorithm.OneTriggersOtherOrder(parent, exit); + Assert.IsTrue(parent.All(leg => leg.Contingency.Links.Single().Role == ContingencyRole.Parent)); + Assert.IsTrue(exit.All(leg => leg.Contingency.Links.Single().Role == ContingencyRole.Child)); + + + Assert.AreEqual(4, tickets.Count); + CollectionAssert.AreEqual(tickets.Take(2), parent.Select(Ticket)); + CollectionAssert.AreEqual(tickets.Skip(2), exit.Select(Ticket)); + var requests = tickets.Select(x => x.SubmitRequest).ToList(); + Assert.IsTrue(requests.All(x => x.Contingency.Count == 4)); + CollectionAssert.AreEqual(new[] { OrderType.ComboMarket, OrderType.ComboMarket, OrderType.ComboLimit, OrderType.ComboLimit }, requests.Select(x => x.OrderType)); + CollectionAssert.AreEqual(new[] { 2m, -2m, -2m, 2m }, requests.Select(x => x.Quantity)); + + // each combo has it's own group manager, shared by its legs + Assert.AreSame(requests[0].GroupOrderManager, requests[1].GroupOrderManager); + Assert.AreSame(requests[2].GroupOrderManager, requests[3].GroupOrderManager); + Assert.AreNotEqual(requests[0].GroupOrderManager.Id, requests[2].GroupOrderManager.Id); + Assert.AreEqual(50, requests[2].GroupOrderManager.LimitPrice); + + // each leg has the contingencies of its combo, their own instance + foreach (var request in requests.Take(2)) + { + var contingency = request.Contingency.Links.Single(); + Assert.AreEqual(ContingencyRole.Parent, contingency.Role); + } + Assert.AreNotSame(requests[0].Contingency.Links[0], requests[1].Contingency.Links[0]); + Assert.IsTrue(requests.Skip(2).All(x => x.Contingency.Links.Single().Role == ContingencyRole.Child && x.Contingency.Links.Single().Id == 1)); + } + + [Test] + public void ComboOrderTriggersOtherOrders() + { + var legs = new List { Leg.Create(_spy, 1), Leg.Create(_aapl, -1) }; + var exit = _algorithm.OrderFactory.ComboLimitOrder(legs, -2, 50); + var parent = _algorithm.OrderFactory.ComboMarketOrder(legs, 2, asynchronous: true); + + // the parent must be a single unit: one order or the legs of one combo order + Assert.Throws(() => _algorithm.OneTriggersOtherOrder(parent.Concat(exit), new[] { _algorithm.OrderFactory.MarketOrder(_spy, 1) })); + Assert.Throws(() => _algorithm.OneTriggersOtherOrder(new List(), exit)); + Assert.Throws(() => _algorithm.OneTriggersOtherOrder(new[] { _algorithm.OrderFactory.MarketOrder(_spy, 1), _algorithm.OrderFactory.MarketOrder(_aapl, 1) }, exit)); + + var tickets = _algorithm.OneTriggersOtherOrder(parent, exit); + + Assert.AreEqual(4, tickets.Count); + CollectionAssert.AreEqual(tickets.Take(2), parent.Select(Ticket)); + CollectionAssert.AreEqual(tickets.Skip(2), exit.Select(Ticket)); + Assert.IsTrue(parent.All(leg => leg.Contingency.Links.Single().Role == ContingencyRole.Parent)); + Assert.IsTrue(exit.All(leg => leg.Contingency.Links.Single().Role == ContingencyRole.Child)); + Assert.IsTrue(tickets.Skip(2).All(ticket => ticket.Contingency.IsWaitingForTrigger)); + } + + [Test] + public void SubmitsUnrelatedOrdersTogether() + { + var plain = _algorithm.OrderFactory.LimitOrder(_spy, 10, 99); + var combo = _algorithm.OrderFactory.ComboLimitOrder(new List { Leg.Create(_spy, 1), Leg.Create(_aapl, -1) }, 2, 50); + var bracket = _algorithm.OrderFactory.LimitOrder(_aapl, 5, 199).Bracket(210, 190); + + var tickets = _algorithm.Order(combo.Append(plain).Append(bracket)); + + // each one on its own: the combo legs, the plain order and the whole bracket + Assert.AreEqual(6, tickets.Count); + CollectionAssert.AreEqual(combo.Append(plain).Append(bracket).Concat(bracket.Contingency.Requests.Skip(1)).Select(Ticket), tickets); + Assert.IsTrue(combo.All(leg => leg.Contingency == null && leg.GroupOrderManager.Id > 0)); + Assert.IsNull(plain.Contingency); + Assert.AreEqual(3, bracket.Contingency.Count); + Assert.AreEqual(2, tickets.Count(ticket => ticket.Contingency?.IsWaitingForTrigger == true)); + } + + [Test] + public void IncompleteComboIsRejected() + { + var plain = _algorithm.OrderFactory.LimitOrder(_spy, 10, 99); + var combo = _algorithm.OrderFactory.ComboLimitOrder(new List { Leg.Create(_spy, 1), Leg.Create(_aapl, -1) }, 2, 50); + + Assert.Throws(() => _algorithm.Order(new[] { plain, combo[1] })); + + // nothing was submitted + Assert.IsTrue(new[] { plain }.Concat(combo).All(request => request.OrderId <= 0)); + Assert.IsEmpty(_algorithm.Transactions.GetOrders()); + + // all the legs are fine + Assert.AreEqual(3, _algorithm.Order(combo.Append(plain)).Count); + } + + [Test] + public void ExistingComboMethodsAreNotContingent() + { + var legs = new List { Leg.Create(_spy, 1, 100), Leg.Create(_aapl, -1, 200) }; + var tickets = _algorithm.ComboLegLimitOrder(legs, 2); + + Assert.AreEqual(2, tickets.Count); + Assert.IsTrue(tickets.All(x => x.SubmitRequest.OrderType == OrderType.ComboLegLimit && x.SubmitRequest.Contingency == null + && x.SubmitRequest.GroupOrderManager.Count == 2)); + CollectionAssert.AreEqual(new[] { 100m, 200m }, tickets.Select(x => x.SubmitRequest.LimitPrice)); + + Assert.Throws(() => _algorithm.ComboLegLimitOrder(new List { Leg.Create(_spy, 1) }, 1)); + Assert.Throws(() => _algorithm.ComboLimitOrder(legs, 1, 10)); + Assert.Throws(() => _algorithm.ComboLimitOrder(new List { Leg.Create(_spy, 1) }, 1, 0)); + } + + [Test] + public void HeldTrailingStopPriceIsSetOnceTriggered() + { + var trailingStop = _algorithm.OrderFactory.TrailingStopOrder(_spy, -10, 0.1m, true); + var explicitTrailingStop = _algorithm.OrderFactory.TrailingStopOrder(_spy, -10, 85, 0.1m, true); + _algorithm.Order(_algorithm.OrderFactory.LimitOrder(_spy, 10, 99).Triggers(trailingStop, explicitTrailingStop)); + + Assert.AreEqual(0, trailingStop.StopPrice); + Assert.AreEqual(0.1m, trailingStop.TrailingAmount); + Assert.IsTrue(trailingStop.TrailingAsPercentage); + Assert.AreEqual(85, explicitTrailingStop.StopPrice); + + // when working right away it's calculated from the current price + var working = _algorithm.OrderFactory.TrailingStopOrder(_spy, -10, 0.1m, true); + _algorithm.Order(working); + Assert.AreEqual(90, working.StopPrice); + Assert.AreEqual(90, Ticket(working).SubmitRequest.StopPrice); + } + + [Test] + public void HeldMarketOrdersAreNotConverted() + { + // market is closed + _algorithm.SetDateTime(new DateTime(2024, 1, 3, 3, 0, 0)); + var child = _algorithm.OrderFactory.MarketOrder(_spy, -10); + var parent = _algorithm.OrderFactory.MarketOrder(_spy, 10).Triggers(child); + + _algorithm.Order(parent); + + // the working market order is converted into market on open, as usual + Assert.AreEqual(OrderType.MarketOnOpen, parent.OrderType); + Assert.AreEqual(OrderType.Market, child.OrderType); + } + + [Test] + public void OrderRequestCanOnlyBeSubmittedOnce() + { + var request = _algorithm.OrderFactory.LimitOrder(_spy, 10, 99); + _algorithm.Order(request); + + Assert.Throws(() => _algorithm.Order(request)); + Assert.Throws(() => _algorithm.Order(_algorithm.OrderFactory.LimitOrder(_spy, 10, 99).Triggers(request))); + Assert.Throws(() => request.Triggers(_algorithm.OrderFactory.MarketOrder(_spy, 1))); + Assert.Throws(() => _algorithm.OrderFactory.OneCancelsOther(request, _algorithm.OrderFactory.MarketOrder(_spy, 1))); + + // present twice + var repeated = _algorithm.OrderFactory.LimitOrder(_spy, 10, 99); + Assert.Throws(() => _algorithm.Order(_algorithm.OrderFactory.LimitOrder(_spy, 10, 99).Triggers(repeated, repeated))); + } + + [Test] + public void InvalidRequests() + { + Assert.IsEmpty(_algorithm.Order(new List())); + Assert.Throws(() => _algorithm.OrderFactory.OneCancelsOther(_algorithm.OrderFactory.MarketOrder(_spy, 1))); + Assert.Throws(() => _algorithm.OrderFactory.OneCancelsOther()); + Assert.Throws(() => _algorithm.OrderFactory.OneUpdatesOther(_algorithm.OrderFactory.MarketOrder(_spy, 1), null)); + Assert.Throws(() => _algorithm.OrderFactory.MarketOrder(_spy, 1).Triggers()); + Assert.Throws(() => _algorithm.OrderFactory.MarketOrder(_spy, 1).Triggers(null, null)); + Assert.Throws(() => _algorithm.OrderFactory.ComboMarketOrder(new List { Leg.Create(_spy, 1) }, 1)[0].Bracket(1, 2)); + Assert.Throws(() => _algorithm.OrderFactory.ComboMarketOrder(new List(), 1)); + // a combo market order has no prices, per leg prices are a combo leg limit order + Assert.Throws(() => _algorithm.OrderFactory.ComboMarketOrder(new List { Leg.Create(_spy, 1, 100), Leg.Create(_aapl, -1) }, 1)); + // all the legs of a combo order are required + Assert.Throws(() => _algorithm.Order(_algorithm.OrderFactory.ComboMarketOrder(new List { Leg.Create(_spy, 1), Leg.Create(_aapl, -1) }, 1)[0])); + // only options can be exercised + Assert.Throws(() => _algorithm.OrderFactory.ExerciseOption(_spy, 1)); + Assert.Throws(() => _algorithm.OneTriggersOtherOrder((SubmitOrderRequest)null, new List())); + + // the legs of a combo order are a single order to relate + var legs = new List { Leg.Create(_spy, 1), Leg.Create(_aapl, -1) }; + Assert.Throws(() => _algorithm.OrderFactory.OneCancelsOther(_algorithm.OrderFactory.ComboMarketOrder(legs, 1))); + + // orders can only be related once + var related = _algorithm.OrderFactory.OneCancelsOther(_algorithm.OrderFactory.LimitOrder(_spy, 10, 99), _algorithm.OrderFactory.LimitOrder(_spy, 10, 98)); + Assert.Throws(() => _algorithm.OrderFactory.OneUpdatesOther(related[0], _algorithm.OrderFactory.LimitOrder(_spy, 10, 97))); + + // an exercise is not a working order, it can't be part of a set of contingent orders in any role + var exercise = _algorithm.OrderFactory.ExerciseOption(Symbols.SPY_C_192_Feb19_2016, 1); + Assert.Throws(() => _algorithm.Order(_algorithm.OrderFactory.LimitOrder(_spy, 10, 99).Triggers(exercise))); + Assert.Throws(() => _algorithm.Order(_algorithm.OrderFactory.ExerciseOption(Symbols.SPY_C_192_Feb19_2016, 1).Triggers(_algorithm.OrderFactory.MarketOrder(_spy, 1)))); + Assert.Throws(() => _algorithm.OneCancelsOtherOrder(new List { _algorithm.OrderFactory.ExerciseOption(Symbols.SPY_C_192_Feb19_2016, 1), _algorithm.OrderFactory.LimitOrder(_spy, 10, 99) })); + } + + [Test] + public void NothingIsSubmittedIfAnyOrderFailsPreOrderChecks() + { + var processor = new FakeOrderProcessor(); + _algorithm.Transactions.SetOrderProcessor(processor); + + // the stop loss has zero quantity + var invalid = _algorithm.OrderFactory.StopMarketOrder(_spy, 0, 90); + var entry = _algorithm.OrderFactory.LimitOrder(_spy, 10, 99).Triggers(_algorithm.OrderFactory.OneCancelsOther(_algorithm.OrderFactory.LimitOrder(_spy, -10, 110), invalid)); + + var tickets = _algorithm.Order(entry); + + var ticket = tickets.Single(); + Assert.AreEqual(OrderStatus.Invalid, ticket.Status); + Assert.AreEqual(OrderResponseErrorCode.OrderQuantityZero, ticket.SubmitRequest.Response.ErrorCode); + Assert.IsEmpty(processor.ProcessedOrdersRequests); + Assert.IsFalse(entry.OrderId > 0); + Assert.IsNull(Ticket(entry)); + } + + [Test] + public void BracketBuildsTheExits() + { + var entry = _algorithm.OrderFactory.LimitOrder(_spy, 10, 99).Bracket(110, 90, stopLossLimitPrice: 89); + + var exits = entry.Contingency.Requests.Skip(1).ToList(); + Assert.AreEqual(2, exits.Count); + var takeProfit = exits[0]; + var stopLoss = exits[1]; + Assert.AreEqual(OrderType.Limit, takeProfit.OrderType); + Assert.AreEqual(-10, takeProfit.Quantity); + Assert.AreEqual(110, takeProfit.LimitPrice); + Assert.AreEqual(OrderType.StopLimit, stopLoss.OrderType); + Assert.AreEqual(-10, stopLoss.Quantity); + Assert.AreEqual(90, stopLoss.StopPrice); + Assert.AreEqual(89, stopLoss.LimitPrice); + // not submitted yet: composed, without a set id + Assert.IsTrue(exits.All(exit => exit.OrderId <= 0 && exit.Contingency.Id == 0 && exit.Time == _algorithm.UtcTime)); + Assert.AreEqual(2, entry.Contingency.Links.Count(link => link.Role == ContingencyRole.Parent) + exits.Count(exit => exit.Contingency.Links[0].Role == ContingencyRole.Child) - 1); + } + + private OrderTicket Ticket(SubmitOrderRequest request) + { + return _algorithm.Transactions.GetOrderTicket(request.OrderId); + } + + private static void AssertBracket(List requests, ContingencyType exitsContingencyType) + { + var contingency = requests[0].Contingency; + Assert.IsNotNull(contingency); + Assert.Greater(contingency.Id, 0); + Assert.AreEqual(3, contingency.Count); + // each order has its own contingency, the set is shared + Assert.IsTrue(requests.All(x => x.Contingency.Id == contingency.Id && ReferenceEquals(x.Contingency.OrderIds, contingency.OrderIds))); + Assert.AreEqual(1, contingency.Symbols.Count); + Assert.AreEqual(2, contingency.Directions.Count); + + var parent = requests[0].Contingency.Links.Single(); + Assert.AreEqual(ContingencyType.OneTriggersOther, parent.Type); + Assert.AreEqual(ContingencyRole.Parent, parent.Role); + + foreach (var request in requests.Skip(1)) + { + Assert.AreEqual(2, request.Contingency.Links.Count); + var child = request.Contingency.Links.Single(x => x.Role == ContingencyRole.Child); + Assert.AreEqual(parent.Id, child.Id); + Assert.IsFalse(child.Triggered); + var member = request.Contingency.Links.Single(x => x.Role == null); + Assert.AreEqual(exitsContingencyType, member.Type); + Assert.AreNotEqual(parent.Id, member.Id); + } + Assert.AreEqual(requests[1].Contingency.Links.Single(x => x.Role == null).Id, + requests[2].Contingency.Links.Single(x => x.Role == null).Id); + } + + private static void AssertContingencies(SubmitOrderRequest request, params (int Id, ContingencyType Type, ContingencyRole? Role)[] expected) + { + CollectionAssert.AreEqual(expected, request.Contingency.Links.Select(link => (link.Id, link.Type, link.Role))); + } + + private Symbol AddEquity(string ticker, decimal price) + { + var security = _algorithm.AddEquity(ticker); + security.SetMarketPrice(new TradeBar(_algorithm.Time, security.Symbol, price, price, price, price, 100)); + return security.Symbol; + } + } +} diff --git a/Tests/Brokerages/BrokerageTests.cs b/Tests/Brokerages/BrokerageTests.cs index 804279d993e2..c270d190d2db 100644 --- a/Tests/Brokerages/BrokerageTests.cs +++ b/Tests/Brokerages/BrokerageTests.cs @@ -131,10 +131,23 @@ private IBrokerage InitializeBrokerage() } brokerage.OrdersStatusChanged += HandleEvents; brokerage.OrderIdChanged += HandleOrderIdChangedEvents; + brokerage.OrderUpdated += HandleOrderUpdatedEvents; return brokerage; } + /// + /// Applies the brokerage updates of the orders the way the transaction handler does: contingent orders held waiting + /// for their parent are released + /// + private void HandleOrderUpdatedEvents(object _, OrderUpdateEvent orderUpdateEvent) + { + if (orderUpdateEvent.ContingencyTriggered && OrderProvider.GetOrderById(orderUpdateEvent.OrderId)?.GetContingencyLink(ContingencyRole.Child) is { } child) + { + child.Triggered = true; + } + } + /// /// Handles the event triggered when a brokerage order ID has changed. /// Logs the event and forwards it to the order provider for further processing. @@ -234,6 +247,7 @@ protected virtual void DisposeBrokerage(IBrokerage brokerage) { brokerage.OrdersStatusChanged -= HandleEvents; brokerage.OrderIdChanged -= HandleOrderIdChangedEvents; + brokerage.OrderUpdated -= HandleOrderUpdatedEvents; brokerage.Disconnect(); brokerage.DisposeSafely(); } @@ -550,6 +564,112 @@ public virtual void LongFromZeroUpdateAndCancel(OrderTestParameters parameters, Brokerage.OrdersStatusChanged -= brokerageOnOrdersStatusChanged; } + /// + /// Places a set of resting contingent orders: all of them are working, the ones triggered by another held. + /// Canceling the first order cancels the orders it triggers too. Whether the rest of its group is canceled depends on the brokerage + /// + public virtual void ContingentOrdersCancel(ContingentOrderTestParameters parameters) + { + var orders = PlaceOrderWaitForStatus(parameters.CreateOrders(GetDefaultQuantity()), OrderStatus.Submitted); + Assert.IsTrue(orders.All(order => order.GetContingencyLink(ContingencyRole.Child) == null || order.IsWaitingForTrigger()), "The triggered orders should be held"); + + var first = orders.First(); + var canceledOrders = first.GetContingentDescendants(orders).Append(first).ToList(); + Assert.IsTrue(Brokerage.CancelOrder(first), $"Brokerage failed to cancel the order: {first}"); + WaitForOrders(() => canceledOrders.All(order => order.Status == OrderStatus.Canceled), "the order and the orders it triggers canceled"); + } + + /// + /// Places a set of resting contingent orders and updates each of them unchanged, the held ones included: the brokerage accepts the updates + /// + public virtual void ContingentOrdersUpdate(ContingentOrderTestParameters parameters) + { + var orders = PlaceOrderWaitForStatus(parameters.CreateOrders(GetDefaultQuantity()), OrderStatus.Submitted); + + var updatedOrderIds = new HashSet(); + EventHandler> onOrdersStatusChanged = (_, orderEvents) => + { + lock (updatedOrderIds) + { + updatedOrderIds.UnionWith(orderEvents.Where(orderEvent => orderEvent.Status == OrderStatus.UpdateSubmitted).Select(orderEvent => orderEvent.OrderId)); + } + }; + Brokerage.OrdersStatusChanged += onOrdersStatusChanged; + try + { + foreach (var order in orders) + { + Assert.IsTrue(Brokerage.UpdateOrder(order), $"Brokerage failed to update the order: {order}"); + } + WaitForOrders(() => + { + lock (updatedOrderIds) + { + return orders.All(order => updatedOrderIds.Contains(order.Id)); + } + }, "all the updates submitted"); + } + finally + { + Brokerage.OrdersStatusChanged -= onOrdersStatusChanged; + } + Assert.IsTrue(orders.All(order => order.Status != OrderStatus.Invalid), "No update should be rejected"); + } + + /// + /// Places a set of resting contingent orders: the brokerage open orders are rebuilt with the same contingencies, + /// like when an algorithm is deployed with existing open orders + /// + public virtual void ContingentOrdersGetOpenOrders(ContingentOrderTestParameters parameters) + { + var orders = PlaceOrderWaitForStatus(parameters.CreateOrders(GetDefaultQuantity()), OrderStatus.Submitted); + + var openOrders = Brokerage.GetOpenOrders(); + // the legs of a combo order share the brokerage id + var rebuiltOrders = orders.Select(order => openOrders.SingleOrDefault(openOrder => openOrder.BrokerId.Contains(order.BrokerId[0]) && openOrder.Symbol == order.Symbol)).ToList(); + Assert.IsTrue(rebuiltOrders.All(order => order?.Contingency != null), + $"Every order should be rebuilt with its contingency: [{string.Join(", ", rebuiltOrders.Select(order => order == null ? "missing" : $"{order}: {order.Contingency}"))}]"); + Assert.IsTrue(rebuiltOrders.All(order => ReferenceEquals(order.Contingency.OrderIds, rebuiltOrders[0].Contingency.OrderIds) && order.Contingency.Count == orders.Count), + "The rebuilt orders should share a single set"); + + foreach (var (order, rebuiltOrder) in orders.Zip(rebuiltOrders)) + { + CollectionAssert.AreEquivalent(order.Contingency.Links.Select(link => (link.Type, link.Role)), rebuiltOrder.Contingency.Links.Select(link => (link.Type, link.Role)), + $"The rebuilt links of {order}"); + Assert.AreEqual(order.IsWaitingForTrigger(), rebuiltOrder.IsWaitingForTrigger(), $"The rebuilt order should be held as {order}"); + } + } + + /// + /// Places a set of contingent orders where the first order fills right away, like a market entry: + /// the orders it triggers are released and working + /// + public virtual void ContingentOrdersTrigger(ContingentOrderTestParameters parameters) + { + var orders = parameters.CreateOrders(GetDefaultQuantity()); + foreach (var order in orders) + { + OrderProvider.Add(order); + Assert.IsTrue(Brokerage.PlaceOrder(order), $"Brokerage failed to place the order: {order}"); + } + WaitForOrders(() => orders[0].Status == OrderStatus.Filled + && orders[0].GetContingentChildren(orders).All(child => !child.IsWaitingForTrigger() && child.Status is OrderStatus.Submitted or OrderStatus.UpdateSubmitted), + "the first order filled and the orders it triggers working"); + } + + /// + /// Waits until the given condition on the orders, kept up to date through the brokerage events, is met + /// + protected static void WaitForOrders(Func condition, string description, double secondsTimeout = 30) + { + var stopwatch = Stopwatch.StartNew(); + while (!condition() && stopwatch.Elapsed.TotalSeconds < secondsTimeout) + { + Thread.Sleep(100); + } + Assert.IsTrue(condition(), $"Timed out waiting for {description}"); + } + [Test] public virtual void GetCashBalanceContainsSomething() { diff --git a/Tests/Brokerages/ContingentOrderTestParameters.cs b/Tests/Brokerages/ContingentOrderTestParameters.cs new file mode 100644 index 000000000000..52af367b8b2d --- /dev/null +++ b/Tests/Brokerages/ContingentOrderTestParameters.cs @@ -0,0 +1,111 @@ +/* + * 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.Orders; +using System.Collections.Generic; + +namespace QuantConnect.Tests.Brokerages +{ + /// + /// A set of contingent orders (OCO, OUO, OTO, brackets and their compositions) for the brokerage tests, made of the orders + /// of other test parameters so any order type, symbol and shape can be tested + /// + public class ContingentOrderTestParameters + { + private readonly string _name; + private readonly Func> _createOrders; + + /// + /// Creates a new instance + /// + /// The name of the test case + /// Creates the related orders of the set for the given quantity, parents before the orders they trigger + public ContingentOrderTestParameters(string name, Func> createOrders) + { + _name = name; + _createOrders = createOrders; + } + + /// + /// Creates the orders of the set, parents before the orders they trigger + /// + public List CreateOrders(decimal quantity) + { + return _createOrders(quantity); + } + + /// + /// Long orders where the first one to fill cancels the rest + /// + public static ContingentOrderTestParameters OneCancelsOther(params OrderTestParameters[] members) + { + return Related(ContingencyType.OneCancelsOther, members); + } + + /// + /// Long orders where a fill of one reduces the rest proportionally + /// + public static ContingentOrderTestParameters OneUpdatesOther(params OrderTestParameters[] members) + { + return Related(ContingencyType.OneUpdatesOther, members); + } + + /// + /// A long order which once filled triggers the short orders, held until then + /// + public static ContingentOrderTestParameters OneTriggersOther(OrderTestParameters parent, params OrderTestParameters[] children) + { + return new($"{ContingencyType.OneTriggersOther} {parent} -> [{string.Join(", ", children.Select(x => x))}]", quantity => + { + var parentOrder = parent.CreateLongOrder(quantity); + var childOrders = children.Select(child => child.CreateShortOrder(quantity)).ToList(); + OrderContingency.Trigger([parentOrder], childOrders); + return [parentOrder, .. childOrders]; + }); + } + + /// + /// A long entry which once filled triggers a short take profit and a short stop loss, where the first one to fill cancels the other + /// + public static ContingentOrderTestParameters Bracket(OrderTestParameters entry, OrderTestParameters takeProfit, OrderTestParameters stopLoss) + { + return new($"Bracket {entry} -> [{takeProfit}, {stopLoss}]", quantity => + { + var entryOrder = entry.CreateLongOrder(quantity); + var exits = new List { takeProfit.CreateShortOrder(quantity), stopLoss.CreateShortOrder(quantity) }; + OrderContingency.Trigger([entryOrder], exits); + OrderContingency.Relate(ContingencyType.OneCancelsOther, exits); + return [entryOrder, .. exits]; + }); + } + + private static ContingentOrderTestParameters Related(ContingencyType type, OrderTestParameters[] members) + { + return new($"{type} [{string.Join(", ", members.Select(x => x))}]", quantity => + { + var orders = members.Select(member => member.CreateLongOrder(quantity)).ToList(); + OrderContingency.Relate(type, orders); + return orders; + }); + } + + public override string ToString() + { + return _name; + } + } +} diff --git a/Tests/Common/Brokerages/ContingentOrdersBrokerageModelTests.cs b/Tests/Common/Brokerages/ContingentOrdersBrokerageModelTests.cs new file mode 100644 index 000000000000..c56bf5f75c36 --- /dev/null +++ b/Tests/Common/Brokerages/ContingentOrdersBrokerageModelTests.cs @@ -0,0 +1,290 @@ +/* + * 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 NUnit.Framework; +using QuantConnect.Algorithm; +using QuantConnect.Brokerages; +using QuantConnect.Data.Market; +using QuantConnect.Orders; +using QuantConnect.Securities; +using QuantConnect.Tests.Brokerages; + +namespace QuantConnect.Tests.Common.Brokerages +{ + [TestFixture] + public class ContingentOrdersBrokerageModelTests + { + private static readonly DateTime Time = new DateTime(2024, 1, 3, 15, 0, 0); + private static readonly OrderFactory Factory = new QCAlgorithm().OrderFactory; + + private static IEnumerable NotSupportedBrokerageModels() + { + foreach (var type in typeof(DefaultBrokerageModel).Assembly.GetTypes()) + { + if (type.IsAbstract || !typeof(DefaultBrokerageModel).IsAssignableFrom(type) || SupportedBrokerageModels.Contains(type)) + { + continue; + } + + var constructor = type.GetConstructors().FirstOrDefault(c => c.GetParameters().All(p => p.IsOptional)); + if (constructor != null) + { + yield return (IBrokerageModel)constructor.Invoke(constructor.GetParameters().Select(p => p.DefaultValue).ToArray()); + } + } + } + + private static readonly HashSet SupportedBrokerageModels = new() + { + typeof(DefaultBrokerageModel), + typeof(AlphaStreamsBrokerageModel), + typeof(InteractiveBrokersBrokerageModel), + typeof(CharlesSchwabBrokerageModel), + typeof(TradeStationBrokerageModel), + typeof(AlpacaBrokerageModel), + typeof(BinanceBrokerageModel), + typeof(BinanceFuturesBrokerageModel), + typeof(BinanceCoinFuturesBrokerageModel) + }; + + [TestCaseSource(nameof(NotSupportedBrokerageModels))] + public void BrokerageModelsReject(IBrokerageModel model) + { + var bracket = CreateBracket(Symbols.SPY); + foreach (var order in bracket) + { + Assert.IsFalse(model.CanSubmitOrder(GetSecurity(order.Symbol), order, out var message), model.GetType().Name); + StringAssert.Contains("does not support contingent orders", message.Message); + } + } + + [Test] + public void DefaultBrokerageModelSupportsEverything() + { + var model = new DefaultBrokerageModel(); + foreach (var order in CreateBracket(Symbols.SPY, ContingencyType.OneUpdatesOther).Concat(CreateChain()).Concat(CreateComboOneCancelsOther())) + { + Assert.IsTrue(model.CanSubmitOrder(GetSecurity(order.Symbol), order, out _)); + } + } + + [Test] + public void InteractiveBrokersSupportsEverything() + { + var model = new InteractiveBrokersBrokerageModel(); + foreach (var order in CreateBracket(Symbols.SPY, ContingencyType.OneUpdatesOther).Concat(CreateChain()).Concat(CreateOneCancelsOther(Symbols.SPY, Symbols.AAPL))) + { + Assert.IsTrue(model.CanSubmitOrder(GetSecurity(order.Symbol), order, out var message), message?.Message); + } + + // but not through FIX + var fixModel = new InteractiveBrokersFixModel(); + Assert.IsFalse(fixModel.CanSubmitOrder(GetSecurity(Symbols.SPY), CreateBracket(Symbols.SPY)[0], out _)); + } + + [TestCase(OrderType.Limit, true)] + [TestCase(OrderType.StopLimit, true)] + [TestCase(OrderType.Market, false)] + [TestCase(OrderType.StopMarket, false)] + public void InteractiveBrokersTrailingStopHasToBeTriggeredByALimitOrder(OrderType parentType, bool expected) + { + var parent = parentType switch + { + OrderType.Limit => Factory.LimitOrder(Symbols.SPY, 1, 100), + OrderType.StopLimit => Factory.StopLimitOrder(Symbols.SPY, 1, 100, 101), + OrderType.Market => Factory.MarketOrder(Symbols.SPY, 1), + _ => Factory.StopMarketOrder(Symbols.SPY, 1, 100) + }; + parent.Triggers(Factory.OneCancelsOther(Factory.LimitOrder(Symbols.SPY, -1, 110), Factory.TrailingStopOrder(Symbols.SPY, -1, 0.02m, true))); + + AssertCanSubmit(new InteractiveBrokersBrokerageModel(), ToOrders(parent), expected, "a trailing stop order can only be triggered by a limit or stop limit order"); + } + + [Test] + public void CharlesSchwab() + { + var model = new CharlesSchwabBrokerageModel(); + AssertCanSubmit(model, CreateBracket(Symbols.SPY), true); + AssertCanSubmit(model, CreateChain(), true); + AssertCanSubmit(model, CreateOneCancelsOther(Symbols.SPY, Symbols.AAPL), true); + AssertCanSubmit(model, CreateBracket(Symbols.SPY, ContingencyType.OneUpdatesOther), false, "OneUpdatesOther"); + + // can't be updated + var order = CreateBracket(Symbols.SPY)[1]; + Assert.IsFalse(model.CanUpdateOrder(GetSecurity(order.Symbol), order, new UpdateOrderRequest(Time, order.Id, new UpdateOrderFields { LimitPrice = 1 }), out var message)); + StringAssert.Contains("does not support updating contingent orders", message.Message); + var plainOrder = new LimitOrder(Symbols.SPY, 1, 1, Time); + Assert.IsTrue(model.CanUpdateOrder(GetSecurity(order.Symbol), plainOrder, new UpdateOrderRequest(Time, order.Id, new UpdateOrderFields { LimitPrice = 1 }), out _)); + } + + [Test] + public void TradeStation() + { + var model = new TradeStationBrokerageModel(); + AssertCanSubmit(model, CreateBracket(Symbols.SPY), true); + AssertCanSubmit(model, CreateBracket(Symbols.SPY, ContingencyType.OneUpdatesOther), true); + AssertCanSubmit(model, CreateOneCancelsOther(Symbols.SPY, Symbols.AAPL), true); + AssertCanSubmit(model, CreateOneCancelsOther(Symbols.SPY, Symbols.AAPL, ContingencyType.OneUpdatesOther), false, "same symbol"); + AssertCanSubmit(model, ToOrders(Factory.OneUpdatesOther(Factory.LimitOrder(Symbols.SPY, -1, 110), Factory.LimitOrder(Symbols.SPY, -1, 111))[0]), + false, "require a stop order"); + AssertCanSubmit(model, CreateChain(), false, "can not trigger other orders in turn"); + } + + [Test] + public void Alpaca() + { + var model = new AlpacaBrokerageModel(); + // bracket, oto & oco + AssertCanSubmit(model, CreateBracket(Symbols.SPY), true); + AssertCanSubmit(model, CreateOneTriggersOther(Symbols.SPY), true); + AssertCanSubmit(model, CreateOneCancelsOther(Symbols.SPY, Symbols.SPY), true); + + AssertCanSubmit(model, CreateBracket(Symbols.SPY, ContingencyType.OneUpdatesOther), false, "OneUpdatesOther"); + AssertCanSubmit(model, CreateOneCancelsOther(Symbols.SPY, Symbols.AAPL), false, "same symbol"); + AssertCanSubmit(model, CreateChain(), false, "can not trigger other orders in turn"); + AssertCanSubmit(model, CreateBracket(Symbols.BTCUSD), false, "only equities"); + + // more than 3 orders + var entry = Factory.LimitOrder(Symbols.SPY, 1, 100).Bracket(110, 90).Triggers(Factory.LimitOrder(Symbols.SPY, -1, 120)); + AssertCanSubmit(model, ToOrders(entry), false, "maximum number of orders"); + + // 3 members, not a bracket + var members = Factory.OneCancelsOther(Factory.LimitOrder(Symbols.SPY, -1, 110), Factory.StopMarketOrder(Symbols.SPY, -1, 90), Factory.LimitOrder(Symbols.SPY, -1, 120)); + AssertCanSubmit(model, ToOrders(members[0]), false, "only supported as a bracket"); + + // two limits + members = Factory.OneCancelsOther(Factory.LimitOrder(Symbols.SPY, -1, 110), Factory.LimitOrder(Symbols.SPY, -1, 120)); + AssertCanSubmit(model, ToOrders(members[0]), false, "requires a limit order (take profit) and a stop"); + + // different sides + members = Factory.OneCancelsOther(Factory.LimitOrder(Symbols.SPY, -1, 110), Factory.StopMarketOrder(Symbols.SPY, 1, 90)); + AssertCanSubmit(model, ToOrders(members[0]), false, "same side"); + + // market exit + entry = Factory.LimitOrder(Symbols.SPY, 1, 100).Triggers(Factory.MarketOrder(Symbols.SPY, -1)); + AssertCanSubmit(model, ToOrders(entry), false, "exit orders have to be"); + + // quantity can't be updated + var exit = CreateBracket(Symbols.SPY)[1]; + Assert.IsTrue(model.CanUpdateOrder(GetSecurity(Symbols.SPY), exit, new UpdateOrderRequest(Time, exit.Id, new UpdateOrderFields { LimitPrice = 1 }), out _)); + Assert.IsFalse(model.CanUpdateOrder(GetSecurity(Symbols.SPY), exit, new UpdateOrderRequest(Time, exit.Id, new UpdateOrderFields { Quantity = -5 }), out var message)); + StringAssert.Contains("updating the quantity of contingent orders", message.Message); + } + + [Test] + public void Binance() + { + var model = new BinanceBrokerageModel(); + var symbol = Symbol.Create("BTCUSDT", SecurityType.Crypto, Market.Binance); + var future = Symbol.Create("BTCUSDT", SecurityType.CryptoFuture, Market.Binance); + + // OTOCO, OTO & OCO. Stop market is not supported by binance spot + AssertCanSubmit(model, CreateBracket(symbol, stopLimit: true), true); + AssertCanSubmit(model, CreateOneTriggersOther(symbol), true); + AssertCanSubmit(model, CreateOneCancelsOther(symbol, symbol, stopLimit: true), true); + + AssertCanSubmit(model, CreateBracket(symbol, ContingencyType.OneUpdatesOther, stopLimit: true), false, "OneUpdatesOther"); + AssertCanSubmit(model, CreateBracket(future, stopLimit: true), false, "only spot crypto"); + + // the working order has to be a limit order + var parent = Factory.MarketOrder(symbol, 1).Triggers(Factory.LimitOrder(symbol, -1, 110000)); + Assert.IsFalse(model.CanSubmitOrder(GetSecurity(symbol), ToOrders(parent)[0], out var message)); + StringAssert.Contains("has to be a single limit order", message.Message); + } + + private static void AssertCanSubmit(IBrokerageModel model, List orders, bool expected, string expectedMessage = null) + { + var results = orders.Select(order => + { + var result = model.CanSubmitOrder(GetSecurity(order.Symbol), order, out var message); + return (result, message); + }).ToList(); + + if (expected) + { + Assert.IsTrue(results.All(x => x.result), $"{model.GetType().Name}: {results.FirstOrDefault(x => !x.result).message?.Message}"); + } + else + { + var failed = results.Where(x => !x.result).ToList(); + Assert.IsNotEmpty(failed, model.GetType().Name); + Assert.IsTrue(failed.Any(x => x.message.Message.Contains(expectedMessage, StringComparison.InvariantCulture)), failed[0].message.Message); + } + } + + /// + /// The orders of the whole set of contingent orders the request belongs to, as the brokerage model gets them + /// + private static List ToOrders(SubmitOrderRequest request) + { + var orders = new List(); + foreach (var member in request.Contingency.Requests) + { + member.SetOrderId(orders.Count + 1); + orders.Add(Order.CreateOrder(member)); + } + return orders; + } + + private static List CreateBracket(Symbol symbol, ContingencyType exitsContingencyType = ContingencyType.OneCancelsOther, bool stopLimit = false) + { + var entry = Factory.LimitOrder(symbol, 1, stopLimit ? 100000 : 100) + .Bracket(stopLimit ? 110000 : 110, stopLimit ? 90000 : 90, stopLimit ? 89000 : null, exitsContingencyType); + return ToOrders(entry); + } + + private static List CreateOneTriggersOther(Symbol symbol) + { + return ToOrders(Factory.LimitOrder(symbol, 1, 100).Triggers(Factory.LimitOrder(symbol, -1, 110))); + } + + private static List CreateOneCancelsOther(Symbol first, Symbol second, ContingencyType type = ContingencyType.OneCancelsOther, bool stopLimit = false) + { + var takeProfit = Factory.LimitOrder(first, -1, stopLimit ? 110000 : 110); + var stopLoss = stopLimit ? Factory.StopLimitOrder(second, -1, 90000, 89000) : Factory.StopMarketOrder(second, -1, 90); + var members = type == ContingencyType.OneUpdatesOther ? Factory.OneUpdatesOther(takeProfit, stopLoss) : Factory.OneCancelsOther(takeProfit, stopLoss); + return ToOrders(members[0]); + } + + /// + /// An order which triggers another which triggers another in turn + /// + private static List CreateChain() + { + var last = Factory.LimitOrder(Symbols.SPY, 1, 100); + var middle = Factory.LimitOrder(Symbols.SPY, -1, 110).Triggers(last); + return ToOrders(Factory.LimitOrder(Symbols.SPY, 1, 100).Triggers(middle)); + } + + private static List CreateComboOneCancelsOther() + { + var combo = Factory.ComboMarketOrder(new List { Leg.Create(Symbols.SPY, 1) }, 1); + return ToOrders(Factory.OneCancelsOther(combo.Concat(new[] { Factory.LimitOrder(Symbols.SPY, 1, 100) }))[0]); + } + + private static Security GetSecurity(Symbol symbol) + { + var isCrypto = symbol.SecurityType == SecurityType.Crypto || symbol.SecurityType == SecurityType.CryptoFuture; + var security = TestsHelpers.GetSecurity(symbol: symbol.Value, securityType: symbol.SecurityType, market: symbol.ID.Market, + quoteCurrency: symbol.Value.EndsWith("USDT", StringComparison.InvariantCulture) ? "USDT" : "USD"); + var price = isCrypto ? 100000 : 100; + security.SetMarketPrice(new Tick(Time, symbol, price, price)); + return security; + } + } +} diff --git a/Tests/Common/Orders/ContingentOrderProcessorTests.cs b/Tests/Common/Orders/ContingentOrderProcessorTests.cs new file mode 100644 index 000000000000..7a6f5b71db5e --- /dev/null +++ b/Tests/Common/Orders/ContingentOrderProcessorTests.cs @@ -0,0 +1,335 @@ +/* + * 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 NUnit.Framework; +using QuantConnect.Brokerages.Backtesting; +using QuantConnect.Data; +using QuantConnect.Data.Market; +using QuantConnect.Orders; +using QuantConnect.Orders.Fees; +using QuantConnect.Securities; + +namespace QuantConnect.Tests.Common.Orders +{ + [TestFixture] + public class ContingentOrderProcessorTests + { + private static readonly DateTime Time = new DateTime(2024, 1, 2, 15, 0, 0); + private Dictionary _orders; + private Dictionary _filledQuantity; + private Dictionary _securities; + private ContingentOrderProcessor _processor; + + [SetUp] + public void SetUp() + { + _orders = new(); + _filledQuantity = new(); + _securities = new(); + _processor = new ContingentOrderProcessor(id => _filledQuantity.GetValueOrDefault(id), new TestSecurityProvider(_securities)); + } + + [Test] + public void IgnoresNonContingentOrders() + { + var order = Add(new MarketOrder(Symbols.SPY, 1, Time) { Id = 1 }); + Assert.IsNull(Process(new[] { Fill(order) })); + Assert.IsTrue(IsWorking(order, Time)); + } + + [Test] + public void ParentFillTriggersItsChildren() + { + var bracket = Add(ContingentOrderTests.CreateBracket()); + + var actions = Process(new[] { Fill(bracket[0]) }); + + CollectionAssert.AreEqual(new[] { bracket[1], bracket[2] }, actions.ToTrigger); + Assert.IsEmpty(actions.ToCancel); + Assert.IsEmpty(actions.ToUpdateQuantity); + } + + [Test] + public void ParentPartialFillDoesNotTriggerItsChildren() + { + var bracket = Add(ContingentOrderTests.CreateBracket()); + Assert.IsNull(Process(new[] { Fill(bracket[0], 40) })); + } + + [TestCase(OrderStatus.Canceled)] + [TestCase(OrderStatus.Invalid)] + public void ParentClosedCancelsItsHeldChildren(OrderStatus status) + { + var bracket = Add(ContingentOrderTests.CreateBracket()); + bracket[0].Status = status; + + var actions = Process(new[] { new OrderEvent(bracket[0], Time, OrderFee.Zero) { Status = status } }); + + CollectionAssert.AreEqual(new[] { bracket[1], bracket[2] }, actions.ToCancel.Select(x => x.Key)); + Assert.IsTrue(actions.ToCancel.All(x => x.Value.Contains($"Contingent parent order 1 was {status.ToString().ToLowerInvariant()}", StringComparison.InvariantCulture))); + Assert.IsEmpty(actions.ToTrigger); + } + + [Test] + public void AlreadyClosedChildrenAreIgnored() + { + var bracket = Add(ContingentOrderTests.CreateBracket()); + bracket[1].Status = OrderStatus.Canceled; + + var actions = Process(new[] { Fill(bracket[0]) }); + CollectionAssert.AreEqual(new[] { bracket[2] }, actions.ToTrigger); + } + + [TestCase(true)] + [TestCase(false)] + public void OneCancelsOtherFillCancelsSiblings(bool partialFill) + { + var bracket = Add(ContingentOrderTests.CreateBracket()); + + var actions = Process(new[] { Fill(bracket[1], partialFill ? -40 : null) }); + + CollectionAssert.AreEqual(new[] { bracket[2] }, actions.ToCancel.Select(x => x.Key)); + StringAssert.Contains("Contingent sibling order 2 was filled", actions.ToCancel[0].Value); + Assert.IsEmpty(actions.ToTrigger); + Assert.IsEmpty(actions.ToUpdateQuantity); + } + + [TestCase(OrderStatus.Canceled)] + [TestCase(OrderStatus.Invalid)] + public void ClosedSiblingCancelsTheRest(OrderStatus status) + { + // the contingency is canceled as a whole, like brokerages do, whether the members are held or working + var bracket = Add(ContingentOrderTests.CreateBracket()); + bracket[1].Status = status; + + var actions = Process(new[] { new OrderEvent(bracket[1], Time, OrderFee.Zero) { Status = status } }); + + CollectionAssert.AreEqual(new[] { bracket[2] }, actions.ToCancel.Select(x => x.Key)); + StringAssert.Contains($"Contingent sibling order 2 was {status.ToString().ToLowerInvariant()}", actions.ToCancel[0].Value); + Assert.IsEmpty(actions.ToTrigger); + + // the parent is not affected + Assert.AreEqual(OrderStatus.Submitted, bracket[0].Status); + } + + [Test] + public void OneUpdatesOtherPartialFillReducesSiblingsProportionally() + { + var bracket = Add(ContingentOrderTests.CreateBracket(exitsContingencyType: ContingencyType.OneUpdatesOther)); + // the stop loss has twice the size + bracket[2].Quantity = -200; + + // 40 out of 100 + var actions = Process(new[] { Fill(bracket[1], -40) }); + Assert.IsEmpty(actions.ToCancel); + var update = actions.ToUpdateQuantity.Single(); + Assert.AreSame(bracket[2], update.Key); + Assert.AreEqual(-120, update.Value); + bracket[2].Quantity = update.Value; + + // 40 out of the remaining 60 + actions = Process(new[] { Fill(bracket[1], -40) }); + Assert.AreEqual(-40, actions.ToUpdateQuantity.Single().Value); + + // completely filled: cancels the sibling + actions = Process(new[] { Fill(bracket[1]) }); + Assert.IsEmpty(actions.ToUpdateQuantity); + CollectionAssert.AreEqual(new[] { bracket[2] }, actions.ToCancel.Select(x => x.Key)); + } + + [Test] + public void OneUpdatesOtherTakesSiblingFillsIntoAccount() + { + var bracket = Add(ContingentOrderTests.CreateBracket(exitsContingencyType: ContingencyType.OneUpdatesOther)); + // the stop loss already filled 20, 80 remaining + _filledQuantity[bracket[2].Id] = -20; + + // take profit fills half => the stop loss remaining is halved too: 20 filled + 40 remaining + var actions = Process(new[] { Fill(bracket[1], -50) }); + Assert.AreEqual(-60, actions.ToUpdateQuantity.Single().Value); + } + + [Test] + public void OneUpdatesOtherRespectsLotSize() + { + var lotSize = 0.001m; + CreateSecurity(Symbols.BTCUSD, lotSize); + + var manager = new OrderContingency(1, 2, []); + var first = Add(new LimitOrder(Symbols.BTCUSD, -1m, 100, Time) + { + Contingency = manager.WithLinks([new(1, ContingencyType.OneUpdatesOther)]), Status = OrderStatus.Submitted, Id = 1 + }); + var second = Add(new StopMarketOrder(Symbols.BTCUSD, -1m, 50, Time) + { + Contingency = manager.WithLinks([new(1, ContingencyType.OneUpdatesOther)]), Status = OrderStatus.Submitted, Id = 2 + }); + + var actions = Process(new[] { Fill(first, -1m / 3) }); + + var newQuantity = actions.ToUpdateQuantity.Single().Value; + Assert.AreEqual(0, newQuantity % lotSize); + Assert.AreEqual((double)(-2m / 3), (double)newQuantity, (double)lotSize); + } + + [Test] + public void ComboParentRequiresAllLegsFilled() + { + var manager = new OrderContingency(1, 3, []); + var combo = new GroupOrderManager(1, 2, 1); + var firstLeg = Add(new ComboMarketOrder(Symbols.SPY, 1, Time, combo) + { + Contingency = manager.WithLinks([new(1, ContingencyType.OneTriggersOther, ContingencyRole.Parent)]), Status = OrderStatus.Submitted, Id = 1 + }); + var secondLeg = Add(new ComboMarketOrder(Symbols.AAPL, -1, Time, combo) + { + Contingency = manager.WithLinks([new(1, ContingencyType.OneTriggersOther, ContingencyRole.Parent)]), Status = OrderStatus.Submitted, Id = 2 + }); + var child = Add(new MarketOrder(Symbols.SPY, -1, Time) + { + Contingency = manager.WithLinks([new(1, ContingencyType.OneTriggersOther, ContingencyRole.Child)]), Status = OrderStatus.Submitted, Id = 3 + }); + + // a single leg filled + Assert.IsNull(Process(new[] { Fill(firstLeg) })); + + // both legs filled + var actions = Process(new[] { Fill(firstLeg), Fill(secondLeg) }); + CollectionAssert.AreEqual(new[] { child }, actions.ToTrigger); + } + + [Test] + public void HeldOrdersAreNotWorking() + { + var bracket = Add(ContingentOrderTests.CreateBracket()); + + Assert.IsTrue(IsWorking(bracket[0], Time)); + Assert.IsFalse(IsWorking(bracket[1], Time)); + Assert.IsFalse(IsWorking(bracket[2], Time.AddDays(10))); + } + + [Test] + public void TriggeredOrdersRequireNewDataToBeWorking() + { + var security = CreateSecurity(Symbols.SPY, 1); + var bracket = Add(ContingentOrderTests.CreateBracket()); + + var triggeredTime = Time.AddMinutes(1); + foreach (var order in bracket.Skip(1)) + { + var child = order.GetContingencyLink(ContingencyRole.Child); + child.TriggeredTime = triggeredTime; + child.Triggered = true; + } + + // no data at all + Assert.IsFalse(IsWorking(bracket[1], triggeredTime.AddMinutes(1))); + + // data from before being triggered + var exchangeTimeZone = security.Exchange.TimeZone; + security.SetMarketPrice(new TradeBar(triggeredTime.ConvertFromUtc(exchangeTimeZone).AddMinutes(-1), Symbols.SPY, 100, 100, 100, 100, 1, TimeSpan.FromMinutes(1))); + Assert.IsFalse(IsWorking(bracket[1], triggeredTime.AddMinutes(1))); + + // new data but same time step it was triggered + security.SetMarketPrice(new TradeBar(triggeredTime.ConvertFromUtc(exchangeTimeZone), Symbols.SPY, 100, 100, 100, 100, 1, TimeSpan.FromMinutes(1))); + Assert.IsFalse(IsWorking(bracket[1], triggeredTime)); + + Assert.IsTrue(IsWorking(bracket[1], triggeredTime.AddMinutes(1))); + Assert.IsTrue(IsWorking(bracket[2], triggeredTime.AddMinutes(1))); + } + + [Test] + public void TriggeredMarketOrdersAreWorkingRightAway() + { + var manager = new OrderContingency(1, 1, []); + var order = new MarketOrder(Symbols.SPY, 1, Time) + { + Contingency = manager.WithLinks([new(1, ContingencyType.OneTriggersOther, ContingencyRole.Child, true, Time.AddMinutes(1))]), + Id = 1 + }; + + Assert.IsTrue(IsWorking(order, Time.AddMinutes(1))); + } + + private bool IsWorking(Order order, DateTime utcTime) + { + return BacktestingBrokerage.IsWorking(order, utcTime, new TestSecurityProvider(_securities)); + } + + private Actions Process(IReadOnlyList orderEvents) + { + var (updates, cancels) = _processor.Process(orderEvents, id => _orders.GetValueOrDefault(id), Time); + if (updates == null && cancels == null) + { + return null; + } + return new Actions( + updates?.Where(x => x.ContingencyTriggered).Select(x => _orders[x.OrderId]).ToList() ?? new(), + cancels?.Select(x => KeyValuePair.Create(_orders[x.OrderId], x.Message)).ToList() ?? new(), + updates?.Where(x => x.Quantity.HasValue).Select(x => KeyValuePair.Create(_orders[x.OrderId], x.Quantity.Value)).ToList() ?? new()); + } + + private record Actions(List ToTrigger, List> ToCancel, List> ToUpdateQuantity); + + private OrderEvent Fill(Order order, decimal? partialQuantity = null) + { + var fillQuantity = partialQuantity ?? order.Quantity - _filledQuantity.GetValueOrDefault(order.Id); + _filledQuantity[order.Id] = _filledQuantity.GetValueOrDefault(order.Id) + fillQuantity; + order.Status = partialQuantity.HasValue ? OrderStatus.PartiallyFilled : OrderStatus.Filled; + return new OrderEvent(order, Time, OrderFee.Zero) { Status = order.Status, FillQuantity = fillQuantity, FillPrice = 100 }; + } + + private T Add(T order) where T : Order + { + _orders[order.Id] = order; + return order; + } + + private List Add(List orders) + { + foreach (var order in orders) + { + Add(order); + } + return orders; + } + + private Security CreateSecurity(Symbol symbol, decimal lotSize) + { + var config = new SubscriptionDataConfig(typeof(TradeBar), symbol, Resolution.Minute, TimeZones.NewYork, TimeZones.NewYork, true, true, false); + var security = new Security(SecurityExchangeHours.AlwaysOpen(TimeZones.NewYork), config, new Cash(Currencies.USD, 0, 1m), + new SymbolProperties(symbol.Value, Currencies.USD, 1, 0.01m, lotSize, symbol.Value), ErrorCurrencyConverter.Instance, + RegisteredSecurityDataTypesProvider.Null, new SecurityCache()); + _securities[symbol] = security; + return security; + } + + private class TestSecurityProvider : ISecurityProvider + { + private readonly Dictionary _securities; + public TestSecurityProvider(Dictionary securities) + { + _securities = securities; + } + public Security GetSecurity(Symbol symbol) + { + return _securities.GetValueOrDefault(symbol); + } + } + } +} diff --git a/Tests/Common/Orders/ContingentOrderTests.cs b/Tests/Common/Orders/ContingentOrderTests.cs new file mode 100644 index 000000000000..3daece12ece8 --- /dev/null +++ b/Tests/Common/Orders/ContingentOrderTests.cs @@ -0,0 +1,432 @@ +/* + * 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 System.Threading.Tasks; +using Newtonsoft.Json; +using NUnit.Framework; +using QuantConnect.Orders; + +namespace QuantConnect.Tests.Common.Orders +{ + [TestFixture] + public class ContingentOrderTests + { + private static readonly DateTime Time = new DateTime(2024, 1, 2, 15, 0, 0); + + [TestCase(ContingencyType.OneTriggersOther, ContingencyRole.Parent, true)] + [TestCase(ContingencyType.OneTriggersOther, ContingencyRole.Child, true)] + [TestCase(ContingencyType.OneTriggersOther, null, false)] + [TestCase(ContingencyType.OneCancelsOther, null, true)] + [TestCase(ContingencyType.OneCancelsOther, ContingencyRole.Parent, false)] + [TestCase(ContingencyType.OneCancelsOther, ContingencyRole.Child, false)] + [TestCase(ContingencyType.OneUpdatesOther, null, true)] + [TestCase(ContingencyType.OneUpdatesOther, ContingencyRole.Parent, false)] + public void ValidatesRoleForContingencyType(ContingencyType type, ContingencyRole? role, bool valid) + { + Assert.AreEqual(valid, ContingencyLink.IsValidRole(type, role)); + if (valid) + { + Assert.DoesNotThrow(() => new ContingencyLink(1, type, role)); + } + else + { + Assert.Throws(() => new ContingencyLink(1, type, role)); + } + } + + [Test] + public void OrderRegistersItsIdInTheSet() + { + var set = new OrderContingency(7, 2, []); + var first = new LimitOrder(Symbols.SPY, 10, 100, Time) { Contingency = set.WithLinks(null) }; + Assert.IsEmpty(set.OrderIds); + + first.Id = 3; + CollectionAssert.AreEquivalent(new[] { 3 }, set.OrderIds); + CollectionAssert.AreEquivalent(new[] { 3 }, first.Contingency.OrderIds); + + // the contingency can be set after the id too + var second = new LimitOrder(Symbols.SPY, 10, 100, Time) { Id = 4 }; + second.Contingency = set.WithLinks(null); + CollectionAssert.AreEquivalent(new[] { 3, 4 }, set.OrderIds); + Assert.AreEqual(7, second.Contingency.Id); + Assert.AreEqual(2, second.Contingency.Count); + } + + [Test] + public void OrderIdRegistrationIsThreadSafe() + { + var set = new OrderContingency(1, 500, []); + Parallel.For(1, 501, id => _ = new MarketOrder(Symbols.SPY, 1, Time) { Contingency = set.WithLinks(null), Id = id }); + Assert.AreEqual(500, set.OrderIds.Count); + } + + [Test] + public void CloneSharesTheSetButNotTheLinks() + { + var bracket = CreateBracket(); + var takeProfit = bracket[1]; + + var clone = takeProfit.Clone(); + + Assert.AreNotSame(takeProfit.Contingency, clone.Contingency); + Assert.AreSame(takeProfit.Contingency.OrderIds, clone.Contingency.OrderIds); + Assert.AreEqual(takeProfit.Contingency.Id, clone.Contingency.Id); + Assert.AreNotSame(takeProfit.Contingency.Links, clone.Contingency.Links); + Assert.AreEqual(2, clone.Contingency.Links.Count); + Assert.IsTrue(clone.IsWaitingForTrigger()); + + // the trigger state of the clone is independent + takeProfit.GetContingencyLink(ContingencyRole.Child).Triggered = true; + Assert.IsFalse(takeProfit.IsWaitingForTrigger()); + Assert.IsTrue(clone.IsWaitingForTrigger()); + } + + [Test] + public void CreateOrderFromRequestSetsContingency() + { + var set = new OrderContingency(1, 1, []); + var links = new List { new(1, ContingencyType.OneTriggersOther, ContingencyRole.Child) }; + var request = new SubmitOrderRequest(OrderType.StopMarket, SecurityType.Equity, Symbols.SPY, -10, 90, 0, 0, 0, false, Time, "tag", + contingency: set.WithLinks(links)); + request.SetOrderId(5); + + var order = Order.CreateOrder(request); + + // the set is shared, the links are cloned + Assert.AreSame(set.OrderIds, order.Contingency.OrderIds); + Assert.AreEqual(1, order.Contingency.Id); + Assert.AreNotSame(links[0], order.Contingency.Links.Single()); + Assert.AreEqual(OrderStatus.New, order.Status); + Assert.IsTrue(order.IsContingent()); + Assert.IsTrue(order.IsWaitingForTrigger()); + CollectionAssert.AreEquivalent(new[] { 5 }, set.OrderIds); + } + + [TestCase(OrderStatus.Filled)] + [TestCase(OrderStatus.Canceled)] + [TestCase(OrderStatus.Invalid)] + public void ClosedOrderIsNotWaitingForTrigger(OrderStatus status) + { + var set = new OrderContingency(1, 1, []); + var request = new SubmitOrderRequest(OrderType.StopMarket, SecurityType.Equity, Symbols.SPY, -10, 90, 0, 0, 0, false, Time, "tag", + contingency: set.WithLinks([new(1, ContingencyType.OneTriggersOther, ContingencyRole.Child)])); + request.SetOrderId(5); + var order = Order.CreateOrder(request); + Assert.IsTrue(order.Contingency.IsWaitingForTrigger); + + order.Status = status; + + Assert.IsFalse(order.Contingency.IsWaitingForTrigger); + Assert.IsFalse(order.IsWaitingForTrigger()); + // the link is still not triggered + Assert.IsFalse(order.Contingency.Links.Single().Triggered); + // nor the clone of the closed order + Assert.IsFalse(order.Clone().Contingency.IsWaitingForTrigger); + } + + [Test] + public void NonContingentOrder() + { + var order = new MarketOrder(Symbols.SPY, 1, Time) { Id = 1 }; + + Assert.IsFalse(order.IsContingent()); + Assert.IsFalse(order.IsWaitingForTrigger()); + Assert.IsNull(order.GetTriggeredTime()); + Assert.AreEqual(Time, order.GetWorkingTime()); + Assert.IsTrue(order.TryGetContingentOrders(_ => null, out var orders)); + Assert.AreSame(order, orders.Single()); + Assert.IsEmpty(order.GetContingentChildren(orders)); + Assert.IsEmpty(order.GetContingentSiblings(orders)); + } + + [Test] + public void BracketRelationships() + { + var bracket = CreateBracket(); + var entry = bracket[0]; + var takeProfit = bracket[1]; + var stopLoss = bracket[2]; + + Assert.IsFalse(entry.IsWaitingForTrigger()); + Assert.IsTrue(takeProfit.IsWaitingForTrigger()); + Assert.IsTrue(stopLoss.IsWaitingForTrigger()); + + CollectionAssert.AreEquivalent(new[] { takeProfit, stopLoss }, entry.GetContingentChildren(bracket)); + CollectionAssert.AreEquivalent(new[] { entry }, takeProfit.GetContingentParents(bracket)); + CollectionAssert.AreEquivalent(new[] { stopLoss }, takeProfit.GetContingentSiblings(bracket)); + CollectionAssert.AreEquivalent(new[] { takeProfit }, stopLoss.GetContingentSiblings(bracket)); + Assert.IsEmpty(entry.GetContingentSiblings(bracket)); + Assert.IsTrue(takeProfit.IsContingentSibling(stopLoss)); + Assert.IsFalse(takeProfit.IsContingentSibling(entry)); + Assert.IsFalse(takeProfit.IsContingentSibling(takeProfit)); + + var triggeredTime = Time.AddMinutes(5); + var child = takeProfit.GetContingencyLink(ContingencyRole.Child); + child.TriggeredTime = triggeredTime; + child.Triggered = true; + Assert.IsFalse(takeProfit.IsWaitingForTrigger()); + Assert.AreEqual(triggeredTime, takeProfit.GetTriggeredTime()); + Assert.AreEqual(triggeredTime, takeProfit.GetWorkingTime()); + Assert.AreEqual(Time, entry.GetWorkingTime()); + } + + [Test] + public void TryGetContingentOrdersRequiresAllOrders() + { + var bracket = CreateBracket(); + var orders = bracket.ToDictionary(x => x.Id); + + Assert.IsTrue(bracket[1].TryGetContingentOrders(id => orders.GetValueOrDefault(id), out var result)); + CollectionAssert.AreEqual(bracket, result); + + orders.Remove(bracket[2].Id); + Assert.IsFalse(bracket[1].TryGetContingentOrders(id => orders.GetValueOrDefault(id), out _)); + CollectionAssert.AreEqual(bracket.Take(2), bracket[1].GetExistingContingentOrders(id => orders.GetValueOrDefault(id))); + } + + [Test] + public void TryGetContingentOrdersRequiresTheExpectedCount() + { + // only 2 out of 3 orders have been created yet + var manager = new OrderContingency(1, 3, []); + var first = new MarketOrder(Symbols.SPY, 1, Time) { Contingency = manager.WithLinks(null), Id = 1 }; + var second = new MarketOrder(Symbols.SPY, 1, Time) { Contingency = manager.WithLinks(null), Id = 2 }; + var orders = new Dictionary { { 1, first }, { 2, second } }; + + Assert.IsFalse(first.TryGetContingentOrders(id => orders.GetValueOrDefault(id), out _)); + } + + [Test] + public void DescendantsOfAChain() + { + // 1 triggers 2 which triggers 3 and 4, where 4 triggers 5 + var manager = new OrderContingency(1, 5, []); + var orders = new List + { + CreateOrder(1, manager, new ContingencyLink(1, ContingencyType.OneTriggersOther, ContingencyRole.Parent)), + CreateOrder(2, manager, new ContingencyLink(1, ContingencyType.OneTriggersOther, ContingencyRole.Child), + new ContingencyLink(2, ContingencyType.OneTriggersOther, ContingencyRole.Parent)), + CreateOrder(3, manager, new ContingencyLink(2, ContingencyType.OneTriggersOther, ContingencyRole.Child)), + CreateOrder(4, manager, new ContingencyLink(2, ContingencyType.OneTriggersOther, ContingencyRole.Child), + new ContingencyLink(3, ContingencyType.OneTriggersOther, ContingencyRole.Parent)), + CreateOrder(5, manager, new ContingencyLink(3, ContingencyType.OneTriggersOther, ContingencyRole.Child)), + }; + + CollectionAssert.AreEquivalent(new[] { 2, 3, 4, 5 }, orders[0].GetContingentDescendants(orders).Select(x => x.Id)); + CollectionAssert.AreEquivalent(new[] { 5 }, orders[3].GetContingentDescendants(orders).Select(x => x.Id)); + Assert.IsEmpty(orders[4].GetContingentDescendants(orders)); + } + + [Test] + public void ComboLegsAreNotSiblings() + { + // two combo orders of two legs each, one cancels the other + var manager = new OrderContingency(1, 4, []); + var firstCombo = new GroupOrderManager(1, 2, 1); + var secondCombo = new GroupOrderManager(2, 2, 1); + ContingencyLink Member() => new(1, ContingencyType.OneCancelsOther); + var orders = new List + { + new ComboMarketOrder(Symbols.SPY, 1, Time, firstCombo) { Contingency = manager.WithLinks([Member()]), Id = 1 }, + new ComboMarketOrder(Symbols.AAPL, -1, Time, firstCombo) { Contingency = manager.WithLinks([Member()]), Id = 2 }, + new ComboMarketOrder(Symbols.SPY, -1, Time, secondCombo) { Contingency = manager.WithLinks([Member()]), Id = 3 }, + new ComboMarketOrder(Symbols.AAPL, 1, Time, secondCombo) { Contingency = manager.WithLinks([Member()]), Id = 4 }, + }; + + Assert.IsTrue(orders[0].IsSameGroupOrder(orders[1])); + Assert.IsFalse(orders[0].IsContingentSibling(orders[1])); + CollectionAssert.AreEquivalent(new[] { 3, 4 }, orders[0].GetContingentSiblings(orders).Select(x => x.Id)); + CollectionAssert.AreEquivalent(new[] { 1, 2 }, orders[3].GetContingentSiblings(orders).Select(x => x.Id)); + } + + [Test] + public void RoundTripSerialization() + { + var bracket = CreateBracket(); + var takeProfit = bracket[1]; + var child = takeProfit.GetContingencyLink(ContingencyRole.Child); + child.TriggeredTime = Time.AddMinutes(1); + child.Triggered = true; + + var json = JsonConvert.SerializeObject(takeProfit); + var deserialized = JsonConvert.DeserializeObject(json, new OrderJsonConverter()); + + Assert.AreEqual(OrderType.Limit, deserialized.Type); + Assert.AreEqual(takeProfit.Contingency.Id, deserialized.Contingency.Id); + Assert.AreEqual(3, deserialized.Contingency.Count); + CollectionAssert.AreEquivalent(new[] { 1, 2, 3 }, deserialized.Contingency.OrderIds); + Assert.AreEqual(2, deserialized.Contingency.Links.Count); + + var deserializedChild = deserialized.GetContingencyLink(ContingencyRole.Child); + Assert.AreEqual(child.Id, deserializedChild.Id); + Assert.AreEqual(ContingencyType.OneTriggersOther, deserializedChild.Type); + Assert.IsTrue(deserializedChild.Triggered); + Assert.AreEqual(child.TriggeredTime, deserializedChild.TriggeredTime); + + var deserializedMember = deserialized.GetSiblingLink(); + Assert.AreEqual(ContingencyType.OneCancelsOther, deserializedMember.Type); + Assert.IsFalse(deserializedMember.Triggered); + Assert.IsNull(deserializedMember.TriggeredTime); + + // held orders don't serialize the trigger state + var stopLossJson = JsonConvert.SerializeObject(bracket[2]); + StringAssert.DoesNotContain("triggered", stopLossJson); + Assert.IsTrue(JsonConvert.DeserializeObject(stopLossJson, new OrderJsonConverter()).IsWaitingForTrigger()); + } + + [Test] + public void NonContingentOrdersDoNotSerializeTheContingency() + { + var json = JsonConvert.SerializeObject(new LimitOrder(Symbols.SPY, 10, 100, Time) { Id = 1 }); + + StringAssert.DoesNotContain("contingency", json); + var deserialized = JsonConvert.DeserializeObject(json, new OrderJsonConverter()); + Assert.IsNull(deserialized.Contingency); + } + + [TestCase("'Contingency':{'Id':4,'Count':2,'OrderIds':[8,9],'Links':[{'Id':1,'Type':0}]}", true)] + [TestCase("'contingency':{'id':4,'count':2,'orderIds':[8,9],'links':[{'id':1,'type':0,'role':null}]}", true)] + // resilient: missing or malformed information + [TestCase("'contingency':{'id':4,'count':2,'orderIds':[8,9]}", false)] + [TestCase("'contingency':{'links':[{'id':1,'type':0}]}", false)] + // a role for a one cancels other link is invalid, it's skipped + [TestCase("'contingency':{'id':4,'count':2,'orderIds':[8,9],'links':[{'id':1,'type':0,'role':0}]}", false)] + [TestCase("'contingency':null", false)] + [TestCase("'contingency':5", false)] + [TestCase("'contingency':{'id':4,'count':2,'orderIds':[8,9],'links':'invalid'}", false)] + public void DeserializesDifferentFormats(string contingency, bool expectedContingent) + { + var json = @"{'Type':1,'LimitPrice':100,'Id':8,'Symbol':{'Value':'SPY','ID':'SPY R735QTJ8XC9X','Permtick':'SPY'},'Price':0, +'Time':'2024-01-02T15:00:00Z','Quantity':10,'Status':1,'BrokerId':[],'SecurityType':1," + contingency + "}"; + + var order = JsonConvert.DeserializeObject(json.Replace('\'', '"'), new OrderJsonConverter()); + + Assert.AreEqual(8, order.Id); + Assert.AreEqual(expectedContingent, order.IsContingent()); + if (expectedContingent) + { + Assert.AreEqual(4, order.Contingency.Id); + Assert.AreEqual(2, order.Contingency.Count); + CollectionAssert.AreEquivalent(new[] { 8, 9 }, order.Contingency.OrderIds); + var link = order.Contingency.Links.Single(); + Assert.AreEqual(ContingencyType.OneCancelsOther, link.Type); + Assert.IsNull(link.Role); + } + } + + [Test] + public void DeserializationSkipsInvalidLinks() + { + // an invalid role for the contingency type and a garbage entry + var json = @"{'type':0,'id':8,'symbol':{'value':'SPY','id':'SPY R735QTJ8XC9X','permtick':'SPY'},'price':0, +'time':'2024-01-02T15:00:00Z','quantity':10,'status':1,'brokerId':[],'securityType':1, +'contingency':{'id':4,'count':2,'orderIds':[8,9],'links':[{'id':1,'type':0,'role':1}, 5, {'id':2,'type':1,'role':1,'triggered':true}]}}"; + + var order = JsonConvert.DeserializeObject(json.Replace('\'', '"'), new OrderJsonConverter()); + + var link = order.Contingency.Links.Single(); + Assert.AreEqual(2, link.Id); + Assert.AreEqual(ContingencyRole.Child, link.Role); + Assert.IsTrue(link.Triggered); + } + + [Test] + public void ContingencyDeserializesOnItsOwn() + { + var json = JsonConvert.SerializeObject(CreateBracket()[1].Contingency); + + var contingency = JsonConvert.DeserializeObject(json); + + Assert.AreEqual(1, contingency.Id); + Assert.AreEqual(3, contingency.Count); + CollectionAssert.AreEquivalent(new[] { 1, 2, 3 }, contingency.OrderIds); + Assert.AreEqual(2, contingency.Links.Count); + Assert.AreEqual(ContingencyRole.Child, contingency.Links[0].Role); + Assert.IsNull(contingency.Links[1].Role); + } + + [Test] + public void OrderTicketExposesTheContingency() + { + var set = new OrderContingency(1, 1, []); + var links = new List { new(1, ContingencyType.OneTriggersOther, ContingencyRole.Child) }; + var request = new SubmitOrderRequest(OrderType.Limit, SecurityType.Equity, Symbols.SPY, -10, 0, 110, 0, 0, false, Time, "", + contingency: set.WithLinks(links)); + request.SetOrderId(1); + var ticket = new OrderTicket(null, request); + + // before the order is set it uses the request + Assert.AreSame(request.Contingency, ticket.Contingency); + Assert.IsTrue(ticket.Contingency.IsWaitingForTrigger); + + var order = Order.CreateOrder(request); + ticket.SetOrder(order); + Assert.IsTrue(ticket.Contingency.IsWaitingForTrigger); + + order.GetContingencyLink(ContingencyRole.Child).Triggered = true; + Assert.IsFalse(ticket.Contingency.IsWaitingForTrigger); + + // turns into a ticket and back + var newTicket = order.ToOrderTicket(null); + Assert.AreSame(order.Contingency, newTicket.SubmitRequest.Contingency); + Assert.AreEqual(1, newTicket.SubmitRequest.Contingency.Links.Count); + Assert.IsFalse(newTicket.Contingency.IsWaitingForTrigger); + } + + /// + /// Creates a bracket: an entry which triggers a take profit and a stop loss where one cancels the other + /// + public static List CreateBracket(int managerId = 1, int firstOrderId = 1, ContingencyType exitsContingencyType = ContingencyType.OneCancelsOther, + decimal quantity = 100) + { + var manager = new OrderContingency(managerId, 3, []); + return new List + { + new LimitOrder(Symbols.SPY, quantity, 100, Time) + { + Contingency = manager.WithLinks([new(1, ContingencyType.OneTriggersOther, ContingencyRole.Parent)]), + Status = OrderStatus.Submitted, + Id = firstOrderId + }, + new LimitOrder(Symbols.SPY, -quantity, 110, Time) + { + Contingency = manager.WithLinks([new(1, ContingencyType.OneTriggersOther, ContingencyRole.Child), new(2, exitsContingencyType)]), + Status = OrderStatus.Submitted, + Id = firstOrderId + 1 + }, + new StopMarketOrder(Symbols.SPY, -quantity, 90, Time) + { + Contingency = manager.WithLinks([new(1, ContingencyType.OneTriggersOther, ContingencyRole.Child), new(2, exitsContingencyType)]), + Status = OrderStatus.Submitted, + Id = firstOrderId + 2 + } + }; + } + + private static Order CreateOrder(int id, OrderContingency set, params ContingencyLink[] links) + { + return new MarketOrder(Symbols.SPY, 1, Time) + { + Contingency = set.WithLinks(links), + Status = OrderStatus.Submitted, + Id = id + }; + } + } +} diff --git a/Tests/Engine/BrokerageTransactionHandlerTests/ContingentOrdersTransactionHandlerTests.cs b/Tests/Engine/BrokerageTransactionHandlerTests/ContingentOrdersTransactionHandlerTests.cs new file mode 100644 index 000000000000..25e37f2236a0 --- /dev/null +++ b/Tests/Engine/BrokerageTransactionHandlerTests/ContingentOrdersTransactionHandlerTests.cs @@ -0,0 +1,595 @@ +/* + * 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 NUnit.Framework; +using QuantConnect.Brokerages; +using QuantConnect.Brokerages.Backtesting; +using QuantConnect.Data.Market; +using QuantConnect.Interfaces; +using QuantConnect.Lean.Engine.Results; +using QuantConnect.Lean.Engine.TransactionHandlers; +using QuantConnect.Orders; +using QuantConnect.Orders.Fees; +using QuantConnect.Securities; + +namespace QuantConnect.Tests.Engine.BrokerageTransactionHandlerTests +{ + /// + /// End to end tests of contingent orders through the algorithm api, the transaction handler and the backtesting brokerage + /// + [TestFixture] + public class ContingentOrdersTransactionHandlerTests + { + private BrokerageTransactionHandlerTests.TestAlgorithm _algorithm; + private BrokerageTransactionHandler _transactionHandler; + private BacktestingBrokerage _brokerage; + private Security _security; + private Symbol _symbol; + private DateTime _time; + + [SetUp] + public void SetUp() + { + _time = new DateTime(2024, 1, 3, 15, 0, 0); + _algorithm = new BrokerageTransactionHandlerTests.TestAlgorithm + { + HistoryProvider = new BrokerageTransactionHandlerTests.EmptyHistoryProvider() + }; + _algorithm.SetCash(1000000); + _algorithm.SetDateTime(_time); + _security = _algorithm.AddSecurity(SecurityType.Forex, "EURUSD"); + _symbol = _security.Symbol; + _algorithm.Portfolio.CashBook["EUR"].ConversionRate = 1.1m; + + Initialize(new BacktestingBrokerage(_algorithm)); + SetPrice(1.10m); + } + + [TearDown] + public void TearDown() + { + _transactionHandler?.Exit(); + _brokerage?.Dispose(); + } + + [TestCase(true)] + [TestCase(false)] + public void BracketLifecycle(bool takeProfitFills) + { + var tickets = _algorithm.BracketOrder(_symbol, 1000, takeProfitPrice: 1.12m, stopLossPrice: 1.05m, limitPrice: 1.09m); + var entry = tickets[0]; + var takeProfit = tickets[1]; + var stopLoss = tickets[2]; + + Step(1.10m); + Assert.IsTrue(tickets.All(x => x.Status == OrderStatus.Submitted)); + Assert.IsFalse(entry.Contingency.IsWaitingForTrigger); + Assert.IsTrue(takeProfit.Contingency.IsWaitingForTrigger); + Assert.IsTrue(stopLoss.Contingency.IsWaitingForTrigger); + // held orders are not accounted for + Assert.AreEqual(1000, _algorithm.Transactions.GetOpenOrdersRemainingQuantity(_symbol)); + + // the children are held even if the price goes through their prices + Step(1.13m); + Step(1.10m); + Assert.IsTrue(tickets.All(x => x.Status == OrderStatus.Submitted)); + + // the entry fills, triggering the children + Step(1.08m); + var triggeredTime = _time; + Assert.AreEqual(OrderStatus.Filled, entry.Status); + foreach (var child in new[] { takeProfit, stopLoss }) + { + Assert.AreEqual(OrderStatus.Submitted, child.Status); + Assert.IsFalse(child.Contingency.IsWaitingForTrigger); + Assert.AreEqual(triggeredTime, child.Contingency.Links.Single(x => x.Role == ContingencyRole.Child).TriggeredTime); + } + // at most one will fill + Assert.AreEqual(-1000, _algorithm.Transactions.GetOpenOrdersRemainingQuantity(_symbol)); + Assert.AreEqual(1000, _security.Holdings.Quantity); + + Step(takeProfitFills ? 1.13m : 1.04m); + var filled = takeProfitFills ? takeProfit : stopLoss; + var canceled = takeProfitFills ? stopLoss : takeProfit; + Assert.AreEqual(OrderStatus.Filled, filled.Status); + Assert.AreEqual(OrderStatus.Canceled, canceled.Status); + StringAssert.Contains($"Contingent sibling order {filled.OrderId} was filled", canceled.OrderEvents.Last().Message); + Assert.AreEqual(0, _security.Holdings.Quantity); + Assert.IsEmpty(_algorithm.Transactions.GetOpenOrders()); + + // the cancel event comes right after the fill + var events = _algorithm.OrderEvents; + var fillIndex = events.FindIndex(x => x.OrderId == filled.OrderId && x.Status == OrderStatus.Filled); + Assert.AreEqual(canceled.OrderId, events[fillIndex + 1].OrderId); + Assert.AreEqual(OrderStatus.Canceled, events[fillIndex + 1].Status); + } + + [Test] + public void StopLossFillsFirstWhenBothCouldFill() + { + var tickets = _algorithm.BracketOrder(_symbol, 1000, takeProfitPrice: 1.12m, stopLossPrice: 1.05m, limitPrice: 1.09m); + Step(1.10m); + Step(1.08m); + Assert.AreEqual(OrderStatus.Filled, tickets[0].Status); + Assert.IsFalse(tickets[1].Contingency.IsWaitingForTrigger); + + // a wide bar goes through both the take profit and the stop loss: we can't know which one happened first, we are pessimistic + _time = _time.AddMinutes(1); + _algorithm.SetDateTime(_time); + var bar = new Bar(1.10m, 1.15m, 1.02m, 1.10m); + _security.SetMarketPrice(new QuoteBar(_time.AddMinutes(-1), _symbol, bar, 0, bar, 0, TimeSpan.FromMinutes(1))); + _transactionHandler.ProcessSynchronousEvents(); + + Assert.AreEqual(OrderStatus.Canceled, tickets[1].Status); + Assert.AreEqual(OrderStatus.Filled, tickets[2].Status); + Assert.AreEqual(0, _security.Holdings.Quantity); + } + + [Test] + public void TriggeredOrdersRequireNewDataToFill() + { + // the take profit is marketable: it would fill right away if it was working + var entry = _algorithm.OrderFactory.LimitOrder(_symbol, 1000, 1.09m).Bracket(takeProfitPrice: 1.01m, stopLossPrice: 1m); + var tickets = _algorithm.Order(entry); + var takeProfit = tickets[1]; + + Step(1.10m); + Step(1.10m); + Assert.AreEqual(OrderStatus.Submitted, takeProfit.Status); + + Step(1.08m); + Assert.AreEqual(OrderStatus.Filled, tickets[0].Status); + Assert.AreEqual(OrderStatus.Submitted, takeProfit.Status); + + // scanning again with the same data does not fill it + _transactionHandler.ProcessSynchronousEvents(); + Assert.AreEqual(OrderStatus.Submitted, takeProfit.Status); + + Step(1.08m); + Assert.AreEqual(OrderStatus.Filled, takeProfit.Status); + Assert.AreEqual(OrderStatus.Canceled, tickets[2].Status); + } + + [Test] + public void TriggeredMarketOrdersFillRightAway() + { + var child = _algorithm.OrderFactory.MarketOrder(_symbol, -1000); + var parent = _algorithm.OrderFactory.LimitOrder(_symbol, 1000, 1.09m).Triggers(child); + var tickets = _algorithm.Order(parent); + Assert.IsTrue(parent.OrderId > 0, tickets[0].SubmitRequest.Response.ToString()); + + Step(1.10m); + Assert.AreEqual(OrderStatus.Submitted, Ticket(child).Status); + + Step(1.08m); + Assert.AreEqual(OrderStatus.Filled, Ticket(parent).Status); + Assert.AreEqual(OrderStatus.Filled, Ticket(child).Status); + Assert.AreEqual(0, _security.Holdings.Quantity); + } + + [Test] + public void CancelingTheParentCancelsItsDescendants() + { + var grandChild = _algorithm.OrderFactory.MarketOrder(_symbol, 1000); + var child = _algorithm.OrderFactory.LimitOrder(_symbol, -1000, 1.2m).Triggers(grandChild); + var parent = _algorithm.OrderFactory.LimitOrder(_symbol, 1000, 1.09m).Triggers(child); + var tickets = _algorithm.Order(parent); + Step(1.10m); + + Assert.IsTrue(Ticket(parent).Cancel().IsSuccess); + Step(1.10m); + + Assert.IsTrue(tickets.All(x => x.Status == OrderStatus.Canceled)); + StringAssert.Contains($"Contingent parent order {Ticket(parent).OrderId} was canceled", Ticket(child).OrderEvents.Last().Message); + StringAssert.Contains($"Contingent parent order {Ticket(child).OrderId} was canceled", Ticket(grandChild).OrderEvents.Last().Message); + Assert.IsEmpty(_algorithm.Transactions.GetOpenOrders()); + + // nothing fills anymore + Step(1.05m); + Assert.AreEqual(0, _security.Holdings.Quantity); + } + + [Test] + public void CancelingASiblingCancelsTheOther() + { + // the contingency is canceled as a whole, like brokerages do + var tickets = _algorithm.OneCancelsOtherOrder(new List { _algorithm.OrderFactory.LimitOrder(_symbol, 1000, 1.05m), _algorithm.OrderFactory.LimitOrder(_symbol, 2000, 1.04m) }); + Step(1.10m); + + Assert.IsTrue(tickets[0].Cancel().IsSuccess); + Step(1.10m); + Assert.AreEqual(OrderStatus.Canceled, tickets[0].Status); + Assert.AreEqual(OrderStatus.Canceled, tickets[1].Status); + StringAssert.Contains($"Contingent sibling order {tickets[0].OrderId} was canceled", tickets[1].OrderEvents.Last().Message); + + Step(1.03m); + Assert.AreEqual(0, _security.Holdings.Quantity); + Assert.IsEmpty(_algorithm.Transactions.GetOpenOrders()); + } + + [Test] + public void HeldOrdersCanBeUpdated() + { + var tickets = _algorithm.BracketOrder(_symbol, 1000, takeProfitPrice: 1.12m, stopLossPrice: 1.05m, limitPrice: 1.09m); + Step(1.10m); + + Assert.IsTrue(tickets[1].UpdateLimitPrice(1.2m).IsSuccess); + Assert.IsTrue(tickets[2].UpdateStopPrice(1.0m).IsSuccess); + Step(1.10m); + + Assert.AreEqual(1.2m, tickets[1].Get(OrderField.LimitPrice)); + Assert.AreEqual(1.0m, tickets[2].Get(OrderField.StopPrice)); + Assert.IsTrue(tickets.Skip(1).All(x => x.Status == OrderStatus.UpdateSubmitted && x.Contingency.IsWaitingForTrigger)); + + Step(1.08m); + Assert.AreEqual(OrderStatus.Filled, tickets[0].Status); + // the original prices would of filled, not the updated ones + Step(1.13m); + Step(1.04m); + Assert.IsTrue(tickets.Skip(1).All(x => x.Status == OrderStatus.UpdateSubmitted && !x.Contingency.IsWaitingForTrigger)); + } + + [Test] + public void HeldTrailingStopStartsTrailingOnceTriggered() + { + var trailingStop = _algorithm.OrderFactory.TrailingStopOrder(_symbol, -1000, 0.01m, trailingAsPercentage: false); + _algorithm.Order(_algorithm.OrderFactory.LimitOrder(_symbol, 1000, 1.09m).Triggers(trailingStop)); + Step(1.10m); + Assert.AreEqual(0, Ticket(trailingStop).Get(OrderField.StopPrice)); + + Step(1.08m); + Assert.IsFalse(Ticket(trailingStop).Contingency.IsWaitingForTrigger); + Assert.AreEqual(1.07m, Ticket(trailingStop).Get(OrderField.StopPrice)); + + // trails the price up + Step(1.15m); + Assert.AreEqual(1.14m, Ticket(trailingStop).Get(OrderField.StopPrice)); + Assert.AreEqual(OrderStatus.Submitted, Ticket(trailingStop).Status); + + Step(1.13m); + Assert.AreEqual(OrderStatus.Filled, Ticket(trailingStop).Status); + } + + [Test] + public void InsufficientBuyingPowerInvalidatesAllOrders() + { + var tickets = _algorithm.BracketOrder(_symbol, 1000000000, takeProfitPrice: 1.12m, stopLossPrice: 1.05m, limitPrice: 1.09m); + Step(1.10m); + + Assert.IsTrue(tickets.All(x => x.Status == OrderStatus.Invalid)); + Assert.IsTrue(tickets.All(x => x.OrderEvents.Last().Message.Contains("Insufficient buying power", StringComparison.InvariantCulture))); + } + + [Test] + public void HeldOrdersDoNotRequireBuyingPowerUntilTriggered() + { + // the children are huge, but they are not validated until triggered: by the brokerage when filling + var child = _algorithm.OrderFactory.LimitOrder(_symbol, 1000000000, 1.2m); + var parent = _algorithm.OrderFactory.LimitOrder(_symbol, 1000, 1.09m).Triggers(child); + _algorithm.Order(parent); + Step(1.10m); + Assert.AreEqual(OrderStatus.Submitted, Ticket(parent).Status); + Assert.AreEqual(OrderStatus.Submitted, Ticket(child).Status); + + Step(1.08m); + Assert.AreEqual(OrderStatus.Filled, Ticket(parent).Status); + Step(1.08m); + Assert.AreEqual(OrderStatus.Invalid, Ticket(child).Status); + } + + [Test] + public void BrokerageModelRejectionInvalidatesAllOrders() + { + _algorithm.SetBrokerageModel(new RejectStopOrdersBrokerageModel()); + + var tickets = _algorithm.BracketOrder(_symbol, 1000, takeProfitPrice: 1.12m, stopLossPrice: 1.05m, limitPrice: 1.09m); + Step(1.10m); + + Assert.IsTrue(tickets.All(x => x.Status == OrderStatus.Invalid)); + Assert.IsTrue(tickets.All(x => x.OrderEvents.Last().Message.Contains("BrokerageModel declared unable to submit order", StringComparison.InvariantCulture))); + } + + [Test] + public void OpenOrdersRemainingQuantityCountsTheLargestSibling() + { + var brokerage = UseContingentTestBrokerage(); + var tickets = _algorithm.OneCancelsOtherOrder(new[] + { + _algorithm.OrderFactory.LimitOrder(_symbol, -1000, 1.12m), + _algorithm.OrderFactory.StopMarketOrder(_symbol, -2000, 1.05m) + }); + _transactionHandler.ProcessSynchronousEvents(); + + // at most one of them fills: the largest, not the first nor the sum + Assert.AreEqual(-2000, _algorithm.Transactions.GetOpenOrdersRemainingQuantity(_symbol)); + + // what competes is the remaining quantity + PublishFill(brokerage, tickets[1], -1500, OrderStatus.PartiallyFilled); + Assert.AreEqual(-1000, _algorithm.Transactions.GetOpenOrdersRemainingQuantity(_symbol)); + + // a filter can leave a sibling out + Assert.AreEqual(-500, _algorithm.Transactions.GetOpenOrdersRemainingQuantity(ticket => ticket.OrderId == tickets[1].OrderId)); + } + + [Test] + public void OpenOrdersRemainingQuantityAddsPlainOrdersAndEachSet() + { + UseContingentTestBrokerage(); + _algorithm.LimitOrder(_symbol, -300, 1.12m); + _algorithm.OneCancelsOtherOrder(new[] + { + _algorithm.OrderFactory.LimitOrder(_symbol, -1000, 1.12m), + _algorithm.OrderFactory.StopMarketOrder(_symbol, -2000, 1.05m) + }); + _algorithm.OneCancelsOtherOrder(new[] + { + _algorithm.OrderFactory.LimitOrder(_symbol, -500, 1.13m), + _algorithm.OrderFactory.StopMarketOrder(_symbol, -700, 1.04m) + }); + _transactionHandler.ProcessSynchronousEvents(); + + // the plain order in full, plus the largest sibling of each set + Assert.AreEqual(-300 - 2000 - 700, _algorithm.Transactions.GetOpenOrdersRemainingQuantity(_symbol)); + } + + [Test] + public void OpenOrdersRemainingQuantityOfSiblingsForDifferentSymbols() + { + UseContingentTestBrokerage(); + var other = _algorithm.AddSecurity(SecurityType.Forex, "GBPUSD"); + _algorithm.Portfolio.CashBook["GBP"].ConversionRate = 1.3m; + other.SetMarketPrice(new Tick(_time, other.Symbol, 1.30m, 1.30m, 1.30m)); + + _algorithm.OneCancelsOtherOrder(new[] + { + _algorithm.OrderFactory.LimitOrder(_symbol, -1000, 1.12m), + _algorithm.OrderFactory.LimitOrder(other.Symbol, -2000, 1.32m) + }); + _transactionHandler.ProcessSynchronousEvents(); + + // each symbol counts its own member + Assert.AreEqual(-1000, _algorithm.Transactions.GetOpenOrdersRemainingQuantity(_symbol)); + Assert.AreEqual(-2000, _algorithm.Transactions.GetOpenOrdersRemainingQuantity(other.Symbol)); + Assert.AreEqual(-3000, _algorithm.Transactions.GetOpenOrdersRemainingQuantity()); + } + + [Test] + public void OpenOrdersRemainingQuantityCountsChildrenOnceTriggered() + { + var brokerage = UseContingentTestBrokerage(); + var tickets = _algorithm.OneTriggersOtherOrder(_algorithm.OrderFactory.LimitOrder(_symbol, 1000, 1.09m), + new[] { _algorithm.OrderFactory.LimitOrder(_symbol, -1000, 1.12m) }); + _transactionHandler.ProcessSynchronousEvents(); + + // the child is held, it's not working + Assert.AreEqual(1000, _algorithm.Transactions.GetOpenOrdersRemainingQuantity(_symbol)); + + PublishFill(brokerage, tickets[0], 1000, OrderStatus.Filled); + brokerage.PublishOrderUpdate(new OrderUpdateEvent { OrderId = tickets[1].OrderId, ContingencyTriggered = true }); + Assert.AreEqual(-1000, _algorithm.Transactions.GetOpenOrdersRemainingQuantity(_symbol)); + } + + [TestCase(OrderStatus.PartiallyFilled)] + [TestCase(OrderStatus.Filled)] + public void FillOfAHeldOrderMarksItTriggered(OrderStatus status) + { + TearDown(); + var brokerage = new ContingentTestBrokerage(_algorithm); + Initialize(null, brokerage); + + var tickets = _algorithm.BracketOrder(_symbol, 1000, takeProfitPrice: 1.12m, stopLossPrice: 1.05m, limitPrice: 1.09m); + _transactionHandler.ProcessSynchronousEvents(); + Assert.IsTrue(tickets[1].Contingency.IsWaitingForTrigger); + + // the brokerage fills the exit without notifying it was triggered first + var order = brokerage.PlacedOrders.Single(x => x.Id == tickets[1].OrderId); + brokerage.PublishOrderEvent(new OrderEvent(order, _algorithm.UtcTime, OrderFee.Zero) + { + Status = status, + FillQuantity = status == OrderStatus.Filled ? order.Quantity : order.Quantity / 2, + FillPrice = 1.12m + }); + + Assert.IsFalse(tickets[1].Contingency.IsWaitingForTrigger); + Assert.AreEqual(_algorithm.UtcTime, tickets[1].Contingency.Links.Single(x => x.Role == ContingencyRole.Child).TriggeredTime); + // its sibling was not filled, it's still held + Assert.IsTrue(tickets[2].Contingency.IsWaitingForTrigger); + } + + [Test] + public void BrokerageOrderUpdatesAreApplied() + { + TearDown(); + var brokerage = new ContingentTestBrokerage(_algorithm); + Initialize(null, brokerage); + + var tickets = _algorithm.BracketOrder(_symbol, 1000, takeProfitPrice: 1.12m, stopLossPrice: 1.05m, limitPrice: 1.09m); + _transactionHandler.ProcessSynchronousEvents(); + Assert.AreEqual(3, brokerage.PlacedOrders.Count); + Assert.IsTrue(tickets[1].Contingency.IsWaitingForTrigger); + + // the brokerage triggers the order and resizes it + brokerage.PublishOrderUpdate(new OrderUpdateEvent { OrderId = tickets[1].OrderId, ContingencyTriggered = true }); + Assert.IsFalse(tickets[1].Contingency.IsWaitingForTrigger); + // the algorithm time at which it was triggered + Assert.AreEqual(_algorithm.UtcTime, tickets[1].Contingency.Links.Single(x => x.Role == ContingencyRole.Child).TriggeredTime); + Assert.IsTrue(tickets[2].Contingency.IsWaitingForTrigger); + Assert.AreEqual(1.12m, tickets[1].Get(OrderField.LimitPrice)); + + brokerage.PublishOrderUpdate(new OrderUpdateEvent { OrderId = tickets[1].OrderId, Quantity = -400 }); + Assert.AreEqual(-400, tickets[1].Quantity); + + // invalid quantities are ignored: different side and zero + brokerage.PublishOrderUpdate(new OrderUpdateEvent { OrderId = tickets[1].OrderId, Quantity = 400 }); + brokerage.PublishOrderUpdate(new OrderUpdateEvent { OrderId = tickets[1].OrderId, Quantity = 0 }); + Assert.AreEqual(-400, tickets[1].Quantity); + + // an order which isn't a contingent child ignores the trigger + brokerage.PublishOrderUpdate(new OrderUpdateEvent { OrderId = tickets[0].OrderId, ContingencyTriggered = true }); + Assert.IsFalse(tickets[0].Contingency.IsWaitingForTrigger); + } + + [Test] + public void ContingencyOrderUpdateDoesNotResetStopLimitTriggerNorTrailingStopPrice() + { + TearDown(); + var brokerage = new ContingentTestBrokerage(_algorithm); + Initialize(null, brokerage); + + var stopLimit = _algorithm.OrderFactory.StopLimitOrder(_symbol, -1000, 1.05m, 1.04m); + var trailingStop = _algorithm.OrderFactory.TrailingStopOrder(_symbol, -1000, 1.06m, 0.01m, false); + _algorithm.Order(_algorithm.OrderFactory.LimitOrder(_symbol, 1000, 1.09m).Triggers(stopLimit, trailingStop)); + _transactionHandler.ProcessSynchronousEvents(); + + brokerage.PublishOrderUpdate(new OrderUpdateEvent { OrderId = Ticket(stopLimit).OrderId, StopTriggered = true }); + brokerage.PublishOrderUpdate(new OrderUpdateEvent { OrderId = Ticket(stopLimit).OrderId, ContingencyTriggered = true }); + brokerage.PublishOrderUpdate(new OrderUpdateEvent { OrderId = Ticket(trailingStop).OrderId, Quantity = -500 }); + + var order = (StopLimitOrder)_algorithm.Transactions.GetOrderById(Ticket(stopLimit).OrderId); + Assert.IsTrue(order.StopTriggered); + Assert.IsFalse(order.IsWaitingForTrigger()); + Assert.AreEqual(1.06m, Ticket(trailingStop).Get(OrderField.StopPrice)); + Assert.AreEqual(-500, Ticket(trailingStop).Quantity); + + // the trigger can carry the trailing stop price + brokerage.PublishOrderUpdate(new OrderUpdateEvent { OrderId = Ticket(trailingStop).OrderId, ContingencyTriggered = true, TrailingStopPrice = 1.07m }); + Assert.AreEqual(1.07m, Ticket(trailingStop).Get(OrderField.StopPrice)); + } + + [Test] + public void OpenOrdersFromTheBrokerageKeepTheirContingencies() + { + // like on a live deployment restart, the brokerage provides the existing open orders + var bracket = QuantConnect.Tests.Common.Orders.ContingentOrderTests.CreateBracket(); + var set = new OrderContingency(3, []); + var orders = new List + { + new LimitOrder(_symbol, -1000, 1.12m, _time) { Contingency = set.WithLinks(bracket[1].Contingency.Links) }, + new StopMarketOrder(_symbol, -1000, 1.05m, _time) { Contingency = set.WithLinks(bracket[2].Contingency.Links) } + }; + + foreach (var order in orders) + { + _transactionHandler.AddOpenOrder(order, _algorithm); + } + + // the shared set gets a new id, once, and the new lean order ids + Assert.AreEqual(1, set.Id); + CollectionAssert.AreEquivalent(orders.Select(x => x.Id), set.OrderIds); + var tickets = _algorithm.Transactions.GetOpenOrderTickets().ToList(); + Assert.AreEqual(2, tickets.Count); + Assert.IsTrue(tickets.All(x => x.Contingency.Id == 1 && x.Contingency.OrderIds == set.OrderIds && x.Contingency.Links.Count == 2 && x.Contingency.IsWaitingForTrigger)); + } + + private OrderTicket Ticket(SubmitOrderRequest request) + { + return _algorithm.Transactions.GetOrderTicket(request.OrderId); + } + + private void Initialize(BacktestingBrokerage backtestingBrokerage, IBrokerage brokerage = null) + { + _brokerage = backtestingBrokerage; + _transactionHandler = brokerage == null ? new BacktestingTransactionHandler() : new SynchronousTransactionHandler(); + _transactionHandler.Initialize(_algorithm, brokerage ?? backtestingBrokerage, new BacktestingResultHandler()); + _algorithm.Transactions.SetOrderProcessor(_transactionHandler); + } + + /// + /// A brokerage which accepts the orders without filling them, the fills are published by the test + /// + private ContingentTestBrokerage UseContingentTestBrokerage() + { + TearDown(); + var brokerage = new ContingentTestBrokerage(_algorithm); + Initialize(null, brokerage); + return brokerage; + } + + private void PublishFill(ContingentTestBrokerage brokerage, OrderTicket ticket, decimal fillQuantity, OrderStatus status) + { + var order = brokerage.PlacedOrders.Single(x => x.Id == ticket.OrderId); + brokerage.PublishOrderEvent(new OrderEvent(order, _algorithm.UtcTime, OrderFee.Zero) + { + Status = status, + FillQuantity = fillQuantity, + FillPrice = _security.Price + }); + } + + private void Step(decimal price) + { + _time = _time.AddMinutes(1); + _algorithm.SetDateTime(_time); + SetPrice(price); + _transactionHandler.ProcessSynchronousEvents(); + } + + private void SetPrice(decimal price) + { + _security.SetMarketPrice(new Tick(_time, _symbol, price, price, price)); + } + + /// + /// Allows using a brokerage different than the backtesting one, processing the order requests synchronously + /// + private class SynchronousTransactionHandler : BrokerageTransactionHandler + { + protected override bool SynchronousProcessing => true; + + protected override void WaitForOrderSubmission(OrderTicket ticket) + { + ProcessPendingRequests(); + } + + public override void ProcessSynchronousEvents() + { + ProcessPendingRequests(); + } + } + + private class RejectStopOrdersBrokerageModel : DefaultBrokerageModel + { + public override bool CanSubmitOrder(Security security, Order order, out BrokerageMessageEvent message) + { + message = null; + return order.Type != OrderType.StopMarket; + } + } + + private class ContingentTestBrokerage : BrokerageTransactionHandlerTests.NoSubmitTestBrokerage + { + public List PlacedOrders { get; } = new(); + public ContingentTestBrokerage(IAlgorithm algorithm) : base(algorithm) + { + } + public override bool PlaceOrder(Order order) + { + PlacedOrders.Add(order); + return true; + } + public void PublishOrderUpdate(OrderUpdateEvent orderUpdateEvent) + { + OnOrderUpdated(orderUpdateEvent); + } + public void PublishOrderEvent(OrderEvent orderEvent) + { + OnOrderEvent(orderEvent); + } + } + } +}