From 7c732a03bd88d06676a61fa4ec218091333caedf Mon Sep 17 00:00:00 2001 From: Martin Molinero Date: Tue, 22 Sep 2026 08:43:34 -0300 Subject: [PATCH 1/6] Add contingent orders: OCO, OTO, OUO and brackets Introduces contingent orders, sets of independent orders whose lifecycle is related: one cancels other (OCO/OCA), one triggers other (OTO), one updates other (OUO) and any composition of them like brackets (OTOCO), including combo orders as members. - Order model: Order.Contingency (OrderContingency) with the set shared by its orders and per order links (contingency id, type, nullable role parent/child, triggered state), JSON serialization (backwards compatible). OrderContingency owns the composition: Trigger/Relate join orders (single orders or all the legs of a combo order) into a set, used by the algorithm and by the brokerages to rebuild the contingencies of their open orders - Algorithm API: OrderFactory on the algorithm creates SubmitOrderRequests composed through Triggers, Bracket, OneCancelsOther and OneUpdatesOther, submitted with Order(); BracketOrder, OneCancelsOtherOrder, OneUpdatesOtherOrder and OneTriggersOtherOrder helpers. Every existing order method goes through the same submit path, no API changes - Transaction handler: atomic submission once all the orders arrive, buying power skipped for held children and shared by OCO siblings, per set request routing, order update events for triggered children and brokerage side resizing - Backtesting/paper brokerage: ContingentOrderProcessor drives the lifecycle (hold, trigger on full fill, cancel siblings, reduce OUO, cascade cancels), triggered orders require new data, stop loss wins same bar ties, trailing stops anchor on trigger - Brokerage models: IB, Charles Schwab, TradeStation, Alpaca and Binance validate what each brokerage supports, the rest reject - Brokerages: ContingentOrderCache and GroupOrderCacheManager on the base brokerage, OnContingentOrdersTriggered and shared helpers for plugins - Regression algorithms (C# and Python) and unit tests Co-Authored-By: Claude Opus 5.5 (1M context) --- ...acketOrderLimitEntryRegressionAlgorithm.cs | 234 +++++++ .../BracketOrderRegressionAlgorithm.cs | 210 +++++++ ...ContingentComboOrderRegressionAlgorithm.cs | 210 +++++++ ...ontingentOrderCancelRegressionAlgorithm.cs | 227 +++++++ ...ontingentOrderUpdateRegressionAlgorithm.cs | 195 ++++++ ...entTrailingStopOrderRegressionAlgorithm.cs | 166 +++++ ...therOrderCashAccountRegressionAlgorithm.cs | 162 +++++ ...OneCancelsOtherOrderRegressionAlgorithm.cs | 202 ++++++ ...neTriggersOtherOrderRegressionAlgorithm.cs | 218 +++++++ ...OneUpdatesOtherOrderRegressionAlgorithm.cs | 211 +++++++ ...acketOrderLimitEntryRegressionAlgorithm.py | 108 ++++ .../BracketOrderRegressionAlgorithm.py | 93 +++ ...ContingentComboOrderRegressionAlgorithm.py | 106 ++++ ...ontingentOrderCancelRegressionAlgorithm.py | 109 ++++ ...ontingentOrderUpdateRegressionAlgorithm.py | 85 +++ ...entTrailingStopOrderRegressionAlgorithm.py | 63 ++ ...OneCancelsOtherOrderRegressionAlgorithm.py | 89 +++ ...neTriggersOtherOrderRegressionAlgorithm.py | 98 +++ .../QCAlgorithm.Trading.ContingentOrders.cs | 346 ++++++++++ Algorithm/QCAlgorithm.Trading.cs | 259 +------- Algorithm/QCAlgorithm.cs | 1 + .../Backtesting/BacktestingBrokerage.cs | 157 ++++- Brokerages/Brokerage.cs | 52 ++ Brokerages/Properties/AssemblyInfo.cs | 5 +- Common/Brokerages/AlpacaBrokerageModel.cs | 72 +++ .../Brokerages/AxosClearingBrokerageModel.cs | 5 + Common/Brokerages/BinanceBrokerageModel.cs | 60 ++ Common/Brokerages/BinanceUSBrokerageModel.cs | 18 + Common/Brokerages/BitfinexBrokerageModel.cs | 5 + .../Brokerages/BloombergFixBrokerageModel.cs | 5 + Common/Brokerages/BrokerageExtensions.cs | 85 +++ Common/Brokerages/BybitBrokerageModel.cs | 5 + .../Brokerages/CharlesSchwabBrokerageModel.cs | 35 ++ .../Brokerages/ClearStreetBrokerageModel.cs | 5 + Common/Brokerages/CoinbaseBrokerageModel.cs | 5 + Common/Brokerages/ExanteBrokerageModel.cs | 5 + Common/Brokerages/EzeBrokerageModel.cs | 5 + Common/Brokerages/FTXBrokerageModel.cs | 5 + Common/Brokerages/FxcmBrokerageModel.cs | 5 + .../InteractiveBrokersBrokerageModel.cs | 22 + .../Brokerages/InteractiveBrokersFixModel.cs | 5 + Common/Brokerages/KrakenBrokerageModel.cs | 5 + Common/Brokerages/OandaBrokerageModel.cs | 5 + Common/Brokerages/PublicBrokerageModel.cs | 5 + Common/Brokerages/RBIBrokerageModel.cs | 5 + Common/Brokerages/SamcoBrokerageModel.cs | 5 + .../Brokerages/TDAmeritradeBrokerageModel.cs | 5 + Common/Brokerages/TastytradeBrokerageModel.cs | 5 + .../Brokerages/TerminalLinkBrokerageModel.cs | 5 + .../Brokerages/TradeStationBrokerageModel.cs | 24 + Common/Brokerages/TradierBrokerageModel.cs | 5 + .../TradingTechnologiesBrokerageModel.cs | 5 + Common/Brokerages/WebullBrokerageModel.cs | 5 + Common/Brokerages/WolverineBrokerageModel.cs | 5 + Common/Brokerages/ZerodhaBrokerageModel.cs | 5 + Common/Brokerages/dYdXBrokerageModel.cs | 5 + Common/Extensions.cs | 3 +- Common/Interfaces/IBrokerage.cs | 1 + Common/Messages/Messages.Brokerages.cs | 47 ++ Common/Orders/ContingencyLink.cs | 109 ++++ Common/Orders/ContingencyType.cs | 57 ++ Common/Orders/ContingentOrderCache.cs | 65 ++ Common/Orders/ContingentOrderExtensions.cs | 264 ++++++++ Common/Orders/ContingentOrderProcessor.cs | 276 ++++++++ Common/Orders/Order.cs | 44 +- Common/Orders/OrderContingency.cs | 543 ++++++++++++++++ Common/Orders/OrderFactory.cs | 294 +++++++++ Common/Orders/OrderJsonConverter.cs | 53 ++ Common/Orders/OrderTicket.cs | 9 + Common/Orders/OrderUpdateEvent.cs | 12 + Common/Orders/SubmitOrderRequest.cs | 78 ++- Common/Orders/TimeInForces/DayTimeInForce.cs | 6 +- Common/Properties/AssemblyInfo.cs | 1 + Common/Securities/CashBuyingPowerModel.cs | 29 +- .../Securities/SecurityTransactionManager.cs | 54 +- .../BrokerageTransactionHandler.cs | 165 ++++- .../OrderRequestProcessingPool.cs | 21 +- Tests/Algorithm/AlgorithmOrderFactoryTests.cs | 500 +++++++++++++++ .../ContingentOrdersBrokerageModelTests.cs | 288 +++++++++ .../Orders/ContingentOrderProcessorTests.cs | 335 ++++++++++ Tests/Common/Orders/ContingentOrderTests.cs | 432 +++++++++++++ ...ContingentOrdersTransactionHandlerTests.cs | 595 ++++++++++++++++++ 82 files changed, 8033 insertions(+), 295 deletions(-) create mode 100644 Algorithm.CSharp/BracketOrderLimitEntryRegressionAlgorithm.cs create mode 100644 Algorithm.CSharp/BracketOrderRegressionAlgorithm.cs create mode 100644 Algorithm.CSharp/ContingentComboOrderRegressionAlgorithm.cs create mode 100644 Algorithm.CSharp/ContingentOrderCancelRegressionAlgorithm.cs create mode 100644 Algorithm.CSharp/ContingentOrderUpdateRegressionAlgorithm.cs create mode 100644 Algorithm.CSharp/ContingentTrailingStopOrderRegressionAlgorithm.cs create mode 100644 Algorithm.CSharp/OneCancelsOtherOrderCashAccountRegressionAlgorithm.cs create mode 100644 Algorithm.CSharp/OneCancelsOtherOrderRegressionAlgorithm.cs create mode 100644 Algorithm.CSharp/OneTriggersOtherOrderRegressionAlgorithm.cs create mode 100644 Algorithm.CSharp/OneUpdatesOtherOrderRegressionAlgorithm.cs create mode 100644 Algorithm.Python/BracketOrderLimitEntryRegressionAlgorithm.py create mode 100644 Algorithm.Python/BracketOrderRegressionAlgorithm.py create mode 100644 Algorithm.Python/ContingentComboOrderRegressionAlgorithm.py create mode 100644 Algorithm.Python/ContingentOrderCancelRegressionAlgorithm.py create mode 100644 Algorithm.Python/ContingentOrderUpdateRegressionAlgorithm.py create mode 100644 Algorithm.Python/ContingentTrailingStopOrderRegressionAlgorithm.py create mode 100644 Algorithm.Python/OneCancelsOtherOrderRegressionAlgorithm.py create mode 100644 Algorithm.Python/OneTriggersOtherOrderRegressionAlgorithm.py create mode 100644 Algorithm/QCAlgorithm.Trading.ContingentOrders.cs create mode 100644 Common/Orders/ContingencyLink.cs create mode 100644 Common/Orders/ContingencyType.cs create mode 100644 Common/Orders/ContingentOrderCache.cs create mode 100644 Common/Orders/ContingentOrderExtensions.cs create mode 100644 Common/Orders/ContingentOrderProcessor.cs create mode 100644 Common/Orders/OrderContingency.cs create mode 100644 Common/Orders/OrderFactory.cs create mode 100644 Tests/Algorithm/AlgorithmOrderFactoryTests.cs create mode 100644 Tests/Common/Brokerages/ContingentOrdersBrokerageModelTests.cs create mode 100644 Tests/Common/Orders/ContingentOrderProcessorTests.cs create mode 100644 Tests/Common/Orders/ContingentOrderTests.cs create mode 100644 Tests/Engine/BrokerageTransactionHandlerTests/ContingentOrdersTransactionHandlerTests.cs 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..d6fd3c259224 --- /dev/null +++ b/Algorithm/QCAlgorithm.Trading.ContingentOrders.cs @@ -0,0 +1,346 @@ +/* + * 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; + foreach (var order in orders) + { + if (requests.Contains(order)) + { + // along with the rest of its set + continue; + } + 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; + 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); + 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..ced4d6c7d4fb 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) + .ThenBy(x => x.Key)) { var order = kvp.Value; if (order == null) @@ -255,6 +266,12 @@ public virtual void Scan() continue; } + if (!_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..9643da00ffbb 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,19 @@ 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 && order.Contingency.Symbols.Count > 1) + { + // a bracket (BRK) group, where a fill reduces the other orders, requires the same symbol + message = this.UnsupportedContingentOrdersShape($"{ContingencyType.OneUpdatesOther} orders have to be for the same symbol."); + 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..a1d07b301800 --- /dev/null +++ b/Common/Orders/OrderContingency.cs @@ -0,0 +1,543 @@ +/* + * 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() + { + var child = GetLink(ContingencyRole.Child); + if (child == 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 == ContingencyRole.Parent && links[i].Id == child.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..3332a143d3e5 --- /dev/null +++ b/Common/Orders/OrderFactory.cs @@ -0,0 +1,294 @@ +/* + * 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) + { + symbol = GetCurrentSymbol(symbol); + return new SubmitOrderRequest(type, symbol.SecurityType, symbol, quantity, stopPrice, limitPrice, triggerPrice, trailingAmount, trailingAsPercentage, + _algorithm.UtcTime, tag, orderProperties ?? _algorithm.DefaultOrderProperties?.Clone(), asynchronous: asynchronous); + } + + /// + /// Gets the current symbol of the security, which can have been renamed since the given one was created + /// + private Symbol GetCurrentSymbol(Symbol symbol) + { + return _algorithm.Securities.TryGetValue(symbol, out var security) ? security.Symbol : symbol; + } + + 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; + } + + var symbol = GetCurrentSymbol(leg.Symbol); + requests.Add(new SubmitOrderRequest(legType, symbol.SecurityType, 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..53f20eb029bf 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 { @@ -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..d3e27bd08832 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,32 @@ 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?.Links.FirstOrDefault(link => link.Role == 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.Links.Any(link => link.Role == null && link.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/Common/Brokerages/ContingentOrdersBrokerageModelTests.cs b/Tests/Common/Brokerages/ContingentOrdersBrokerageModelTests.cs new file mode 100644 index 000000000000..a20cef6d2ea9 --- /dev/null +++ b/Tests/Common/Brokerages/ContingentOrdersBrokerageModelTests.cs @@ -0,0 +1,288 @@ +/* + * 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, 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); + } + } + } +} From 209eceeb5ea2fec6e3b8ba7ece8f5d4b369f6efc Mon Sep 17 00:00:00 2001 From: Martin Molinero Date: Thu, 24 Sep 2026 13:01:19 -0300 Subject: [PATCH 2/6] Add reusable contingent orders brokerage tests ContingentOrderTestParameters builds OCO, OUO, OTO and bracket sets out of the existing order test parameters, or any custom set. BrokerageTests gets ContingentOrdersCancel, ContingentOrdersUpdate and ContingentOrdersTrigger, which brokerages opt into like the rest. Co-Authored-By: Claude Opus 5.5 (1M context) --- Tests/Brokerages/BrokerageTests.cs | 95 +++++++++++++++ .../ContingentOrderTestParameters.cs | 111 ++++++++++++++++++ 2 files changed, 206 insertions(+) create mode 100644 Tests/Brokerages/ContingentOrderTestParameters.cs diff --git a/Tests/Brokerages/BrokerageTests.cs b/Tests/Brokerages/BrokerageTests.cs index 804279d993e2..cb7a8d0202c7 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,87 @@ 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 whole set + /// + 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(); + Assert.IsTrue(Brokerage.CancelOrder(first), $"Brokerage failed to cancel the order: {first}"); + WaitForOrders(() => orders.All(order => order.Status == OrderStatus.Canceled), "all the orders 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 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 == OrderStatus.Submitted), + "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; + } + } +} From 6f6f55e0c44021f5dda55220c6e90ddd549175eb Mon Sep 17 00:00:00 2001 From: Martin Molinero Date: Thu, 24 Sep 2026 15:07:26 -0300 Subject: [PATCH 3/6] Reject TradeStation OUO groups without a stop order, relax the contingent brokerage tests TradeStation rejects a bracket group (BRK) without a stop order. The contingent cancel test no longer assumes the rest of a group is canceled with its canceled member, and the trigger test takes updated orders as working. Co-Authored-By: Claude Opus 5.5 (1M context) --- .../Brokerages/TradeStationBrokerageModel.cs | 16 ++++++++++---- Common/Orders/OrderContingency.cs | 21 ++++++++++++++++--- Tests/Brokerages/BrokerageTests.cs | 7 ++++--- .../ContingentOrdersBrokerageModelTests.cs | 2 ++ 4 files changed, 36 insertions(+), 10 deletions(-) diff --git a/Common/Brokerages/TradeStationBrokerageModel.cs b/Common/Brokerages/TradeStationBrokerageModel.cs index 9643da00ffbb..88babe4d5076 100644 --- a/Common/Brokerages/TradeStationBrokerageModel.cs +++ b/Common/Brokerages/TradeStationBrokerageModel.cs @@ -157,11 +157,19 @@ public override bool CanSubmitOrder(Security security, Order order, out Brokerag return false; } - if (order.GetSiblingLink()?.Type == ContingencyType.OneUpdatesOther && order.Contingency.Symbols.Count > 1) + if (order.GetSiblingLink()?.Type == ContingencyType.OneUpdatesOther) { - // a bracket (BRK) group, where a fill reduces the other orders, requires the same symbol - message = this.UnsupportedContingentOrdersShape($"{ContingencyType.OneUpdatesOther} orders have to be for the same symbol."); - return false; + // 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)) diff --git a/Common/Orders/OrderContingency.cs b/Common/Orders/OrderContingency.cs index a1d07b301800..46d303161836 100644 --- a/Common/Orders/OrderContingency.cs +++ b/Common/Orders/OrderContingency.cs @@ -204,8 +204,23 @@ internal void SetOrder(Order order) /// internal IEnumerable GetParentOrderTypes() { - var child = GetLink(ContingencyRole.Child); - if (child == null) + 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; } @@ -218,7 +233,7 @@ internal IEnumerable GetParentOrderTypes() } for (var i = 0; i < links.Count; i++) { - if (links[i].Role == ContingencyRole.Parent && links[i].Id == child.Id) + if (links[i].Role == role && links[i].Id == link.Id) { yield return member.OrderType; break; diff --git a/Tests/Brokerages/BrokerageTests.cs b/Tests/Brokerages/BrokerageTests.cs index cb7a8d0202c7..62de5ce89e08 100644 --- a/Tests/Brokerages/BrokerageTests.cs +++ b/Tests/Brokerages/BrokerageTests.cs @@ -566,7 +566,7 @@ public virtual void LongFromZeroUpdateAndCancel(OrderTestParameters parameters, /// /// Places a set of resting contingent orders: all of them are working, the ones triggered by another held. - /// Canceling the first order cancels the whole set + /// 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) { @@ -574,8 +574,9 @@ public virtual void ContingentOrdersCancel(ContingentOrderTestParameters paramet 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(() => orders.All(order => order.Status == OrderStatus.Canceled), "all the orders canceled"); + WaitForOrders(() => canceledOrders.All(order => order.Status == OrderStatus.Canceled), "the order and the orders it triggers canceled"); } /// @@ -628,7 +629,7 @@ public virtual void ContingentOrdersTrigger(ContingentOrderTestParameters parame 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 == OrderStatus.Submitted), + && 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"); } diff --git a/Tests/Common/Brokerages/ContingentOrdersBrokerageModelTests.cs b/Tests/Common/Brokerages/ContingentOrdersBrokerageModelTests.cs index a20cef6d2ea9..c56bf5f75c36 100644 --- a/Tests/Common/Brokerages/ContingentOrdersBrokerageModelTests.cs +++ b/Tests/Common/Brokerages/ContingentOrdersBrokerageModelTests.cs @@ -140,6 +140,8 @@ public void TradeStation() 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"); } From 8cf533b5a0fcebd9b887960bd2d1c791ccb9ec2e Mon Sep 17 00:00:00 2001 From: Martin Molinero Date: Thu, 24 Sep 2026 15:31:26 -0300 Subject: [PATCH 4/6] Add the contingent orders open orders brokerage test The brokerage open orders are rebuilt with the same contingencies as the placed orders. Co-Authored-By: Claude Opus 5.5 (1M context) --- Tests/Brokerages/BrokerageTests.cs | 23 +++++++++++++++++++++++ 1 file changed, 23 insertions(+) diff --git a/Tests/Brokerages/BrokerageTests.cs b/Tests/Brokerages/BrokerageTests.cs index 62de5ce89e08..941bc8511db5 100644 --- a/Tests/Brokerages/BrokerageTests.cs +++ b/Tests/Brokerages/BrokerageTests.cs @@ -616,6 +616,29 @@ public virtual void ContingentOrdersUpdate(ContingentOrderTestParameters paramet 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(); + var rebuiltOrders = orders.Select(order => openOrders.SingleOrDefault(openOrder => openOrder.BrokerId.Contains(order.BrokerId[0]))).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 From d52da36efbb96ff46c76a49c651ea80cb21f050c Mon Sep 17 00:00:00 2001 From: Martin Molinero Date: Thu, 24 Sep 2026 15:50:06 -0300 Subject: [PATCH 5/6] Match the rebuilt combo legs by symbol in the contingent open orders test Co-Authored-By: Claude Opus 5.5 (1M context) --- Tests/Brokerages/BrokerageTests.cs | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/Tests/Brokerages/BrokerageTests.cs b/Tests/Brokerages/BrokerageTests.cs index 941bc8511db5..c270d190d2db 100644 --- a/Tests/Brokerages/BrokerageTests.cs +++ b/Tests/Brokerages/BrokerageTests.cs @@ -625,7 +625,8 @@ public virtual void ContingentOrdersGetOpenOrders(ContingentOrderTestParameters var orders = PlaceOrderWaitForStatus(parameters.CreateOrders(GetDefaultQuantity()), OrderStatus.Submitted); var openOrders = Brokerage.GetOpenOrders(); - var rebuiltOrders = orders.Select(order => openOrders.SingleOrDefault(openOrder => openOrder.BrokerId.Contains(order.BrokerId[0]))).ToList(); + // 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), From a1cf3385a245f6d4195dee3e5d317584ffddc35f Mon Sep 17 00:00:00 2001 From: Martin Molinero Date: Fri, 25 Sep 2026 09:57:23 -0300 Subject: [PATCH 6/6] Reduce the per order work of contingent orders: single sort key and lookups, no request search Co-Authored-By: Claude Opus 5.5 (1M context) --- Algorithm/QCAlgorithm.Trading.ContingentOrders.cs | 14 +++++++++----- Brokerages/Backtesting/BacktestingBrokerage.cs | 8 ++++---- Common/Orders/OrderFactory.cs | 12 +----------- Common/Orders/SubmitOrderRequest.cs | 2 +- .../BrokerageTransactionHandler.cs | 5 ++--- 5 files changed, 17 insertions(+), 24 deletions(-) diff --git a/Algorithm/QCAlgorithm.Trading.ContingentOrders.cs b/Algorithm/QCAlgorithm.Trading.ContingentOrders.cs index d6fd3c259224..12a54b7989e4 100644 --- a/Algorithm/QCAlgorithm.Trading.ContingentOrders.cs +++ b/Algorithm/QCAlgorithm.Trading.ContingentOrders.cs @@ -166,13 +166,10 @@ private List SubmitOrders(IEnumerable orders) 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 (requests.Contains(order)) - { - // along with the rest of its set - continue; - } if (order.Contingency == null) { if (order.GroupOrderManager != null) @@ -189,6 +186,11 @@ private List SubmitOrders(IEnumerable orders) 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]; @@ -242,6 +244,8 @@ private OrderResponse PrepareRequest(SubmitOrderRequest request, ref Action conv } 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) { diff --git a/Brokerages/Backtesting/BacktestingBrokerage.cs b/Brokerages/Backtesting/BacktestingBrokerage.cs index ced4d6c7d4fb..16b593f589f1 100644 --- a/Brokerages/Backtesting/BacktestingBrokerage.cs +++ b/Brokerages/Backtesting/BacktestingBrokerage.cs @@ -254,9 +254,9 @@ public virtual void Scan() // 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) - .ThenBy(x => x.Key)) + 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) @@ -266,7 +266,7 @@ public virtual void Scan() continue; } - if (!_pending.ContainsKey(kvp.Key)) + 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; diff --git a/Common/Orders/OrderFactory.cs b/Common/Orders/OrderFactory.cs index 3332a143d3e5..41164d45afff 100644 --- a/Common/Orders/OrderFactory.cs +++ b/Common/Orders/OrderFactory.cs @@ -238,19 +238,10 @@ public List OneUpdatesOther(IEnumerable 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) { - symbol = GetCurrentSymbol(symbol); return new SubmitOrderRequest(type, symbol.SecurityType, symbol, quantity, stopPrice, limitPrice, triggerPrice, trailingAmount, trailingAsPercentage, _algorithm.UtcTime, tag, orderProperties ?? _algorithm.DefaultOrderProperties?.Clone(), asynchronous: asynchronous); } - /// - /// Gets the current symbol of the security, which can have been renamed since the given one was created - /// - private Symbol GetCurrentSymbol(Symbol symbol) - { - return _algorithm.Securities.TryGetValue(symbol, out var security) ? security.Symbol : symbol; - } - private List Combo(OrderType type, List legs, decimal quantity, decimal limitPrice, bool asynchronous, string tag, IOrderProperties orderProperties) { @@ -284,8 +275,7 @@ private List Combo(OrderType type, List legs, decimal q legType = OrderType.ComboLegLimit; } - var symbol = GetCurrentSymbol(leg.Symbol); - requests.Add(new SubmitOrderRequest(legType, symbol.SecurityType, symbol, ((decimal)leg.Quantity).GetOrderLegGroupQuantity(groupOrderManager), + 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/SubmitOrderRequest.cs b/Common/Orders/SubmitOrderRequest.cs index 53f20eb029bf..469acfb2d09b 100644 --- a/Common/Orders/SubmitOrderRequest.cs +++ b/Common/Orders/SubmitOrderRequest.cs @@ -48,7 +48,7 @@ public SecurityType SecurityType /// public Symbol Symbol { - get; private set; + get; internal set; } /// diff --git a/Engine/TransactionHandlers/BrokerageTransactionHandler.cs b/Engine/TransactionHandlers/BrokerageTransactionHandler.cs index d3e27bd08832..70ef75f7ef86 100644 --- a/Engine/TransactionHandlers/BrokerageTransactionHandler.cs +++ b/Engine/TransactionHandlers/BrokerageTransactionHandler.cs @@ -2133,7 +2133,7 @@ private void SendWarningOnPriceChange(string priceType, decimal priceRound, deci private bool IsShortable(SubmitOrderRequest request) { var contingency = request.Contingency; - var member = contingency?.Links.FirstOrDefault(link => link.Role == null); + var member = contingency?.GetLink(null); if (member == null) { return _algorithm.Shortable(request.Symbol, request.Quantity); @@ -2147,8 +2147,7 @@ private bool IsShortable(SubmitOrderRequest request) } var openOrderQuantity = _algorithm.Transactions.GetOpenOrdersRemainingQuantity(ticket => ticket.Symbol == request.Symbol - && !(ticket.Contingency?.Id == contingency.Id - && ticket.Contingency.Links.Any(link => link.Role == null && link.Id == member.Id))); + && !(ticket.Contingency?.Id == contingency.Id && ticket.Contingency.GetLink(null)?.Id == member.Id)); return security.Holdings.Quantity + openOrderQuantity - Math.Abs(request.Quantity) >= -shortableQuantity; }