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..33953edd5907
--- /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)
+ 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
+ // (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");
+ 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..0207c8bb8d9f
--- /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: 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));
+ }
+ }
+}