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