Skip to content

Commit

Permalink
Fix combo orders update (#90)
Browse files Browse the repository at this point in the history
* Fix combo order update

Fix error 105 by using IB generated contract instead of the one created by us, since TWS may adjust the contracts attributes slightly to be consistent with IB servers

* Clean up

* Fetch combo order contracts on demand

* Minor fix

* Add unit test

* Minor changes
  • Loading branch information
jhonabreul authored Jan 3, 2024
1 parent d8a6db8 commit 4c250bf
Show file tree
Hide file tree
Showing 2 changed files with 205 additions and 3 deletions.
Original file line number Diff line number Diff line change
Expand Up @@ -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<Order> { leg1, leg2 };

using var submittedEvent = new ManualResetEvent(false);
EventHandler<List<OrderEvent>> 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<List<OrderEvent>> 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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -167,6 +167,11 @@ public sealed class InteractiveBrokersBrokerage : Brokerage, IDataQueueHandler,
private InteractiveBrokersSymbolMapper _symbolMapper;
private readonly HashSet<string> _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<long, Contract> _comboOrdersContracts = new();

// Prioritized list of exchanges used to find right futures contract
private readonly Dictionary<string, string> _futuresExchanges = new Dictionary<string, string>
{
Expand Down Expand Up @@ -577,6 +582,48 @@ private List<Order> 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<IB.OpenOrderEventArgs> 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;
}

/// <summary>
/// Gets all holdings for the account
/// </summary>
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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)
{
Expand Down Expand Up @@ -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.
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -2475,7 +2550,6 @@ private IBApi.Order ConvertOrder(List<Order> 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<ComboLegLimitOrder>().OrderBy(o => o.Id))
{
Expand Down

0 comments on commit 4c250bf

Please sign in to comment.