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() {