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)]