Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 10 additions & 1 deletion Tests/Brokerages/BrokerageTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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");
}

/// <summary>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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;

Expand Down Expand Up @@ -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;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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<OrderRequest>();
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();
}
}
}
}
Loading