diff --git a/Algorithm.CSharp/OneCancelsTheOtherOrderCancelRegressionAlgorithm.cs b/Algorithm.CSharp/OneCancelsTheOtherOrderCancelRegressionAlgorithm.cs
new file mode 100644
index 000000000000..5ea5679caf2b
--- /dev/null
+++ b/Algorithm.CSharp/OneCancelsTheOtherOrderCancelRegressionAlgorithm.cs
@@ -0,0 +1,168 @@
+/*
+ * 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 for the cancel path of a one-cancels-the-other (OCO) order group: the group is
+ /// placed with both legs far from the market, so neither can fill inside the test window, then one of the
+ /// two tickets is explicitly canceled. Asserts that canceling one leg cancels the whole group, not just
+ /// the leg that was canceled
+ ///
+ public class OneCancelsTheOtherOrderCancelRegressionAlgorithm : QCAlgorithm, IRegressionAlgorithmDefinition
+ {
+ private Symbol _spy;
+ private List _tickets;
+ private bool _canceled;
+
+ public override void Initialize()
+ {
+ SetStartDate(2019, 1, 1);
+ SetEndDate(2019, 1, 31);
+
+ _spy = AddEquity("SPY", Resolution.Hour).Symbol;
+ }
+
+ public override void OnData(Slice slice)
+ {
+ if (!Portfolio.Invested)
+ {
+ MarketOrder(_spy, 100);
+
+ // both legs sit far from the market: limit sell +30% and stop sell -30% should never be
+ // reachable in this test window, so only the explicit cancel below can close the group
+ _tickets = OneCancelsTheOtherOrder(_spy, -100,
+ limitPrice: Securities[_spy].Price * 1.30m,
+ stopPrice: Securities[_spy].Price * 0.70m);
+ }
+ else if (!_canceled && Time.Day > 5)
+ {
+ // cancel only one leg: the whole OCO group must cancel with it
+ _tickets[0].Cancel();
+ _canceled = true;
+ }
+ }
+
+ public override void OnOrderEvent(OrderEvent orderEvent)
+ {
+ if (_tickets == null || orderEvent.Status != OrderStatus.Filled)
+ {
+ return;
+ }
+
+ // neither OCO leg's price should ever be reachable in this test window; a fill here means the
+ // regression scenario itself is broken, not just the cancellation behavior being tested
+ if (_tickets.Any(ticket => ticket.OrderId == orderEvent.OrderId))
+ {
+ throw new RegressionTestException(
+ $"Unexpected fill for OCO leg {orderEvent.OrderId}: prices were set far from the market so the group should only close through the explicit cancel");
+ }
+ }
+
+ public override void OnEndOfAlgorithm()
+ {
+ if (!_canceled)
+ {
+ throw new RegressionTestException("Expected to have canceled one of the OCO legs before the end of the algorithm");
+ }
+
+ if (_tickets == null || _tickets.Count != 2)
+ {
+ throw new RegressionTestException("Expected the OCO group to have exactly 2 legs");
+ }
+
+ foreach (var ticket in _tickets)
+ {
+ if (ticket.Status != OrderStatus.Canceled)
+ {
+ throw new RegressionTestException(
+ $"Expected every OCO leg to be Canceled, including the leg that was not explicitly canceled. Leg {ticket.OrderId} has status {ticket.Status}");
+ }
+ }
+
+ // canceling the OCO exit group must not touch the original market order fill
+ if (!Portfolio.Invested)
+ {
+ throw new RegressionTestException("Expected the algorithm to still be invested: the market order fill is independent from the canceled OCO group");
+ }
+ }
+
+ ///
+ /// 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 => 302;
+
+ ///
+ /// 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", "29.303%"},
+ {"Drawdown", "0.700%"},
+ {"Expectancy", "0"},
+ {"Start Equity", "100000"},
+ {"End Equity", "102182.68"},
+ {"Net Profit", "2.183%"},
+ {"Sharpe Ratio", "4.501"},
+ {"Sortino Ratio", "5.158"},
+ {"Probabilistic Sharpe Ratio", "85.073%"},
+ {"Loss Rate", "0%"},
+ {"Win Rate", "0%"},
+ {"Profit-Loss Ratio", "0"},
+ {"Alpha", "-0.047"},
+ {"Beta", "0.24"},
+ {"Annual Standard Deviation", "0.038"},
+ {"Annual Variance", "0.001"},
+ {"Information Ratio", "-6.241"},
+ {"Tracking Error", "0.117"},
+ {"Treynor Ratio", "0.708"},
+ {"Total Fees", "$1.00"},
+ {"Estimated Strategy Capacity", "$470000000.00"},
+ {"Lowest Capacity Asset", "SPY R735QTJ8XC9X"},
+ {"Portfolio Turnover", "0.76%"},
+ {"Drawdown Recovery", "12"},
+ {"OrderListHash", "1f0c221acd9f91c1a73a4ea0096eef2f"}
+ };
+ }
+}
diff --git a/Algorithm.CSharp/OneCancelsTheOtherOrderDemoAlgorithm.cs b/Algorithm.CSharp/OneCancelsTheOtherOrderDemoAlgorithm.cs
new file mode 100644
index 000000000000..c95a0f191c50
--- /dev/null
+++ b/Algorithm.CSharp/OneCancelsTheOtherOrderDemoAlgorithm.cs
@@ -0,0 +1,94 @@
+/*
+ * 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.Data;
+using QuantConnect.Orders;
+using System.Collections.Generic;
+
+namespace QuantConnect.Algorithm.CSharp
+{
+ ///
+ /// Demo algorithm for manually testing one-cancels-the-other (OCO) order groups end to end against a live
+ /// or paper brokerage. Alpaca is the first brokerage to support them.
+ /// Buys a small position at market, then places a 2-leg OCO exit (take-profit limit above the entry price,
+ /// stop-loss below it) and logs every order event so the outcome is visible in the live log/console.
+ ///
+ ///
+ /// This is a manual/live testing aid, not part of the automated backtest regression suite - it deliberately
+ /// does not implement IRegressionAlgorithmDefinition. For the automated backtest version of this scenario,
+ /// see OneCancelsTheOtherOrderRegressionAlgorithm.
+ ///
+ public class OneCancelsTheOtherOrderDemoAlgorithm : QCAlgorithm
+ {
+ private Symbol _symbol;
+ private List _tickets;
+
+ public override void Initialize()
+ {
+ // ignored when deployed live; only used if this is run as a quick local backtest sanity check first
+ SetStartDate(2019, 1, 1);
+ SetEndDate(2019, 1, 31);
+ SetCash(100000);
+
+ _symbol = AddEquity("AAPL", Resolution.Hour).Symbol;
+ }
+
+ public override void OnData(Slice slice)
+ {
+ if (_tickets != null)
+ {
+ // the OCO exit group has already been placed, nothing left to do
+ return;
+ }
+
+ if (!Portfolio.Invested)
+ {
+ Debug("Buying 10 AAPL at market to open the position the OCO exit group will close.");
+ MarketOrder(_symbol, 10);
+ return;
+ }
+
+ // just went long: place the exit as one OCO group (take profit +1%, stop loss -2% from here).
+ // Tighten these offsets if you want one leg to trigger quickly for a faster manual test.
+ var price = Securities[_symbol].Price;
+ var takeProfitLimitPrice = price * 1.01m;
+ var stopLossStopPrice = price * 0.98m;
+
+ Debug($"Placing OCO exit group on {_symbol}: sell limit {takeProfitLimitPrice} (take profit) / sell stop {stopLossStopPrice} (stop loss)");
+
+ _tickets = OneCancelsTheOtherOrder(_symbol, -10, limitPrice: takeProfitLimitPrice, stopPrice: stopLossStopPrice);
+ }
+
+ public override void OnOrderEvent(OrderEvent orderEvent)
+ {
+ Debug($"{Time}: {orderEvent}");
+ }
+
+ public override void OnEndOfAlgorithm()
+ {
+ if (_tickets == null)
+ {
+ Debug("OCO exit group was never placed.");
+ return;
+ }
+
+ foreach (var ticket in _tickets)
+ {
+ Debug($"Final status - Order {ticket.OrderId} ({ticket.OrderType}): {ticket.Status}");
+ }
+ }
+ }
+}
diff --git a/Algorithm.CSharp/OneCancelsTheOtherOrderPartialFillRegressionAlgorithm.cs b/Algorithm.CSharp/OneCancelsTheOtherOrderPartialFillRegressionAlgorithm.cs
new file mode 100644
index 000000000000..91c50ae91194
--- /dev/null
+++ b/Algorithm.CSharp/OneCancelsTheOtherOrderPartialFillRegressionAlgorithm.cs
@@ -0,0 +1,285 @@
+/*
+ * 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;
+using QuantConnect.Orders.Fees;
+using QuantConnect.Orders.Fills;
+using QuantConnect.Securities;
+
+namespace QuantConnect.Algorithm.CSharp
+{
+ ///
+ /// Regression algorithm for the reduce rule of a one-cancels-the-other (OCO) order group. It buys 100 SPY and
+ /// then places a 2 leg OCO group to sell the same 100 shares back. A custom fill model fills the stop market leg
+ /// once, partially, and then stops filling it, so the limit leg is the one that finishes the group. Neither leg
+ /// looks at the market price, so the case is reproduced on every run instead of depending on one bar reaching a
+ /// trigger price.
+ ///
+ /// When one leg executes X units, every other open leg must be reduced by X. Here the stop leg executes 30 of
+ /// the 100 shares, so the limit leg must shrink from 100 to 70 and sell only 70. Without the reduce the limit
+ /// leg would still be for 100 and the group would sell 130 shares while it was only given 100.
+ ///
+ public class OneCancelsTheOtherOrderPartialFillRegressionAlgorithm : QCAlgorithm, IRegressionAlgorithmDefinition
+ {
+ ///
+ /// The quantity the group is allowed to execute in total, across every leg
+ ///
+ private const decimal GroupQuantity = 100m;
+
+ private Symbol _spy;
+ private PartialStopFillModel _fillModel;
+ private List _tickets;
+
+ ///
+ /// How much the whole group has executed so far, across every leg. Both legs are allowed to execute here,
+ /// what must never happen is the total passing the quantity the group was given
+ ///
+ private decimal _groupExecutedQuantity;
+
+ private bool _stopLegReportedPartialFill;
+
+ public override void Initialize()
+ {
+ SetStartDate(2019, 1, 1);
+ SetEndDate(2019, 1, 10);
+
+ var equity = AddEquity("SPY", Resolution.Hour);
+ _spy = equity.Symbol;
+
+ // the stop leg fills 30 shares per bar, the limit leg fills all 100 at once. Neither decision looks at
+ // the market price, so the run does not depend on a bar reaching a trigger price
+ _fillModel = new PartialStopFillModel(stopSliceQuantity: 30m);
+ equity.SetFillModel(_fillModel);
+ }
+
+ public override void OnData(Slice slice)
+ {
+ // trade exactly once
+ if (_tickets != null || !slice.ContainsKey(_spy))
+ {
+ return;
+ }
+
+ MarketOrder(_spy, GroupQuantity);
+
+ // both trigger prices sit 30% away from the market and are never reached in this window, so every leg
+ // fill in this algorithm comes from the custom fill model and never from the price of a bar
+ _tickets = OneCancelsTheOtherOrder(_spy, -GroupQuantity,
+ limitPrice: Securities[_spy].Price * 1.30m,
+ stopPrice: Securities[_spy].Price * 0.70m);
+ }
+
+ public override void OnOrderEvent(OrderEvent orderEvent)
+ {
+ Log(orderEvent.ToString());
+ // OneCancelsTheOtherOrder returns the limit leg first and the stop market leg second
+ if (_tickets == null || (orderEvent.OrderId != _tickets[0].OrderId && orderEvent.OrderId != _tickets[1].OrderId))
+ {
+ // not one of our group legs, for example the entry market order
+ return;
+ }
+
+ if (!orderEvent.Status.IsFill())
+ {
+ return;
+ }
+
+ if (orderEvent.OrderId == _tickets[1].OrderId && orderEvent.Status == OrderStatus.PartiallyFilled)
+ {
+ _stopLegReportedPartialFill = true;
+ }
+
+ _groupExecutedQuantity += orderEvent.FillQuantity;
+
+ // this is the rule under test. Both legs may execute, but every unit one leg executes is taken off the
+ // others, so the group can never execute more than the quantity it was given
+ if (Math.Abs(_groupExecutedQuantity) > GroupQuantity)
+ {
+ throw new RegressionTestException(
+ $"Leg {orderEvent.OrderId} executed {orderEvent.FillQuantity} units and took the one-cancels-the-other " +
+ $"group to {Math.Abs(_groupExecutedQuantity)} units in total, but the group was only given {GroupQuantity}. " +
+ "Every unit a leg executes must be taken off the other legs.");
+ }
+ }
+
+ public override void OnEndOfAlgorithm()
+ {
+ if (_tickets == null || _tickets.Count != 2)
+ {
+ throw new RegressionTestException("Expected the one-cancels-the-other order group to have been placed with 2 legs.");
+ }
+
+ if (!_stopLegReportedPartialFill)
+ {
+ throw new RegressionTestException(
+ "Expected the stop leg to report at least one PartiallyFilled order event, otherwise this algorithm is not " +
+ "testing the partial fill rule at all.");
+ }
+
+ var executedQuantity = Math.Abs(_tickets[0].QuantityFilled) + Math.Abs(_tickets[1].QuantityFilled);
+ if (executedQuantity != GroupQuantity)
+ {
+ throw new RegressionTestException(
+ $"The group executed {executedQuantity} units in total but it was given {GroupQuantity}: " +
+ $"limit leg {_tickets[0].QuantityFilled}, stop leg {_tickets[1].QuantityFilled}.");
+ }
+
+ // the stop leg executed 30, so the limit leg must have been reduced from 100 to 70. This is the assertion
+ // that actually proves the reduce happened, rather than the group simply stopping after the first leg
+ var expectedLimitQuantity = -(GroupQuantity - Math.Abs(_tickets[1].QuantityFilled));
+ if (_tickets[0].Quantity != expectedLimitQuantity)
+ {
+ throw new RegressionTestException(
+ $"Expected the limit leg to have been reduced to {expectedLimitQuantity} after the stop leg executed " +
+ $"{_tickets[1].QuantityFilled}, but it is still for {_tickets[0].Quantity}.");
+ }
+
+ // the limit leg finishes the group at its reduced size, which completes it and cancels the stop leg
+ if (_tickets[0].Status != OrderStatus.Filled)
+ {
+ throw new RegressionTestException($"Expected the limit leg to end up Filled, but it was {_tickets[0].Status}.");
+ }
+
+ if (_tickets[1].Status != OrderStatus.Canceled)
+ {
+ throw new RegressionTestException($"Expected the stop leg to be canceled by the group, but it was {_tickets[1].Status}.");
+ }
+
+ if (Portfolio.Invested)
+ {
+ throw new RegressionTestException(
+ $"Expected no open position at the end of the algorithm, but SPY holdings are {Portfolio[_spy].Quantity}.");
+ }
+ }
+
+ ///
+ /// Fill model that drives both group legs from its own state instead of from the market price: the stop market
+ /// leg comes back partially filled in fixed slices until it is complete, and the limit leg comes back
+ /// completely filled. Both legs only fill while the exchange is open, so a bar outside market hours leaves the
+ /// whole group untouched rather than letting the limit leg fill on its own
+ ///
+ private class PartialStopFillModel : ImmediateFillModel
+ {
+ private readonly decimal _stopSliceQuantity;
+ private bool _stopLegFilled;
+
+ public PartialStopFillModel(decimal stopSliceQuantity)
+ {
+ _stopSliceQuantity = stopSliceQuantity;
+ }
+
+ public override OrderEvent StopMarketFill(Security asset, StopMarketOrder order)
+ {
+ // a fresh order event carries the order's current status and a zero fill quantity, which is how this
+ // model says "no fill this bar"
+ var fill = new OrderEvent(order, asset.LocalTime.ConvertToUtc(asset.Exchange.TimeZone), OrderFee.Zero);
+ if (!IsExchangeOpen(asset, false) || _stopLegFilled)
+ {
+ // after the one slice this leg goes quiet, so the limit leg is evaluated on the next bar and
+ // finishes the group at whatever size the reduce left it
+ return fill;
+ }
+
+ _stopLegFilled = true;
+ fill.FillPrice = asset.Price;
+ fill.FillQuantity = Math.Sign(order.Quantity) * Math.Min(_stopSliceQuantity, order.AbsoluteQuantity);
+ fill.Status = OrderStatus.PartiallyFilled;
+
+ return fill;
+ }
+
+ public override OrderEvent LimitFill(Security asset, LimitOrder order)
+ {
+ var fill = new OrderEvent(order, asset.LocalTime.ConvertToUtc(asset.Exchange.TimeZone), OrderFee.Zero);
+ if (!IsExchangeOpen(asset, false))
+ {
+ return fill;
+ }
+
+ // fills whatever this leg is for at this moment, which is the point: if the reduce worked the leg is
+ // for 70 by now, not the 100 it was submitted with
+ fill.FillPrice = asset.Price;
+ fill.FillQuantity = order.Quantity;
+ fill.Status = OrderStatus.Filled;
+
+ 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 => 106;
+
+ ///
+ /// 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.08%"},
+ {"Average Loss", "0%"},
+ {"Compounding Annual Return", "6.272%"},
+ {"Drawdown", "0.000%"},
+ {"Expectancy", "0"},
+ {"Start Equity", "100000"},
+ {"End Equity", "100161.25"},
+ {"Net Profit", "0.161%"},
+ {"Sharpe Ratio", "1.896"},
+ {"Sortino Ratio", "0"},
+ {"Probabilistic Sharpe Ratio", "56.608%"},
+ {"Loss Rate", "0%"},
+ {"Win Rate", "100%"},
+ {"Profit-Loss Ratio", "0"},
+ {"Alpha", "0.021"},
+ {"Beta", "-0.003"},
+ {"Annual Standard Deviation", "0.009"},
+ {"Annual Variance", "0"},
+ {"Information Ratio", "-7.254"},
+ {"Tracking Error", "0.234"},
+ {"Treynor Ratio", "-5.838"},
+ {"Total Fees", "$2.00"},
+ {"Estimated Strategy Capacity", "$60000000.00"},
+ {"Lowest Capacity Asset", "SPY R735QTJ8XC9X"},
+ {"Portfolio Turnover", "4.76%"},
+ {"Drawdown Recovery", "0"},
+ {"OrderListHash", "3fc0ad2cc36dc33821b8d84ca25c5ab5"}
+ };
+ }
+}
diff --git a/Algorithm.CSharp/OneCancelsTheOtherOrderRegressionAlgorithm.cs b/Algorithm.CSharp/OneCancelsTheOtherOrderRegressionAlgorithm.cs
new file mode 100644
index 000000000000..93e7f78e6e26
--- /dev/null
+++ b/Algorithm.CSharp/OneCancelsTheOtherOrderRegressionAlgorithm.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 QuantConnect.Data;
+using QuantConnect.Interfaces;
+using QuantConnect.Orders;
+
+namespace QuantConnect.Algorithm.CSharp
+{
+ ///
+ /// Regression algorithm for one-cancels-the-other (OCO) order groups. It shows that both leg types can win.
+ ///
+ /// Buy 100 SPY, then place two groups one after the other:
+ /// - sell 200: the limit leg wins, so we go from long 100 to short 100
+ /// - buy 100: the stop leg wins, so we end flat
+ ///
+ /// Holdings go 0 -> 100 -> -100 -> 0. In each group the losing leg must be canceled in the same event batch
+ /// as the winning fill. The second group matters because stop legs are checked before limit legs, so a
+ /// winning stop leg takes a different path than a winning limit leg
+ ///
+ public class OneCancelsTheOtherOrderRegressionAlgorithm : QCAlgorithm, IRegressionAlgorithmDefinition
+ {
+ private Symbol _spy;
+
+ // counts every order event we get, so we can tell if two events arrived one after the other
+ private int _orderEventCount;
+
+ private bool _positionOpened;
+ private OrderGroupTracker _reversalGroup;
+ private OrderGroupTracker _coverGroup;
+
+ ///
+ /// What each order group is for
+ ///
+ private enum GroupRole
+ {
+ /// Sells 200, so the winning leg turns long 100 into short 100
+ Reversal,
+
+ /// Buys 100 back, so the winning leg leaves us flat
+ Cover
+ }
+
+ public override void Initialize()
+ {
+ SetStartDate(2019, 1, 1);
+ SetEndDate(2019, 1, 20);
+
+ _spy = AddEquity("SPY", Resolution.Hour).Symbol;
+ }
+
+ public override void OnData(Slice slice)
+ {
+ if (!slice.ContainsKey(_spy))
+ {
+ return;
+ }
+
+ // open the position on its own bar, so the groups below start from a position that is already there
+ if (!_positionOpened)
+ {
+ MarketOrder(_spy, 100);
+ _positionOpened = true;
+ return;
+ }
+
+ // no rounding here: Lean rounds order prices to the brokerage's precision before it sends them
+ var price = Securities[_spy].Price;
+
+ if (_reversalGroup == null)
+ {
+ // sell 200. The January rally reaches the limit +1%, the stop -30% never fills, so the limit wins
+ _reversalGroup = new OrderGroupTracker(OneCancelsTheOtherOrder(_spy, -200,
+ limitPrice: price * 1.01m,
+ stopPrice: price * 0.70m));
+ return;
+ }
+
+ if (_coverGroup == null && _reversalGroup.HasWinner)
+ {
+ // now short 100, so buy it back with the prices swapped: the rally reaches the stop +1% and the
+ // limit -30% never fills, so this time the stop wins. We wait for the first group to have a
+ // winner instead of checking Portfolio.Invested, which is also false while an order is working
+ _coverGroup = new OrderGroupTracker(OneCancelsTheOtherOrder(_spy, 100,
+ limitPrice: price * 0.70m,
+ stopPrice: price * 1.01m));
+ }
+ }
+
+ public override void OnOrderEvent(OrderEvent orderEvent)
+ {
+ _orderEventCount++;
+
+ // events that belong to no group are skipped, for example the opening market order
+ FindGroup(orderEvent.OrderId)?.Track(orderEvent, _orderEventCount);
+ }
+
+ private OrderGroupTracker FindGroup(int orderId)
+ {
+ if (_reversalGroup != null && _reversalGroup.Contains(orderId))
+ {
+ return _reversalGroup;
+ }
+
+ return _coverGroup != null && _coverGroup.Contains(orderId) ? _coverGroup : null;
+ }
+
+ public override void OnEndOfAlgorithm()
+ {
+ AssertGroupResolved(_reversalGroup, GroupRole.Reversal, winningOrderType: OrderType.Limit);
+ AssertGroupResolved(_coverGroup, GroupRole.Cover, winningOrderType: OrderType.StopMarket);
+
+ // bought 100, sold 200, bought 100 back, so we end with nothing
+ var holdings = Portfolio[_spy].Quantity;
+ if (holdings != 0m)
+ {
+ throw new RegressionTestException(
+ $"Expected to end flat after the cover group's stop leg bought the short back, but SPY holdings are {holdings}.");
+ }
+ }
+
+ ///
+ /// Checks one group: the leg of the given type filled, the other leg was canceled, and the cancel came
+ /// in the same event batch as the fill
+ ///
+ /// The group to check
+ /// What this group was for, used in the error messages
+ /// The type of the leg that should have filled
+ private static void AssertGroupResolved(OrderGroupTracker group, GroupRole role, OrderType winningOrderType)
+ {
+ if (group == null || group.Tickets.Count != 2)
+ {
+ throw new RegressionTestException(
+ $"Expected the {role} one-cancels-the-other group to have been placed with 2 legs.");
+ }
+
+ var winner = group.Tickets.Single(ticket => ticket.OrderType == winningOrderType);
+ if (winner.Status != OrderStatus.Filled)
+ {
+ throw new RegressionTestException(
+ $"Expected the {role} group's {winner.OrderType} leg to be filled, but it was {winner.Status}.");
+ }
+
+ var loser = group.Tickets.Single(ticket => ticket.OrderType != winningOrderType);
+ if (loser.Status != OrderStatus.Canceled)
+ {
+ throw new RegressionTestException(
+ $"Expected the {role} group's {loser.OrderType} leg to be canceled by the group, but it was {loser.Status}.");
+ }
+
+ if (!group.SiblingCanceledInSameBatch)
+ {
+ throw new RegressionTestException(
+ $"Expected the {role} group's losing leg Canceled event to have arrived in the same order-event batch as the winning fill.");
+ }
+ }
+
+ ///
+ /// Watches one group: only one leg may fill, and the other leg must be canceled in the same event batch
+ ///
+ private sealed class OrderGroupTracker
+ {
+ private int? _winnerOrderId;
+ private DateTime _winnerFillUtcTime;
+ private int _winnerFillEventCount;
+
+ public OrderGroupTracker(List tickets)
+ {
+ Tickets = tickets;
+ }
+
+ public List Tickets { get; }
+
+ public bool HasWinner => _winnerOrderId.HasValue;
+
+ public bool SiblingCanceledInSameBatch { get; private set; }
+
+ public bool Contains(int orderId) => Tickets.Any(ticket => ticket.OrderId == orderId);
+
+ public void Track(OrderEvent orderEvent, int orderEventCount)
+ {
+ if (orderEvent.Status == OrderStatus.Filled)
+ {
+ if (_winnerOrderId.HasValue)
+ {
+ throw new RegressionTestException(
+ $"Order {orderEvent.OrderId} filled after order {_winnerOrderId.Value} had already won the group. " +
+ "Only one leg of a one-cancels-the-other group should ever fill.");
+ }
+
+ _winnerOrderId = orderEvent.OrderId;
+ _winnerFillUtcTime = orderEvent.UtcTime;
+ _winnerFillEventCount = orderEventCount;
+ }
+ else if (orderEvent.Status == OrderStatus.Canceled)
+ {
+ if (!_winnerOrderId.HasValue)
+ {
+ throw new RegressionTestException(
+ $"Order {orderEvent.OrderId} was canceled before any leg of the group had filled.");
+ }
+
+ // same batch means same timestamp, and the very next event we get after the fill
+ if (orderEvent.UtcTime != _winnerFillUtcTime || orderEventCount != _winnerFillEventCount + 1)
+ {
+ throw new RegressionTestException(
+ "Expected the losing leg's Canceled event to arrive in the same order-event batch as the winning Filled event.");
+ }
+
+ SiblingCanceledInSameBatch = true;
+ }
+ }
+ }
+
+ ///
+ /// 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 => 190;
+
+ ///
+ /// 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", "5"},
+ {"Average Win", "0.40%"},
+ {"Average Loss", "-0.23%"},
+ {"Compounding Annual Return", "3.683%"},
+ {"Drawdown", "0.700%"},
+ {"Expectancy", "0.389"},
+ {"Start Equity", "100000"},
+ {"End Equity", "100175.21"},
+ {"Net Profit", "0.175%"},
+ {"Sharpe Ratio", "-0.089"},
+ {"Sortino Ratio", "-0.063"},
+ {"Probabilistic Sharpe Ratio", "39.090%"},
+ {"Loss Rate", "50%"},
+ {"Win Rate", "50%"},
+ {"Profit-Loss Ratio", "1.78"},
+ {"Alpha", "-0.254"},
+ {"Beta", "0.158"},
+ {"Annual Standard Deviation", "0.035"},
+ {"Annual Variance", "0.001"},
+ {"Information Ratio", "-10.492"},
+ {"Tracking Error", "0.152"},
+ {"Treynor Ratio", "-0.02"},
+ {"Total Fees", "$3.00"},
+ {"Estimated Strategy Capacity", "$170000000.00"},
+ {"Lowest Capacity Asset", "SPY R735QTJ8XC9X"},
+ {"Portfolio Turnover", "5.34%"},
+ {"Drawdown Recovery", "1"},
+ {"OrderListHash", "ee50ff86401969f4159066ba8fceee62"}
+ };
+ }
+}
diff --git a/Algorithm.Python/OneCancelsTheOtherOrderCancelRegressionAlgorithm.py b/Algorithm.Python/OneCancelsTheOtherOrderCancelRegressionAlgorithm.py
new file mode 100644
index 000000000000..c6adb46b6cc6
--- /dev/null
+++ b/Algorithm.Python/OneCancelsTheOtherOrderCancelRegressionAlgorithm.py
@@ -0,0 +1,72 @@
+# 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 for the cancel path of a one-cancels-the-other (OCO) order group: the group is
+### placed with both legs far from the market, so neither can fill inside the test window, then one of the
+### two tickets is explicitly canceled. Asserts that canceling one leg cancels the whole group, not just
+### the leg that was canceled
+###
+class OneCancelsTheOtherOrderCancelRegressionAlgorithm(QCAlgorithm):
+ '''Regression algorithm for the cancel path of a one-cancels-the-other (OCO) order group'''
+
+ def initialize(self) -> None:
+ self.set_start_date(2019, 1, 1)
+ self.set_end_date(2019, 1, 31)
+
+ self._spy = self.add_equity("SPY", Resolution.HOUR).symbol
+ self._tickets = None
+ self._canceled = False
+
+ def on_data(self, data: Slice) -> None:
+ if not self.portfolio.invested:
+ self.market_order(self._spy, 100)
+
+ # both legs sit far from the market: limit sell +30% and stop sell -30% should never be
+ # reachable in this test window, so only the explicit cancel below can close the group
+ self._tickets = self.one_cancels_the_other_order(self._spy, -100,
+ limit_price=self.securities[self._spy].price * 1.30,
+ stop_price=self.securities[self._spy].price * 0.70)
+
+ elif not self._canceled and self.time.day > 5:
+ # cancel only one leg: the whole OCO group must cancel with it
+ self._tickets[0].cancel()
+ self._canceled = True
+
+ def on_order_event(self, order_event: OrderEvent) -> None:
+ if self._tickets is None or order_event.status != OrderStatus.FILLED:
+ return
+
+ # neither OCO leg's price should ever be reachable in this test window; a fill here means the
+ # regression scenario itself is broken, not just the cancellation behavior being tested
+ if any(ticket.order_id == order_event.order_id for ticket in self._tickets):
+ raise RegressionTestException(
+ f"Unexpected fill for OCO leg {order_event.order_id}: prices were set far from the market so the group should only close through the explicit cancel")
+
+ def on_end_of_algorithm(self) -> None:
+ if not self._canceled:
+ raise RegressionTestException("Expected to have canceled one of the OCO legs before the end of the algorithm")
+
+ if self._tickets is None or len(self._tickets) != 2:
+ raise RegressionTestException("Expected the OCO group to have exactly 2 legs")
+
+ for ticket in self._tickets:
+ if ticket.status != OrderStatus.CANCELED:
+ raise RegressionTestException(
+ f"Expected every OCO leg to be Canceled, including the leg that was not explicitly canceled. Leg {ticket.order_id} has status {ticket.status}")
+
+ # canceling the OCO exit group must not touch the original market order fill
+ if not self.portfolio.invested:
+ raise RegressionTestException("Expected the algorithm to still be invested: the market order fill is independent from the canceled OCO group")
diff --git a/Algorithm.Python/OneCancelsTheOtherOrderRegressionAlgorithm.py b/Algorithm.Python/OneCancelsTheOtherOrderRegressionAlgorithm.py
new file mode 100644
index 000000000000..b065492a19e8
--- /dev/null
+++ b/Algorithm.Python/OneCancelsTheOtherOrderRegressionAlgorithm.py
@@ -0,0 +1,170 @@
+# 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 *
+from enum import Enum
+
+###
+### Regression algorithm for one-cancels-the-other (OCO) order groups. It shows that both leg types can win.
+###
+### Buy 100 SPY, then place two groups one after the other:
+### - sell 200: the limit leg wins, so we go from long 100 to short 100
+### - buy 100: the stop leg wins, so we end flat
+###
+### Holdings go 0 -> 100 -> -100 -> 0. In each group the losing leg must be canceled in the same event batch
+### as the winning fill. The second group matters because stop legs are checked before limit legs, so a
+### winning stop leg takes a different path than a winning limit leg
+###
+class OneCancelsTheOtherOrderRegressionAlgorithm(QCAlgorithm):
+ '''Regression algorithm for one-cancels-the-other (OCO) order groups: both leg types can win'''
+
+ def initialize(self) -> None:
+ self.set_start_date(2019, 1, 1)
+ self.set_end_date(2019, 1, 20)
+
+ self._spy = self.add_equity("SPY", Resolution.HOUR).symbol
+
+ # counts every order event we get, so we can tell if two events arrived one after the other
+ self._order_event_count = 0
+
+ self._position_opened = False
+ self._reversal_group = None
+ self._cover_group = None
+
+ def on_data(self, data: Slice) -> None:
+ if not data.contains_key(self._spy):
+ return
+
+ # open the position on its own bar, so the groups below start from a position that is already there
+ if not self._position_opened:
+ self.market_order(self._spy, 100)
+ self._position_opened = True
+ return
+
+ # no rounding here: Lean rounds order prices to the brokerage's precision before it sends them
+ price = self.securities[self._spy].price
+
+ if self._reversal_group is None:
+ # sell 200. The January rally reaches the limit +1%, the stop -30% never fills, so the limit wins
+ self._reversal_group = OrderGroupTracker(self.one_cancels_the_other_order(self._spy, -200,
+ limit_price=price * 1.01,
+ stop_price=price * 0.70))
+ return
+
+ if self._cover_group is None and self._reversal_group.has_winner:
+ # now short 100, so buy it back with the prices swapped: the rally reaches the stop +1% and the
+ # limit -30% never fills, so this time the stop wins. We wait for the first group to have a
+ # winner instead of checking portfolio.invested, which is also false while an order is working
+ self._cover_group = OrderGroupTracker(self.one_cancels_the_other_order(self._spy, 100,
+ limit_price=price * 0.70,
+ stop_price=price * 1.01))
+
+ def on_order_event(self, order_event: OrderEvent) -> None:
+ self._order_event_count += 1
+
+ # events that belong to no group are skipped, for example the opening market order
+ group = self._find_group(order_event.order_id)
+ if group is not None:
+ group.track(order_event, self._order_event_count)
+
+ def _find_group(self, order_id: int):
+ if self._reversal_group is not None and self._reversal_group.contains(order_id):
+ return self._reversal_group
+
+ if self._cover_group is not None and self._cover_group.contains(order_id):
+ return self._cover_group
+
+ return None
+
+ def on_end_of_algorithm(self) -> None:
+ self._assert_group_resolved(self._reversal_group, GroupRole.REVERSAL, OrderType.LIMIT)
+ self._assert_group_resolved(self._cover_group, GroupRole.COVER, OrderType.STOP_MARKET)
+
+ # bought 100, sold 200, bought 100 back, so we end with nothing
+ holdings = self.portfolio[self._spy].quantity
+ if holdings != 0:
+ raise RegressionTestException(
+ f"Expected to end flat after the cover group's stop leg bought the short back, but SPY holdings are {holdings}.")
+
+ def _assert_group_resolved(self, group, role, winning_order_type) -> None:
+ '''Checks one group: the leg of the given type filled, the other leg was canceled, and the cancel came
+ in the same event batch as the fill'''
+ if group is None or len(group.tickets) != 2:
+ raise RegressionTestException(
+ f"Expected the {role.name} one-cancels-the-other group to have been placed with 2 legs.")
+
+ winner = next(ticket for ticket in group.tickets if ticket.order_type == winning_order_type)
+ if winner.status != OrderStatus.FILLED:
+ raise RegressionTestException(
+ f"Expected the {role.name} group's {winner.order_type} leg to be filled, but it was {winner.status}.")
+
+ loser = next(ticket for ticket in group.tickets if ticket.order_type != winning_order_type)
+ if loser.status != OrderStatus.CANCELED:
+ raise RegressionTestException(
+ f"Expected the {role.name} group's {loser.order_type} leg to be canceled by the group, but it was {loser.status}.")
+
+ if not group.sibling_canceled_in_same_batch:
+ raise RegressionTestException(
+ f"Expected the {role.name} group's losing leg Canceled event to have arrived in the same order-event batch as the winning fill.")
+
+
+class GroupRole(Enum):
+ '''What each order group is for'''
+
+ # sells 200, so the winning leg turns long 100 into short 100
+ REVERSAL = 0
+
+ # buys 100 back, so the winning leg leaves us flat
+ COVER = 1
+
+
+class OrderGroupTracker:
+ '''Watches one group: only one leg may fill, and the other leg must be canceled in the same event batch'''
+
+ def __init__(self, tickets) -> None:
+ self.tickets = tickets
+ self.sibling_canceled_in_same_batch = False
+
+ self._winner_order_id = None
+ self._winner_fill_utc_time = None
+ self._winner_fill_event_count = None
+
+ @property
+ def has_winner(self) -> bool:
+ return self._winner_order_id is not None
+
+ def contains(self, order_id: int) -> bool:
+ return any(ticket.order_id == order_id for ticket in self.tickets)
+
+ def track(self, order_event: OrderEvent, order_event_count: int) -> None:
+ if order_event.status == OrderStatus.FILLED:
+ if self._winner_order_id is not None:
+ raise RegressionTestException(
+ f"Order {order_event.order_id} filled after order {self._winner_order_id} had already won the group. "
+ "Only one leg of a one-cancels-the-other group should ever fill.")
+
+ self._winner_order_id = order_event.order_id
+ self._winner_fill_utc_time = order_event.utc_time
+ self._winner_fill_event_count = order_event_count
+
+ elif order_event.status == OrderStatus.CANCELED:
+ if self._winner_order_id is None:
+ raise RegressionTestException(
+ f"Order {order_event.order_id} was canceled before any leg of the group had filled.")
+
+ # same batch means same timestamp, and the very next event we get after the fill
+ if order_event.utc_time != self._winner_fill_utc_time or order_event_count != self._winner_fill_event_count + 1:
+ raise RegressionTestException(
+ "Expected the losing leg's Canceled event to arrive in the same order-event batch as the winning Filled event.")
+
+ self.sibling_canceled_in_same_batch = True
diff --git a/Algorithm/QCAlgorithm.Trading.cs b/Algorithm/QCAlgorithm.Trading.cs
index ddba02f0249c..64bd552631a6 100644
--- a/Algorithm/QCAlgorithm.Trading.cs
+++ b/Algorithm/QCAlgorithm.Trading.cs
@@ -928,6 +928,37 @@ public List ComboLimitOrder(List legs, int quantity, decimal l
return SubmitComboOrder(legs, quantity, limitPrice, asynchronous, tag, orderProperties);
}
+ ///
+ /// Creates a one-cancels-the-other (OCO) order group on a single symbol: a limit order and a stop
+ /// market order for the same quantity, placed together. Both legs are live in the market at the same
+ /// time; the first one to fully fill cancels the other. The common use is a take profit limit leg
+ /// plus a stop loss leg protecting an open position
+ ///
+ /// The symbol both legs trade
+ /// The signed quantity both legs share, it cannot be zero
+ /// The limit price of the limit leg
+ /// The stop price of the stop market leg
+ /// Send the orders asynchronously (false). Otherwise we'll block until every leg is submitted
+ /// String tag applied to both legs (optional)
+ ///
+ /// The order properties to use for both legs, including their shared time in force. Defaults to
+ ///
+ ///
+ /// Two order tickets: the limit leg first, the stop market leg second. If a pre-order check
+ /// fails, nothing is placed and the list contains a single invalid ticket
+ [DocumentationAttribute(TradingAndOrders)]
+ public List OneCancelsTheOtherOrder(Symbol symbol, decimal quantity, decimal limitPrice, decimal stopPrice,
+ bool asynchronous = false, string tag = "", IOrderProperties orderProperties = null)
+ {
+ var orders = new List
+ {
+ new LimitOrder(symbol, quantity, limitPrice, UtcTime),
+ new StopMarketOrder(symbol, quantity, stopPrice, UtcTime)
+ };
+
+ return SubmitGroupOrder(GroupExecutionType.OneCancelsTheOther, orders, 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.
@@ -1008,6 +1039,74 @@ private List SubmitComboOrder(List legs, decimal quantity, dec
return orderTickets;
}
+ ///
+ /// Builds fresh s for every leg of an order group from a list of order
+ /// specs, runs the pre-order checks for every leg before submitting any of them, and submits them in
+ /// list order. This is the shared submitter for order group types that are not the existing ratio-based
+ /// combo ( is unrelated and unchanged):
+ /// is the first caller, and the future conditional (OTO) and bracket order types add their own thin
+ /// wrapper over this same method
+ ///
+ /// How the legs of the group execute relative to each other
+ /// The order specs that make up the group's legs
+ /// Send the orders asynchronously (false). Otherwise we'll block until every leg is submitted
+ /// String tag applied to every leg
+ /// The order properties to use for every leg. Defaults to
+ /// One order ticket per leg, in the same order as
+ private List SubmitGroupOrder(GroupExecutionType groupExecutionType, List orders, bool asynchronous, string tag, IOrderProperties orderProperties)
+ {
+ // one clock and one group manager for every leg: a stale user time would corrupt Day-TIF expiry,
+ // and the legs must share a single clock so one can't fill a bar early relative to the others
+ var groupOrderManager = new GroupOrderManager(Transactions.GetIncrementGroupOrderManagerId(), orders.Count, orders[0].Quantity)
+ {
+ ExecutionType = groupExecutionType
+ };
+
+ List orderTickets = new(capacity: orders.Count);
+ List submitRequests = new(capacity: orders.Count);
+ foreach (var order in orders)
+ {
+ var security = GetSecurityForOrder(order.Symbol);
+
+ order.GetOrderPrices(out var limitPrice, out var stopPrice, out var triggerPrice, out var trailingAmount,
+ out var trailingAsPercentage);
+
+ var request = CreateSubmitOrderRequest(
+ order.Type,
+ security,
+ order.Quantity,
+ tag,
+ orderProperties ?? DefaultOrderProperties?.Clone(),
+ asynchronous: asynchronous,
+ groupOrderManager: groupOrderManager,
+ limitPrice: limitPrice ?? 0m,
+ stopPrice: stopPrice ?? 0m,
+ triggerPrice: triggerPrice ?? 0m,
+ trailingAmount: trailingAmount ?? 0m,
+ trailingAsPercentage: trailingAsPercentage);
+
+ // we execute pre order checks for all requests before submitting, so that if anything fails we
+ // are not left with half submitted groups
+ var response = PreOrderChecks(request);
+ if (response.IsError)
+ {
+ orderTickets.Add(OrderTicket.InvalidSubmitRequest(Transactions, request, response));
+ return orderTickets;
+ }
+
+ submitRequests.Add(request);
+ }
+
+ foreach (var request in submitRequests)
+ {
+ orderTickets.Add(Transactions.AddOrder(request));
+ }
+
+ // unlike a combo market order, a group of this kind has nothing that fills at submit time (resting
+ // legs stay open by design), so there is nothing to synchronously wait for here
+ return orderTickets;
+ }
+
///
/// Will submit an order request to the algorithm
///
diff --git a/Brokerages/Backtesting/BacktestingBrokerage.cs b/Brokerages/Backtesting/BacktestingBrokerage.cs
index 8f6b2e1068d8..f2f946ba1f37 100644
--- a/Brokerages/Backtesting/BacktestingBrokerage.cs
+++ b/Brokerages/Backtesting/BacktestingBrokerage.cs
@@ -204,6 +204,12 @@ public override bool CancelOrder(Order order)
var result = true;
foreach (var orderInGroup in orders)
{
+ if (orderInGroup.Status.IsClosed())
+ {
+ // already resolved (e.g. filled as the winner of a one-cancels-the-other group): leave it untouched
+ continue;
+ }
+
lock (_needsScanLock)
{
if (!_pending.TryRemove(orderInGroup.Id, out var _))
@@ -243,6 +249,9 @@ public virtual void Scan()
}
var stillNeedsScan = false;
+ // _pending holds one entry per leg, so the same group shows up more than once in this pass. this set
+ // remembers the handled groups, so a partial fill is not counted twice. built only when a group appears
+ HashSet processedGroupIds = null;
// process each pending order to produce fills/fire events
foreach (var kvp in _pending.OrderBySafe(x => x.Key))
@@ -284,6 +293,32 @@ public virtual void Scan()
continue;
}
+ if (order.GroupOrderManager != null && order.GroupOrderManager.ExecutionType != GroupExecutionType.Combo)
+ {
+ // this group has already been fully evaluated earlier in this same Scan() pass
+ // (through one of its other legs); nothing more to do for it this round
+ if (!(processedGroupIds ??= []).Add(order.GroupOrderManager.Id))
+ {
+ continue;
+ }
+
+ switch (order.GroupOrderManager.ExecutionType)
+ {
+ case GroupExecutionType.OneCancelsTheOther:
+ stillNeedsScan |= ProcessOneCancelsTheOtherGroup(orders, securities);
+ break;
+
+ default:
+ Log.Error($"BacktestingBrokerage.Scan(): unsupported order group execution type " +
+ $"{order.GroupOrderManager.ExecutionType} for group {order.GroupOrderManager.Id}");
+ RemoveOrders(orders, OrderStatus.Invalid,
+ $"Order groups of type {order.GroupOrderManager.ExecutionType} are not supported.");
+ break;
+ }
+
+ continue;
+ }
+
if (!TryOrderPreChecks(securities, out stillNeedsScan))
{
continue;
@@ -587,6 +622,11 @@ private void RemoveOrders(List orders, OrderStatus orderStatus, string me
for (var i = 0; i < orders.Count; i++)
{
var order = orders[i];
+ if (order.Status.IsClosed())
+ {
+ // already resolved (e.g. filled as the winner of a one-cancels-the-other group): leave it untouched
+ continue;
+ }
orderEvents.Add(new OrderEvent(order, Algorithm.UtcTime, OrderFee.Zero, message) { Status = orderStatus });
_pending.TryRemove(order.Id, out var _);
}
@@ -642,6 +682,196 @@ private bool TryOrderPreChecks(Dictionary ordersSecurities, out
return result;
}
+ ///
+ /// Processes a one-cancels-the-other group: evaluates the open legs in a fixed, deterministic order
+ /// (stop-type legs first, then limit legs, then by Id) and, as soon as one leg fully fills, cancels
+ /// every other leg in the same event batch. This is reused as-is by the future conditional (OTO) and
+ /// bracket order types for their own OCO-shaped exit pair
+ ///
+ /// Every leg of the group
+ /// The security of each leg
+ /// True when the group is still open and has to be evaluated again on a later scan
+ private bool ProcessOneCancelsTheOtherGroup(List orders, Dictionary securities)
+ {
+ if (orders.Any(o => o.Type != OrderType.Limit && o.Type != OrderType.StopMarket))
+ {
+ Log.Error($"BacktestingBrokerage.ProcessOneCancelsTheOtherGroup(): unsupported order type(s) in group " +
+ $"{orders[0].GroupOrderManager.Id}: [{string.Join(",", orders.Select(o => o.Type))}]");
+ RemoveOrders(orders, OrderStatus.Invalid, "One-cancels-the-other groups only support Limit and StopMarket orders.");
+ return false;
+ }
+
+ if (!TryOrderPreChecks(securities, out var groupNeedsScan))
+ {
+ return groupNeedsScan;
+ }
+
+ HasSufficientBuyingPowerForOrderResult hasSufficientBuyingPowerResult;
+ try
+ {
+ hasSufficientBuyingPowerResult = Algorithm.Portfolio.HasSufficientBuyingPowerForOrder(orders);
+ }
+ catch (Exception err)
+ {
+ RemoveOrders(orders, OrderStatus.Invalid, err.Message);
+
+ Log.Error(err);
+ Algorithm.Error($"Order Error: ids: [{string.Join(",", orders.Select(o => o.Id))}], Error executing margin models: {err.Message}");
+ return false;
+ }
+
+ if (!hasSufficientBuyingPowerResult.IsSufficient)
+ {
+ if (orders.Any(o => o.Status == OrderStatus.CancelPending))
+ {
+ // the pending CancelOrderRequest will be handled during the next transaction handler run
+ return true;
+ }
+
+ var message = securities.GetErrorMessage(hasSufficientBuyingPowerResult);
+ RemoveOrders(orders, OrderStatus.Invalid, message);
+ Algorithm.Error(message);
+ return false;
+ }
+
+ // a bar does not say which price came first, so when it covers both legs we take the stop: the worse
+ // outcome for the algorithm. Same Id order every run, so the same backtest gives the same answer
+ var openLegs = orders.Where(o => !o.Status.IsClosed())
+ .OrderBy(o => o.Type == OrderType.StopMarket ? 0 : 1)
+ .ThenBy(o => o.Id);
+
+ var legEvents = new List();
+ foreach (var leg in openLegs)
+ {
+ var fills = TryFillLeg(leg, securities[leg], securities);
+ if (fills.Count == 0)
+ {
+ continue;
+ }
+
+ legEvents.AddRange(fills);
+
+ // the group has one quantity to trade. this leg used part of it, so take that part off the other
+ // legs and stop here: no second leg may fill in the same pass. events with no quantity change nothing
+ var executedQuantity = fills.Sum(fill => fill.FillQuantity);
+ if (executedQuantity != 0)
+ {
+ ReduceOpenSiblings(orders, leg, executedQuantity, legEvents);
+ break;
+ }
+ }
+
+ if (legEvents.Count == 0)
+ {
+ return true;
+ }
+
+ OnOrderEvents(legEvents);
+
+ if (!orders.All(o => o.Status.IsClosed()))
+ {
+ return true;
+ }
+
+ foreach (var o in orders)
+ {
+ _pending.TryRemove(o.Id, out _);
+ }
+
+ return false;
+ }
+
+ ///
+ /// Evaluates the fill for a single leg using its security's fill model, honoring its time in force and
+ /// computing its fee. Knows nothing about the group the leg might belong to, so it is reused unchanged
+ /// by every group processor
+ ///
+ private List TryFillLeg(Order order, Security security, Dictionary securities)
+ {
+ var legEvents = new List();
+ try
+ {
+ var context = new FillModelParameters(
+ security,
+ order,
+ Algorithm.SubscriptionManager.SubscriptionDataConfigService,
+ Algorithm.Settings.StalePriceTimeSpan,
+ securities,
+ OnOrderUpdated);
+
+ var fill = security.FillModel.Fill(context);
+ if (!fill.All(x => order.TimeInForce.IsFillValid(security, order, x)))
+ {
+ return legEvents;
+ }
+
+ foreach (var fillEvent in fill.Where(x => x.OrderId == order.Id))
+ {
+ if (fillEvent.Status == OrderStatus.Filled && fillEvent.OrderFee.Value.Amount == 0m)
+ {
+ fillEvent.OrderFee = security.FeeModel.GetOrderFee(new OrderFeeParameters(security, order));
+ }
+
+ if (order.Status != fillEvent.Status || fillEvent.FillQuantity != 0)
+ {
+ order.Status = fillEvent.Status;
+ legEvents.Add(fillEvent);
+ }
+ }
+ }
+ catch (Exception err)
+ {
+ Log.Error(err);
+ Algorithm.Error($"Order Error: id: {order.Id}, Transaction model failed to fill for order type: {order.Type} with error: {err.Message}");
+ }
+
+ return legEvents;
+ }
+
+ ///
+ /// Makes the other legs smaller after one leg trades. Say the group is for 100 shares and one leg sells 30.
+ /// Every other leg drops from 100 to 70, so the group can never sell more than the 100 it was given, and the
+ /// 70 that are left keep both exits. A leg that drops to 0 is canceled, which is how a full fill on one leg
+ /// cancels the rest. The events go into the same batch as the fill. The bracket order type reuses this
+ ///
+ /// Every leg of the group
+ /// The leg that just executed
+ /// The signed quantity that leg just executed
+ /// The event batch of this pass, appended to in place
+ /// The new size is written straight onto the order: the brokerage and the transaction handler share
+ /// the same instance, so shows it right away.
+ /// keeps the size the group was submitted with, which is what it
+ /// means and nothing on this path reads it
+ private void ReduceOpenSiblings(List orders, Order executingLeg, decimal executedQuantity, List events)
+ {
+ // this leg is Filled, so it used the whole group quantity. the other legs are left with nothing to trade
+ var groupIsComplete = executingLeg.Status == OrderStatus.Filled;
+ var absoluteExecutedQuantity = Math.Abs(executedQuantity);
+
+ foreach (var sibling in orders)
+ {
+ if (sibling.Id == executingLeg.Id || sibling.Status.IsClosed())
+ {
+ continue;
+ }
+
+ var absoluteQuantity = sibling.AbsoluteQuantity - absoluteExecutedQuantity;
+ if (groupIsComplete || absoluteQuantity <= 0)
+ {
+ // cancel it instead of setting its quantity to 0: an order of zero has no side and never fills
+ sibling.Status = OrderStatus.Canceled;
+ events.Add(new OrderEvent(sibling, Algorithm.UtcTime, OrderFee.Zero, "OCO") { Status = OrderStatus.Canceled });
+ continue;
+ }
+
+ // set the quantity before building the event so it carries the new size
+ sibling.Quantity = Math.Sign(sibling.Quantity) * absoluteQuantity;
+ events.Add(new OrderEvent(sibling, Algorithm.UtcTime, OrderFee.Zero,
+ $"OCO: reduced by {absoluteExecutedQuantity} executed by leg {executingLeg.Id}")
+ { Status = OrderStatus.UpdateSubmitted });
+ }
+ }
+
private Order TryGetOrder(int orderId)
{
_pending.TryGetValue(orderId, out var order);
diff --git a/Common/Brokerages/AlpacaBrokerageModel.cs b/Common/Brokerages/AlpacaBrokerageModel.cs
index 8637e25fd358..8a3af09b382d 100644
--- a/Common/Brokerages/AlpacaBrokerageModel.cs
+++ b/Common/Brokerages/AlpacaBrokerageModel.cs
@@ -115,9 +115,71 @@ public override bool CanSubmitOrder(Security security, Order order, out Brokerag
return false;
}
+ if (order.GroupOrderManager?.ExecutionType == GroupExecutionType.OneCancelsTheOther &&
+ !CanSubmitOneCancelsTheOtherLeg(security, order, out message))
+ {
+ return false;
+ }
+
return base.CanSubmitOrder(security, order, out message);
}
+ ///
+ /// Alpaca's one-cancels-the-other order class is stricter than Lean's generic order group: exactly 2 legs,
+ /// US equities only, both legs on the same side, one limit take profit plus one stop market stop loss, and a
+ /// day or good til canceled time in force
+ ///
+ /// The security of the leg being checked
+ /// The leg being checked
+ /// The reason the leg cannot be submitted, when this returns false
+ /// True when this leg is allowed in an Alpaca one-cancels-the-other group
+ /// This runs once per leg, so it only covers the rules a single leg can answer. The two rules that
+ /// need both legs at once, that they share a symbol and that there is exactly one of each order type, stay in
+ /// the brokerage plugin, which sees the whole group after it is buffered
+ private bool CanSubmitOneCancelsTheOtherLeg(Security security, Order order, out BrokerageMessageEvent message)
+ {
+ message = null;
+ var groupOrderManager = order.GroupOrderManager;
+
+ if (groupOrderManager.Count != 2)
+ {
+ message = new BrokerageMessageEvent(BrokerageMessageType.Warning, "NotSupported",
+ Messages.AlpacaBrokerageModel.UnsupportedOneCancelsTheOtherLegCount(this, groupOrderManager.Count));
+ return false;
+ }
+
+ if (security.Type != SecurityType.Equity)
+ {
+ message = new BrokerageMessageEvent(BrokerageMessageType.Warning, "NotSupported",
+ Messages.AlpacaBrokerageModel.UnsupportedOneCancelsTheOtherSecurityType(this, security.Type));
+ return false;
+ }
+
+ if (order.Type != OrderType.Limit && order.Type != OrderType.StopMarket)
+ {
+ message = new BrokerageMessageEvent(BrokerageMessageType.Warning, "NotSupported",
+ Messages.AlpacaBrokerageModel.UnsupportedOneCancelsTheOtherOrderType(this, order.Type));
+ return false;
+ }
+
+ // the group takes its direction from the first leg, so a leg facing the other way is a mixed-side group
+ if (order.Direction != groupOrderManager.Direction)
+ {
+ message = new BrokerageMessageEvent(BrokerageMessageType.Warning, "NotSupported",
+ Messages.AlpacaBrokerageModel.UnsupportedOneCancelsTheOtherDirection(this, order.Direction, groupOrderManager.Direction));
+ return false;
+ }
+
+ if (order.TimeInForce is not DayTimeInForce and not GoodTilCanceledTimeInForce)
+ {
+ message = new BrokerageMessageEvent(BrokerageMessageType.Warning, "NotSupported",
+ Messages.AlpacaBrokerageModel.UnsupportedOneCancelsTheOtherTimeInForce(this, order.TimeInForce));
+ return false;
+ }
+
+ return true;
+ }
+
///
/// Returns true if the brokerage would allow updating the order as specified by the request
///
@@ -132,6 +194,7 @@ public override bool CanUpdateOrder(Security security, Order order, UpdateOrderR
return true;
}
+ ///
///
/// Returns the allowed Market-on-Open submission window for Alpaca.
///
diff --git a/Common/Extensions.cs b/Common/Extensions.cs
index be982b6c8f16..c101c17ce236 100644
--- a/Common/Extensions.cs
+++ b/Common/Extensions.cs
@@ -2805,18 +2805,22 @@ public static string ResolutionToLower(this Resolution resolution)
}
///
- /// Turn order into an order ticket
- ///
- /// The being converted
- /// The transaction manager,
- ///
- public static OrderTicket ToOrderTicket(this Order order, SecurityTransactionManager transactionManager)
- {
- var limitPrice = 0m;
- var stopPrice = 0m;
- var triggerPrice = 0m;
- var trailingAmount = 0m;
- var trailingAsPercentage = false;
+ /// Reads the prices an order carries. A price the given order type does not use comes back as null
+ ///
+ /// The to read the prices from
+ /// The order's limit price, null when it has none
+ /// The order's stop price, null when it has none
+ /// The order's trigger price, null when it has none
+ /// The order's trailing amount, null when it has none
+ /// True when is a percentage
+ public static void GetOrderPrices(this Order order, out decimal? limitPrice, out decimal? stopPrice, out decimal? triggerPrice,
+ out decimal? trailingAmount, out bool trailingAsPercentage)
+ {
+ limitPrice = null;
+ stopPrice = null;
+ triggerPrice = null;
+ trailingAmount = null;
+ trailingAsPercentage = false;
switch (order.Type)
{
@@ -2860,17 +2864,29 @@ public static OrderTicket ToOrderTicket(this Order order, SecurityTransactionMan
limitPrice = legLimitOrder.LimitPrice;
break;
default:
- throw new ArgumentOutOfRangeException();
+ throw new ArgumentOutOfRangeException(nameof(order), order.Type, "Unsupported order type.");
}
+ }
+
+ ///
+ /// Turn order into an order ticket
+ ///
+ /// The being converted
+ /// The transaction manager,
+ ///
+ public static OrderTicket ToOrderTicket(this Order order, SecurityTransactionManager transactionManager)
+ {
+ order.GetOrderPrices(out var limitPrice, out var stopPrice, out var triggerPrice, out var trailingAmount,
+ out var trailingAsPercentage);
var submitOrderRequest = new SubmitOrderRequest(order.Type,
order.SecurityType,
order.Symbol,
order.Quantity,
- stopPrice,
- limitPrice,
- triggerPrice,
- trailingAmount,
+ stopPrice ?? 0m,
+ limitPrice ?? 0m,
+ triggerPrice ?? 0m,
+ trailingAmount ?? 0m,
trailingAsPercentage,
order.Time,
order.Tag,
diff --git a/Common/Messages/Messages.Brokerages.cs b/Common/Messages/Messages.Brokerages.cs
index fbdb8ee51da0..d5e6e7e3fa08 100644
--- a/Common/Messages/Messages.Brokerages.cs
+++ b/Common/Messages/Messages.Brokerages.cs
@@ -182,6 +182,56 @@ public static string TradingOutsideRegularHoursNotSupported(IBrokerageModel brok
return Invariant($"The {brokerageModel.GetType().Name} does not support {orderType} orders with {timeInForce} TIF outside regular hours. ") +
Invariant($"Only {OrderType.Limit} orders with {TimeInForce.Day} TIF are supported outside regular trading hours.");
}
+
+ ///
+ /// Returns a message indicating that a one-cancels-the-other group has the wrong number of legs
+ ///
+ [MethodImpl(MethodImplOptions.AggressiveInlining)]
+ public static string UnsupportedOneCancelsTheOtherLegCount(IBrokerageModel brokerageModel, int legCount)
+ {
+ return Invariant($"The {brokerageModel.GetType().Name} only supports one-cancels-the-other order groups with exactly 2 legs, ") +
+ Invariant($"a take profit and a stop loss, but the group has {legCount}.");
+ }
+
+ ///
+ /// Returns a message indicating that one-cancels-the-other groups are not supported for the given security type
+ ///
+ [MethodImpl(MethodImplOptions.AggressiveInlining)]
+ public static string UnsupportedOneCancelsTheOtherSecurityType(IBrokerageModel brokerageModel, SecurityType securityType)
+ {
+ return Invariant($"The {brokerageModel.GetType().Name} only supports one-cancels-the-other order groups on {SecurityType.Equity}, ") +
+ Invariant($"but received {securityType}.");
+ }
+
+ ///
+ /// Returns a message indicating that a one-cancels-the-other leg uses an order type the group does not allow
+ ///
+ [MethodImpl(MethodImplOptions.AggressiveInlining)]
+ public static string UnsupportedOneCancelsTheOtherOrderType(IBrokerageModel brokerageModel, OrderType orderType)
+ {
+ return Invariant($"The {brokerageModel.GetType().Name} only supports a {OrderType.Limit} take profit leg and a ") +
+ Invariant($"{OrderType.StopMarket} stop loss leg in a one-cancels-the-other order group, but received {orderType}.");
+ }
+
+ ///
+ /// Returns a message indicating that the legs of a one-cancels-the-other group are not on the same side
+ ///
+ [MethodImpl(MethodImplOptions.AggressiveInlining)]
+ public static string UnsupportedOneCancelsTheOtherDirection(IBrokerageModel brokerageModel, OrderDirection legDirection, OrderDirection groupDirection)
+ {
+ return Invariant($"The {brokerageModel.GetType().Name} requires every leg of a one-cancels-the-other order group to be on the ") +
+ Invariant($"same side, but a {legDirection} leg was placed in a {groupDirection} group.");
+ }
+
+ ///
+ /// Returns a message indicating that a one-cancels-the-other group uses an unsupported time in force
+ ///
+ [MethodImpl(MethodImplOptions.AggressiveInlining)]
+ public static string UnsupportedOneCancelsTheOtherTimeInForce(IBrokerageModel brokerageModel, TimeInForce timeInForce)
+ {
+ return Invariant($"The {brokerageModel.GetType().Name} only supports one-cancels-the-other order groups with a ") +
+ Invariant($"{TimeInForce.Day} or {TimeInForce.GoodTilCanceled} time in force, but received {timeInForce.GetType().Name}.");
+ }
}
///
diff --git a/Common/Orders/GroupExecutionType.cs b/Common/Orders/GroupExecutionType.cs
new file mode 100644
index 000000000000..05531a7a7d2f
--- /dev/null
+++ b/Common/Orders/GroupExecutionType.cs
@@ -0,0 +1,33 @@
+/*
+ * 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
+{
+ ///
+ /// How the orders that share a execute relative to each other
+ ///
+ public enum GroupExecutionType
+ {
+ ///
+ /// All legs are placed and filled together as one unit (today's combo behavior) (0)
+ ///
+ Combo = 0,
+
+ ///
+ /// One leg fills and every other leg in the group is canceled (1)
+ ///
+ OneCancelsTheOther = 1
+ }
+}
diff --git a/Common/Orders/GroupOrderExtensions.cs b/Common/Orders/GroupOrderExtensions.cs
index 21848d8c62b1..8fa105b3f55d 100644
--- a/Common/Orders/GroupOrderExtensions.cs
+++ b/Common/Orders/GroupOrderExtensions.cs
@@ -72,6 +72,79 @@ public static bool TryGetGroupOrders(this Order order, Func orderPro
return true;
}
+ ///
+ /// Reduces a sequence of open order tickets down to the ones whose remaining quantity should count
+ /// towards a per-symbol open-order quantity aggregation (for example projected holdings or a shortable
+ /// check). Tickets that are not part of a group count individually; for a one-cancels-the-other group,
+ /// only the leg with the largest absolute remaining quantity counts, since exactly one leg of the
+ /// group can ever execute
+ ///
+ /// The open order tickets to reduce. It is enumerated twice when a group is
+ /// present, so it must be a re-enumerable sequence
+ /// The tickets whose remaining quantity should count towards the aggregation. When no
+ /// one-cancels-the-other group is present this is itself
+ public static IEnumerable GetEffectiveOpenQuantityTickets(this IEnumerable tickets)
+ {
+ foreach (var ticket in tickets)
+ {
+ var groupOrderManager = ticket.SubmitRequest.GroupOrderManager;
+ if (groupOrderManager != null && groupOrderManager.ExecutionType == GroupExecutionType.OneCancelsTheOther)
+ {
+ // there is something to reduce, only now do the work
+ return ReduceOneCancelsTheOtherGroups(tickets);
+ }
+ }
+
+ // nothing to reduce: hand back the very same sequence, so callers that have no group order
+ // allocate nothing and aggregate exactly what they would have aggregated without this call
+ return tickets;
+ }
+
+ ///
+ /// Keeps, for every one-cancels-the-other group, only the leg with the largest absolute remaining
+ /// quantity, since exactly one leg of the group can ever execute. Every other ticket is kept as is
+ ///
+ private static IEnumerable ReduceOneCancelsTheOtherGroups(IEnumerable tickets)
+ {
+ Dictionary largestLegPerGroup = null;
+ foreach (var ticket in tickets)
+ {
+ var groupOrderManager = ticket.SubmitRequest.GroupOrderManager;
+ if (groupOrderManager == null || groupOrderManager.ExecutionType != GroupExecutionType.OneCancelsTheOther)
+ {
+ yield return ticket;
+ continue;
+ }
+
+ largestLegPerGroup ??= new Dictionary();
+ if (!largestLegPerGroup.TryGetValue(groupOrderManager.Id, out var largestLeg) ||
+ IsLargerExposure(ticket, largestLeg))
+ {
+ largestLegPerGroup[groupOrderManager.Id] = ticket;
+ }
+ }
+
+ if (largestLegPerGroup != null)
+ {
+ foreach (var largestLeg in largestLegPerGroup.Values)
+ {
+ yield return largestLeg;
+ }
+ }
+ }
+
+ ///
+ /// True when the candidate leg has more open exposure than the current one. Ties break on the lower
+ /// order id, so the result does not depend on the order the tickets happen to be enumerated in
+ ///
+ private static bool IsLargerExposure(OrderTicket candidate, OrderTicket current)
+ {
+ var candidateQuantity = Math.Abs(candidate.QuantityRemaining);
+ var currentQuantity = Math.Abs(current.QuantityRemaining);
+ return candidateQuantity > currentQuantity ||
+ (candidateQuantity == currentQuantity && candidate.OrderId < current.OrderId);
+ }
+
///
/// Gets the securities corresponding to each order in the group
///
diff --git a/Common/Orders/GroupOrderManager.cs b/Common/Orders/GroupOrderManager.cs
index 46f2c2af6b89..d971f12d156b 100644
--- a/Common/Orders/GroupOrderManager.cs
+++ b/Common/Orders/GroupOrderManager.cs
@@ -48,6 +48,14 @@ public class GroupOrderManager
[JsonProperty(PropertyName = "limitPrice")]
public decimal LimitPrice { get; set; }
+ ///
+ /// How the orders in this group execute relative to each other
+ ///
+ /// keeps previously serialized groups unchanged:
+ /// they load as
+ [JsonProperty(PropertyName = "executionType", DefaultValueHandling = DefaultValueHandling.Ignore)]
+ public GroupExecutionType ExecutionType { get; set; }
+
///
/// The order Ids in this group
///
diff --git a/Common/Orders/Order.cs b/Common/Orders/Order.cs
index 9f6ced0b4f64..bb5dde3cf53e 100644
--- a/Common/Orders/Order.cs
+++ b/Common/Orders/Order.cs
@@ -476,6 +476,10 @@ private static Order CreateOrder(int orderId, OrderType type, Symbol symbol, dec
throw new ArgumentOutOfRangeException();
}
order.Status = OrderStatus.New;
+ if (order.GroupOrderManager == null && groupOrderManager != null)
+ {
+ order.GroupOrderManager = groupOrderManager;
+ }
order.Id = orderId;
return order;
}
diff --git a/Common/Orders/OrderJsonConverter.cs b/Common/Orders/OrderJsonConverter.cs
index 6e9b284699ae..900ea1b174ae 100644
--- a/Common/Orders/OrderJsonConverter.cs
+++ b/Common/Orders/OrderJsonConverter.cs
@@ -86,6 +86,12 @@ public static Order CreateOrderFromJObject(JObject jObject)
var orderType = (OrderType)(jObject["Type"]?.Value() ?? jObject["type"].Value());
var order = CreateOrder(orderType, jObject);
+ var groupOrderManagerToken = jObject["GroupOrderManager"] ?? jObject["groupOrderManager"];
+ if (order.GroupOrderManager == null && groupOrderManagerToken != null && groupOrderManagerToken.Type != JTokenType.Null)
+ {
+ order.GroupOrderManager = DeserializeGroupOrderManager(jObject);
+ }
+
// populate common order properties
order.Id = jObject["Id"]?.Value() ?? jObject["id"].Value();
@@ -376,6 +382,12 @@ private static GroupOrderManager DeserializeGroupOrderManager(JObject jObject)
SafeDecimalValue(groupOrderManagerJObject["LimitPrice"] ?? groupOrderManagerJObject["limitPrice"])
);
+ var groupExecutionType = groupOrderManagerJObject["ExecutionType"] ?? groupOrderManagerJObject["executionType"];
+ if (groupExecutionType != null && groupExecutionType.Type != JTokenType.Null)
+ {
+ result.ExecutionType = (GroupExecutionType)groupExecutionType.Value();
+ }
+
foreach (var orderId in (groupOrderManagerJObject["OrderIds"]?.Values() ?? groupOrderManagerJObject["orderIds"].Values()))
{
result.OrderIds.Add(orderId);
diff --git a/Common/Securities/CashBuyingPowerModel.cs b/Common/Securities/CashBuyingPowerModel.cs
index 92c44eca0eec..4a135f840411 100644
--- a/Common/Securities/CashBuyingPowerModel.cs
+++ b/Common/Securities/CashBuyingPowerModel.cs
@@ -423,6 +423,10 @@ private static decimal GetOpenOrdersReservedQuantity(SecurityPortfolioManager po
}
}
+ var oneCancelsTheOtherGroupId = order.GroupOrderManager?.ExecutionType == GroupExecutionType.OneCancelsTheOther
+ ? order.GroupOrderManager.Id
+ : (int?)null;
+
// fetch open orders with matching symbol/side
var openOrders = portfolio.Transactions.GetOpenOrders(x =>
{
@@ -432,6 +436,8 @@ private static decimal GetOpenOrdersReservedQuantity(SecurityPortfolioManager po
dir == x.Direction &&
// don't count our current order
x.Id != order.Id &&
+ // don't count siblings of the same one-cancels-the-other group: only one of them can ever execute
+ (oneCancelsTheOtherGroupId == null || x.GroupOrderManager?.Id != oneCancelsTheOtherGroupId) &&
// only count working orders
(x.Type == OrderType.Limit || x.Type == OrderType.StopMarket);
}
diff --git a/Common/Securities/SecurityPortfolioManager.cs b/Common/Securities/SecurityPortfolioManager.cs
index 66fa8dd9b084..65fe87a311fa 100644
--- a/Common/Securities/SecurityPortfolioManager.cs
+++ b/Common/Securities/SecurityPortfolioManager.cs
@@ -941,11 +941,51 @@ public void SetMarginCallModel(PyObject pyObject)
/// True if the algorithm has enough buying power available
public HasSufficientBuyingPowerForOrderResult HasSufficientBuyingPowerForOrder(List orders)
{
+ if (orders.Count > 1 && orders[0].GroupOrderManager is { ExecutionType: not GroupExecutionType.Combo } groupOrderManager)
+ {
+ switch (groupOrderManager.ExecutionType)
+ {
+ case GroupExecutionType.OneCancelsTheOther:
+ return HasSufficientBuyingPowerForOneCancelsTheOtherGroup(orders);
+
+ default:
+ throw new NotSupportedException(
+ $"SecurityPortfolioManager.HasSufficientBuyingPowerForOrder(): unsupported order group execution type: {groupOrderManager.ExecutionType}");
+ }
+ }
+
if (Positions.TryCreatePositionGroup(orders, out var group))
{
return group.BuyingPowerModel.HasSufficientBuyingPowerForOrder(new HasSufficientPositionGroupBuyingPowerForOrderParameters(this, group, orders));
}
+ return HasSufficientBuyingPowerForEachOrder(orders);
+ }
+
+ ///
+ /// Checks the buying power of a one-cancels-the-other group. Exactly one leg of the group can ever
+ /// execute, so only that one leg has to be affordable. This runs before the position group path on
+ /// purpose: two option legs on different contracts could otherwise form a valid strategy there and get
+ /// margined as if both execute, which is wrong for a one-winner group
+ ///
+ private HasSufficientBuyingPowerForOrderResult HasSufficientBuyingPowerForOneCancelsTheOtherGroup(List orders)
+ {
+ if (orders.All(order => order.Symbol == orders[0].Symbol))
+ {
+ var mostExpensiveLeg = orders.OrderByDescending(order => Math.Abs(order.GetValue(Securities[order.Symbol]))).First();
+ var mostExpensiveLegSecurity = Securities[mostExpensiveLeg.Symbol];
+ return mostExpensiveLegSecurity.BuyingPowerModel.HasSufficientBuyingPowerForOrder(this, mostExpensiveLegSecurity, mostExpensiveLeg);
+ }
+
+ // legs on different symbols have no shared price to compare, so ask every leg to pass on its own
+ return HasSufficientBuyingPowerForEachOrder(orders);
+ }
+
+ ///
+ /// Checks that every one of the given orders individually has sufficient buying power
+ ///
+ private HasSufficientBuyingPowerForOrderResult HasSufficientBuyingPowerForEachOrder(List orders)
+ {
for (var i = 0; i < orders.Count; i++)
{
var order = orders[i];
diff --git a/Common/Securities/SecurityTransactionManager.cs b/Common/Securities/SecurityTransactionManager.cs
index bad9422ba463..3c4555634c63 100644
--- a/Common/Securities/SecurityTransactionManager.cs
+++ b/Common/Securities/SecurityTransactionManager.cs
@@ -255,6 +255,9 @@ public List CancelOpenOrders()
throw new InvalidOperationException(Messages.SecurityTransactionManager.CancelOpenOrdersNotAllowedOnInitializeOrWarmUp());
}
+ // note: a leg whose sibling was already canceled by an earlier iteration (canceling one leg of a
+ // group cancels every leg) quietly no-ops here instead of failing loudly, so every ticket can safely
+ // go through the same Cancel() call and keep a real CancelRequest
var cancelledOrders = new List();
foreach (var ticket in GetOpenOrderTickets(null, memoize: false))
{
@@ -277,6 +280,9 @@ public List CancelOpenOrders(Symbol symbol, string tag = null)
throw new InvalidOperationException(Messages.SecurityTransactionManager.CancelOpenOrdersNotAllowedOnInitializeOrWarmUp());
}
+ // note: a leg whose sibling was already canceled by an earlier iteration (canceling one leg of a
+ // group cancels every leg) quietly no-ops here instead of failing loudly, so every ticket can safely
+ // go through the same Cancel() call and keep a real CancelRequest
var cancelledOrders = new List();
foreach (var ticket in GetOpenOrderTickets(x => x.Symbol == symbol, memoize: false))
{
@@ -390,6 +396,7 @@ private IEnumerable GetOpenOrderTickets(Func fil
public decimal GetOpenOrdersRemainingQuantity(Func filter = null)
{
return GetOpenOrderTickets(filter, memoize: false)
+ .GetEffectiveOpenQuantityTickets()
.Aggregate(0m, (d, t) => d + t.QuantityRemaining);
}
diff --git a/Engine/TransactionHandlers/BrokerageTransactionHandler.cs b/Engine/TransactionHandlers/BrokerageTransactionHandler.cs
index fa9616d65623..51d1edefd2c8 100644
--- a/Engine/TransactionHandlers/BrokerageTransactionHandler.cs
+++ b/Engine/TransactionHandlers/BrokerageTransactionHandler.cs
@@ -820,7 +820,7 @@ public ProjectedHoldings GetProjectedHoldings(Security security)
lock (_lockHandleOrderEvent)
{
- var openOrderQuantity = openOrderTickets.Aggregate(0m, (d, t) => d + t.QuantityRemaining);
+ var openOrderQuantity = openOrderTickets.GetEffectiveOpenQuantityTickets().Aggregate(0m, (d, t) => d + t.QuantityRemaining);
return new ProjectedHoldings(security.Holdings.Quantity, openOrderQuantity);
}
}
@@ -1011,8 +1011,8 @@ 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)
+ // only a combo skips the buying power check. every other group can update one leg, so check it
+ if (order.GroupOrderManager == null || order.GroupOrderManager.ExecutionType != GroupExecutionType.Combo)
{
var updatedOrder = order.Clone();
updatedOrder.ApplyUpdateOrderRequest(request);
diff --git a/Tests/Algorithm/AlgorithmTradingTests.cs b/Tests/Algorithm/AlgorithmTradingTests.cs
index 831a9702e59a..1e6861589953 100644
--- a/Tests/Algorithm/AlgorithmTradingTests.cs
+++ b/Tests/Algorithm/AlgorithmTradingTests.cs
@@ -1591,6 +1591,45 @@ public void LiquidateIgnoresSymbolsNotAddedToTheAlgorithm(Language language, boo
Assert.IsEmpty(liquidatedTickets);
}
+ [Test]
+ public void LiquidateCancelsEveryOpenOneCancelsTheOtherLeg()
+ {
+ Security msft;
+ var algo = GetAlgorithm(out msft, 1, 0);
+ var aapl = algo.AddEquity("AAPL");
+ // keep the exchange always open so the closing trade is a regular market order, not a MarketOnOpen/Close conversion
+ msft.Exchange.SetMarketHours(new List { MarketHoursSegment.OpenAllDay() });
+ aapl.Exchange.SetMarketHours(new List { MarketHoursSegment.OpenAllDay() });
+ Update(msft, 25);
+ Update(aapl, 25);
+ algo.Portfolio.SetCash(1000000);
+
+ // other, unrelated holdings besides the OCO group
+ msft.Holdings.SetHoldings(25, 100);
+ aapl.Holdings.SetHoldings(25, 50);
+
+ // an open OCO group with 2 legs on MSFT
+ var ocoTickets = algo.OneCancelsTheOtherOrder(Symbols.MSFT, -50m, limitPrice: 30m, stopPrice: 20m);
+ foreach (var ticket in ocoTickets)
+ {
+ Assert.AreNotEqual(OrderStatus.Invalid, ticket.Status);
+ // make the leg visible to Transactions.GetOpenOrders(), which Liquidate() reads
+ _fakeOrderProcessor.AddOrder(Order.CreateOrder(ticket.SubmitRequest));
+ }
+
+ List liquidatedTickets = null;
+ Assert.DoesNotThrow(() => liquidatedTickets = algo.Liquidate());
+
+ // every leg gets its own cancel request. Canceling one leg already cancels its siblings, so the
+ // later requests find nothing left to cancel and answer with an error instead of throwing
+ var canceledLegsCount = ocoTickets.Count(ticket => ticket.CancelRequest != null);
+ Assert.AreEqual(ocoTickets.Count, canceledLegsCount);
+
+ // both symbols still got their closing market order
+ Assert.IsTrue(liquidatedTickets.Any(x => x.Symbol == Symbols.MSFT));
+ Assert.IsTrue(liquidatedTickets.Any(x => x.Symbol == Symbols.AAPL));
+ }
+
[Test]
public void MarketOrdersAreSupportedForFuturesOnExtendedMarketHours()
{
@@ -1803,6 +1842,68 @@ public void ComboOrderLegsRatiosAreValidated(int[] quantities, bool shouldThrow)
}
}
+ [Test]
+ public void OneCancelsTheOtherOrderReturnsLimitAndStopTicketsSharingGroupManager()
+ {
+ Security msft;
+ var algo = GetAlgorithm(out msft, 1, 0);
+ Update(msft, 25);
+
+ var tickets = algo.OneCancelsTheOtherOrder(Symbols.MSFT, -50m, limitPrice: 30m, stopPrice: 20m);
+
+ Assert.AreEqual(2, tickets.Count);
+ foreach (var ticket in tickets)
+ {
+ Assert.AreNotEqual(OrderStatus.Invalid, ticket.Status);
+ Assert.AreEqual(Symbols.MSFT, ticket.Symbol);
+ Assert.AreEqual(-50m, ticket.Quantity);
+ }
+
+ // the limit leg comes first, the stop market leg second, each with its own price
+ Assert.AreEqual(OrderType.Limit, tickets[0].OrderType);
+ Assert.AreEqual(30m, tickets[0].SubmitRequest.LimitPrice);
+ Assert.AreEqual(OrderType.StopMarket, tickets[1].OrderType);
+ Assert.AreEqual(20m, tickets[1].SubmitRequest.StopPrice);
+
+ var groupOrderManager = tickets[0].SubmitRequest.GroupOrderManager;
+ Assert.IsNotNull(groupOrderManager);
+ Assert.AreEqual(GroupExecutionType.OneCancelsTheOther, groupOrderManager.ExecutionType);
+ Assert.AreEqual(2, groupOrderManager.Count);
+ Assert.AreSame(groupOrderManager, tickets[1].SubmitRequest.GroupOrderManager);
+ }
+
+ [Test]
+ public void OneCancelsTheOtherOrderWithZeroQuantityReturnsSingleInvalidTicket()
+ {
+ var algo = GetAlgorithm(out var msft, 1, 0);
+ Update(msft, 25);
+
+ var tickets = algo.OneCancelsTheOtherOrder(Symbols.MSFT, 0m, limitPrice: 30m, stopPrice: 20m);
+
+ Assert.AreEqual(1, tickets.Count);
+ Assert.AreEqual(OrderStatus.Invalid, tickets[0].Status);
+ Assert.AreEqual(OrderResponseErrorCode.OrderQuantityZero, tickets[0].SubmitRequest.Response.ErrorCode);
+ }
+
+ [Test]
+ public void OneCancelsTheOtherOrderAppliesTagAndPropertiesToBothLegs()
+ {
+ Security msft;
+ var algo = GetAlgorithm(out msft, 1, 0);
+ Update(msft, 25);
+
+ var groupProperties = new OrderProperties { TimeInForce = TimeInForce.GoodTilCanceled };
+ var tickets = algo.OneCancelsTheOtherOrder(Symbols.MSFT, -50m, limitPrice: 30m, stopPrice: 20m,
+ tag: "group-tag", orderProperties: groupProperties);
+
+ foreach (var ticket in tickets)
+ {
+ Assert.AreNotEqual(OrderStatus.Invalid, ticket.Status);
+ Assert.AreEqual("group-tag", ticket.SubmitRequest.Tag);
+ Assert.AreEqual(TimeInForce.GoodTilCanceled, ticket.SubmitRequest.OrderProperties.TimeInForce);
+ }
+ }
+
[Test]
public void MarketOnCloseOrdersSubmissionTimeCheck([Values] bool beforeLatestSubmissionTime)
{
diff --git a/Tests/Brokerages/Backtesting/BacktestingBrokerageTests.cs b/Tests/Brokerages/Backtesting/BacktestingBrokerageTests.cs
new file mode 100644
index 000000000000..584e81f5c354
--- /dev/null
+++ b/Tests/Brokerages/Backtesting/BacktestingBrokerageTests.cs
@@ -0,0 +1,394 @@
+/*
+ * 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.Concurrent;
+using System.Collections.Generic;
+using System.Linq;
+using System.Reflection;
+using Moq;
+using NUnit.Framework;
+using QuantConnect.Algorithm;
+using QuantConnect.Brokerages;
+using QuantConnect.Brokerages.Backtesting;
+using QuantConnect.Data.Market;
+using QuantConnect.Interfaces;
+using QuantConnect.Orders;
+using QuantConnect.Orders.Fees;
+using QuantConnect.Orders.Fills;
+using QuantConnect.Securities;
+using QuantConnect.Tests.Engine.DataFeeds;
+
+namespace QuantConnect.Tests.Brokerages.Backtesting
+{
+ ///
+ /// Covers the one-cancels-the-other (OCO) group processing added to :
+ /// exposes no seam to inject a controllable fill outcome, so every test
+ /// here drives real fills through a small test that fills/holds a leg based on the
+ /// security's current price, and reaches into the private pending-order dictionary via reflection to check
+ /// the group's pending-set lifecycle, since that state is not otherwise observable from the public API.
+ ///
+ [TestFixture]
+ public class BacktestingBrokerageTests
+ {
+ private static readonly DateTime ReferenceTime = new DateTime(2024, 1, 25, 15, 0, 0, DateTimeKind.Utc);
+ private static readonly FieldInfo PendingOrdersField =
+ typeof(BacktestingBrokerage).GetField("_pending", BindingFlags.NonPublic | BindingFlags.Instance);
+
+ private QCAlgorithm _algorithm;
+ private Security _security;
+ private BacktestingBrokerage _brokerage;
+ private ControlledFillModel _fillModel;
+ private List> _eventBatches;
+ private DateTime _orderTime;
+
+ [SetUp]
+ public void Setup()
+ {
+ _algorithm = new QCAlgorithm();
+ _algorithm.SubscriptionManager.SetDataManager(new DataManagerStub(_algorithm));
+ _algorithm.SetBrokerageModel(BrokerageName.Default);
+ _algorithm.SetCash(100000);
+ _security = _algorithm.AddEquity("SPY");
+ _algorithm.SetDateTime(ReferenceTime);
+ _algorithm.SetFinishedWarmingUp();
+
+ _fillModel = new ControlledFillModel();
+ _security.SetFillModel(_fillModel);
+ SetPrice(100m);
+
+ _brokerage = new BacktestingBrokerage(_algorithm);
+ _eventBatches = new List>();
+ _brokerage.OrdersStatusChanged += (_, orderEvents) => _eventBatches.Add(orderEvents);
+
+ // legs must not be submitted on the same bar as "now", or Scan() defers them to the next pass
+ _orderTime = ReferenceTime.AddMinutes(-1);
+ }
+
+ [TearDown]
+ public void TearDown()
+ {
+ _brokerage?.Dispose();
+ }
+
+ [Test]
+ public void LegFillCancelsSiblingInSameEventBatch()
+ {
+ // the limit leg touches at 100, the stop leg (120) does not
+ var (limitOrder, stopOrder) = PlaceOcoGroup(limitPrice: 100m, stopPrice: 120m);
+
+ _brokerage.Scan();
+
+ Assert.AreEqual(1, _eventBatches.Count);
+ var batch = _eventBatches.Single();
+ Assert.AreEqual(2, batch.Count);
+
+ var filledEvent = batch.Single(e => e.OrderId == limitOrder.Id);
+ var canceledEvent = batch.Single(e => e.OrderId == stopOrder.Id);
+ Assert.AreEqual(OrderStatus.Filled, filledEvent.Status);
+ Assert.AreEqual(OrderStatus.Canceled, canceledEvent.Status);
+ Assert.AreEqual("OCO", canceledEvent.Message);
+
+ Assert.AreEqual(OrderStatus.Filled, limitOrder.Status);
+ Assert.AreEqual(OrderStatus.Canceled, stopOrder.Status);
+
+ // the group leaves the pending set only once every leg is closed
+ var pending = GetPendingOrders();
+ Assert.IsFalse(pending.ContainsKey(limitOrder.Id));
+ Assert.IsFalse(pending.ContainsKey(stopOrder.Id));
+ }
+
+ [Test]
+ public void StopLegWinsTieOverLimitLeg()
+ {
+ // both legs touch on the same bar (100 == 100): the fixed evaluation order (stop-type legs
+ // first, then limit legs) must make the stop leg the deterministic winner
+ var (limitOrder, stopOrder) = PlaceOcoGroup(limitPrice: 100m, stopPrice: 100m);
+
+ _brokerage.Scan();
+
+ Assert.AreEqual(1, _eventBatches.Count);
+ Assert.AreEqual(2, _eventBatches.Single().Count);
+
+ Assert.AreEqual(OrderStatus.Filled, stopOrder.Status);
+ Assert.AreEqual(OrderStatus.Canceled, limitOrder.Status);
+
+ var canceledEvent = _eventBatches.Single().Single(e => e.OrderId == limitOrder.Id);
+ Assert.AreEqual("OCO", canceledEvent.Message);
+
+ // the limit leg's fill model must never even be asked to fill: the stop leg won first
+ Assert.AreEqual(0, _fillModel.LimitFillInvocations);
+ }
+
+ [Test]
+ public void CancelingOneLegCancelsWholeGroup()
+ {
+ var (limitOrder, stopOrder) = PlaceOcoGroup(limitPrice: 50m, stopPrice: 150m);
+
+ var result = _brokerage.CancelOrder(limitOrder);
+ ApplyEventsToOrders(limitOrder, stopOrder);
+
+ Assert.IsTrue(result);
+ Assert.AreEqual(OrderStatus.Canceled, limitOrder.Status);
+ Assert.AreEqual(OrderStatus.Canceled, stopOrder.Status);
+
+ // CancelOrder fires one event per leg (not a single combined batch like Scan() does)
+ Assert.AreEqual(2, _eventBatches.Count);
+ Assert.IsTrue(_eventBatches.All(batch => batch.Count == 1 && batch[0].Status == OrderStatus.Canceled));
+
+ var pending = GetPendingOrders();
+ Assert.IsFalse(pending.ContainsKey(limitOrder.Id));
+ Assert.IsFalse(pending.ContainsKey(stopOrder.Id));
+ }
+
+ [Test]
+ public void CancelOrderLeavesAlreadyClosedLegUntouched()
+ {
+ // Under the shipped design a group is always either fully pending or fully removed
+ // (ProcessOneCancelsTheOtherGroup resolves fill+cancel-siblings+remove-if-closed atomically), so a
+ // pending set with one closed leg next to an open sibling cannot arise from Scan()/CancelOrder alone.
+ // We set that precondition directly here to exercise CancelOrder's defensive "already closed" guard.
+ var (limitOrder, stopOrder) = PlaceOcoGroup(limitPrice: 50m, stopPrice: 150m);
+ stopOrder.Status = OrderStatus.Filled;
+
+ var result = _brokerage.CancelOrder(limitOrder);
+ ApplyEventsToOrders(limitOrder, stopOrder);
+
+ Assert.IsTrue(result);
+ Assert.AreEqual(OrderStatus.Canceled, limitOrder.Status);
+ // the already-closed leg must not be overwritten back to Canceled
+ Assert.AreEqual(OrderStatus.Filled, stopOrder.Status);
+
+ Assert.AreEqual(1, _eventBatches.Count);
+ var batch = _eventBatches.Single();
+ Assert.AreEqual(1, batch.Count);
+ Assert.AreEqual(limitOrder.Id, batch[0].OrderId);
+
+ var pending = GetPendingOrders();
+ Assert.IsFalse(pending.ContainsKey(limitOrder.Id));
+ // left untouched: the guard skips it before it is ever removed
+ Assert.IsTrue(pending.ContainsKey(stopOrder.Id));
+ }
+
+ [Test]
+ public void TimeInForceExpiryOnAnyLegCancelsWholeGroup()
+ {
+ var properties = new OrderProperties { TimeInForce = TimeInForce.GoodTilDate(ReferenceTime.AddDays(-10)) };
+ var (limitOrder, stopOrder) = PlaceOcoGroup(limitPrice: 50m, stopPrice: 150m, properties: properties);
+
+ _brokerage.Scan();
+ ApplyEventsToOrders(limitOrder, stopOrder);
+
+ Assert.AreEqual(1, _eventBatches.Count);
+ var batch = _eventBatches.Single();
+ Assert.AreEqual(2, batch.Count);
+ Assert.IsTrue(batch.All(e => e.Status == OrderStatus.Canceled));
+ Assert.IsTrue(batch.All(e => e.Message.Contains("expired")));
+
+ Assert.AreEqual(OrderStatus.Canceled, limitOrder.Status);
+ Assert.AreEqual(OrderStatus.Canceled, stopOrder.Status);
+
+ var pending = GetPendingOrders();
+ Assert.IsFalse(pending.ContainsKey(limitOrder.Id));
+ Assert.IsFalse(pending.ContainsKey(stopOrder.Id));
+ }
+
+ [Test]
+ public void PartialFillReducesSiblingsAndGroupStaysPending()
+ {
+ _fillModel.LimitPartialFillQuantity = 5m;
+ var (limitOrder, stopOrder) = PlaceOcoGroup(limitPrice: 100m, stopPrice: 150m);
+ var groupQuantity = limitOrder.Quantity;
+
+ _brokerage.Scan();
+
+ Assert.AreEqual(1, _eventBatches.Count);
+ var batch = _eventBatches.Single();
+
+ // the partial fill and the sibling reduction land in the same batch
+ Assert.AreEqual(2, batch.Count);
+ Assert.AreEqual(limitOrder.Id, batch[0].OrderId);
+ Assert.AreEqual(OrderStatus.PartiallyFilled, batch[0].Status);
+ Assert.AreEqual(stopOrder.Id, batch[1].OrderId);
+ Assert.AreEqual(OrderStatus.UpdateSubmitted, batch[1].Status);
+ Assert.AreEqual(0m, batch[1].FillQuantity);
+
+ Assert.AreEqual(OrderStatus.PartiallyFilled, limitOrder.Status);
+
+ // the sibling stays open but is reduced by what the limit leg executed, so the two legs always cover the
+ // same outstanding quantity and the group can never execute more than it was given. The brokerage only
+ // emits the event, promoting the order to UpdateSubmitted is the transaction handler's job
+ Assert.IsFalse(stopOrder.Status.IsClosed());
+ Assert.AreEqual(groupQuantity - 5m, stopOrder.Quantity);
+
+ // the group stays in the pending set - Scan() must keep finding both legs next time around
+ var pending = GetPendingOrders();
+ Assert.IsTrue(pending.ContainsKey(limitOrder.Id));
+ Assert.IsTrue(pending.ContainsKey(stopOrder.Id));
+ }
+
+ [Test]
+ public void GroupIsProcessedOnlyOnceExactlyPerScanDespiteTwoPendingEntries()
+ {
+ // _pending has one dictionary entry per leg (2 entries for this single group); without the
+ // processedGroupIds guard in Scan(), the group would be evaluated twice in the same pass
+ var (limitOrder, stopOrder) = PlaceOcoGroup(limitPrice: 50m, stopPrice: 150m);
+
+ _brokerage.Scan();
+
+ Assert.AreEqual(1, _fillModel.LimitFillInvocations);
+ Assert.AreEqual(1, _fillModel.StopFillInvocations);
+
+ // neither leg actually touched, so nothing should have fired
+ Assert.AreEqual(0, _eventBatches.Count);
+ Assert.AreEqual(OrderStatus.Submitted, limitOrder.Status);
+ Assert.AreEqual(OrderStatus.Submitted, stopOrder.Status);
+ }
+
+ private void SetPrice(decimal price)
+ {
+ _security.SetMarketPrice(new Tick(ReferenceTime, _security.Symbol, price, price));
+ }
+
+ private ConcurrentDictionary GetPendingOrders()
+ {
+ return (ConcurrentDictionary)PendingOrdersField.GetValue(_brokerage);
+ }
+
+ ///
+ /// Applies every fired order event in back onto the matching Order instance.
+ /// In production this is the transaction handler's job; there is none in this test, so tests that call
+ /// directly (which only fires events, it never mutates the
+ /// Order objects itself) need this to see the resulting status on the Order instances they hold
+ ///
+ private void ApplyEventsToOrders(params Order[] orders)
+ {
+ var ordersById = orders.ToDictionary(o => o.Id);
+ foreach (var orderEvent in _eventBatches.SelectMany(batch => batch))
+ {
+ if (ordersById.TryGetValue(orderEvent.OrderId, out var order))
+ {
+ order.Status = orderEvent.Status;
+ }
+ }
+ }
+
+ ///
+ /// Builds and places a 2-leg one-cancels-the-other group (one Limit leg, one StopMarket leg, both buy
+ /// orders on the same security) directly against the brokerage, mirroring the SubmitOrderRequest/
+ /// GroupOrderManager wiring QCAlgorithm.OneCancelsTheOtherOrder produces
+ ///
+ private (Order Limit, Order Stop) PlaceOcoGroup(decimal limitPrice, decimal stopPrice, decimal quantity = 10m,
+ IOrderProperties properties = null)
+ {
+ var groupOrderManager = new GroupOrderManager(1, 2, quantity) { ExecutionType = GroupExecutionType.OneCancelsTheOther };
+
+ var limitRequest = new SubmitOrderRequest(OrderType.Limit, _security.Type, _security.Symbol, quantity, 0, limitPrice,
+ _orderTime, "", properties, groupOrderManager);
+ limitRequest.SetOrderId(1);
+
+ var stopRequest = new SubmitOrderRequest(OrderType.StopMarket, _security.Type, _security.Symbol, quantity, stopPrice, 0,
+ _orderTime, "", properties, groupOrderManager);
+ stopRequest.SetOrderId(2);
+
+ var limitOrder = Order.CreateOrder(limitRequest);
+ var stopOrder = Order.CreateOrder(stopRequest);
+
+ // BuyingPowerModel.HasSufficientBuyingPowerForOrder looks up an order's ticket via the order
+ // processor, so one must be wired up even though nothing else in this bare-bones setup needs it
+ var orderProcessorMock = new Mock();
+ orderProcessorMock.Setup(m => m.GetOrderTicket(1)).Returns(new OrderTicket(_algorithm.Transactions, limitRequest));
+ orderProcessorMock.Setup(m => m.GetOrderTicket(2)).Returns(new OrderTicket(_algorithm.Transactions, stopRequest));
+ _algorithm.Transactions.SetOrderProcessor(orderProcessorMock.Object);
+
+ _brokerage.PlaceOrder(limitOrder);
+ _brokerage.PlaceOrder(stopOrder);
+
+ // PlaceOrder only fires the Submitted OrderEvent; in production the transaction handler is the one
+ // that applies it back onto the Order it is holding. There is no transaction handler in this test,
+ // so we apply it directly to keep the two Order instances consistent with what Scan() will see.
+ limitOrder.Status = OrderStatus.Submitted;
+ stopOrder.Status = OrderStatus.Submitted;
+
+ // drop the two Submitted events fired by PlaceOrder so each test starts from a clean slate
+ _eventBatches.Clear();
+
+ return (limitOrder, stopOrder);
+ }
+
+ ///
+ /// A fill model whose Limit/StopMarket fills are driven only by the security's current price, so tests
+ /// can force a fill (or a partial fill, or no fill) deterministically without needing real bar/tick
+ /// market-hours mechanics. Also counts invocations so a test can prove a leg's fill model was (or was
+ /// not) asked to fill on a given Scan() pass.
+ ///
+ private class ControlledFillModel : FillModel
+ {
+ public decimal? LimitPartialFillQuantity { get; set; }
+
+ public int LimitFillInvocations { get; private set; }
+
+ public int StopFillInvocations { get; private set; }
+
+ public override OrderEvent LimitFill(Security asset, LimitOrder order)
+ {
+ LimitFillInvocations++;
+
+ var fill = new OrderEvent(order, asset.LocalTime.ConvertToUtc(asset.Exchange.TimeZone), OrderFee.Zero);
+ var touched = order.Direction == OrderDirection.Buy
+ ? asset.Price <= order.LimitPrice
+ : asset.Price >= order.LimitPrice;
+
+ if (!touched)
+ {
+ return fill;
+ }
+
+ if (LimitPartialFillQuantity.HasValue)
+ {
+ fill.Status = OrderStatus.PartiallyFilled;
+ fill.FillQuantity = Math.Sign(order.Quantity) * LimitPartialFillQuantity.Value;
+ }
+ else
+ {
+ fill.Status = OrderStatus.Filled;
+ fill.FillQuantity = order.Quantity;
+ }
+ fill.FillPrice = asset.Price;
+
+ return fill;
+ }
+
+ public override OrderEvent StopMarketFill(Security asset, StopMarketOrder order)
+ {
+ StopFillInvocations++;
+
+ var fill = new OrderEvent(order, asset.LocalTime.ConvertToUtc(asset.Exchange.TimeZone), OrderFee.Zero);
+ var touched = order.Direction == OrderDirection.Buy
+ ? asset.Price >= order.StopPrice
+ : asset.Price <= order.StopPrice;
+
+ if (touched)
+ {
+ fill.Status = OrderStatus.Filled;
+ fill.FillPrice = asset.Price;
+ fill.FillQuantity = order.Quantity;
+ }
+
+ return fill;
+ }
+ }
+ }
+}
diff --git a/Tests/Common/Brokerages/AlpacaBrokerageModelTests.cs b/Tests/Common/Brokerages/AlpacaBrokerageModelTests.cs
index 300823d39b1d..955b1fce6c9b 100644
--- a/Tests/Common/Brokerages/AlpacaBrokerageModelTests.cs
+++ b/Tests/Common/Brokerages/AlpacaBrokerageModelTests.cs
@@ -75,5 +75,64 @@ public void CanSubmitOrderWhenOutsideRegularTradingHours(OrderType orderType, Ti
Assert.That(canSubmit, Is.EqualTo(shouldSubmit));
}
+
+ [Test]
+ public void CanSubmitValidOneCancelsTheOtherGroup()
+ {
+ var groupOrderManager = new GroupOrderManager(1, legCount: 2, quantity: -100) { ExecutionType = GroupExecutionType.OneCancelsTheOther };
+ var security = TestsHelpers.GetSecurity(symbol: Symbols.AAPL.Value, securityType: SecurityType.Equity, market: Market.USA);
+ var order = new LimitOrder(Symbols.AAPL, -100, 220m, DateTime.UtcNow) { GroupOrderManager = groupOrderManager };
+
+ Assert.IsTrue(new AlpacaBrokerageModel().CanSubmitOrder(security, order, out var message));
+ Assert.IsNull(message);
+ }
+
+ private static IEnumerable InvalidOneCancelsTheOtherLegTestCases
+ {
+ get
+ {
+ // Alpaca's group is always exactly a take profit plus a stop loss
+ yield return new TestCaseData(3, SecurityType.Equity, OrderType.Limit, -100m, TimeInForce.GoodTilCanceled)
+ .SetName("RejectsMoreThanTwoLegs");
+
+ // US equities only: crypto and options do not support the OCO order class
+ yield return new TestCaseData(2, SecurityType.Crypto, OrderType.Limit, -100m, TimeInForce.GoodTilCanceled)
+ .SetName("RejectsNonEquitySecurityType");
+
+ // only a Limit take profit and a StopMarket stop loss are mapped
+ yield return new TestCaseData(2, SecurityType.Equity, OrderType.StopLimit, -100m, TimeInForce.GoodTilCanceled)
+ .SetName("RejectsUnsupportedLegOrderType");
+
+ // the group's direction comes from its first leg, so a leg facing the other way is a mixed-side group
+ yield return new TestCaseData(2, SecurityType.Equity, OrderType.Limit, 100m, TimeInForce.GoodTilCanceled)
+ .SetName("RejectsLegOnTheOppositeSide");
+
+ // Alpaca only accepts a day or good til canceled time in force for these groups
+ yield return new TestCaseData(2, SecurityType.Equity, OrderType.Limit, -100m, TimeInForce.GoodTilDate(DateTime.UtcNow.AddDays(7)))
+ .SetName("RejectsUnsupportedTimeInForce");
+ }
+ }
+
+ [TestCaseSource(nameof(InvalidOneCancelsTheOtherLegTestCases))]
+ public void CannotSubmitInvalidOneCancelsTheOtherLeg(int legCount, SecurityType securityType, OrderType orderType,
+ decimal legQuantity, TimeInForce timeInForce)
+ {
+ // the group quantity stays negative, so a positive leg quantity is a leg on the opposite side
+ var groupOrderManager = new GroupOrderManager(1, legCount, quantity: -100) { ExecutionType = GroupExecutionType.OneCancelsTheOther };
+ var symbol = securityType == SecurityType.Crypto ? Symbols.BTCUSD : Symbols.AAPL;
+ var security = TestsHelpers.GetSecurity(symbol: symbol.Value, securityType: securityType,
+ market: securityType == SecurityType.Crypto ? Market.Coinbase : Market.USA);
+ var orderProperties = new OrderProperties { TimeInForce = timeInForce };
+
+ Order order = orderType switch
+ {
+ OrderType.StopLimit => new StopLimitOrder(symbol, legQuantity, 190m, 189m, DateTime.UtcNow, properties: orderProperties),
+ _ => new LimitOrder(symbol, legQuantity, 220m, DateTime.UtcNow, properties: orderProperties)
+ };
+ order.GroupOrderManager = groupOrderManager;
+
+ Assert.IsFalse(new AlpacaBrokerageModel().CanSubmitOrder(security, order, out var message));
+ Assert.IsNotNull(message);
+ }
}
}
diff --git a/Tests/Common/Orders/OrderJsonConverterTests.cs b/Tests/Common/Orders/OrderJsonConverterTests.cs
index fa0aa09087f3..231c4077a98c 100644
--- a/Tests/Common/Orders/OrderJsonConverterTests.cs
+++ b/Tests/Common/Orders/OrderJsonConverterTests.cs
@@ -857,6 +857,58 @@ public void DeserializesGroupOrderManagerQuantityTooLargeForDecimal()
Assert.AreEqual(decimal.MinValue, order.GroupOrderManager.LimitPrice);
}
+ [Test]
+ public void DeserializesGroupOrderManagerWithoutExecutionTypeAsCombo()
+ {
+ // old-style JSON, from before the "executionType" field existed: must default to GroupExecutionType.Combo, not throw
+ const string json = @"{'Type':8,
+'Id':1,
+'ContingentId':0,
+'BrokerId':['1'],
+'Symbol':{'Value':'SPY','Permtick':'SPY'},
+'Price':100.086914328,
+'Time':'2010-03-04T14:31:00Z',
+'Quantity':100.0,
+'Status':3,
+'TimeInForce':0,
+'Tag':'',
+'SecurityType':1,
+'Direction':0,
+'GroupOrderManager':{'Id':1,'Count':2,'Quantity':100,'LimitPrice':210.1,'OrderIds':[1,2]}}";
+
+ var order = (ComboMarketOrder)DeserializeOrder(json);
+
+ Assert.AreEqual(GroupExecutionType.Combo, order.GroupOrderManager.ExecutionType);
+ }
+
+ [Test]
+ public void RoundTripsLimitOrderWithOneCancelsTheOtherGroupOrderManagerTwice()
+ {
+ var groupOrderManager = new GroupOrderManager(1, 2, 100) { ExecutionType = GroupExecutionType.OneCancelsTheOther };
+ var expected = new LimitOrder(Symbols.SPY, 100, 210.10m, new DateTime(2015, 11, 23, 17, 15, 37), "oco")
+ {
+ GroupOrderManager = groupOrderManager,
+ Id = 12345
+ };
+ groupOrderManager.OrderIds.Add(12346);
+
+ AssertGroupOrderManagerSurvivesRoundTripTwice(expected);
+ }
+
+ [Test]
+ public void RoundTripsStopMarketOrderWithOneCancelsTheOtherGroupOrderManagerTwice()
+ {
+ var groupOrderManager = new GroupOrderManager(1, 2, 100) { ExecutionType = GroupExecutionType.OneCancelsTheOther };
+ var expected = new StopMarketOrder(Symbols.SPY, 100, 210.10m, new DateTime(2015, 11, 23, 17, 15, 37), "oco")
+ {
+ GroupOrderManager = groupOrderManager,
+ Id = 12345
+ };
+ groupOrderManager.OrderIds.Add(12346);
+
+ AssertGroupOrderManagerSurvivesRoundTripTwice(expected);
+ }
+
private static T TestOrderType(T expected)
where T : Order
{
@@ -906,6 +958,32 @@ private static void TestGroupOrderManager(GroupOrderManager expected, GroupOrder
CollectionAssert.AreEqual(expected.OrderIds, actual.OrderIds);
}
+ ///
+ /// Serializes and deserializes the given order twice in a row and checks that the OCO GroupOrderManager
+ /// (GroupExecutionType, Count and OrderIds) survives both round trips. The second round trip specifically catches a
+ /// bug where DeserializeGroupOrderManager drops a field that was never explicitly serialized because of
+ /// DefaultValueHandling.Ignore.
+ ///
+ private static void AssertGroupOrderManagerSurvivesRoundTripTwice(Order expected)
+ {
+ var expectedGroupOrderManager = expected.GroupOrderManager;
+
+ var json = JsonConvert.SerializeObject(expected);
+ var actual = DeserializeOrder(json);
+
+ Assert.AreEqual(GroupExecutionType.OneCancelsTheOther, actual.GroupOrderManager.ExecutionType);
+ Assert.AreEqual(expectedGroupOrderManager.Count, actual.GroupOrderManager.Count);
+ CollectionAssert.AreEqual(expectedGroupOrderManager.OrderIds, actual.GroupOrderManager.OrderIds);
+
+ // serialize/deserialize a second time, starting from the already-deserialized order
+ var json2 = JsonConvert.SerializeObject(actual);
+ var actual2 = DeserializeOrder(json2);
+
+ Assert.AreEqual(GroupExecutionType.OneCancelsTheOther, actual2.GroupOrderManager.ExecutionType);
+ Assert.AreEqual(expectedGroupOrderManager.Count, actual2.GroupOrderManager.Count);
+ CollectionAssert.AreEqual(expectedGroupOrderManager.OrderIds, actual2.GroupOrderManager.OrderIds);
+ }
+
private static Order DeserializeOrder(string json) where T : Order
{
var converter = new OrderJsonConverter();
diff --git a/Tests/Common/Orders/OrderTests.cs b/Tests/Common/Orders/OrderTests.cs
index 4d5d65d81079..f0aeca444e07 100644
--- a/Tests/Common/Orders/OrderTests.cs
+++ b/Tests/Common/Orders/OrderTests.cs
@@ -80,6 +80,24 @@ public void TrailingStopOrder_UpdatesStopPriceIfNecessary(OrderDirection directi
}
}
+ [TestCase(OrderType.Limit)]
+ [TestCase(OrderType.StopMarket)]
+ public void CreateOrderAttachesGroupOrderManagerBeforeIdIsSet(OrderType orderType)
+ {
+ var time = new DateTime(2015, 11, 23, 17, 15, 37);
+ var groupOrderManager = new GroupOrderManager(1, 2, 100) { ExecutionType = GroupExecutionType.OneCancelsTheOther };
+ var request = new SubmitOrderRequest(orderType, SecurityType.Equity, Symbols.SPY, 100, 195m, 210.10m, time, "oco",
+ groupOrderManager: groupOrderManager);
+ request.SetOrderId(12345);
+
+ var order = Order.CreateOrder(request);
+
+ Assert.AreEqual(orderType, order.Type);
+ Assert.AreSame(groupOrderManager, order.GroupOrderManager);
+ Assert.AreEqual(12345, order.Id);
+ Assert.IsTrue(groupOrderManager.OrderIds.Contains(12345));
+ }
+
private static TestCaseData[] GetValueTestParameters()
{
const decimal delta = 1m;
diff --git a/Tests/Common/Securities/CashBuyingPowerModelTests.cs b/Tests/Common/Securities/CashBuyingPowerModelTests.cs
index 5c6d07401b9b..31e23e3b5f00 100644
--- a/Tests/Common/Securities/CashBuyingPowerModelTests.cs
+++ b/Tests/Common/Securities/CashBuyingPowerModelTests.cs
@@ -280,6 +280,34 @@ public void LimitSellOrderChecksOpenOrders()
Assert.IsFalse(_buyingPowerModel.HasSufficientBuyingPowerForOrder(_portfolio, _btcusd, stopOrder).IsSufficient);
}
+ [Test]
+ public void OneCancelsTheOtherSellPairDoesNotDoubleCountReservedQuantity()
+ {
+ // holding exactly 1 BTC: the take profit leg and the stop loss leg both sell that same 1 BTC, and only
+ // one of them can ever execute, so the group must be accepted. Without the sibling exclusion in
+ // CashBuyingPowerModel.GetOpenOrdersReservedQuantity the checked leg counts its sibling's -1 BTC as
+ // already reserved and the whole group is rejected for insufficient buying power
+ _portfolio.SetCash(0);
+ _portfolio.CashBook["BTC"].SetAmount(1m);
+
+ // the Coinbase model stopped accepting StopMarket orders in March 2019, before the time of this fixture
+ _algorithm.SetBrokerageModel(new DefaultBrokerageModel(AccountType.Cash));
+
+ _btcusd = _algorithm.AddCrypto("BTCUSD");
+ _btcusd.SetLocalTimeKeeper(_timeKeeper);
+ _btcusd.SetMarketPrice(new Tick { Value = 15000m });
+ _algorithm.SetFinishedWarmingUp();
+
+ // take profit above the market and stop loss below it, so neither leg can fill right away
+ var tickets = _algorithm.OneCancelsTheOtherOrder(_btcusd.Symbol, -1m, limitPrice: 20000m, stopPrice: 10000m);
+
+ Assert.AreEqual(2, tickets.Count);
+ foreach (var ticket in tickets)
+ {
+ Assert.AreEqual(OrderStatus.Submitted, ticket.Status, ticket.SubmitRequest.Response.ErrorMessage);
+ }
+ }
+
[Test]
public void MarketBuyBtcWithUsdRequiresUsdInPortfolioPlusFees()
{
diff --git a/Tests/Common/Securities/SecurityPortfolioManagerTests.cs b/Tests/Common/Securities/SecurityPortfolioManagerTests.cs
index 508eae8c5856..3c00eb1a23f6 100644
--- a/Tests/Common/Securities/SecurityPortfolioManagerTests.cs
+++ b/Tests/Common/Securities/SecurityPortfolioManagerTests.cs
@@ -666,6 +666,120 @@ public void MarginComputesProperlyWithMultipleSecurities()
Assert.IsFalse(hasSufficientBuyingPower);
}
+ [Test]
+ public void OneCancelsTheOtherChecksOnlyMostExpensiveLegBuyingPower()
+ {
+ var (portfolio, orderProcessor) = CreateOneCancelsTheOtherPortfolio(10000m);
+ var groupOrderManager = new GroupOrderManager(1, 2, 50m) { ExecutionType = GroupExecutionType.OneCancelsTheOther };
+
+ // the "most expensive leg only" shortcut only applies to same-symbol legs, where comparing notional
+ // value is meaningful; mixed-symbol/mixed-security-type groups fall back to a conservative per-leg
+ // check instead, since an option's premium is not its margin requirement (see the sibling test below)
+ // AAPL leg: 50 shares at 100 = 5,000
+ var cheaperLeg = CreateOneCancelsTheOtherLeg(orderProcessor, OrderType.Limit, Symbols.AAPL, 50m, 0m, 100m, 1, groupOrderManager);
+ // AAPL leg: 60 shares at 100 = 6,000, the most expensive leg
+ var moreExpensiveLeg = CreateOneCancelsTheOtherLeg(orderProcessor, OrderType.StopMarket, Symbols.AAPL, 60m, 100m, 0m, 2, groupOrderManager);
+
+ // the sum of both legs (11,000) exceeds the 10,000 cash available, but a same-symbol OCO group only
+ // needs to afford its single most expensive leg (6,000), since exactly one leg can ever execute
+ var result = portfolio.HasSufficientBuyingPowerForOrder(new List { cheaperLeg, moreExpensiveLeg });
+
+ Assert.IsTrue(result.IsSufficient, result.Reason);
+ }
+
+ [Test]
+ public void OneCancelsTheOtherRejectsWhenMostExpensiveLegAloneIsUnaffordable()
+ {
+ var (portfolio, orderProcessor) = CreateOneCancelsTheOtherPortfolio(5500m);
+ var groupOrderManager = new GroupOrderManager(1, 2, 50m) { ExecutionType = GroupExecutionType.OneCancelsTheOther };
+
+ // AAPL leg: 50 shares at 100 = 5,000
+ var cheaperLeg = CreateOneCancelsTheOtherLeg(orderProcessor, OrderType.Limit, Symbols.AAPL, 50m, 0m, 100m, 1, groupOrderManager);
+ // AAPL leg: 60 shares at 100 = 6,000, the most expensive leg
+ var moreExpensiveLeg = CreateOneCancelsTheOtherLeg(orderProcessor, OrderType.StopMarket, Symbols.AAPL, 60m, 100m, 0m, 2, groupOrderManager);
+
+ // even the most expensive leg alone (6,000) is more than the 5,500 cash available
+ var result = portfolio.HasSufficientBuyingPowerForOrder(new List { cheaperLeg, moreExpensiveLeg });
+
+ Assert.IsFalse(result.IsSufficient);
+ }
+
+ [Test]
+ public void OneCancelsTheOtherWithDifferentSymbolsChecksEveryLegConservatively()
+ {
+ // AAPL alone ($5,000) is affordable, but there is no valid common notional metric across different
+ // symbols/security types (an option's premium is not its margin requirement), so a mixed-symbol OCO
+ // group conservatively requires every leg to individually pass, same as an ungrouped order list
+ var (portfolio, orderProcessor) = CreateOneCancelsTheOtherPortfolio(5500m);
+ var groupOrderManager = new GroupOrderManager(1, 2, 50m) { ExecutionType = GroupExecutionType.OneCancelsTheOther };
+
+ // AAPL leg: 50 shares at 100 = 5,000, affordable alone
+ var affordableLeg = CreateOneCancelsTheOtherLeg(orderProcessor, OrderType.Limit, Symbols.AAPL, 50m, 0m, 100m, 1, groupOrderManager);
+ // MSFT leg: 60 shares at 100 = 6,000, not affordable alone even though it is not the largest notional
+ // once compared using an invalid cross-symbol metric
+ var unaffordableLeg = CreateOneCancelsTheOtherLeg(orderProcessor, OrderType.StopMarket, Symbols.MSFT, 60m, 100m, 0m, 2, groupOrderManager);
+
+ var result = portfolio.HasSufficientBuyingPowerForOrder(new List { affordableLeg, unaffordableLeg });
+
+ Assert.IsFalse(result.IsSufficient);
+ }
+
+ [Test]
+ public void OneCancelsTheOtherWithSameSymbolLegsSkipsPositionGroupPath()
+ {
+ var (portfolio, orderProcessor) = CreateOneCancelsTheOtherPortfolio(10000m);
+ var groupOrderManager = new GroupOrderManager(1, 2, 50m) { ExecutionType = GroupExecutionType.OneCancelsTheOther };
+
+ // two legs on the same symbol would break the position-groups per-symbol dictionary if resolved as a
+ // regular combo; the OCO branch must run before the position-group path and never reach it
+ var takeProfitLeg = CreateOneCancelsTheOtherLeg(orderProcessor, OrderType.Limit, Symbols.AAPL, 50m, 0m, 95m, 1, groupOrderManager);
+ var stopLossLeg = CreateOneCancelsTheOtherLeg(orderProcessor, OrderType.StopMarket, Symbols.AAPL, 50m, 105m, 0m, 2, groupOrderManager);
+
+ HasSufficientBuyingPowerForOrderResult result = null;
+ Assert.DoesNotThrow(() => result = portfolio.HasSufficientBuyingPowerForOrder(new List { takeProfitLeg, stopLossLeg }));
+ Assert.IsTrue(result.IsSufficient);
+ }
+
+ private (SecurityPortfolioManager Portfolio, OrderProcessor OrderProcessor) CreateOneCancelsTheOtherPortfolio(decimal cash)
+ {
+ var securities = new SecurityManager(TimeKeeper);
+ var transactions = new SecurityTransactionManager(null, securities);
+ var orderProcessor = new OrderProcessor();
+ transactions.SetOrderProcessor(orderProcessor);
+ var portfolio = new SecurityPortfolioManager(securities, transactions, new AlgorithmSettings());
+ portfolio.CashBook[Currencies.USD].SetAmount(cash);
+
+ foreach (var symbol in new[] { Symbols.AAPL, Symbols.MSFT })
+ {
+ securities.Add(symbol, new Security(
+ SecurityExchangeHours,
+ CreateTradeBarDataConfig(SecurityType.Equity, symbol),
+ new Cash(Currencies.USD, 0, 1m),
+ SymbolProperties.GetDefault(Currencies.USD),
+ ErrorCurrencyConverter.Instance,
+ RegisteredSecurityDataTypesProvider.Null,
+ new SecurityCache()
+ ));
+ securities[symbol].SetLeverage(1m);
+ securities[symbol].SetMarketPrice(new TradeBar { Time = DateTime.Now, Value = 100m });
+ }
+
+ return (portfolio, orderProcessor);
+ }
+
+ private static Order CreateOneCancelsTheOtherLeg(OrderProcessor orderProcessor, OrderType orderType, Symbol symbol, decimal quantity,
+ decimal stopPrice, decimal limitPrice, int orderId, GroupOrderManager groupOrderManager)
+ {
+ var request = new SubmitOrderRequest(orderType, SecurityType.Equity, symbol, quantity, stopPrice, limitPrice,
+ DateTime.UtcNow, "", groupOrderManager: groupOrderManager);
+ request.SetOrderId(orderId);
+
+ var order = Order.CreateOrder(request);
+ orderProcessor.AddOrder(order);
+ orderProcessor.AddTicket(new OrderTicket(null, request));
+ return order;
+ }
+
[Test]
public void BuyingSellingFuturesDoesntAddToCash()
{
diff --git a/Tests/Engine/BrokerageTransactionHandlerTests/BrokerageTransactionHandlerTests.cs b/Tests/Engine/BrokerageTransactionHandlerTests/BrokerageTransactionHandlerTests.cs
index a7c22d903ec1..f73a5c5e68f0 100644
--- a/Tests/Engine/BrokerageTransactionHandlerTests/BrokerageTransactionHandlerTests.cs
+++ b/Tests/Engine/BrokerageTransactionHandlerTests/BrokerageTransactionHandlerTests.cs
@@ -516,6 +516,60 @@ public void GetOpenOrderTicketsDoesNotReturnInvalidatedOrder()
Assert.IsEmpty(processedTicket);
}
+ [Test]
+ public void GetProjectedHoldingsCountsOnlyTheMaxExposureLegOfAnOpenOneCancelsTheOtherGroup()
+ {
+ //Initializes the transaction handler
+ _transactionHandler = new TestBrokerageTransactionHandler();
+ using var brokerage = new BacktestingBrokerage(_algorithm);
+ _transactionHandler.Initialize(_algorithm, brokerage, new BacktestingResultHandler());
+
+ _algorithm.SetBrokerageModel(new DefaultBrokerageModel());
+ var security = _algorithm.AddEquity("SPY");
+ var price = 400m;
+ security.SetMarketPrice(new Tick(DateTime.Now, security.Symbol, price, price, price));
+ // an existing 100 share long position that the group order is meant to exit
+ security.Holdings.SetHoldings(price, 100);
+
+ var dateTime = DateTime.Now;
+ var groupOrderManager = new GroupOrderManager(1, 2, -100) { ExecutionType = GroupExecutionType.OneCancelsTheOther };
+
+ // take-profit leg: sell the full 100 share position
+ var takeProfitRequest = new SubmitOrderRequest(OrderType.Limit, security.Type, security.Symbol, -100, 0, 420m,
+ dateTime, "", groupOrderManager: groupOrderManager);
+ // stop-loss leg: sell the full 100 share position
+ var stopLossRequest = new SubmitOrderRequest(OrderType.StopMarket, security.Type, security.Symbol, -100, 380m, 0,
+ dateTime, "", groupOrderManager: groupOrderManager);
+
+ takeProfitRequest.SetOrderId(1);
+ stopLossRequest.SetOrderId(2);
+ groupOrderManager.OrderIds.Add(1);
+ groupOrderManager.OrderIds.Add(2);
+
+ // Mock the order processor
+ var orderProcessorMock = new Mock();
+ orderProcessorMock.Setup(m => m.GetOrderTicket(1)).Returns(new OrderTicket(_algorithm.Transactions, takeProfitRequest));
+ orderProcessorMock.Setup(m => m.GetOrderTicket(2)).Returns(new OrderTicket(_algorithm.Transactions, stopLossRequest));
+ _algorithm.Transactions.SetOrderProcessor(orderProcessorMock.Object);
+
+ // Act: both legs of the group are submitted and become open at the same time
+ var takeProfitTicket = _transactionHandler.Process(takeProfitRequest);
+ _transactionHandler.HandleOrderRequest(takeProfitRequest);
+
+ var stopLossTicket = _transactionHandler.Process(stopLossRequest);
+ _transactionHandler.HandleOrderRequest(stopLossRequest);
+
+ Assert.AreEqual(OrderStatus.Submitted, takeProfitTicket.Status);
+ Assert.AreEqual(OrderStatus.Submitted, stopLossTicket.Status);
+
+ var projectedHoldings = _transactionHandler.GetProjectedHoldings(security);
+
+ // both legs are still open, 100 shares each; only the max-exposure leg should count towards the
+ // open orders quantity, not the sum of both (exactly one leg of the group can ever execute)
+ Assert.AreEqual(100, projectedHoldings.HoldingsQuantity);
+ Assert.AreEqual(-100, projectedHoldings.OpenOrdersQuantity);
+ }
+
[TestCase("NDX", "1.14", "1.15")]
[TestCase("NDX", "1.16", "1.15")]
[TestCase("NDX", "4.14", "4.10")]
@@ -1116,6 +1170,69 @@ public void UpdateOrderRequestShouldWork()
Assert.IsTrue(_algorithm.OrderEvents[1].Status == OrderStatus.UpdateSubmitted);
}
+ // combo groups keep the pre-existing "manager means combo" skip: no buying power validation on update
+ [TestCase(GroupExecutionType.Combo, true)]
+ // OCO legs are validated like a regular order: an update the algorithm cannot afford is rejected
+ [TestCase(GroupExecutionType.OneCancelsTheOther, false)]
+ public void HandleUpdateOrderRequestValidatesBuyingPowerOnlyForNonComboGroups(GroupExecutionType groupExecutionType, bool expectUpdateSucceeds)
+ {
+ _algorithm.SetBrokerageModel(new DefaultBrokerageModel());
+
+ _transactionHandler = new TestBrokerageTransactionHandler();
+ using var brokerage = new BacktestingBrokerage(_algorithm);
+ _transactionHandler.Initialize(_algorithm, brokerage, new BacktestingResultHandler());
+
+ var security = _algorithm.Securities[_symbol];
+ var price = 1.12m;
+ security.SetMarketPrice(new Tick(DateTime.Now, security.Symbol, price, price, price));
+
+ var dateTime = DateTime.UtcNow;
+ var groupOrderManager = new GroupOrderManager(1, 2, 1000) { ExecutionType = groupExecutionType };
+
+ var orderRequest1 = new SubmitOrderRequest(OrderType.Limit, security.Type, security.Symbol, 1000, 0, 1.05m, dateTime, "",
+ groupOrderManager: groupOrderManager);
+ var orderRequest2 = new SubmitOrderRequest(OrderType.StopMarket, security.Type, security.Symbol, 1000, 1.20m, 0, dateTime, "",
+ groupOrderManager: groupOrderManager);
+
+ orderRequest1.SetOrderId(1);
+ orderRequest2.SetOrderId(2);
+ groupOrderManager.OrderIds.Add(1);
+ groupOrderManager.OrderIds.Add(2);
+
+ var orderProcessorMock = new Mock();
+ orderProcessorMock.Setup(m => m.GetOrderTicket(1)).Returns(new OrderTicket(_algorithm.Transactions, orderRequest1));
+ orderProcessorMock.Setup(m => m.GetOrderTicket(2)).Returns(new OrderTicket(_algorithm.Transactions, orderRequest2));
+ _algorithm.Transactions.SetOrderProcessor(orderProcessorMock.Object);
+
+ var orderTicket1 = _transactionHandler.Process(orderRequest1);
+ _transactionHandler.HandleOrderRequest(orderRequest1);
+ var orderTicket2 = _transactionHandler.Process(orderRequest2);
+ _transactionHandler.HandleOrderRequest(orderRequest2);
+
+ Assert.AreEqual(OrderStatus.Submitted, orderTicket1.Status);
+ Assert.AreEqual(OrderStatus.Submitted, orderTicket2.Status);
+
+ // a huge quantity increase on just one leg that the algorithm cannot possibly afford
+ var updateRequest = new UpdateOrderRequest(DateTime.Now, orderTicket1.OrderId, new UpdateOrderFields { Quantity = 1_000_000_000m });
+ _transactionHandler.Process(updateRequest);
+ _transactionHandler.HandleOrderRequest(updateRequest);
+
+ if (expectUpdateSucceeds)
+ {
+ Assert.IsTrue(updateRequest.Response.IsSuccess);
+ Assert.AreEqual(OrderStatus.UpdateSubmitted, orderTicket1.Status);
+ }
+ else
+ {
+ Assert.IsTrue(updateRequest.Response.IsError);
+ Assert.AreEqual(OrderResponseErrorCode.BrokerageFailedToUpdateOrder, updateRequest.Response.ErrorCode);
+ Assert.AreEqual(OrderStatus.Submitted, orderTicket1.Status);
+ }
+
+ // the sibling leg is untouched either way: updates apply per-leg
+ Assert.AreEqual(1000, orderTicket2.Quantity);
+ }
+
[Test]
public void UpdatePartiallyFilledOrderRequestShouldWork()
{