From b4edfcd7dd94abfb8fdb5d847dda5edd117b2cbb Mon Sep 17 00:00:00 2001 From: Jhonathan Abreu Date: Tue, 11 Aug 2026 13:05:10 -0400 Subject: [PATCH 1/2] Add BracketOrder API with engine-guaranteed OCO semantics QCAlgorithm.BracketOrder places an entry order plus linked stop loss / take profit exit tickets (BracketOrderTicket). The transaction handler enforces the linkage on every order event: the entry fill places the exit legs sized to the filled quantity, a leg fill cancels its sibling (deterministically stop-loss-first on a bar spanning both legs), an unrelated order closing or flipping the position cancels the remaining legs, and a new bracket is refused while one is still active for the symbol. --- .../BracketOrderRegressionAlgorithm.cs | 257 +++++++++++ .../BracketOrderRegressionAlgorithm.py | 137 ++++++ Algorithm/QCAlgorithm.Trading.cs | 149 +++++++ Common/Orders/BracketOrderTicket.cs | 419 ++++++++++++++++++ .../Securities/SecurityTransactionManager.cs | 85 ++++ .../BrokerageTransactionHandler.cs | 12 + .../BracketOrderTests.cs | 389 ++++++++++++++++ 7 files changed, 1448 insertions(+) create mode 100644 Algorithm.CSharp/BracketOrderRegressionAlgorithm.cs create mode 100644 Algorithm.Python/BracketOrderRegressionAlgorithm.py create mode 100644 Common/Orders/BracketOrderTicket.cs create mode 100644 Tests/Engine/BrokerageTransactionHandlerTests/BracketOrderTests.cs diff --git a/Algorithm.CSharp/BracketOrderRegressionAlgorithm.cs b/Algorithm.CSharp/BracketOrderRegressionAlgorithm.cs new file mode 100644 index 000000000000..01a292424917 --- /dev/null +++ b/Algorithm.CSharp/BracketOrderRegressionAlgorithm.cs @@ -0,0 +1,257 @@ +/* + * 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 exercising the engine-guaranteed OCO semantics of : + /// the entry fill places the protective legs, a leg fill cancels its sibling, an unrelated order + /// closing the position cancels the remaining legs and a new bracket is refused while one is active. + /// + /// + /// + /// + public class BracketOrderRegressionAlgorithm : QCAlgorithm, IRegressionAlgorithmDefinition + { + private Symbol _symbol; + private BracketOrderTicket _bracket1; + private BracketOrderTicket _bracket2; + private bool _legsVerified; + private bool _refusalVerified; + private bool _phase1Verified; + private DateTime _manualCloseTime; + private bool _manualCloseDone; + private bool _phase2Verified; + private bool _takeProfitFilled; + + public override void Initialize() + { + SetStartDate(2013, 10, 07); + SetEndDate(2013, 10, 11); + SetCash(100000); + + _symbol = AddEquity("SPY", Resolution.Minute).Symbol; + } + + public override void OnData(Slice slice) + { + if (Math.Abs(Portfolio[_symbol].Quantity) > 10) + { + throw new RegressionTestException("The position must never exceed the bracket entry quantity."); + } + + var price = Securities[_symbol].Price; + + // Phase 1: entry fill places the legs, then the take profit fill cancels the stop loss + if (_bracket1 == null) + { + _bracket1 = BracketOrder(_symbol, 10, + stopLossPrice: Math.Round(price * 0.975m, 2), + takeProfitPrice: Math.Round(price * 1.008m, 2)); + return; + } + + if (!_legsVerified && _bracket1.StopLossTicket != null) + { + if (_bracket1.EntryTicket.Status != OrderStatus.Filled) + { + throw new RegressionTestException("The exit legs must not be placed before the entry order fills."); + } + if (_bracket1.StopLossTicket.OrderType != OrderType.StopMarket || _bracket1.StopLossTicket.Quantity != -10) + { + throw new RegressionTestException("Expected a stop market leg for -10 units."); + } + if (_bracket1.TakeProfitTicket == null || + _bracket1.TakeProfitTicket.OrderType != OrderType.Limit || _bracket1.TakeProfitTicket.Quantity != -10) + { + throw new RegressionTestException("Expected a limit take profit leg for -10 units."); + } + + // a new bracket must be refused while this one is live instead of silently + // overwriting it and stranding its legs + try + { + BracketOrder(_symbol, 10, stopLossPrice: 100m, takeProfitPrice: 200m); + throw new RegressionTestException("A second bracket order for the same symbol should have been refused."); + } + catch (InvalidOperationException) + { + _refusalVerified = true; + } + _legsVerified = true; + return; + } + + // Phase 2: with a fresh bracket in place, manually closing the position cancels both legs + if (_bracket2 == null) + { + if (_legsVerified && !_bracket1.IsActive) + { + if (_bracket1.TakeProfitTicket.Status != OrderStatus.Filled) + { + throw new RegressionTestException("Expected the take profit leg of the first bracket to fill."); + } + if (_bracket1.StopLossTicket.Status != OrderStatus.Canceled) + { + throw new RegressionTestException("Expected the stop loss leg to be canceled when its sibling filled."); + } + if (Portfolio.Invested) + { + throw new RegressionTestException("Expected a flat position after the take profit filled."); + } + if (Transactions.GetBracketOrderTicket(_symbol) != null) + { + throw new RegressionTestException("Expected no active bracket after the first one completed."); + } + _phase1Verified = true; + + // legs far away from the market so only the manual close can end this bracket + _bracket2 = BracketOrder(_symbol, 10, + stopLossPrice: Math.Round(price * 0.93m, 2), + takeProfitPrice: Math.Round(price * 1.07m, 2)); + } + return; + } + + if (_manualCloseTime == default && _bracket2.StopLossTicket != null) + { + _manualCloseTime = Time.AddMinutes(30); + return; + } + + if (!_manualCloseDone && _manualCloseTime != default && Time >= _manualCloseTime) + { + MarketOrder(_symbol, -10); + _manualCloseDone = true; + return; + } + + if (_manualCloseDone && !_phase2Verified) + { + if (_bracket2.StopLossTicket.Status != OrderStatus.Canceled || + _bracket2.TakeProfitTicket.Status != OrderStatus.Canceled) + { + throw new RegressionTestException("Expected both legs to be canceled after the position was closed manually."); + } + if (Portfolio.Invested || _bracket2.IsActive || Transactions.GetBracketOrderTicket(_symbol) != null) + { + throw new RegressionTestException("Expected a flat position and no active bracket after the manual close."); + } + _phase2Verified = true; + } + } + + public override void OnOrderEvent(OrderEvent orderEvent) + { + if (_bracket1 != null && _bracket1.TakeProfitTicket != null && + orderEvent.OrderId == _bracket1.TakeProfitTicket.OrderId && orderEvent.Status == OrderStatus.Filled) + { + _takeProfitFilled = true; + } + if (_bracket1 != null && _bracket1.StopLossTicket != null && + orderEvent.OrderId == _bracket1.StopLossTicket.OrderId && orderEvent.Status == OrderStatus.Canceled && + !_takeProfitFilled) + { + throw new RegressionTestException("The stop loss must only be canceled after its sibling take profit filled."); + } + } + + public override void OnEndOfAlgorithm() + { + if (!_legsVerified || !_refusalVerified || !_phase1Verified || !_manualCloseDone || !_phase2Verified) + { + throw new RegressionTestException($"Not every phase completed: legs placed {_legsVerified}, " + + $"re-entry refused {_refusalVerified}, sibling canceled on fill {_phase1Verified}, " + + $"manual close {_manualCloseDone}, legs canceled on position close {_phase2Verified}"); + } + // entry, stop loss and take profit per bracket, plus the manual close + if (Transactions.OrdersCount != 7) + { + throw new RegressionTestException($"Expected 7 orders, found {Transactions.OrdersCount}"); + } + if (Transactions.GetOpenOrders().Count != 0) + { + throw new RegressionTestException("Expected no dangling open orders at the end of the algorithm."); + } + } + + /// + /// This is used by the regression test system to indicate if the open source Lean repository has the required data to run this algorithm. + /// + public bool CanRunLocally { get; } = true; + + /// + /// This is used by the regression test system to indicate which languages this algorithm is written in. + /// + public List Languages { get; } = new() { Language.CSharp, Language.Python }; + + /// + /// Data Points count of all timeslices of algorithm + /// + public long DataPoints => 3943; + + /// + /// Data Points count of the algorithm history + /// + public int AlgorithmHistoryDataPoints => 0; + + /// + /// Final status of the algorithm + /// + public AlgorithmStatus AlgorithmStatus => AlgorithmStatus.Completed; + + /// + /// This is used by the regression test system to indicate what the expected statistics are from running the algorithm + /// + public Dictionary ExpectedStatistics => new Dictionary + { + {"Total Orders", "7"}, + {"Average Win", "0.01%"}, + {"Average Loss", "0.00%"}, + {"Compounding Annual Return", "0.648%"}, + {"Drawdown", "0.000%"}, + {"Expectancy", "3.381"}, + {"Start Equity", "100000"}, + {"End Equity", "100008.26"}, + {"Net Profit", "0.008%"}, + {"Sharpe Ratio", "-0.536"}, + {"Sortino Ratio", "0"}, + {"Probabilistic Sharpe Ratio", "43.394%"}, + {"Loss Rate", "50%"}, + {"Win Rate", "50%"}, + {"Profit-Loss Ratio", "7.76"}, + {"Alpha", "-0.026"}, + {"Beta", "0.012"}, + {"Annual Standard Deviation", "0.003"}, + {"Annual Variance", "0"}, + {"Information Ratio", "-8.993"}, + {"Tracking Error", "0.22"}, + {"Treynor Ratio", "-0.121"}, + {"Total Fees", "$4.00"}, + {"Estimated Strategy Capacity", "$19000000.00"}, + {"Lowest Capacity Asset", "SPY R735QTJ8XC9X"}, + {"Portfolio Turnover", "1.17%"}, + {"Drawdown Recovery", "3"}, + {"OrderListHash", "86d5cef4794178bd4f6eb46202b54fd5"} + }; + } +} diff --git a/Algorithm.Python/BracketOrderRegressionAlgorithm.py b/Algorithm.Python/BracketOrderRegressionAlgorithm.py new file mode 100644 index 000000000000..173cf46af7fc --- /dev/null +++ b/Algorithm.Python/BracketOrderRegressionAlgorithm.py @@ -0,0 +1,137 @@ +# 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 exercising the engine-guaranteed OCO semantics of bracket orders: +### the entry fill places the protective legs, a leg fill cancels its sibling, an unrelated order +### closing the position cancels the remaining legs and a new bracket is refused while one is active. +### +### +### +### +class BracketOrderRegressionAlgorithm(QCAlgorithm): + + def initialize(self): + self.set_start_date(2013, 10, 7) + self.set_end_date(2013, 10, 11) + self.set_cash(100000) + + self._symbol = self.add_equity("SPY", Resolution.MINUTE).symbol + + self._bracket1 = None + self._bracket2 = None + self._legs_verified = False + self._refusal_verified = False + self._phase1_verified = False + self._manual_close_time = None + self._manual_close_done = False + self._phase2_verified = False + self._take_profit_filled = False + + def on_data(self, slice: Slice): + if abs(self.portfolio[self._symbol].quantity) > 10: + raise AssertionError("The position must never exceed the bracket entry quantity.") + + price = self.securities[self._symbol].price + + # Phase 1: entry fill places the legs, then the take profit fill cancels the stop loss + if self._bracket1 is None: + self._bracket1 = self.bracket_order(self._symbol, 10, + stop_loss_price=round(price * 0.975, 2), + take_profit_price=round(price * 1.008, 2)) + return + + if not self._legs_verified and self._bracket1.stop_loss_ticket is not None: + if self._bracket1.entry_ticket.status != OrderStatus.FILLED: + raise AssertionError("The exit legs must not be placed before the entry order fills.") + if self._bracket1.stop_loss_ticket.order_type != OrderType.STOP_MARKET or self._bracket1.stop_loss_ticket.quantity != -10: + raise AssertionError("Expected a stop market leg for -10 units.") + if (self._bracket1.take_profit_ticket is None + or self._bracket1.take_profit_ticket.order_type != OrderType.LIMIT + or self._bracket1.take_profit_ticket.quantity != -10): + raise AssertionError("Expected a limit take profit leg for -10 units.") + + # a new bracket must be refused while this one is live instead of silently + # overwriting it and stranding its legs + refused = False + try: + self.bracket_order(self._symbol, 10, stop_loss_price=100, take_profit_price=200) + except Exception as exception: + if "already active" in str(exception): + refused = True + if not refused: + raise AssertionError("A second bracket order for the same symbol should have been refused.") + self._refusal_verified = True + self._legs_verified = True + return + + # Phase 2: with a fresh bracket in place, manually closing the position cancels both legs + if self._bracket2 is None: + if self._legs_verified and not self._bracket1.is_active: + if self._bracket1.take_profit_ticket.status != OrderStatus.FILLED: + raise AssertionError("Expected the take profit leg of the first bracket to fill.") + if self._bracket1.stop_loss_ticket.status != OrderStatus.CANCELED: + raise AssertionError("Expected the stop loss leg to be canceled when its sibling filled.") + if self.portfolio.invested: + raise AssertionError("Expected a flat position after the take profit filled.") + if self.transactions.get_bracket_order_ticket(self._symbol) is not None: + raise AssertionError("Expected no active bracket after the first one completed.") + self._phase1_verified = True + + # legs far away from the market so only the manual close can end this bracket + self._bracket2 = self.bracket_order(self._symbol, 10, + stop_loss_price=round(price * 0.93, 2), + take_profit_price=round(price * 1.07, 2)) + return + + if self._manual_close_time is None and self._bracket2.stop_loss_ticket is not None: + self._manual_close_time = self.time + timedelta(minutes=30) + return + + if not self._manual_close_done and self._manual_close_time is not None and self.time >= self._manual_close_time: + self.market_order(self._symbol, -10) + self._manual_close_done = True + return + + if self._manual_close_done and not self._phase2_verified: + if (self._bracket2.stop_loss_ticket.status != OrderStatus.CANCELED + or self._bracket2.take_profit_ticket.status != OrderStatus.CANCELED): + raise AssertionError("Expected both legs to be canceled after the position was closed manually.") + if self.portfolio.invested or self._bracket2.is_active or self.transactions.get_bracket_order_ticket(self._symbol) is not None: + raise AssertionError("Expected a flat position and no active bracket after the manual close.") + self._phase2_verified = True + + def on_order_event(self, order_event: OrderEvent): + if (self._bracket1 is not None and self._bracket1.take_profit_ticket is not None + and order_event.order_id == self._bracket1.take_profit_ticket.order_id + and order_event.status == OrderStatus.FILLED): + self._take_profit_filled = True + if (self._bracket1 is not None and self._bracket1.stop_loss_ticket is not None + and order_event.order_id == self._bracket1.stop_loss_ticket.order_id + and order_event.status == OrderStatus.CANCELED + and not self._take_profit_filled): + raise AssertionError("The stop loss must only be canceled after its sibling take profit filled.") + + def on_end_of_algorithm(self): + if (not self._legs_verified or not self._refusal_verified or not self._phase1_verified + or not self._manual_close_done or not self._phase2_verified): + raise AssertionError(f"Not every phase completed: legs placed {self._legs_verified}, " + f"re-entry refused {self._refusal_verified}, sibling canceled on fill {self._phase1_verified}, " + f"manual close {self._manual_close_done}, legs canceled on position close {self._phase2_verified}") + # entry, stop loss and take profit per bracket, plus the manual close + if self.transactions.orders_count != 7: + raise AssertionError(f"Expected 7 orders, found {self.transactions.orders_count}") + if len(self.transactions.get_open_orders()) != 0: + raise AssertionError("Expected no dangling open orders at the end of the algorithm.") diff --git a/Algorithm/QCAlgorithm.Trading.cs b/Algorithm/QCAlgorithm.Trading.cs index ddba02f0249c..7155af7fd401 100644 --- a/Algorithm/QCAlgorithm.Trading.cs +++ b/Algorithm/QCAlgorithm.Trading.cs @@ -777,6 +777,155 @@ public OrderTicket LimitIfTouchedOrder(Symbol symbol, decimal quantity, decimal return SubmitOrderRequest(request); } + /// + /// Send a bracket order: an entry order plus protective stop loss and/or take profit exit orders + /// linked with one-cancels-the-other (OCO) semantics guaranteed by the engine + /// + /// Symbol of the asset to trade + /// Quantity of the entry order. The exit legs are automatically sized to the filled entry quantity + /// Stop price of the protective stop market leg, null for no stop loss + /// Limit price of the take profit leg, null for no take profit + /// Optional limit price for the entry order. If null the entry is a market order + /// String tag for the orders (optional) + /// The order properties to use. Defaults to + /// The bracket order ticket holding the linked entry, stop loss and take profit tickets + /// A bracket order is still active for the symbol + [DocumentationAttribute(TradingAndOrders)] + public BracketOrderTicket BracketOrder(Symbol symbol, int quantity, decimal? stopLossPrice = null, decimal? takeProfitPrice = null, + decimal? entryLimitPrice = null, string tag = "", IOrderProperties orderProperties = null) + { + return BracketOrder(symbol, (decimal)quantity, stopLossPrice, takeProfitPrice, entryLimitPrice, tag, orderProperties); + } + + /// + /// Send a bracket order: an entry order plus protective stop loss and/or take profit exit orders + /// linked with one-cancels-the-other (OCO) semantics guaranteed by the engine + /// + /// Symbol of the asset to trade + /// Quantity of the entry order. The exit legs are automatically sized to the filled entry quantity + /// Stop price of the protective stop market leg, null for no stop loss + /// Limit price of the take profit leg, null for no take profit + /// Optional limit price for the entry order. If null the entry is a market order + /// String tag for the orders (optional) + /// The order properties to use. Defaults to + /// The bracket order ticket holding the linked entry, stop loss and take profit tickets + /// A bracket order is still active for the symbol + [DocumentationAttribute(TradingAndOrders)] + public BracketOrderTicket BracketOrder(Symbol symbol, double quantity, decimal? stopLossPrice = null, decimal? takeProfitPrice = null, + decimal? entryLimitPrice = null, string tag = "", IOrderProperties orderProperties = null) + { + return BracketOrder(symbol, quantity.SafeDecimalCast(), stopLossPrice, takeProfitPrice, entryLimitPrice, tag, orderProperties); + } + + /// + /// Send a bracket order: an entry order plus protective stop loss and/or take profit exit orders + /// linked with one-cancels-the-other (OCO) semantics guaranteed by the engine + /// + /// + /// The exit legs are placed by the engine once the entry order fills, sized to the actual filled + /// quantity: a stop market order at and/or a limit order at + /// . When one leg fills the other is canceled, and when the + /// position is closed or flipped by an unrelated order the remaining legs are canceled, all + /// without any user code. A new bracket order for the same symbol is refused while one is + /// still active, to avoid stranding its protective legs: cancel it first via + /// or let it complete + /// + /// Symbol of the asset to trade + /// Quantity of the entry order. The exit legs are automatically sized to the filled entry quantity + /// Stop price of the protective stop market leg, null for no stop loss + /// Limit price of the take profit leg, null for no take profit + /// Optional limit price for the entry order. If null the entry is a market order + /// String tag for the orders (optional) + /// The order properties to use. Defaults to + /// The bracket order ticket holding the linked entry, stop loss and take profit tickets + /// A bracket order is still active for the symbol + [DocumentationAttribute(TradingAndOrders)] + public BracketOrderTicket BracketOrder(Symbol symbol, decimal quantity, decimal? stopLossPrice = null, decimal? takeProfitPrice = null, + decimal? entryLimitPrice = null, string tag = "", IOrderProperties orderProperties = null) + { + if (!stopLossPrice.HasValue && !takeProfitPrice.HasValue) + { + throw new ArgumentException("BracketOrder(): must specify at least one of stopLossPrice or takeProfitPrice."); + } + + // validate the exit prices are on the correct side of each other and of the entry so a leg + // cannot trigger immediately against the entry price. Zero quantity has no direction, it is + // rejected by the pre order checks below + if (quantity != 0) + { + var isLong = quantity > 0; + if (stopLossPrice.HasValue && takeProfitPrice.HasValue && + (isLong ? stopLossPrice.Value >= takeProfitPrice.Value : stopLossPrice.Value <= takeProfitPrice.Value)) + { + throw new ArgumentException($"BracketOrder(): for a {(isLong ? "long" : "short")} entry the stop loss price " + + $"({stopLossPrice.Value}) must be {(isLong ? "below" : "above")} the take profit price ({takeProfitPrice.Value})."); + } + if (entryLimitPrice.HasValue) + { + if (stopLossPrice.HasValue && (isLong ? stopLossPrice.Value >= entryLimitPrice.Value : stopLossPrice.Value <= entryLimitPrice.Value)) + { + throw new ArgumentException($"BracketOrder(): for a {(isLong ? "long" : "short")} entry the stop loss price " + + $"({stopLossPrice.Value}) must be {(isLong ? "below" : "above")} the entry limit price ({entryLimitPrice.Value})."); + } + if (takeProfitPrice.HasValue && (isLong ? takeProfitPrice.Value <= entryLimitPrice.Value : takeProfitPrice.Value >= entryLimitPrice.Value)) + { + throw new ArgumentException($"BracketOrder(): for a {(isLong ? "long" : "short")} entry the take profit price " + + $"({takeProfitPrice.Value}) must be {(isLong ? "above" : "below")} the entry limit price ({entryLimitPrice.Value})."); + } + } + } + + // early refusal so the common misuse does not consume an order id. The authoritative, + // race-free check lives in SecurityTransactionManager.AddBracketOrder + if (Transactions.GetBracketOrderTicket(symbol) != null) + { + throw new InvalidOperationException( + $"A bracket order is already active for {symbol}. Placing a new one would leave " + + "the previous stop loss/take profit legs unmanaged. Cancel it first, e.g. " + + "'Transactions.GetBracketOrderTicket(symbol).Cancel()', or wait for it to complete."); + } + + var security = GetSecurityForOrder(symbol); + var properties = orderProperties ?? DefaultOrderProperties?.Clone(); + + var entryType = OrderType.Market; + var limitPrice = 0m; + if (entryLimitPrice.HasValue) + { + entryType = OrderType.Limit; + limitPrice = entryLimitPrice.Value; + } + else if (security.Type != SecurityType.Future && security.Type != SecurityType.FutureOption && !security.Exchange.ExchangeOpen) + { + // mirror MarketOrder: when the market is closed the entry is converted to fill at the next open + entryType = OrderType.MarketOnOpen; + InvalidateGoodTilDateTimeInForce(properties); + } + + var request = CreateSubmitOrderRequest(entryType, security, quantity, tag, properties, asynchronous: false, limitPrice: limitPrice); + var response = PreOrderChecks(request); + if (response.IsError) + { + return BracketOrderTicket.InvalidEntry(Transactions, request, response, stopLossPrice, takeProfitPrice); + } + + if (entryType == OrderType.MarketOnOpen && !_isMarketOnOpenOrderWarningSent) + { + Debug("Warning: market orders submitted while the market is closed are automatically converted into MarketOnOpen orders to fill at the next market open."); + _isMarketOnOpenOrderWarningSent = true; + } + + // assign the entry order id up front so the bracket can be registered before the submission: + // in live trading the entry can fill while the submit call is in flight and the fill must + // already find the bracket to place the exit legs + Transactions.SetOrderId(request); + var bracket = new BracketOrderTicket(Transactions, request, stopLossPrice, takeProfitPrice); + // throws InvalidOperationException if a bracket order is still active for the symbol: + // silently overwriting a live bracket strands its protective legs + Transactions.AddBracketOrder(bracket); + return bracket; + } + /// /// Send an exercise order to the transaction handler /// diff --git a/Common/Orders/BracketOrderTicket.cs b/Common/Orders/BracketOrderTicket.cs new file mode 100644 index 000000000000..1ce28d54277c --- /dev/null +++ b/Common/Orders/BracketOrderTicket.cs @@ -0,0 +1,419 @@ +/* + * QUANTCONNECT.COM - Democratizing Finance, Empowering Individuals. + * Lean Algorithmic Trading Engine v2.0. Copyright 2014 QuantConnect Corporation. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. +*/ + +using System; +using QuantConnect.Securities; + +namespace QuantConnect.Orders +{ + /// + /// Provides a single handle to the linked tickets of a bracket order: an entry order plus protective + /// stop loss and/or take profit exit orders with one-cancels-the-other (OCO) semantics. + /// + /// + /// The exit legs are placed by the engine once the entry order closes, sized to the actual filled + /// quantity, so and are null until then. + /// The linkage is guaranteed by the engine without any user code (see + /// ): when one leg fills its sibling + /// is canceled, and when the position is closed or flipped by an unrelated order the remaining legs + /// are canceled. The stop loss leg is submitted first so that when a single bar spans both leg prices + /// it wins deterministically (backtesting fills scan orders by ascending id). + /// + public class BracketOrderTicket + { + private readonly object _lock = new object(); + private readonly SecurityTransactionManager _transactionManager; + private readonly SubmitOrderRequest _entryRequest; + + private decimal? _stopLossPrice; + private decimal? _takeProfitPrice; + // entry state tracked from order events so it is accurate even before the entry ticket + // reference is set (in live trading fills can be processed while the submit call is in flight) + private decimal _entryFilledQuantity; + private bool _entryClosed; + private bool _legsPlaced; + private bool _canceled; + private bool _completed; + + /// + /// The symbol being traded by this bracket + /// + public Symbol Symbol => _entryRequest.Symbol; + + /// + /// The quantity of the entry order. The exit legs are sized to the actual filled entry quantity + /// + public decimal Quantity => _entryRequest.Quantity; + + /// + /// The stop loss price, null if no stop loss leg was requested + /// + public decimal? StopLossPrice + { + get { lock (_lock) { return _stopLossPrice; } } + } + + /// + /// The take profit price, null if no take profit leg was requested + /// + public decimal? TakeProfitPrice + { + get { lock (_lock) { return _takeProfitPrice; } } + } + + /// + /// The ticket of the entry order + /// + public OrderTicket EntryTicket { get; private set; } + + /// + /// The ticket of the protective stop loss order. Null until the entry order fills + /// + public OrderTicket StopLossTicket { get; private set; } + + /// + /// The ticket of the take profit order. Null until the entry order fills + /// + public OrderTicket TakeProfitTicket { get; private set; } + + /// + /// True while any piece of the bracket is still working: the entry order is open, the entry + /// filled and the exit legs are pending placement, or any exit leg is open. A new bracket + /// cannot be placed for the same symbol while an active one exists + /// + public bool IsActive + { + get + { + lock (_lock) + { + if (_completed) + { + return false; + } + if (_legsPlaced) + { + return IsTicketOpen(StopLossTicket) || IsTicketOpen(TakeProfitTicket); + } + // consult the live ticket state and not only our event-driven flags: ticket statuses + // are updated before the user's OnOrderEvent runs, while our flags are updated after + // it, so checks made inside the event handler see the true state + if (_entryClosed || EntryTicket != null && EntryTicket.Status.IsClosed()) + { + return !_canceled && EntryFilledQuantity != 0; + } + return true; + } + } + } + + /// + /// Initializes a new instance of the class + /// + /// The transaction manager used for submitting the exit legs and cancels + /// The submit request of the entry order. Must already have its order id set + /// The stop price of the protective stop market leg, null for no stop loss + /// The limit price of the take profit leg, null for no take profit + public BracketOrderTicket(SecurityTransactionManager transactionManager, SubmitOrderRequest entryRequest, + decimal? stopLossPrice, decimal? takeProfitPrice) + { + _transactionManager = transactionManager; + _entryRequest = entryRequest; + _stopLossPrice = stopLossPrice; + _takeProfitPrice = takeProfitPrice; + } + + /// + /// Requests cancellation of every open piece of this bracket: the entry order if it is still + /// open and any open exit leg. If the entry order already partially filled, the resulting + /// position is left as is, no protective legs will be placed for it + /// + /// Optional reason to attach to the cancel requests + public void Cancel(string tag = null) + { + lock (_lock) + { + _canceled = true; + TryCancel(EntryTicket, tag ?? "Canceled bracket order"); + TryCancel(StopLossTicket, tag ?? "Canceled bracket order"); + TryCancel(TakeProfitTicket, tag ?? "Canceled bracket order"); + } + } + + /// + /// Moves the stop loss to the specified stop price. If the exit legs have not been placed yet, + /// the pending stop loss price is updated in place, otherwise an update request is submitted + /// for the live stop loss order + /// + /// The new stop price + /// Optional new tag for the order + /// The response of the update + public OrderResponse MoveStopLoss(decimal stopPrice, string tag = null) + { + lock (_lock) + { + if (StopLossTicket != null) + { + var response = StopLossTicket.UpdateStopPrice(stopPrice, tag); + if (!response.IsError) + { + _stopLossPrice = stopPrice; + } + return response; + } + + var request = new UpdateOrderRequest(_transactionManager.UtcTime, _entryRequest.OrderId, + new UpdateOrderFields { StopPrice = stopPrice, Tag = tag }); + if (!_stopLossPrice.HasValue || !IsActive) + { + return OrderResponse.Error(request, OrderResponseErrorCode.InvalidRequest, + $"BracketOrderTicket.MoveStopLoss(): the bracket for {Symbol} has no live or pending stop loss to move."); + } + _stopLossPrice = stopPrice; + return OrderResponse.Success(request); + } + } + + /// + /// Moves the take profit to the specified limit price. If the exit legs have not been placed yet, + /// the pending take profit price is updated in place, otherwise an update request is submitted + /// for the live take profit order + /// + /// The new limit price + /// Optional new tag for the order + /// The response of the update + public OrderResponse MoveTakeProfit(decimal limitPrice, string tag = null) + { + lock (_lock) + { + if (TakeProfitTicket != null) + { + var response = TakeProfitTicket.UpdateLimitPrice(limitPrice, tag); + if (!response.IsError) + { + _takeProfitPrice = limitPrice; + } + return response; + } + + var request = new UpdateOrderRequest(_transactionManager.UtcTime, _entryRequest.OrderId, + new UpdateOrderFields { LimitPrice = limitPrice, Tag = tag }); + if (!_takeProfitPrice.HasValue || !IsActive) + { + return OrderResponse.Error(request, OrderResponseErrorCode.InvalidRequest, + $"BracketOrderTicket.MoveTakeProfit(): the bracket for {Symbol} has no live or pending take profit to move."); + } + _takeProfitPrice = limitPrice; + return OrderResponse.Success(request); + } + } + + /// + /// Returns a string that represents the current object. + /// + public override string ToString() + { + lock (_lock) + { + return $"BracketOrderTicket for {Symbol}: Quantity: {Quantity}, StopLoss: {_stopLossPrice}, " + + $"TakeProfit: {_takeProfitPrice}, Entry: {EntryTicket?.ToString() ?? "not submitted"}, " + + $"StopLossTicket: {StopLossTicket?.ToString() ?? "not placed"}, TakeProfitTicket: {TakeProfitTicket?.ToString() ?? "not placed"}"; + } + } + + /// + /// Creates a new whose entry order submission had errors embodied + /// in the . The resulting bracket is not active + /// + public static BracketOrderTicket InvalidEntry(SecurityTransactionManager transactionManager, + SubmitOrderRequest entryRequest, OrderResponse response, decimal? stopLossPrice, decimal? takeProfitPrice) + { + var bracket = new BracketOrderTicket(transactionManager, entryRequest, stopLossPrice, takeProfitPrice); + bracket.EntryTicket = OrderTicket.InvalidSubmitRequest(transactionManager, entryRequest, response); + bracket._completed = true; + return bracket; + } + + /// + /// Submits the entry order. Called by + /// after the bracket has been registered, so that in live trading a fill processed while the + /// submission is in flight already finds the bracket + /// + internal void SubmitEntryOrder() + { + EntryTicket = _transactionManager.AddOrder(_entryRequest); + } + + /// + /// Processes an order event for the bracket's symbol, driving the OCO state machine. Called by + /// the transaction handler for every order event, after the user's OnOrderEvent handler + /// + /// The order event to process + /// The current holdings quantity for the bracket's symbol + /// True if any order request (submit, update or cancel) was issued as a result + internal bool HandleOrderEvent(OrderEvent orderEvent, decimal holdingsQuantity) + { + lock (_lock) + { + if (_completed) + { + return false; + } + + var requestsIssued = false; + if (orderEvent.OrderId == _entryRequest.OrderId) + { + if (orderEvent.Status.IsFill()) + { + _entryFilledQuantity += orderEvent.FillQuantity; + } + if (orderEvent.Status.IsClosed() && !_entryClosed) + { + _entryClosed = true; + var filledQuantity = EntryFilledQuantity; + if (_canceled || filledQuantity == 0) + { + // entry never filled (canceled or invalid) or the user canceled the whole + // bracket: there is nothing to protect + _completed = true; + } + else + { + // the entry closed with fills (including a canceled entry that partially + // filled: that position still needs its protective exits) + PlaceExitLegs(filledQuantity); + requestsIssued = true; + } + } + } + else if (_legsPlaced && (orderEvent.OrderId == StopLossTicket?.OrderId || orderEvent.OrderId == TakeProfitTicket?.OrderId)) + { + var leg = orderEvent.OrderId == StopLossTicket?.OrderId ? StopLossTicket : TakeProfitTicket; + var sibling = ReferenceEquals(leg, StopLossTicket) ? TakeProfitTicket : StopLossTicket; + if (orderEvent.Status == OrderStatus.Filled) + { + // one-cancels-the-other: this leg exited the bracket's position, its sibling + // must not fill too (both legs filling on a gapping bar flips the position, + // exhausting margin: fleet deployment A-20b9ed) + requestsIssued |= TryCancel(sibling, $"Bracket #{_entryRequest.OrderId} sibling leg filled"); + } + else if (orderEvent.Status == OrderStatus.PartiallyFilled && IsTicketOpen(sibling)) + { + // keep the sibling sized to what the bracket still holds so it cannot overshoot. + // both legs exit the same position so they share the sign of the filling leg's + // remaining quantity + requestsIssued |= TryResize(sibling, leg.QuantityRemaining); + } + + if (!IsTicketOpen(StopLossTicket) && !IsTicketOpen(TakeProfitTicket)) + { + _completed = true; + } + } + else if (_legsPlaced && orderEvent.Status.IsFill()) + { + // an unrelated order for the same symbol filled: keep the protective legs in sync + // with the remaining position so a stranded leg cannot idle against a closed position + // (fleet deployment A-6eb8558a: dangling stop leg produced 1931x InsufficientBuyingPower) + if (holdingsQuantity == 0 || Math.Sign(holdingsQuantity) != Math.Sign(Quantity)) + { + requestsIssued |= TryCancel(StopLossTicket, $"Bracket #{_entryRequest.OrderId} position closed"); + requestsIssued |= TryCancel(TakeProfitTicket, $"Bracket #{_entryRequest.OrderId} position closed"); + } + else + { + // the position was partially reduced: downsize the legs so a later leg fill + // cannot flip the position. The legs are never sized up, the bracket only + // protects the quantity it filled + requestsIssued |= TryResize(StopLossTicket, -holdingsQuantity); + requestsIssued |= TryResize(TakeProfitTicket, -holdingsQuantity); + } + } + + return requestsIssued; + } + } + + /// + /// The entry quantity filled so far, from the ticket when available + /// + private decimal EntryFilledQuantity => EntryTicket?.QuantityFilled ?? _entryFilledQuantity; + + /// + /// Places the protective exit legs for the filled entry quantity. The stop loss is submitted + /// first on purpose: it gets the lower order id, and backtesting fills scan orders by ascending + /// id, so when a single bar spans both leg prices the conservative stop loss exit wins + /// + private void PlaceExitLegs(decimal entryFilledQuantity) + { + var legQuantity = -entryFilledQuantity; + var utcTime = _transactionManager.UtcTime; + + if (_stopLossPrice.HasValue) + { + var stopLossRequest = new SubmitOrderRequest(OrderType.StopMarket, _entryRequest.SecurityType, Symbol, + legQuantity, _stopLossPrice.Value, 0, utcTime, GetLegTag("stop loss"), + _entryRequest.OrderProperties?.Clone(), asynchronous: true); + StopLossTicket = _transactionManager.AddOrder(stopLossRequest); + } + if (_takeProfitPrice.HasValue) + { + var takeProfitRequest = new SubmitOrderRequest(OrderType.Limit, _entryRequest.SecurityType, Symbol, + legQuantity, 0, _takeProfitPrice.Value, utcTime, GetLegTag("take profit"), + _entryRequest.OrderProperties?.Clone(), asynchronous: true); + TakeProfitTicket = _transactionManager.AddOrder(takeProfitRequest); + } + _legsPlaced = true; + } + + private string GetLegTag(string legName) + { + return string.IsNullOrEmpty(_entryRequest.Tag) + ? $"Bracket #{_entryRequest.OrderId} {legName}" + : $"{_entryRequest.Tag} ({legName})"; + } + + /// + /// Requests cancellation of the ticket if it is open and not already being canceled + /// + private static bool TryCancel(OrderTicket ticket, string tag) + { + if (IsTicketOpen(ticket) && ticket.CancelRequest == null) + { + ticket.Cancel(tag); + return true; + } + return false; + } + + /// + /// Requests an update of the ticket quantity if it is open and oversized versus the target + /// + private static bool TryResize(OrderTicket ticket, decimal quantity) + { + if (IsTicketOpen(ticket) && ticket.CancelRequest == null && Math.Abs(ticket.Quantity) > Math.Abs(quantity)) + { + ticket.UpdateQuantity(quantity); + return true; + } + return false; + } + + private static bool IsTicketOpen(OrderTicket ticket) + { + return ticket != null && !ticket.Status.IsClosed(); + } + } +} diff --git a/Common/Securities/SecurityTransactionManager.cs b/Common/Securities/SecurityTransactionManager.cs index bad9422ba463..23e5e4b66caf 100644 --- a/Common/Securities/SecurityTransactionManager.cs +++ b/Common/Securities/SecurityTransactionManager.cs @@ -14,6 +14,7 @@ */ using System; +using System.Collections.Concurrent; using System.Collections.Generic; using System.Linq; using System.Threading; @@ -45,6 +46,12 @@ private class TransactionRecordEntry private IOrderProcessor _orderProcessor; + /// + /// The current bracket order of each symbol. Completed brackets are removed by + /// , inactive leftovers are replaced by + /// + private readonly ConcurrentDictionary _bracketOrders = new(); + /// /// Gets the time the security information was last updated /// @@ -224,6 +231,84 @@ public OrderTicket AddOrder(SubmitOrderRequest request) return ProcessRequest(request); } + /// + /// Registers the given bracket order and submits its entry order. Once the entry fills, the + /// engine places the protective exit legs and keeps them linked with one-cancels-the-other + /// semantics, see + /// + /// The bracket order to register and submit + /// A bracket order is still active for the symbol. + /// Placing a new one on top of it would strand the previous protective legs against a position + /// they no longer track, so the caller must cancel or complete the previous bracket first + public void AddBracketOrder(BracketOrderTicket bracket) + { + while (true) + { + if (_bracketOrders.TryGetValue(bracket.Symbol, out var existing)) + { + if (existing.IsActive) + { + throw new InvalidOperationException( + $"A bracket order is already active for {bracket.Symbol}. Placing a new one would leave " + + "the previous stop loss/take profit legs unmanaged. Cancel it first, e.g. " + + "'Transactions.GetBracketOrderTicket(symbol).Cancel()', or wait for it to complete."); + } + if (_bracketOrders.TryUpdate(bracket.Symbol, bracket, existing)) + { + break; + } + } + else if (_bracketOrders.TryAdd(bracket.Symbol, bracket)) + { + break; + } + } + + // submit after registering: in live trading the entry can fill while the submission call is + // still in flight and the fill must already find the bracket to place the exit legs + bracket.SubmitEntryOrder(); + } + + /// + /// Gets the active bracket order for the specified symbol, or null if there is none + /// + /// The symbol to get the active bracket order for + public BracketOrderTicket GetBracketOrderTicket(Symbol symbol) + { + return _bracketOrders.TryGetValue(symbol, out var bracket) && bracket.IsActive ? bracket : null; + } + + /// + /// Routes an order event to the symbol's bracket order, if any, driving the engine-guaranteed + /// one-cancels-the-other behavior: the entry fill places the exit legs, a leg fill cancels its + /// sibling and an unrelated order closing or flipping the position cancels the remaining legs. + /// Called by the transaction handler for every order event + /// + /// The order event to process + /// True if any order request was issued as a result + public bool ProcessBracketOrderEvent(OrderEvent orderEvent) + { + if (_bracketOrders.IsEmpty || !_bracketOrders.TryGetValue(orderEvent.Symbol, out var bracket)) + { + return false; + } + + decimal holdingsQuantity = 0; + if (_securities.TryGetValue(orderEvent.Symbol, out var security)) + { + holdingsQuantity = security.Holdings.Quantity; + } + + var requestsIssued = bracket.HandleOrderEvent(orderEvent, holdingsQuantity); + if (!bracket.IsActive) + { + // remove only if it's still this same bracket, a new one might have been registered + // by the user's OnOrderEvent handler already + _bracketOrders.TryRemove(new KeyValuePair(orderEvent.Symbol, bracket)); + } + return requestsIssued; + } + /// /// Update an order yet to be filled such as stop or limit orders. /// diff --git a/Engine/TransactionHandlers/BrokerageTransactionHandler.cs b/Engine/TransactionHandlers/BrokerageTransactionHandler.cs index 8477400c1158..1cf9baaf1668 100644 --- a/Engine/TransactionHandlers/BrokerageTransactionHandler.cs +++ b/Engine/TransactionHandlers/BrokerageTransactionHandler.cs @@ -1368,6 +1368,18 @@ private void HandleOrderEvents(List orderEvents) // unexpected error, we need to close down shop _algorithm.SetRuntimeError(err, "Order Event Handler"); } + + // Engine-guaranteed bracket order (OCO) management, after the user's OnOrderEvent so the + // triggering event is delivered before the events of the requests it generates: the entry + // fill places the protective legs, a leg fill cancels its sibling and an unrelated order + // closing the position cancels the remaining legs. See BracketOrderTicket + if (_algorithm.Transactions.ProcessBracketOrderEvent(orderEvent) && SynchronousProcessing) + { + // drain the generated requests before the next fill scan so a canceled sibling leg + // cannot also fill when a single bar spans both leg prices (deterministic: the leg + // with the lower order id, the stop loss, wins) + ProcessPendingRequests(); + } } LogOrderEvent(orderEvent); diff --git a/Tests/Engine/BrokerageTransactionHandlerTests/BracketOrderTests.cs b/Tests/Engine/BrokerageTransactionHandlerTests/BracketOrderTests.cs new file mode 100644 index 000000000000..eabf16cf0876 --- /dev/null +++ b/Tests/Engine/BrokerageTransactionHandlerTests/BracketOrderTests.cs @@ -0,0 +1,389 @@ +/* + * QUANTCONNECT.COM - Democratizing Finance, Empowering Individuals. + * Lean Algorithmic Trading Engine v2.0. Copyright 2014 QuantConnect Corporation. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. +*/ + +using System; +using System.Linq; +using NUnit.Framework; +using QuantConnect.Brokerages.Backtesting; +using QuantConnect.Data.Market; +using QuantConnect.Lean.Engine.Results; +using QuantConnect.Lean.Engine.TransactionHandlers; +using QuantConnect.Orders; +using QuantConnect.Securities; + +namespace QuantConnect.Tests.Engine.BrokerageTransactionHandlerTests +{ + /// + /// Tests the engine-guaranteed OCO semantics of : + /// the entry fill places the exit legs, a leg fill cancels its sibling (also when a single bar spans + /// both legs), an unrelated order closing the position cancels the remaining legs and a new bracket + /// is refused while one is still active + /// + [TestFixture] + public class BracketOrderTests + { + // Monday 2013-10-07 10:30 New York, regular equity market hours + private static readonly DateTime ReferenceUtc = new DateTime(2013, 10, 07, 14, 30, 0); + + private BrokerageTransactionHandlerTests.TestAlgorithm _algorithm; + private BacktestingTransactionHandler _transactionHandler; + private BacktestingBrokerage _brokerage; + private Security _security; + private Symbol _spy; + + [SetUp] + public void Initialize() + { + _algorithm = new BrokerageTransactionHandlerTests.TestAlgorithm + { + HistoryProvider = new BrokerageTransactionHandlerTests.EmptyHistoryProvider() + }; + _algorithm.SetCash(100000); + _security = _algorithm.AddEquity("SPY"); + _spy = _security.Symbol; + _algorithm.SetDateTime(ReferenceUtc); + SetPrice(100m, 100m, 100m); + + _transactionHandler = new BacktestingTransactionHandler(); + _brokerage = new BacktestingBrokerage(_algorithm); + _transactionHandler.Initialize(_algorithm, _brokerage, new BacktestingResultHandler()); + _algorithm.Transactions.SetOrderProcessor(_transactionHandler); + // as in backtesting deployments: fills are synchronous, MarketOrder must not wait + _algorithm.Transactions.MarketOrderFillTimeout = TimeSpan.Zero; + } + + [TearDown] + public void TearDown() + { + _transactionHandler.Exit(); + _brokerage.Dispose(); + } + + [Test] + public void EntryFillPlacesExitLegsSizedToFilledQuantity() + { + // the market entry fills synchronously in backtesting, so the returned bracket already + // carries the exit legs + var bracket = _algorithm.BracketOrder(_spy, 10, stopLossPrice: 90m, takeProfitPrice: 110m); + + Assert.IsTrue(bracket.IsActive); + Assert.AreEqual(bracket, _algorithm.Transactions.GetBracketOrderTicket(_spy)); + Assert.AreEqual(OrderStatus.Filled, bracket.EntryTicket.Status); + Assert.AreEqual(10, _security.Holdings.Quantity); + + Assert.IsNotNull(bracket.StopLossTicket); + Assert.AreEqual(OrderType.StopMarket, bracket.StopLossTicket.OrderType); + Assert.AreEqual(-10, bracket.StopLossTicket.Quantity); + Assert.AreEqual(90m, bracket.StopLossTicket.Get(OrderField.StopPrice)); + + Assert.IsNotNull(bracket.TakeProfitTicket); + Assert.AreEqual(OrderType.Limit, bracket.TakeProfitTicket.OrderType); + Assert.AreEqual(-10, bracket.TakeProfitTicket.Quantity); + Assert.AreEqual(110m, bracket.TakeProfitTicket.Get(OrderField.LimitPrice)); + + // the stop loss is submitted first so it wins deterministically on a bar spanning both legs + Assert.Less(bracket.StopLossTicket.OrderId, bracket.TakeProfitTicket.OrderId); + Assert.IsTrue(bracket.IsActive); + } + + [Test] + public void TakeProfitFillCancelsStopLoss() + { + var bracket = FillEntry(10, stopLossPrice: 90m, takeProfitPrice: 110m); + + Step(open: 111m, high: 112m, low: 111m); + + Assert.AreEqual(OrderStatus.Filled, bracket.TakeProfitTicket.Status); + Assert.AreEqual(OrderStatus.Canceled, bracket.StopLossTicket.Status); + Assert.AreEqual(0, _security.Holdings.Quantity); + Assert.IsFalse(bracket.IsActive); + Assert.IsNull(_algorithm.Transactions.GetBracketOrderTicket(_spy)); + Assert.IsEmpty(_algorithm.Transactions.GetOpenOrders()); + } + + [Test] + public void StopLossFillCancelsTakeProfit() + { + var bracket = FillEntry(10, stopLossPrice: 90m, takeProfitPrice: 110m); + + Step(open: 89m, high: 89m, low: 88m); + + Assert.AreEqual(OrderStatus.Filled, bracket.StopLossTicket.Status); + Assert.AreEqual(OrderStatus.Canceled, bracket.TakeProfitTicket.Status); + Assert.AreEqual(0, _security.Holdings.Quantity); + Assert.IsFalse(bracket.IsActive); + Assert.IsEmpty(_algorithm.Transactions.GetOpenOrders()); + } + + [Test] + public void BarSpanningBothLegsFillsOnlyTheStopLoss() + { + var bracket = FillEntry(10, stopLossPrice: 90m, takeProfitPrice: 110m); + + // a single wide bar crosses both the stop loss and the take profit: without the engine + // canceling the sibling in the same scan both legs would fill, flipping the position short + Step(open: 100m, high: 115m, low: 85m); + + Assert.AreEqual(OrderStatus.Filled, bracket.StopLossTicket.Status); + Assert.AreEqual(OrderStatus.Canceled, bracket.TakeProfitTicket.Status); + Assert.AreEqual(0, _security.Holdings.Quantity); + Assert.IsFalse(bracket.IsActive); + } + + [Test] + public void ManualPositionCloseCancelsBothLegs() + { + var bracket = FillEntry(10, stopLossPrice: 90m, takeProfitPrice: 110m); + + _algorithm.MarketOrder(_spy, -10); + Step(); + + Assert.AreEqual(0, _security.Holdings.Quantity); + Assert.AreEqual(OrderStatus.Canceled, bracket.StopLossTicket.Status); + Assert.AreEqual(OrderStatus.Canceled, bracket.TakeProfitTicket.Status); + Assert.IsFalse(bracket.IsActive); + Assert.IsEmpty(_algorithm.Transactions.GetOpenOrders()); + } + + [Test] + public void PartialPositionReductionDownsizesTheLegs() + { + var bracket = FillEntry(10, stopLossPrice: 90m, takeProfitPrice: 110m); + + _algorithm.MarketOrder(_spy, -4); + Step(); + + Assert.AreEqual(6, _security.Holdings.Quantity); + Assert.AreEqual(-6, bracket.StopLossTicket.Quantity); + Assert.AreEqual(-6, bracket.TakeProfitTicket.Quantity); + Assert.IsTrue(bracket.IsActive); + + // the downsized stop loss closes the remaining position exactly, without flipping it + Step(open: 89m, high: 89m, low: 88m); + Assert.AreEqual(OrderStatus.Filled, bracket.StopLossTicket.Status); + Assert.AreEqual(0, _security.Holdings.Quantity); + Assert.IsFalse(bracket.IsActive); + } + + [Test] + public void RefusesNewBracketWhileOneIsActive() + { + // refused while the entry is still working (limit entry far from the market stays open) + var pendingBracket = _algorithm.BracketOrder(_spy, 10, stopLossPrice: 70m, takeProfitPrice: 110m, entryLimitPrice: 80m); + Step(); + Assert.AreEqual(OrderStatus.Submitted, pendingBracket.EntryTicket.Status); + Assert.Throws(() => _algorithm.BracketOrder(_spy, 10, stopLossPrice: 91m, takeProfitPrice: 111m)); + + pendingBracket.Cancel(); + Step(); + Assert.IsFalse(pendingBracket.IsActive); + + // refused while the exit legs are live + var bracket = FillEntry(10, stopLossPrice: 90m, takeProfitPrice: 110m); + Assert.Throws(() => _algorithm.BracketOrder(_spy, 10, stopLossPrice: 91m, takeProfitPrice: 111m)); + + // allowed again once the bracket completes + Step(open: 111m, high: 112m, low: 111m); + Assert.IsFalse(bracket.IsActive); + var newBracket = _algorithm.BracketOrder(_spy, 10, stopLossPrice: 91m, takeProfitPrice: 111m); + Assert.AreNotEqual(bracket, newBracket); + Assert.AreEqual(newBracket, _algorithm.Transactions.GetBracketOrderTicket(_spy)); + } + + [Test] + public void CancelBeforeEntryFillCancelsTheEntryAndCompletes() + { + // entry limit far below the market so it does not fill + var bracket = _algorithm.BracketOrder(_spy, 10, stopLossPrice: 70m, takeProfitPrice: 110m, entryLimitPrice: 80m); + Step(); + Assert.AreEqual(OrderStatus.Submitted, bracket.EntryTicket.Status); + + bracket.Cancel(); + Step(); + + Assert.AreEqual(OrderStatus.Canceled, bracket.EntryTicket.Status); + Assert.IsNull(bracket.StopLossTicket); + Assert.IsNull(bracket.TakeProfitTicket); + Assert.IsFalse(bracket.IsActive); + Assert.IsEmpty(_algorithm.Transactions.GetOpenOrders()); + + // a new bracket can be placed right away + Assert.DoesNotThrow(() => _algorithm.BracketOrder(_spy, 10, stopLossPrice: 90m, takeProfitPrice: 110m)); + } + + [Test] + public void CancelAfterLegsArePlacedCancelsBothLegs() + { + var bracket = FillEntry(10, stopLossPrice: 90m, takeProfitPrice: 110m); + + bracket.Cancel(); + Step(); + + Assert.AreEqual(OrderStatus.Canceled, bracket.StopLossTicket.Status); + Assert.AreEqual(OrderStatus.Canceled, bracket.TakeProfitTicket.Status); + Assert.IsFalse(bracket.IsActive); + // the position is left as is, canceling the bracket only cancels its orders + Assert.AreEqual(10, _security.Holdings.Quantity); + } + + [Test] + public void EntryCanceledExternallyBeforeFillCompletesTheBracket() + { + var bracket = _algorithm.BracketOrder(_spy, 10, stopLossPrice: 70m, takeProfitPrice: 110m, entryLimitPrice: 80m); + Step(); + + bracket.EntryTicket.Cancel(); + Step(); + + Assert.AreEqual(OrderStatus.Canceled, bracket.EntryTicket.Status); + Assert.IsFalse(bracket.IsActive); + Assert.IsNull(_algorithm.Transactions.GetBracketOrderTicket(_spy)); + } + + [Test] + public void LimitEntryFillsAndPlacesLegs() + { + var bracket = _algorithm.BracketOrder(_spy, 10, stopLossPrice: 90m, takeProfitPrice: 110m, entryLimitPrice: 98m); + Step(); + Assert.AreEqual(OrderStatus.Submitted, bracket.EntryTicket.Status); + + Step(open: 97.5m, high: 98m, low: 97m); + + Assert.AreEqual(OrderStatus.Filled, bracket.EntryTicket.Status); + Assert.IsNotNull(bracket.StopLossTicket); + Assert.IsNotNull(bracket.TakeProfitTicket); + Assert.AreEqual(-10, bracket.StopLossTicket.Quantity); + } + + [Test] + public void ShortBracketStopLossFillCancelsTakeProfit() + { + var bracket = FillEntry(-10, stopLossPrice: 110m, takeProfitPrice: 90m); + Assert.AreEqual(-10, _security.Holdings.Quantity); + Assert.AreEqual(10, bracket.StopLossTicket.Quantity); + Assert.AreEqual(10, bracket.TakeProfitTicket.Quantity); + + Step(open: 111m, high: 112m, low: 111m); + + Assert.AreEqual(OrderStatus.Filled, bracket.StopLossTicket.Status); + Assert.AreEqual(OrderStatus.Canceled, bracket.TakeProfitTicket.Status); + Assert.AreEqual(0, _security.Holdings.Quantity); + Assert.IsFalse(bracket.IsActive); + } + + [Test] + public void StopLossOnlyBracketCompletesWhenTheLegFills() + { + var bracket = FillEntry(10, stopLossPrice: 90m, takeProfitPrice: null); + Assert.IsNotNull(bracket.StopLossTicket); + Assert.IsNull(bracket.TakeProfitTicket); + + Step(open: 89m, high: 89m, low: 88m); + + Assert.AreEqual(OrderStatus.Filled, bracket.StopLossTicket.Status); + Assert.AreEqual(0, _security.Holdings.Quantity); + Assert.IsFalse(bracket.IsActive); + } + + [Test] + public void MoveStopLossBeforeAndAfterLegPlacement() + { + var bracket = _algorithm.BracketOrder(_spy, 10, stopLossPrice: 90m, takeProfitPrice: 110m); + + // before the legs are placed the pending price is updated in place + var response = bracket.MoveStopLoss(92m); + Assert.IsFalse(response.IsError); + Assert.AreEqual(92m, bracket.StopLossPrice); + + Step(); + Assert.AreEqual(92m, bracket.StopLossTicket.Get(OrderField.StopPrice)); + + // after the legs are placed an update request is submitted for the live order + response = bracket.MoveStopLoss(94m); + Assert.IsFalse(response.IsError); + Step(); + Assert.AreEqual(94m, bracket.StopLossTicket.Get(OrderField.StopPrice)); + Assert.AreEqual(94m, bracket.StopLossPrice); + } + + [Test] + public void ValidatesExitPricesAgainstEachOtherAndTheEntry() + { + // at least one exit is required + Assert.Throws(() => _algorithm.BracketOrder(_spy, 10)); + // long: stop loss must be below the take profit + Assert.Throws(() => _algorithm.BracketOrder(_spy, 10, stopLossPrice: 110m, takeProfitPrice: 90m)); + // short: stop loss must be above the take profit + Assert.Throws(() => _algorithm.BracketOrder(_spy, -10, stopLossPrice: 90m, takeProfitPrice: 110m)); + // long: stop loss below the entry limit, take profit above it + Assert.Throws(() => _algorithm.BracketOrder(_spy, 10, stopLossPrice: 99m, takeProfitPrice: 110m, entryLimitPrice: 98m)); + Assert.Throws(() => _algorithm.BracketOrder(_spy, 10, stopLossPrice: 90m, takeProfitPrice: 97m, entryLimitPrice: 98m)); + + // nothing was registered by the refused calls + Assert.IsNull(_algorithm.Transactions.GetBracketOrderTicket(_spy)); + Assert.DoesNotThrow(() => _algorithm.BracketOrder(_spy, 10, stopLossPrice: 90m, takeProfitPrice: 110m)); + } + + [Test] + public void InvalidEntryProducesAnInactiveBracket() + { + // zero quantity fails the pre order checks + var bracket = _algorithm.BracketOrder(_spy, 0, stopLossPrice: 90m, takeProfitPrice: 110m); + + Assert.IsFalse(bracket.IsActive); + Assert.AreEqual(OrderStatus.Invalid, bracket.EntryTicket.Status); + Assert.IsNull(_algorithm.Transactions.GetBracketOrderTicket(_spy)); + + // and does not block a subsequent bracket + Assert.DoesNotThrow(() => _algorithm.BracketOrder(_spy, 10, stopLossPrice: 90m, takeProfitPrice: 110m)); + } + + /// + /// Places a bracket order and processes events until the entry is filled and the exit legs are placed + /// + private BracketOrderTicket FillEntry(decimal quantity, decimal? stopLossPrice, decimal? takeProfitPrice) + { + var bracket = _algorithm.BracketOrder(_spy, quantity, stopLossPrice, takeProfitPrice); + Step(); + Assert.AreEqual(OrderStatus.Filled, bracket.EntryTicket.Status); + return bracket; + } + + /// + /// Advances the algorithm one minute, optionally publishing a new price bar, and processes the + /// transaction handler's synchronous events (request draining plus the brokerage fill scan) + /// + private void Step(decimal? open = null, decimal? high = null, decimal? low = null) + { + _algorithm.SetDateTime(_algorithm.UtcTime.AddMinutes(1)); + if (open.HasValue) + { + SetPrice(open.Value, high ?? open.Value, low ?? open.Value); + } + else + { + var price = _security.Price == 0 ? 100m : _security.Price; + SetPrice(price, price, price); + } + _transactionHandler.ProcessSynchronousEvents(); + } + + private void SetPrice(decimal open, decimal high, decimal low) + { + var close = (high + low) / 2; + _security.SetMarketPrice(new TradeBar(_algorithm.Time.AddMinutes(-1), _spy, open, high, low, close, 100)); + } + } +} From 7a2d4d3c9c50ad185e510e930cf360eea1bce29e Mon Sep 17 00:00:00 2001 From: Jhonathan Abreu Date: Wed, 12 Aug 2026 18:38:26 -0400 Subject: [PATCH 2/2] Generalize code comments --- Common/Orders/BracketOrderTicket.cs | 4 ++-- .../BrokerageTransactionHandlerTests/BracketOrderTests.cs | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/Common/Orders/BracketOrderTicket.cs b/Common/Orders/BracketOrderTicket.cs index 1ce28d54277c..33953edd5907 100644 --- a/Common/Orders/BracketOrderTicket.cs +++ b/Common/Orders/BracketOrderTicket.cs @@ -306,7 +306,7 @@ internal bool HandleOrderEvent(OrderEvent orderEvent, decimal holdingsQuantity) { // one-cancels-the-other: this leg exited the bracket's position, its sibling // must not fill too (both legs filling on a gapping bar flips the position, - // exhausting margin: fleet deployment A-20b9ed) + // exhausting margin) requestsIssued |= TryCancel(sibling, $"Bracket #{_entryRequest.OrderId} sibling leg filled"); } else if (orderEvent.Status == OrderStatus.PartiallyFilled && IsTicketOpen(sibling)) @@ -326,7 +326,7 @@ internal bool HandleOrderEvent(OrderEvent orderEvent, decimal holdingsQuantity) { // an unrelated order for the same symbol filled: keep the protective legs in sync // with the remaining position so a stranded leg cannot idle against a closed position - // (fleet deployment A-6eb8558a: dangling stop leg produced 1931x InsufficientBuyingPower) + // (a dangling stop leg would otherwise repeatedly reject with InsufficientBuyingPower) if (holdingsQuantity == 0 || Math.Sign(holdingsQuantity) != Math.Sign(Quantity)) { requestsIssued |= TryCancel(StopLossTicket, $"Bracket #{_entryRequest.OrderId} position closed"); diff --git a/Tests/Engine/BrokerageTransactionHandlerTests/BracketOrderTests.cs b/Tests/Engine/BrokerageTransactionHandlerTests/BracketOrderTests.cs index eabf16cf0876..0207c8bb8d9f 100644 --- a/Tests/Engine/BrokerageTransactionHandlerTests/BracketOrderTests.cs +++ b/Tests/Engine/BrokerageTransactionHandlerTests/BracketOrderTests.cs @@ -60,7 +60,7 @@ public void Initialize() _brokerage = new BacktestingBrokerage(_algorithm); _transactionHandler.Initialize(_algorithm, _brokerage, new BacktestingResultHandler()); _algorithm.Transactions.SetOrderProcessor(_transactionHandler); - // as in backtesting deployments: fills are synchronous, MarketOrder must not wait + // as in backtesting: fills are synchronous, MarketOrder must not wait _algorithm.Transactions.MarketOrderFillTimeout = TimeSpan.Zero; }