From 99e07281646d47847a1a6a52eb8160a8fa42b36c Mon Sep 17 00:00:00 2001 From: Alexandre Catarino Date: Sat, 29 Aug 2026 19:02:55 +0100 Subject: [PATCH] Add cryptocurrency support to the Interactive Brokers brokerage model - interactivebrokers crypto symbol properties measured from IB's contract details, placed after the coinbase rows; the default crypto market stays coinbase, where the data lives - CanSubmitOrder: listed pairs, market and limit only, no short sales, buy limit within 10 USD or 0.25% of the ask, buy market needs a price, venue open Sunday 03:00 to Friday 16:00 New York - FIX model rejects crypto, fee 0.18% with a 1.75 USD minimum, leverage 1 Co-Authored-By: Claude Opus 5 Co-Authored-By: Claude Fable 5 --- .../InteractiveBrokersBrokerageModel.cs | 137 ++++++++++++- .../Brokerages/InteractiveBrokersFixModel.cs | 10 + Common/Messages/Messages.Brokerages.cs | 77 +++++++ .../Orders/Fees/InteractiveBrokersFeeModel.cs | 22 +- Data/market-hours/market-hours-database.json | 48 +++++ .../symbol-properties-database.csv | 12 ++ .../InteractiveBrokersBrokerageModelTests.cs | 194 ++++++++++++++++++ .../InteractiveBrokersFixModelTests.cs | 25 +++ .../Fees/InteractiveBrokersFeeModelTests.cs | 32 ++- .../SymbolPropertiesDatabaseTests.cs | 15 ++ 10 files changed, 563 insertions(+), 9 deletions(-) diff --git a/Common/Brokerages/InteractiveBrokersBrokerageModel.cs b/Common/Brokerages/InteractiveBrokersBrokerageModel.cs index 80f193f6eafb..dc0d87d5e5bb 100644 --- a/Common/Brokerages/InteractiveBrokersBrokerageModel.cs +++ b/Common/Brokerages/InteractiveBrokersBrokerageModel.cs @@ -57,9 +57,42 @@ public class InteractiveBrokersBrokerageModel : DefaultBrokerageModel {SecurityType.Future, Market.CME}, {SecurityType.FutureOption, Market.CME}, {SecurityType.Forex, Market.Oanda}, - {SecurityType.Cfd, Market.InteractiveBrokers} + {SecurityType.Cfd, Market.InteractiveBrokers}, + // where the backtest data lives, IB's listing is checked by ticker + {SecurityType.Crypto, Market.Coinbase} }.ToReadOnlyDictionary(); + /// + /// The only order types IB accepts for cryptocurrencies + /// + private static readonly IReadOnlySet _supportedCryptoOrderTypes = new HashSet + { + OrderType.Market, + OrderType.Limit + }; + + /// + /// How far from the best ask IB lets a cryptocurrency buy limit order sit, the greater of these two + /// + private const decimal _cryptoLimitPriceBand = 10m; + private const decimal _cryptoLimitPriceBandPercent = 0.0025m; + + /// + /// IB routes API cryptocurrency orders from Sunday 03:00 to Friday 16:00 New York time only + /// + private static readonly Lazy _cryptoVenueHours = new(() => + MarketHoursDatabase.FromDataFolder().GetExchangeHours(Market.InteractiveBrokers, null, SecurityType.Crypto)); + + /// + /// The cryptocurrency pairs IB lists, the entries of the symbol + /// properties database, keyed by ticker: the traded symbol stays on the market holding the backtest data + /// + private static readonly Lazy> _supportedCryptoPairs = new(() => + SymbolPropertiesDatabase.FromDataFolder() + .GetSymbolPropertiesList(Market.InteractiveBrokers, SecurityType.Crypto) + .Select(entry => entry.Key.Symbol) + .ToHashSet(StringComparer.InvariantCultureIgnoreCase)); + /// /// Supported time in force /// @@ -137,7 +170,13 @@ public override decimal GetLeverage(Security security) return 1m; } - return security.Type == SecurityType.Cfd ? 10m : base.GetLeverage(security); + return security.Type switch + { + SecurityType.Cfd => 10m, + // IB does not lend against cryptocurrencies + SecurityType.Crypto => 1m, + _ => base.GetLeverage(security) + }; } /// @@ -189,7 +228,8 @@ public override bool CanSubmitOrder(Security security, Order order, out Brokerag security.Type != SecurityType.FutureOption && security.Type != SecurityType.Index && security.Type != SecurityType.IndexOption && - security.Type != SecurityType.Cfd) + security.Type != SecurityType.Cfd && + security.Type != SecurityType.Crypto) { message = new BrokerageMessageEvent(BrokerageMessageType.Warning, "NotSupported", Messages.DefaultBrokerageModel.UnsupportedSecurityType(this, security)); @@ -197,6 +237,63 @@ public override bool CanSubmitOrder(Security security, Order order, out Brokerag return false; } + if (security.Type == SecurityType.Crypto) + { + // from what is permanently wrong to what depends on the market: the pair, then the + // order, then the holdings, then the price, which is the only one a retry can fix + if (!_supportedCryptoPairs.Value.Contains(security.Symbol.Value)) + { + message = new BrokerageMessageEvent(BrokerageMessageType.Warning, "NotSupported", + Messages.InteractiveBrokersBrokerageModel.UnsupportedCryptoPair(this, security)); + + return false; + } + + if (!_supportedCryptoOrderTypes.Contains(order.Type)) + { + message = new BrokerageMessageEvent(BrokerageMessageType.Warning, "NotSupported", + Messages.InteractiveBrokersBrokerageModel.UnsupportedCryptoOrderType(this, order, _supportedCryptoOrderTypes)); + + return false; + } + + if (!IsValidOrderSize(security, order.Quantity, out message)) + { + return false; + } + + if (order.Quantity < 0 && security.Holdings.Quantity < order.AbsoluteQuantity) + { + message = new BrokerageMessageEvent(BrokerageMessageType.Warning, "NotSupported", + Messages.InteractiveBrokersBrokerageModel.UnsupportedCryptoShortSale(this, security)); + + return false; + } + + if (!IsWithinCryptoLimitPriceBand(security, order, out message)) + { + return false; + } + + if (order.Type == OrderType.Market && order.Direction == OrderDirection.Buy && security.Price <= 0) + { + message = new BrokerageMessageEvent(BrokerageMessageType.Warning, "NotSupported", + Messages.InteractiveBrokersBrokerageModel.CryptoBuyMarketOrderWithoutPrice(security)); + + return false; + } + + // the crypto market never closes, IB's venue does + var venueTime = security.LocalTime.ConvertTo(security.Exchange.TimeZone, _cryptoVenueHours.Value.TimeZone); + if (!_cryptoVenueHours.Value.IsOpen(venueTime, false)) + { + message = new BrokerageMessageEvent(BrokerageMessageType.Warning, "NotSupported", + Messages.InteractiveBrokersBrokerageModel.CryptoVenueClosed(security, _cryptoVenueHours.Value.GetNextMarketOpen(venueTime, false))); + + return false; + } + } + // validate order quantity //https://www.interactivebrokers.com/en/?f=%2Fen%2Ftrading%2FforexOrderSize.php if (security.Type == SecurityType.Forex && @@ -263,6 +360,40 @@ public override bool CanExecuteOrder(Security security, Order order) return order.SecurityType != SecurityType.Base; } + /// + /// Returns true if the given cryptocurrency limit order is priced where IB accepts it. A buy has + /// to be within 10 dollars or 0.25% of the best ask, whichever is greater, so it cannot rest below + /// the market. Sells are not restricted. The order is let through when there is no price to + /// compare against. + /// + private bool IsWithinCryptoLimitPriceBand(Security security, Order order, out BrokerageMessageEvent message) + { + message = null; + + if (order is not LimitOrder limitOrder || order.Direction != OrderDirection.Buy) + { + return true; + } + + // the ask is not always there, the last price is a good enough reference for the check + var reference = security.AskPrice > 0 ? security.AskPrice : security.Price; + if (reference <= 0) + { + return true; + } + + var tolerance = Math.Max(_cryptoLimitPriceBand, reference * _cryptoLimitPriceBandPercent); + if (Math.Abs(limitOrder.LimitPrice - reference) <= tolerance) + { + return true; + } + + message = new BrokerageMessageEvent(BrokerageMessageType.Warning, "NotSupported", + Messages.InteractiveBrokersBrokerageModel.InvalidCryptoLimitPrice(limitOrder, reference, tolerance)); + + return false; + } + /// /// Returns true if the specified order is within IB's order size limits /// diff --git a/Common/Brokerages/InteractiveBrokersFixModel.cs b/Common/Brokerages/InteractiveBrokersFixModel.cs index 2157dc29aa6e..064bbff5c3a2 100644 --- a/Common/Brokerages/InteractiveBrokersFixModel.cs +++ b/Common/Brokerages/InteractiveBrokersFixModel.cs @@ -77,6 +77,16 @@ public InteractiveBrokersFixModel(AccountType accountType = AccountType.Margin) /// True if the brokerage could process the order, false otherwise public override bool CanSubmitOrder(Security security, Order order, out BrokerageMessageEvent message) { + // IB does not route cryptocurrencies over FIX: the session has no CRYPTO security type, + // no PAXOS/ZEROHASH destination and no immediate-or-cancel time in force + if (security.Type == SecurityType.Crypto) + { + message = new BrokerageMessageEvent(BrokerageMessageType.Warning, "NotSupported", + Messages.InteractiveBrokersFixModel.UnsupportedCryptoSecurityType(this, security)); + + return false; + } + // only check supported combo order types if (order is ComboOrder && order.GroupOrderManager != null && SupportedOrderTypes.Contains(order.Type)) { diff --git a/Common/Messages/Messages.Brokerages.cs b/Common/Messages/Messages.Brokerages.cs index fbdb8ee51da0..1e978900a929 100644 --- a/Common/Messages/Messages.Brokerages.cs +++ b/Common/Messages/Messages.Brokerages.cs @@ -459,6 +459,18 @@ public static string UnsupportedFopFutureComboOrders(Brokerages.InteractiveBroke { return Invariant($@"The {brokerageModel.GetType().Name} does not support {order.Type} combining future options and futures legs."); } + + /// + /// Returns a string message saying the given brokerage model does not support cryptocurrencies, + /// which Interactive Brokers does not route over FIX + /// + [MethodImpl(MethodImplOptions.AggressiveInlining)] + public static string UnsupportedCryptoSecurityType(Brokerages.InteractiveBrokersFixModel brokerageModel, + Securities.Security security) + { + return Invariant($@"The {brokerageModel.GetType().Name} does not support {SecurityType.Crypto + }, Interactive Brokers does not route {security.Symbol.Value} over FIX. Use the Interactive Brokers brokerage instead."); + } } /// @@ -487,6 +499,71 @@ public static string UnsupportedFourLegComboLegLimitOrders(Brokerages.Interactiv return Invariant($"The {brokerageModel.GetType().Name} does not support four-leg ComboLegLimit orders. Use ComboLimit orders for four-leg combinations or more."); } + /// + /// Returns a string message saying the given brokerage model does not support the given order type for cryptocurrencies + /// + [MethodImpl(MethodImplOptions.AggressiveInlining)] + public static string UnsupportedCryptoOrderType(Brokerages.InteractiveBrokersBrokerageModel brokerageModel, + Orders.Order order, IEnumerable supportedOrderTypes) + { + return Invariant($@"The {brokerageModel.GetType().Name} does not support {order.Type + } orders for {SecurityType.Crypto}. Only {string.Join(", ", supportedOrderTypes)} orders are supported."); + } + + /// + /// Returns a string message saying the given brokerage model does not support the given cryptocurrency pair + /// + [MethodImpl(MethodImplOptions.AggressiveInlining)] + public static string UnsupportedCryptoPair(Brokerages.InteractiveBrokersBrokerageModel brokerageModel, + Securities.Security security) + { + return Invariant($@"The {brokerageModel.GetType().Name} does not support {security.Symbol.Value + }, Interactive Brokers does not list it. The pairs it lists are the {SecurityType.Crypto + } entries of the {QuantConnect.Market.InteractiveBrokers} market in the symbol properties database."); + } + + /// + /// Returns a string message saying the given brokerage model does not support short selling cryptocurrencies + /// + [MethodImpl(MethodImplOptions.AggressiveInlining)] + public static string UnsupportedCryptoShortSale(Brokerages.InteractiveBrokersBrokerageModel brokerageModel, + Securities.Security security) + { + return Invariant($@"The {brokerageModel.GetType().Name} does not support short sales of { + SecurityType.Crypto}, {security.Symbol.Value} holdings are {security.Holdings.Quantity}."); + } + + /// + /// Returns a string message saying the given cryptocurrency limit order is priced too far from the market + /// + [MethodImpl(MethodImplOptions.AggressiveInlining)] + public static string InvalidCryptoLimitPrice(Orders.LimitOrder order, decimal reference, decimal tolerance) + { + return Invariant($@"Interactive Brokers cancels {SecurityType.Crypto} buy limit orders priced further than { + tolerance} from the best ask: the limit price of {order.LimitPrice} for {order.Symbol.Value + } is away from {reference}."); + } + + /// + /// Returns a string message saying the given cryptocurrency buy market order cannot be sized without a price + /// + [MethodImpl(MethodImplOptions.AggressiveInlining)] + public static string CryptoBuyMarketOrderWithoutPrice(Securities.Security security) + { + return Invariant($@"Interactive Brokers sizes {SecurityType.Crypto} buy market orders by the cash amount to spend, so { + security.Symbol.Value} needs a known price to convert the quantity. Use a limit order or wait for data."); + } + + /// + /// Returns a string message saying Interactive Brokers is not routing cryptocurrency orders at this time + /// + [MethodImpl(MethodImplOptions.AggressiveInlining)] + public static string CryptoVenueClosed(Securities.Security security, DateTime nextOpen) + { + return Invariant($@"Interactive Brokers routes {SecurityType.Crypto} orders from Sunday 03:00 to Friday 16:00 New York time only, a { + security.Symbol.Value} order placed now would be held until it reopens on {nextOpen:yyyy-MM-dd HH:mm} New York time."); + } + /// /// Returns a string message containing the minimum and maximum limits for the allowable order size as well as the currency /// diff --git a/Common/Orders/Fees/InteractiveBrokersFeeModel.cs b/Common/Orders/Fees/InteractiveBrokersFeeModel.cs index da617af9c469..fb7befff83bf 100644 --- a/Common/Orders/Fees/InteractiveBrokersFeeModel.cs +++ b/Common/Orders/Fees/InteractiveBrokersFeeModel.cs @@ -53,6 +53,18 @@ public class InteractiveBrokersFeeModel : FeeModel /// private const decimal _koreaFutureFeeRate = 0.00004m; + /// + /// Cryptocurrency commissions go from 0.12% to 0.18% of the trade value depending on the + /// monthly volume, we assume the highest rate. + /// Reference at https://www.interactivebrokers.com/en/pricing/commissions-cryptocurrencies.php + /// + private const decimal _cryptoCommissionRate = 0.0018m; + + /// + /// Minimum cryptocurrency commission charged per order, USD 1.75 or its equivalent in the quote currency + /// + private const decimal _cryptoMinimumOrderFee = 1.75m; + /// /// Initializes a new instance of the /// @@ -94,7 +106,8 @@ public override OrderFee GetOrderFee(OrderFeeParameters parameters) var quantity = order.AbsoluteQuantity; decimal feeResult; - string feeCurrency; + // IB Forex and Crypto fees are all in USD + var feeCurrency = Currencies.USD; var market = security.Symbol.ID.Market; switch (security.Type) { @@ -103,8 +116,6 @@ public override OrderFee GetOrderFee(OrderFeeParameters parameters) var totalOrderValue = order.GetValue(security); var fee = Math.Abs(_forexCommissionRate*totalOrderValue); feeResult = Math.Max(_forexMinimumOrderFee, fee); - // IB Forex fees are all in USD - feeCurrency = Currencies.USD; break; case SecurityType.Option: @@ -191,6 +202,11 @@ public override OrderFee GetOrderFee(OrderFeeParameters parameters) feeResult = Math.Max(feeResult, minimumFee); break; + case SecurityType.Crypto: + var cryptoValue = Math.Abs(order.GetValue(security)); + feeResult = Math.Max(_cryptoMinimumOrderFee, _cryptoCommissionRate * cryptoValue); + break; + default: // unsupported security type throw new ArgumentException(Messages.FeeModel.UnsupportedSecurityType(security)); diff --git a/Data/market-hours/market-hours-database.json b/Data/market-hours/market-hours-database.json index 373f8814ea9d..40f8c1bc9325 100644 --- a/Data/market-hours/market-hours-database.json +++ b/Data/market-hours/market-hours-database.json @@ -91630,6 +91630,54 @@ "holidays": [], "earlyCloses": {} }, + "Crypto-interactivebrokers-[*]": { + "dataTimeZone": "UTC", + "exchangeTimeZone": "America/New_York", + "sunday": [ + { + "start": "03:00:00", + "end": "1.00:00:00", + "state": "market" + } + ], + "monday": [ + { + "start": "00:00:00", + "end": "1.00:00:00", + "state": "market" + } + ], + "tuesday": [ + { + "start": "00:00:00", + "end": "1.00:00:00", + "state": "market" + } + ], + "wednesday": [ + { + "start": "00:00:00", + "end": "1.00:00:00", + "state": "market" + } + ], + "thursday": [ + { + "start": "00:00:00", + "end": "1.00:00:00", + "state": "market" + } + ], + "friday": [ + { + "start": "00:00:00", + "end": "16:00:00", + "state": "market" + } + ], + "saturday": [], + "holidays": [] + }, "Crypto-coinbase-[*]": { "dataTimeZone": "UTC", "exchangeTimeZone": "UTC", diff --git a/Data/symbol-properties/symbol-properties-database.csv b/Data/symbol-properties/symbol-properties-database.csv index 12b6b95df076..aa5e557092bb 100644 --- a/Data/symbol-properties/symbol-properties-database.csv +++ b/Data/symbol-properties/symbol-properties-database.csv @@ -1516,6 +1516,18 @@ coinbase,ZRXEUR,crypto,0x-Euro,EUR,1,0.000001,0.00001,ZRX-EUR,0.79, coinbase,ZRXUSD,crypto,0x Protocol-US Dollar,USD,1,0.000001,0.00001,ZRX-USD,0.00001 coinbase,ZRXUSDC,crypto,0x Protocol-USDC,USDC,1,0.000001,0.00001,ZRX-USDC,0.00001 +interactivebrokers,AAVEUSD,crypto,Aave-US Dollar,USD,1,0.01,0.001,AAVE,0.001 +interactivebrokers,BCHUSD,crypto,Bitcoin Cash-US Dollar,USD,1,0.05,0.00000001,BCH,0.00000001 +interactivebrokers,BTCUSD,crypto,Bitcoin-US Dollar,USD,1,0.25,0.00000001,BTC,0.00000001 +interactivebrokers,ETHUSD,crypto,Ethereum-US Dollar,USD,1,0.05,0.00000001,ETH,0.00000001 +interactivebrokers,LINKUSD,crypto,Chainlink-US Dollar,USD,1,0.01,0.00000001,LINK,0.00000001 +interactivebrokers,LTCUSD,crypto,Litecoin-US Dollar,USD,1,0.01,0.00000001,LTC,0.00000001 +interactivebrokers,MATICUSD,crypto,Polygon-US Dollar,USD,1,0.01,0.00000001,MATIC,0.00000001 +interactivebrokers,PAXGUSD,crypto,PAX Gold-US Dollar,USD,1,0.01,0.00001,PAXG,0.00001 +interactivebrokers,SHIBUSD,crypto,Shiba Inu-US Dollar,USD,1,0.00000001,0.00000001,SHIB,0.00000001 +interactivebrokers,SOLUSD,crypto,Solana-US Dollar,USD,1,0.01,0.00000001,SOL,0.00000001 +interactivebrokers,UNIUSD,crypto,Uniswap-US Dollar,USD,1,0.001,0.000001,UNI,0.000001 + bitfinex,1INCHUSD,crypto,1INCH-US Dollar,USD,1,0.00001,0.00000001,t1INCH:USD,4.0, bitfinex,1INCHUSDT,crypto,1INCH-Tether USDt,USDT,1,0.00001,0.00000001,t1INCH:UST,4.0, bitfinex,AAABBB,crypto,AAA-BBB,BBB,1,0.00001,0.00000001,tAAABBB,2.0, diff --git a/Tests/Common/Brokerages/InteractiveBrokersBrokerageModelTests.cs b/Tests/Common/Brokerages/InteractiveBrokersBrokerageModelTests.cs index fecf694da5d8..dcfb7f892e88 100644 --- a/Tests/Common/Brokerages/InteractiveBrokersBrokerageModelTests.cs +++ b/Tests/Common/Brokerages/InteractiveBrokersBrokerageModelTests.cs @@ -196,6 +196,200 @@ public void CanSubmitMOCOrdersForFutureAndEquity(string ticker, SecurityType sec Assert.IsTrue(result); } + // where the backtest data lives + [Test] + public void CryptoDefaultsToTheCoinbaseMarket() + { + Assert.AreEqual(Market.Coinbase, InteractiveBrokersBrokerageModel.DefaultMarketMap[SecurityType.Crypto]); + + var security = GetInteractiveBrokersCrypto(); + Assert.AreEqual(Market.Coinbase, security.Symbol.ID.Market); + } + + // the interactivebrokers entries are the registry of what IB lists + [Test] + public void KeepsTheBrokerageTickSizeOnTheInteractiveBrokersEntry() + { + var symbol = Symbol.Create("BTCUSD", SecurityType.Crypto, Market.InteractiveBrokers); + var properties = SymbolPropertiesDatabase.FromDataFolder() + .GetSymbolProperties(symbol.ID.Market, symbol, symbol.SecurityType, Currencies.USD); + + // measured from IB's contract details, the crypto market says 0.01 + Assert.AreEqual(0.25m, properties.MinimumPriceVariation); + } + + // IB only accepts market and limit orders for cryptocurrencies + [TestCase(OrderType.Market, true)] + [TestCase(OrderType.Limit, true)] + [TestCase(OrderType.StopMarket, false)] + [TestCase(OrderType.StopLimit, false)] + [TestCase(OrderType.TrailingStop, false)] + [TestCase(OrderType.LimitIfTouched, false)] + public void CanSubmitOnlyMarketAndLimitCryptoOrders(OrderType orderType, bool shouldSubmit) + { + var security = GetInteractiveBrokersCrypto(); + var now = new DateTime(2024, 1, 3); + // a buy market order needs a price, a buy limit has to sit at the market + security.SetMarketPrice(new Tick(now, security.Symbol, 100m, 100m)); + + Order order = orderType switch + { + OrderType.Market => new MarketOrder(security.Symbol, 1, now), + OrderType.Limit => new LimitOrder(security.Symbol, 1, 100m, now), + OrderType.StopMarket => new StopMarketOrder(security.Symbol, 1, 100m, now), + OrderType.StopLimit => new StopLimitOrder(security.Symbol, 1, 100m, 100m, now), + OrderType.TrailingStop => new TrailingStopOrder(security.Symbol, 1, 100m, 1m, false, now), + OrderType.LimitIfTouched => new LimitIfTouchedOrder(security.Symbol, 1, 100m, 100m, now), + _ => throw new ArgumentOutOfRangeException(nameof(orderType), orderType, "Unexpected crypto order type") + }; + + var canSubmit = _interactiveBrokersBrokerageModel.CanSubmitOrder(security, order, out var message); + Assert.AreEqual(shouldSubmit, canSubmit); + + if (shouldSubmit) + { + Assert.IsNull(message); + } + else + { + Assert.AreEqual(BrokerageMessageType.Warning, message.Type); + Assert.AreEqual("NotSupported", message.Code); + StringAssert.Contains($"does not support {orderType} orders for {SecurityType.Crypto}", message.Message); + } + } + + [TestCase("BTCUSD")] + [TestCase("ETHUSD")] + [TestCase("SOLUSD")] + public void CreatesListedCryptoPairs(string ticker) + { + var security = GetInteractiveBrokersCrypto(ticker); + + Assert.AreEqual(Market.Coinbase, security.Symbol.ID.Market); + Assert.AreEqual(Currencies.USD, security.QuoteCurrency.Symbol); + } + + // creatable, so it can be backtested, rejected at order time + [TestCase("BTCEUR")] // IB quotes crypto against US dollars only + [TestCase("ETHBTC")] // no crypto quoted pairs either + [TestCase("ZRXUSD")] // a coinbase pair IB does not list + public void CannotSubmitOrdersForUnlistedCryptoPairs(string ticker) + { + var security = GetInteractiveBrokersCrypto(ticker); + var order = new MarketOrder(security.Symbol, 1, new DateTime(2024, 1, 3)); + + Assert.IsFalse(_interactiveBrokersBrokerageModel.CanSubmitOrder(security, order, out var message)); + Assert.AreEqual("NotSupported", message.Code); + StringAssert.Contains($"does not support {ticker}", message.Message); + } + + [TestCase(2024, 1, 4, 15, true)] // Thursday + [TestCase(2024, 1, 5, 20, true)] // Friday 15:00 New York + [TestCase(2024, 1, 5, 22, false)] // Friday 17:00 New York + [TestCase(2024, 1, 6, 15, false)] // Saturday + [TestCase(2024, 1, 7, 7, false)] // Sunday 02:00 New York + [TestCase(2024, 1, 7, 9, true)] // Sunday 04:00 New York + public void CanSubmitCryptoOrdersOnlyWhileTheVenueIsOpen(int year, int month, int day, int utcHour, bool shouldSubmit) + { + var algorithm = new AlgorithmStub(); + algorithm.SetBrokerageModel(BrokerageName.InteractiveBrokersBrokerage); + var security = algorithm.AddCrypto("BTCUSD"); + algorithm.SetDateTime(new DateTime(year, month, day, utcHour, 0, 0, DateTimeKind.Utc)); + security.SetMarketPrice(new Tick(algorithm.UtcTime, security.Symbol, 100m, 100m)); + + var order = new LimitOrder(security.Symbol, 1, 100m, algorithm.UtcTime); + + var canSubmit = _interactiveBrokersBrokerageModel.CanSubmitOrder(security, order, out var message); + Assert.AreEqual(shouldSubmit, canSubmit, message?.Message); + + if (!shouldSubmit) + { + Assert.AreEqual("NotSupported", message.Code); + StringAssert.Contains("Sunday 03:00 to Friday 16:00", message.Message); + } + } + + [Test] + public void CannotSubmitCryptoBuyMarketOrdersWithoutAPrice() + { + var security = GetInteractiveBrokersCrypto(); + var order = new MarketOrder(security.Symbol, 1, new DateTime(2024, 1, 3)); + + Assert.IsFalse(_interactiveBrokersBrokerageModel.CanSubmitOrder(security, order, out var message)); + Assert.AreEqual("NotSupported", message.Code); + StringAssert.Contains("needs a known price", message.Message); + + security.SetMarketPrice(new Tick(new DateTime(2024, 1, 3), security.Symbol, 100m, 100m)); + Assert.IsTrue(_interactiveBrokersBrokerageModel.CanSubmitOrder(security, order, out message)); + Assert.IsNull(message); + } + + [TestCase(1, true)] + [TestCase(0.00000001, true)] + [TestCase(0.000000001, false)] // below the pair's minimum order size + public void CanSubmitCryptoOrdersAboveTheMinimumOrderSize(decimal quantity, bool shouldSubmit) + { + var security = GetInteractiveBrokersCrypto(); + Assert.AreEqual(0.00000001m, security.SymbolProperties.MinimumOrderSize, + "unexpected database value, the test needs updating"); + + var order = new LimitOrder(security.Symbol, quantity, 100m, new DateTime(2024, 1, 3)); + + var canSubmit = _interactiveBrokersBrokerageModel.CanSubmitOrder(security, order, out var message); + Assert.AreEqual(shouldSubmit, canSubmit); + + if (shouldSubmit) + { + Assert.IsNull(message); + } + else + { + Assert.AreEqual(BrokerageMessageType.Warning, message.Type); + Assert.AreEqual("NotSupported", message.Code); + } + } + + // IB cancels a crypto BUY limit priced further than 10 dollars or 0.25% from the best ask. + // Sells are not restricted, a sell limit far above the market rests as usual. + [TestCase(OrderDirection.Buy, 100000, true)] // at the ask + [TestCase(OrderDirection.Buy, 99991, true)] // within the 250 dollar band + [TestCase(OrderDirection.Buy, 20000, false)] // resting far below + [TestCase(OrderDirection.Sell, 99800, true)] + [TestCase(OrderDirection.Sell, 500000, true)] // resting far above, accepted by IB + public void CanSubmitCryptoLimitOrdersOnlyAtTheMarket(OrderDirection direction, decimal limitPrice, bool shouldSubmit) + { + var security = GetInteractiveBrokersCrypto(); + security.SetMarketPrice(new Tick(new DateTime(2024, 1, 3), security.Symbol, 99900m, 100000m)); + // sells would otherwise be rejected as short sales + security.Holdings.SetHoldings(99900m, 10m); + + var quantity = direction == OrderDirection.Buy ? 1m : -1m; + var order = new LimitOrder(security.Symbol, quantity, limitPrice, new DateTime(2024, 1, 3)); + + var canSubmit = _interactiveBrokersBrokerageModel.CanSubmitOrder(security, order, out var message); + Assert.AreEqual(shouldSubmit, canSubmit, message?.Message); + + if (!shouldSubmit) + { + StringAssert.Contains("further than", message.Message); + } + } + + [TestCase(AccountType.Cash)] + [TestCase(AccountType.Margin)] + public void GetsUnleveragedCrypto(AccountType accountType) + { + var brokerageModel = new InteractiveBrokersBrokerageModel(accountType); + Assert.AreEqual(1m, brokerageModel.GetLeverage(GetInteractiveBrokersCrypto())); + } + + private static Security GetInteractiveBrokersCrypto(string ticker = "BTCUSD") + { + var algorithm = new AlgorithmStub(); + algorithm.SetBrokerageModel(BrokerageName.InteractiveBrokersBrokerage); + return algorithm.AddCrypto(ticker); + } + [TestCase(AccountType.Cash, 1)] [TestCase(AccountType.Margin, 10)] public void GetsCorrectLeverageForCfds(AccountType accounType, decimal expectedLeverage) diff --git a/Tests/Common/Brokerages/InteractiveBrokersFixModelTests.cs b/Tests/Common/Brokerages/InteractiveBrokersFixModelTests.cs index 79ae1fa90429..baf367c53730 100644 --- a/Tests/Common/Brokerages/InteractiveBrokersFixModelTests.cs +++ b/Tests/Common/Brokerages/InteractiveBrokersFixModelTests.cs @@ -21,6 +21,7 @@ using QuantConnect.Securities; using QuantConnect.Data.Market; using QuantConnect.Tests.Common.Securities; +using QuantConnect.Tests.Engine.DataFeeds; namespace QuantConnect.Tests.Common.Brokerages { @@ -57,6 +58,30 @@ public void ComboOrderValidatesSecurityTypes(OrderType orderType, SecurityType s Assert.AreEqual(expected, canSubmit); } + // IB routes cryptocurrencies through Paxos, which its FIX session does not reach + [TestCase(OrderType.Market)] + [TestCase(OrderType.Limit)] + public void CannotSubmitCryptoOrders(OrderType orderType) + { + var algorithm = new AlgorithmStub(); + algorithm.SetBrokerageModel(BrokerageName.InteractiveBrokersFix); + var security = algorithm.AddCrypto("BTCUSD"); + var now = new DateTime(2024, 1, 3); + + Order order = orderType == OrderType.Market + ? new MarketOrder(security.Symbol, 1, now) + : new LimitOrder(security.Symbol, 1, 100m, now); + + var model = new InteractiveBrokersFixModel(); + Assert.IsFalse(model.CanSubmitOrder(security, order, out var message)); + + Assert.AreEqual(BrokerageMessageType.Warning, message.Type); + Assert.AreEqual("NotSupported", message.Code); + StringAssert.Contains($"does not support {SecurityType.Crypto}", message.Message); + // distinctive of the FIX model: the base model accepts crypto market and limit orders + StringAssert.Contains($"does not route {security.Symbol.Value} over FIX", message.Message); + } + private static Security CreateSecurity(SecurityType securityType, int type) { var futureSymbol = Symbol.CreateFuture("ES", Market.CME, new DateTime(2025, 12, 19)); diff --git a/Tests/Common/Orders/Fees/InteractiveBrokersFeeModelTests.cs b/Tests/Common/Orders/Fees/InteractiveBrokersFeeModelTests.cs index 76bf8fc4430a..e5e2e1b4ee53 100644 --- a/Tests/Common/Orders/Fees/InteractiveBrokersFeeModelTests.cs +++ b/Tests/Common/Orders/Fees/InteractiveBrokersFeeModelTests.cs @@ -128,6 +128,32 @@ public void CalculatesCFDFee(string quoteCurrency, decimal price, decimal expect Assert.AreEqual(expectedFee, fee.Value.Amount); } + // IB only trades US dollar quoted cryptocurrencies, the brokerage model rejects the rest + [TestCase("BTCUSD", 2, 50000, 0.0018 * 2 * 50000)] + [TestCase("BTCUSD", 0.5, 4000, 0.0018 * 0.5 * 4000)] + [TestCase("BTCUSD", 0.001, 50000, 1.75)] // The calculated fee will be under 1.75, but that is the minimum fee + public void CalculatesCryptoFee(string ticker, decimal quantity, decimal price, decimal expectedFee) + { + var symbol = Symbol.Create(ticker, SecurityType.Crypto, Market.InteractiveBrokers); + var properties = SymbolPropertiesDatabase.FromDataFolder() + .GetSymbolProperties(symbol.ID.Market, symbol, symbol.SecurityType, Currencies.USD); + var security = new Crypto(symbol, + SecurityExchangeHours.AlwaysOpen(TimeZones.Utc), + new Cash(properties.QuoteCurrency, 0, 1), + new Cash(ticker.RemoveFromEnd(properties.QuoteCurrency), 0, 0), + properties, + ErrorCurrencyConverter.Instance, + RegisteredSecurityDataTypesProvider.Null, + new SecurityCache()); + security.SetMarketPrice(new Tick(DateTime.UtcNow, symbol, price, price)); + + var order = new MarketOrder(symbol, quantity, DateTime.UtcNow); + var fee = _feeModel.GetOrderFee(new OrderFeeParameters(security, order)); + + Assert.AreEqual(Currencies.USD, fee.Value.Currency); + Assert.AreEqual(expectedFee, fee.Value.Amount); + } + [TestCase(false)] [TestCase(true)] public void HongKongFutureFee(bool canonical) @@ -352,11 +378,11 @@ public void GetOrderFeeThrowsForUnsupportedSecurityType() () => { var tz = TimeZones.NewYork; - var security = new Crypto( - Symbols.BTCUSD, + var symbol = Symbol.Create("XYZ", SecurityType.Base, Market.USA); + var security = new Security( SecurityExchangeHours.AlwaysOpen(tz), + new SubscriptionDataConfig(typeof(TradeBar), symbol, Resolution.Minute, tz, tz, true, false, false), new Cash("USD", 0, 0), - new Cash("BTC", 0, 0), SymbolProperties.GetDefault("USD"), ErrorCurrencyConverter.Instance, RegisteredSecurityDataTypesProvider.Null, diff --git a/Tests/Common/Securities/SymbolPropertiesDatabaseTests.cs b/Tests/Common/Securities/SymbolPropertiesDatabaseTests.cs index 9efee18eb883..a7bad25df64a 100644 --- a/Tests/Common/Securities/SymbolPropertiesDatabaseTests.cs +++ b/Tests/Common/Securities/SymbolPropertiesDatabaseTests.cs @@ -192,6 +192,21 @@ public void CanQueryMarketAfterRefresh() Globals.Reset(); } + // the interactivebrokers crypto rows sit after the coinbase ones so a market-less lookup, like an + // order deserialized without a market, keeps resolving to coinbase + [Test] + public void ListedInteractiveBrokersCryptoPairsDoNotChangeTheDefaultCryptoMarket() + { + var database = SymbolPropertiesDatabase.FromDataFolder(); + var symbol = Symbol.Create("BTCUSD", SecurityType.Crypto, Market.InteractiveBrokers); + + var properties = database.GetSymbolProperties(Market.InteractiveBrokers, symbol, SecurityType.Crypto, Currencies.USD); + Assert.AreEqual(0.25m, properties.MinimumPriceVariation); + + Assert.IsTrue(database.TryGetMarket("BTCUSD", SecurityType.Crypto, out var market)); + Assert.AreEqual(Market.Coinbase, market); + } + [TestCase(Market.FXCM, SecurityType.Cfd)] [TestCase(Market.Oanda, SecurityType.Cfd)] [TestCase(Market.CFE, SecurityType.Future)]