diff --git a/Tests/Brokerages/BrokerageTests.cs b/Tests/Brokerages/BrokerageTests.cs index c270d190d2db..2aeee2965a59 100644 --- a/Tests/Brokerages/BrokerageTests.cs +++ b/Tests/Brokerages/BrokerageTests.cs @@ -652,9 +652,18 @@ public virtual void ContingentOrdersTrigger(ContingentOrderTestParameters parame OrderProvider.Add(order); Assert.IsTrue(Brokerage.PlaceOrder(order), $"Brokerage failed to place the order: {order}"); } + var children = orders[0].GetContingentChildren(orders).ToList(); WaitForOrders(() => orders[0].Status == OrderStatus.Filled - && orders[0].GetContingentChildren(orders).All(child => !child.IsWaitingForTrigger() && child.Status is OrderStatus.Submitted or OrderStatus.UpdateSubmitted), + && children.All(child => !child.IsWaitingForTrigger() && child.Status is OrderStatus.Submitted or OrderStatus.UpdateSubmitted), "the first order filled and the orders it triggers working"); + + // the triggered orders are canceled before the position is closed on tear down: some brokerages reserve the position for + // the working exit orders until they are canceled. Canceling one can cancel the others, like the siblings of a bracket + foreach (var child in children.Where(child => child.Status.IsOpen())) + { + Brokerage.CancelOrder(child); + } + WaitForOrders(() => children.All(child => child.Status == OrderStatus.Canceled), "the triggered orders canceled"); } /// diff --git a/Tests/Engine/BrokerageTransactionHandlerTests/BacktestingTransactionHandlerTests.cs b/Tests/Engine/BrokerageTransactionHandlerTests/BacktestingTransactionHandlerTests.cs index 3f326076447b..822662d4e7bc 100644 --- a/Tests/Engine/BrokerageTransactionHandlerTests/BacktestingTransactionHandlerTests.cs +++ b/Tests/Engine/BrokerageTransactionHandlerTests/BacktestingTransactionHandlerTests.cs @@ -30,6 +30,7 @@ using QuantConnect.Orders; using QuantConnect.Orders.Fees; using QuantConnect.Orders.Fills; +using QuantConnect.Packets; using QuantConnect.Securities; using QuantConnect.Tests.Engine.DataFeeds; @@ -397,6 +398,67 @@ public void ProcessesOrdersConcurrentlyInLivePaperTrading() } } + [Test] + public void ProcessesContingentOrdersInLivePaperTrading() + { + // the fxcm brokerage model doesn't support contingent orders + _algorithm.SetBrokerageModel(BrokerageName.Default); + _algorithm.SetLiveMode(true); + using var brokerage = new PaperBrokerage(_algorithm, new LiveNodePacket()); + var transactionHandler = new BacktestingTransactionHandler(); + transactionHandler.Initialize(_algorithm, brokerage, new BacktestingResultHandler()); + + try + { + _algorithm.Transactions.SetOrderProcessor(transactionHandler); + var security = _algorithm.Securities[Ticker]; + _algorithm.Portfolio.CashBook["EUR"].ConversionRate = 1.1m; + var time = new DateTime(2025, 07, 03, 10, 0, 0); + void Step(decimal price) + { + time = time.AddMinutes(1); + _algorithm.SetDateTime(time); + security.SetMarketPrice(new Tick(time, security.Symbol, price, price, price)); + // like the live engine on each time loop, scans the paper brokerage + transactionHandler.ProcessSynchronousEvents(); + } + Step(1.10m); + + var tickets = _algorithm.BracketOrder(security.Symbol, 1000, takeProfitPrice: 1.12m, stopLossPrice: 1.05m, limitPrice: 1.09m); + var entry = tickets[0]; + Assert.AreEqual(3, tickets.Count, tickets[0].SubmitRequest.Response.ErrorMessage); + var takeProfit = tickets[1]; + var stopLoss = tickets[2]; + + // the transaction threads place the set once all its orders arrived + Assert.IsTrue(SpinWait.SpinUntil(() => tickets.All(x => x.Status == OrderStatus.Submitted), 10000), + $"The contingent orders were not submitted: {string.Join(", ", tickets.Select(x => x.Status))}"); + Assert.IsTrue(takeProfit.Contingency.IsWaitingForTrigger); + Assert.IsTrue(stopLoss.Contingency.IsWaitingForTrigger); + + // the children are held even if the price goes through their prices + Step(1.13m); + Assert.IsTrue(tickets.All(x => x.Status == OrderStatus.Submitted)); + + // the entry fills, triggering the children + Step(1.08m); + Assert.AreEqual(OrderStatus.Filled, entry.Status); + Assert.IsTrue(new[] { takeProfit, stopLoss }.All(x => x.Status == OrderStatus.Submitted && !x.Contingency.IsWaitingForTrigger)); + Assert.AreEqual(1000, security.Holdings.Quantity); + + // the take profit fills, canceling the stop loss + Step(1.13m); + Assert.AreEqual(OrderStatus.Filled, takeProfit.Status); + Assert.AreEqual(OrderStatus.Canceled, stopLoss.Status); + Assert.AreEqual(0, security.Holdings.Quantity); + Assert.IsEmpty(_algorithm.Transactions.GetOpenOrders()); + } + finally + { + transactionHandler.Exit(); + } + } + private class TestablePaperBrokerageTransactionHandler : BacktestingTransactionHandler { private readonly int _expectedOrdersCount; diff --git a/Tests/Engine/BrokerageTransactionHandlerTests/OrderRequestProcessingPoolTests.cs b/Tests/Engine/BrokerageTransactionHandlerTests/OrderRequestProcessingPoolTests.cs index 60e1eb6eddfd..5d4954e0fa81 100644 --- a/Tests/Engine/BrokerageTransactionHandlerTests/OrderRequestProcessingPoolTests.cs +++ b/Tests/Engine/BrokerageTransactionHandlerTests/OrderRequestProcessingPoolTests.cs @@ -275,5 +275,64 @@ public void SynchronousPoolDisposeLeavesPendingRequestsUnprocessed() Assert.IsNull(processingError, $"the pool reported an error: {processingError}"); Assert.IsFalse(pool.IsActive); } + + // The orders of a set of contingent orders are processed one at a time in arrival order, like the requests of a + // single order, so the set is placed once all of them arrived. Other orders don't wait for them + [Test] + public void ContingentOrdersAreProcessedInArrivalOrderWithoutBlockingOtherOrders() + { + using var gate = new ManualResetEventSlim(false); + var processed = new ConcurrentQueue(); + Exception processingError = null; + var pool = new OrderRequestProcessingPool(concurrencyEnabled: true, minimumThreads: 2, maximumThreads: 2, + request => + { + processed.Enqueue(request); + if (request.Tag == "entry") + { + gate.Wait(); + } + }, + exception => processingError = exception); + + try + { + var symbol = Symbols.SPY; + var reference = new DateTime(2025, 07, 03, 10, 0, 0); + var entry = new SubmitOrderRequest(OrderType.Limit, symbol.SecurityType, symbol, 1, 0, 100, reference, "entry"); + entry.Bracket(takeProfitPrice: 110, stopLossPrice: 90); + // the entry, then the take profit and the stop loss + var contingentOrders = entry.Contingency.Requests; + Assert.AreSame(entry, contingentOrders[0]); + // the set id matches the id of the other order: they are still routed apart + entry.Contingency.SetId(4); + for (var i = 0; i < contingentOrders.Count; i++) + { + contingentOrders[i].SetOrderId(i + 1); + } + var other = new SubmitOrderRequest(OrderType.Market, symbol.SecurityType, symbol, 1, 0, 0, reference, "other"); + other.SetOrderId(4); + + pool.Dispatch(contingentOrders[0], Order.CreateOrder(contingentOrders[0])); + Assert.IsTrue(SpinWait.SpinUntil(() => processed.Count >= 1, 10000), "the worker never got the entry"); + pool.Dispatch(contingentOrders[1], Order.CreateOrder(contingentOrders[1])); + pool.Dispatch(other, Order.CreateOrder(other)); + pool.Dispatch(contingentOrders[2], Order.CreateOrder(contingentOrders[2])); + + // the other order runs while the entry is processed, the rest of the set waits for it + Assert.IsTrue(SpinWait.SpinUntil(() => processed.Contains(other), 10000), "the other order waited for the contingent orders"); + CollectionAssert.AreEqual(new OrderRequest[] { contingentOrders[0], other }, processed); + + gate.Set(); + Assert.IsTrue(SpinWait.SpinUntil(() => processed.Count == 4, 10000), "the contingent orders were not processed"); + CollectionAssert.AreEqual(contingentOrders, processed.Where(request => request != other)); + Assert.IsNull(processingError, $"the pool reported an error: {processingError}"); + } + finally + { + gate.Set(); + pool.DisposeSafely(); + } + } } }