diff --git a/QuantConnect.InteractiveBrokersBrokerage.Tests/InteractiveBrokersBrokerageAdditionalTests.cs b/QuantConnect.InteractiveBrokersBrokerage.Tests/InteractiveBrokersBrokerageAdditionalTests.cs index 422579e..ab973d5 100644 --- a/QuantConnect.InteractiveBrokersBrokerage.Tests/InteractiveBrokersBrokerageAdditionalTests.cs +++ b/QuantConnect.InteractiveBrokersBrokerage.Tests/InteractiveBrokersBrokerageAdditionalTests.cs @@ -243,6 +243,134 @@ public void SendComboOrderWithUnderlying(OrderType orderType, decimal comboLimit } } + [TestCase(OrderType.ComboMarket)] + [TestCase(OrderType.ComboLimit)] + [TestCase(OrderType.ComboLegLimit)] + public void UpdateComboOrder(OrderType orderType) + { + var algo = new AlgorithmStub(); + var orderProvider = new OrderProvider(); + // wait for the previous run to finish, avoid any race condition + Thread.Sleep(2000); + using var brokerage = new InteractiveBrokersBrokerage(algo, orderProvider, algo.Portfolio, new AggregationManager(), TestGlobals.MapFileProvider); + brokerage.Connect(); + + var openOrders = brokerage.GetOpenOrders(); + foreach (var order in openOrders) + { + brokerage.CancelOrder(order); + } + + var optionsExpiration = new DateTime(2023, 12, 29); + var orderProperties = new InteractiveBrokersOrderProperties(); + var comboLimitPrice = orderType == OrderType.ComboLimit ? 400m : 0m; + var group = new GroupOrderManager(1, legCount: 2, quantity: 2, limitPrice: comboLimitPrice); + + var underlying = Symbols.SPY; + + var symbol1 = Symbol.CreateOption(underlying, Market.USA, OptionStyle.American, OptionRight.Call, 475m, optionsExpiration); + var leg1 = BuildOrder(orderType, symbol1, -1, comboLimitPrice, group, orderType == OrderType.ComboLegLimit ? 490m : 0m, + orderProperties, algo.Transactions); + + var symbol2 = Symbol.CreateOption(underlying, Market.USA, OptionStyle.American, OptionRight.Call, 480m, optionsExpiration); + var leg2 = BuildOrder(orderType, symbol2, +1, comboLimitPrice, group, orderType == OrderType.ComboLegLimit ? 460m : 0m, + orderProperties, algo.Transactions); + + var orders = new List { leg1, leg2 }; + + using var submittedEvent = new ManualResetEvent(false); + EventHandler> handleSubmission = (_, orderEvents) => + { + foreach (var order in orders) + { + foreach (var orderEvent in orderEvents) + { + if (orderEvent.OrderId == order.Id) + { + // update the order like the BTH would do + order.Status = orderEvent.Status; + } + } + + if (orders.All(o => o.Status == OrderStatus.Submitted)) + { + submittedEvent.Set(); + } + } + }; + brokerage.OrdersStatusChanged += handleSubmission; + + foreach (var order in orders) + { + group.OrderIds.Add(order.Id); + orderProvider.Add(order); + brokerage.PlaceOrder(order); + } + + Assert.IsTrue(submittedEvent.WaitOne(TimeSpan.FromSeconds(20))); + + brokerage.OrdersStatusChanged -= handleSubmission; + + using var updatedEvent = new ManualResetEvent(false); + EventHandler> handleUpdate = (_, orderEvents) => + { + foreach (var order in orders) + { + foreach (var orderEvent in orderEvents) + { + if (orderEvent.OrderId == order.Id) + { + // update the order like the BTH would do + order.Status = orderEvent.Status; + } + } + + if (orders.All(o => o.Status == OrderStatus.UpdateSubmitted)) + { + updatedEvent.Set(); + } + } + }; + brokerage.OrdersStatusChanged += handleUpdate; + + // Update order quantity + orders[0].ApplyUpdateOrderRequest(new UpdateOrderRequest( + DateTime.UtcNow, + orders[0].Id, + new UpdateOrderFields { Quantity = group.Quantity * 2 })); + + // Update global limit price + if (orderType == OrderType.ComboLimit) + { + orders[0].ApplyUpdateOrderRequest(new UpdateOrderRequest( + DateTime.UtcNow, + orders[0].Id, + new UpdateOrderFields { LimitPrice = 450m })); + } + + foreach (var order in orders) + { + // Update leg limit price + if (orderType == OrderType.ComboLegLimit) + { + var legLimitOrder = (ComboLegLimitOrder)order; + legLimitOrder.ApplyUpdateOrderRequest(new UpdateOrderRequest( + DateTime.UtcNow, + order.Id, + new UpdateOrderFields + { + LimitPrice = legLimitOrder.LimitPrice + Math.Sign(order.Quantity) * 5m + })); + } + + brokerage.UpdateOrder(order); + } + + Assert.IsTrue(updatedEvent.WaitOne(TimeSpan.FromSeconds(20))); + + brokerage.OrdersStatusChanged -= handleUpdate; + } + // NOTEs: // - The initial stop price should be far enough from current market price in order to trigger at least one stop price update // - Stop price and trailing amount should be updated when the tests are run with real time data diff --git a/QuantConnect.InteractiveBrokersBrokerage/InteractiveBrokersBrokerage.cs b/QuantConnect.InteractiveBrokersBrokerage/InteractiveBrokersBrokerage.cs index ae78d70..1d6886e 100644 --- a/QuantConnect.InteractiveBrokersBrokerage/InteractiveBrokersBrokerage.cs +++ b/QuantConnect.InteractiveBrokersBrokerage/InteractiveBrokersBrokerage.cs @@ -167,6 +167,11 @@ public sealed class InteractiveBrokersBrokerage : Brokerage, IDataQueueHandler, private InteractiveBrokersSymbolMapper _symbolMapper; private readonly HashSet _invalidContracts = new(); + // IB TWS may adjust the contract details to define the combo orders more accurately, so we keep track of them in order to be able + // to update these orders without getting error 105 ("Order being modified does not match original order"). + // https://groups.io/g/twsapi/topic/5333246?p=%2C%2C%2C20%2C0%2C0%2C0%3A%3A%2C%2C%2C0%2C0%2C0%2C5333246 + private ConcurrentDictionary _comboOrdersContracts = new(); + // Prioritized list of exchanges used to find right futures contract private readonly Dictionary _futuresExchanges = new Dictionary { @@ -577,6 +582,48 @@ private List GetOpenOrdersInternal(bool all) return orders.Select(orderContract => ConvertOrders(orderContract.Order, orderContract.Contract, orderContract.OrderState)).SelectMany(orders => orders).ToList(); } + private Contract GetOpenOrderContract(int orderId) + { + Contract contract = null; + var manualResetEvent = new ManualResetEvent(false); + + // define our handlers + EventHandler clientOnOpenOrder = (sender, args) => + { + if (args.OrderId == orderId) + { + contract = args.Contract; + manualResetEvent.Set(); + } + }; + EventHandler clientOnOpenOrderEnd = (sender, args) => + { + // this signals the end of our RequestOpenOrders call + manualResetEvent.Set(); + }; + + _client.OpenOrder += clientOnOpenOrder; + _client.OpenOrderEnd += clientOnOpenOrderEnd; + + CheckRateLimiting(); + + _client.ClientSocket.reqOpenOrders(); + + // wait for our end signal + var timedOut = !manualResetEvent.WaitOne(15000); + + // remove our handlers + _client.OpenOrder -= clientOnOpenOrder; + _client.OpenOrderEnd -= clientOnOpenOrderEnd; + + if (timedOut) + { + throw new TimeoutException("InteractiveBrokersBrokerage.GetOpenOrders(): Operation took longer than 15 seconds."); + } + + return contract; + } + /// /// Gets all holdings for the account /// @@ -1289,7 +1336,23 @@ private void IBPlaceOrder(Order order, bool needsNewId, string exchange = null) exchange = Market.CBOE.ToUpperInvariant(); } - var contract = CreateContract(orders[0].Symbol, false, orders, exchange); + // If a combo order is being updated, let's use the existing contract to overcome IB's bug. + // See https://github.com/QuantConnect/Lean.Brokerages.InteractiveBrokers/issues/66 and + // https://groups.io/g/twsapi/topic/5333246?p=%2C%2C%2C20%2C0%2C0%2C0%3A%3A%2C%2C%2C0%2C0%2C0%2C5333246 + Contract contract; + if (needsNewId || order.GroupOrderManager == null) + { + contract = CreateContract(orders[0].Symbol, false, orders, exchange); + } + else + { + if (!_comboOrdersContracts.TryGetValue(order.GroupOrderManager.Id, out contract)) + { + // We need the contract created by IB in order to update combo orders, so lets fetch it + contract = GetOpenOrderContract(int.Parse(order.BrokerId[0])); + // No need to cache the contract here, it will be cached by our default OpenOrder handler + } + } int ibOrderId; if (needsNewId) @@ -1337,7 +1400,7 @@ private void IBPlaceOrder(Order order, bool needsNewId, string exchange = null) var noSubmissionOrderTypes = _noSubmissionOrderTypes.Contains(order.Type); if (!eventSlim.Wait(noSubmissionOrderTypes ? _noSubmissionOrdersResponseTimeout : _responseTimeout)) { - if(noSubmissionOrderTypes) + if (noSubmissionOrderTypes) { if(!_submissionOrdersWarningSent) { @@ -1917,6 +1980,12 @@ private void HandleOrderStatusUpdates(object sender, IB.OrderStatusEventArgs upd var status = ConvertOrderStatus(update.Status); + // Let's remove the contract for combo orders when they are canceled or filled + if (firstOrder.GroupOrderManager != null && (status == OrderStatus.Filled || status == OrderStatus.Canceled)) + { + _comboOrdersContracts.TryRemove(firstOrder.GroupOrderManager.Id, out _); + } + if (status == OrderStatus.Filled || status == OrderStatus.PartiallyFilled) { // fill events will be only processed in HandleExecutionDetails and HandleCommissionReports. @@ -2009,6 +2078,12 @@ private void HandleOpenOrder(object sender, IB.OpenOrderEventArgs e) return; } + if (orders[0].GroupOrderManager != null) + { + _comboOrdersContracts[orders[0].GroupOrderManager.Id] = e.Contract; + return; + } + // the only changes we handle now are trail stop price updates var order = orders[0]; if (order.Type != OrderType.TrailingStop) @@ -2475,7 +2550,6 @@ private IBApi.Order ConvertOrder(List orders, Contract contract, int ibOr // Combo per-leg prices are only supported for non-guaranteed smart combos with two legs AddGuaranteedTag(ibOrder, true); - // comboLegLimitOrder inherits from LimitOrder so we process it's 'if' first ibOrder.OrderComboLegs = new(); foreach (var comboLegLimit in orders.OfType().OrderBy(o => o.Id)) {