diff --git a/Engine/TransactionHandlers/BrokerageTransactionHandler.cs b/Engine/TransactionHandlers/BrokerageTransactionHandler.cs
index fa9616d65623..47d87fa04951 100644
--- a/Engine/TransactionHandlers/BrokerageTransactionHandler.cs
+++ b/Engine/TransactionHandlers/BrokerageTransactionHandler.cs
@@ -993,9 +993,7 @@ private OrderResponse HandleUpdateOrderRequest(UpdateOrderRequest request)
isClosedOrderUpdate = true;
}
- // rounds off the order towards 0 to the nearest multiple of lot size
security ??= _algorithm.Securities[order.Symbol];
- order.Quantity = RoundOffOrder(order, security);
// verify that our current brokerage can actually update the order
BrokerageMessageEvent message;
@@ -1011,10 +1009,39 @@ private OrderResponse HandleUpdateOrderRequest(UpdateOrderRequest request)
return response;
}
- // If the order is not part of a ComboLegLimit update, validate sufficient buying power
- if (order.GroupOrderManager == null)
+ // Validate the proposed order without changing the order held by the transaction handler. For combo orders,
+ // the buying power model must see every leg with the proposed group-level update applied.
+ if (order.GroupOrderManager != null)
+ {
+ if (!order.TryGetGroupOrders(GetComboOrderLeg, out var comboOrders))
+ {
+ var errorMessage = $"Unable to update order with id {request.OrderId} because its combo group is incomplete.";
+ _algorithm.Error(errorMessage);
+ return OrderResponse.Error(request, OrderResponseErrorCode.ProcessingError, errorMessage);
+ }
+
+ var updatedOrders = CloneGroupOrders(comboOrders);
+ var updatedOrder = updatedOrders.Single(o => o.Id == order.Id);
+ // Round the candidate rather than the live order so a rejected update has no side effects.
+ updatedOrder.Quantity = RoundOffOrder(updatedOrder, security);
+ updatedOrder.ApplyUpdateOrderRequest(request);
+ if (!updatedOrders.TryGetGroupOrdersSecurities(_algorithm.Portfolio, out var securities))
+ {
+ var errorMessage = $"Unable to update order with id {request.OrderId} because a combo leg security is missing.";
+ _algorithm.Error(errorMessage);
+ return OrderResponse.Error(request, OrderResponseErrorCode.ProcessingError, errorMessage);
+ }
+
+ if (!HasSufficientBuyingPowerForOrders(updatedOrder, request, out var validationResult, updatedOrders, securities))
+ {
+ return validationResult;
+ }
+ }
+ else
{
var updatedOrder = order.Clone();
+ // Round the candidate rather than the live order so a rejected update has no side effects.
+ updatedOrder.Quantity = RoundOffOrder(updatedOrder, security);
updatedOrder.ApplyUpdateOrderRequest(request);
if (!HasSufficientBuyingPowerForOrders(updatedOrder, request, out var validationResult))
{
@@ -1023,6 +1050,8 @@ private OrderResponse HandleUpdateOrderRequest(UpdateOrderRequest request)
}
// modify the values of the order object
+ // rounds off the order towards 0 to the nearest multiple of lot size
+ order.Quantity = RoundOffOrder(order, security);
order.ApplyUpdateOrderRequest(request);
// rounds the order prices
@@ -1917,6 +1946,38 @@ private Order GetComboOrderLeg(int orderId)
return order;
}
+ ///
+ /// Creates isolated copies of all orders in a combo for a what-if buying power check.
+ /// Combo orders share their group manager, so the manager must be copied as well before applying an update.
+ ///
+ private static List CloneGroupOrders(List orders)
+ {
+ var groupOrderManager = orders[0].GroupOrderManager;
+ var clonedGroupOrderManager = new GroupOrderManager(
+ groupOrderManager.Id,
+ groupOrderManager.Count,
+ groupOrderManager.Quantity,
+ groupOrderManager.LimitPrice);
+
+ lock (groupOrderManager.OrderIds)
+ {
+ foreach (var orderId in groupOrderManager.OrderIds)
+ {
+ clonedGroupOrderManager.OrderIds.Add(orderId);
+ }
+ }
+
+ var clonedOrders = new List(orders.Count);
+ foreach (var order in orders)
+ {
+ var clonedOrder = order.Clone();
+ clonedOrder.GroupOrderManager = clonedGroupOrderManager;
+ clonedOrders.Add(clonedOrder);
+ }
+
+ return clonedOrders;
+ }
+
private void InvalidateOrders(List orders, string message)
{
for (var i = 0; i < orders.Count; i++)
diff --git a/Tests/Engine/BrokerageTransactionHandlerTests/BrokerageTransactionHandlerTests.cs b/Tests/Engine/BrokerageTransactionHandlerTests/BrokerageTransactionHandlerTests.cs
index a7c22d903ec1..44bc0c49bc13 100644
--- a/Tests/Engine/BrokerageTransactionHandlerTests/BrokerageTransactionHandlerTests.cs
+++ b/Tests/Engine/BrokerageTransactionHandlerTests/BrokerageTransactionHandlerTests.cs
@@ -1116,6 +1116,96 @@ public void UpdateOrderRequestShouldWork()
Assert.IsTrue(_algorithm.OrderEvents[1].Status == OrderStatus.UpdateSubmitted);
}
+ [Test]
+ public void ComboOrderUpdateShouldRejectInsufficientBuyingPowerWithoutMutatingTheGroup()
+ {
+ _transactionHandler = new TestBrokerageTransactionHandler();
+ using var brokerage = new NoSubmitTestBrokerage(_algorithm);
+ _transactionHandler.Initialize(_algorithm, brokerage, new BacktestingResultHandler());
+
+ var security1 = (Security)_algorithm.AddEquity("SPY");
+ var security2 = (Security)_algorithm.AddEquity("AAPL");
+ security1.SetMarketPrice(new Tick(DateTime.UtcNow, security1.Symbol, 100, 100));
+ security2.SetMarketPrice(new Tick(DateTime.UtcNow, security2.Symbol, 100, 100));
+
+ var firstBuyingPowerModel = new Mock();
+ firstBuyingPowerModel
+ .Setup(model => model.HasSufficientBuyingPowerForOrder(It.IsAny()))
+ .Returns(new HasSufficientBuyingPowerForOrderResult(true));
+ security1.SetBuyingPowerModel(firstBuyingPowerModel.Object);
+
+ var secondBuyingPowerModel = new Mock();
+ secondBuyingPowerModel
+ .Setup(model => model.HasSufficientBuyingPowerForOrder(It.IsAny()))
+ .Returns((HasSufficientBuyingPowerForOrderParameters parameters) =>
+ new HasSufficientBuyingPowerForOrderResult(parameters.Order.Quantity <= 1, "insufficient combo buying power"));
+ security2.SetBuyingPowerModel(secondBuyingPowerModel.Object);
+
+ var groupOrderManager = new GroupOrderManager(1, 2, 1);
+ var firstOrder = new ComboMarketOrder(security1.Symbol, 1, DateTime.UtcNow, groupOrderManager);
+ var secondOrder = new ComboMarketOrder(security2.Symbol, 1, DateTime.UtcNow, groupOrderManager);
+ _transactionHandler.AddOpenOrder(firstOrder, _algorithm);
+ _transactionHandler.AddOpenOrder(secondOrder, _algorithm);
+
+ var updateRequest = new UpdateOrderRequest(DateTime.UtcNow, firstOrder.Id, new UpdateOrderFields { Quantity = 2 });
+ _transactionHandler.Process(updateRequest);
+ _transactionHandler.HandleOrderRequest(updateRequest);
+
+ Assert.IsTrue(updateRequest.Response.IsError);
+ Assert.AreEqual(OrderResponseErrorCode.BrokerageFailedToUpdateOrder, updateRequest.Response.ErrorCode);
+ Assert.AreEqual(1, groupOrderManager.Quantity);
+ Assert.AreEqual(1, firstOrder.Quantity);
+ Assert.AreEqual(1, secondOrder.Quantity);
+ secondBuyingPowerModel.Verify(model => model.HasSufficientBuyingPowerForOrder(
+ It.Is(parameters => parameters.Order.Quantity == 2)), Times.Once);
+ Assert.IsEmpty(brokerage.UpdatedOrders);
+ }
+
+ [Test]
+ public void ComboOrderUpdateShouldValidateTheCompleteGroupAndUpdateBrokerage()
+ {
+ _transactionHandler = new TestBrokerageTransactionHandler();
+ using var brokerage = new NoSubmitTestBrokerage(_algorithm);
+ _transactionHandler.Initialize(_algorithm, brokerage, new BacktestingResultHandler());
+
+ var security1 = (Security)_algorithm.AddEquity("SPY");
+ var security2 = (Security)_algorithm.AddEquity("AAPL");
+ security1.SetMarketPrice(new Tick(DateTime.UtcNow, security1.Symbol, 100, 100));
+ security2.SetMarketPrice(new Tick(DateTime.UtcNow, security2.Symbol, 100, 100));
+
+ var firstBuyingPowerModel = new Mock();
+ firstBuyingPowerModel
+ .Setup(model => model.HasSufficientBuyingPowerForOrder(It.IsAny()))
+ .Returns(new HasSufficientBuyingPowerForOrderResult(true));
+ security1.SetBuyingPowerModel(firstBuyingPowerModel.Object);
+
+ var secondBuyingPowerModel = new Mock();
+ secondBuyingPowerModel
+ .Setup(model => model.HasSufficientBuyingPowerForOrder(It.IsAny()))
+ .Returns(new HasSufficientBuyingPowerForOrderResult(true));
+ security2.SetBuyingPowerModel(secondBuyingPowerModel.Object);
+
+ var groupOrderManager = new GroupOrderManager(1, 2, 1);
+ var firstOrder = new ComboMarketOrder(security1.Symbol, 1, DateTime.UtcNow, groupOrderManager);
+ var secondOrder = new ComboMarketOrder(security2.Symbol, 1, DateTime.UtcNow, groupOrderManager);
+ _transactionHandler.AddOpenOrder(firstOrder, _algorithm);
+ _transactionHandler.AddOpenOrder(secondOrder, _algorithm);
+
+ var updateRequest = new UpdateOrderRequest(DateTime.UtcNow, firstOrder.Id, new UpdateOrderFields { Quantity = 2 });
+ _transactionHandler.Process(updateRequest);
+ _transactionHandler.HandleOrderRequest(updateRequest);
+
+ Assert.IsTrue(updateRequest.Response.IsSuccess);
+ Assert.AreEqual(2, groupOrderManager.Quantity);
+ Assert.AreEqual(2, firstOrder.Quantity);
+ Assert.AreEqual(2, secondOrder.Quantity);
+ secondBuyingPowerModel.Verify(model => model.HasSufficientBuyingPowerForOrder(
+ It.Is(parameters => parameters.Order.Quantity == 2)), Times.Once);
+ Assert.That(brokerage.UpdatedOrders, Has.Count.EqualTo(1));
+ Assert.AreEqual(firstOrder.Id, brokerage.UpdatedOrders[0].Id);
+ Assert.AreEqual(2, brokerage.UpdatedOrders[0].Quantity);
+ }
+
[Test]
public void UpdatePartiallyFilledOrderRequestShouldWork()
{
@@ -3039,6 +3129,8 @@ internal class NoSubmitTestBrokerage : Brokerage
{
private BacktestingBrokerage _underlyingBrokerage;
+ public List UpdatedOrders { get; } = new();
+
public override bool IsConnected => _underlyingBrokerage.IsConnected;
public NoSubmitTestBrokerage(IAlgorithm algorithm) : base("NoSubmitTestBrokerage")
@@ -3051,6 +3143,7 @@ public override bool PlaceOrder(Order order)
}
public override bool UpdateOrder(Order order)
{
+ UpdatedOrders.Add(order);
return true;
}
public void PublishOrderEvent(OrderEvent orderEvent)