From d97d70e00e34b18906d156ac7450b74ac724b634 Mon Sep 17 00:00:00 2001 From: mameikagou Date: Wed, 26 Aug 2026 17:48:42 +0800 Subject: [PATCH] Validate buying power for combo order updates --- .../BrokerageTransactionHandler.cs | 69 +++++++++++++- .../BrokerageTransactionHandlerTests.cs | 93 +++++++++++++++++++ 2 files changed, 158 insertions(+), 4 deletions(-) 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)