diff --git a/Cargo.lock b/Cargo.lock index 048f16d9b62f..0ae6d6f9776f 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -4639,8 +4639,7 @@ dependencies = [ [[package]] name = "ibapi" version = "3.0.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "dab7f26e4336d0cb94be12bc6c0779cced423c3aceda01f5fd23cbcb0446a43e" +source = "git+https://github.com/bebop23/rust-ibapi?branch=feat%2Faggtrades-whattoshow#e08cbcdf91bdf122371040af4876ba81ec151899" dependencies = [ "async-trait", "byteorder", diff --git a/Cargo.toml b/Cargo.toml index f6a3abe5c0c6..c93280af56cf 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -390,6 +390,11 @@ walkdir = "2.5.0" [patch.crates-io] # Keep the pinned pyo3-stub-gen buildable with PyO3 0.29 API and exception alias changes. pyo3-stub-gen = { path = "patches/pyo3-stub-gen" } +# IB requires whatToShow=AGGTRADES for crypto (TWS rejects TRADES with error 10299), +# but stock ibapi 3.0.1's WhatToShow enums have no AggTrades variant. This patch adds it, +# pending upstream merge/release (see https://github.com/wboayue/rust-ibapi/pull/693). +# When that lands in a released ibapi, delete this patch and bump the `ibapi` version. +ibapi = { git = "https://github.com/bebop23/rust-ibapi", branch = "feat/aggtrades-whattoshow" } # ----------------------------------------------------------------------------- # Tools (for cargo install, used by Makefile and CI) diff --git a/crates/adapters/interactive_brokers/src/common/parse.rs b/crates/adapters/interactive_brokers/src/common/parse.rs index dc65d4844750..c9ecb6533a62 100644 --- a/crates/adapters/interactive_brokers/src/common/parse.rs +++ b/crates/adapters/interactive_brokers/src/common/parse.rs @@ -73,7 +73,7 @@ pub fn ib_contract_to_instrument_id_simplified( } } SecurityType::ForexPair => Venue::from("IDEALPRO"), - SecurityType::Crypto => Venue::from("PAXOS"), + SecurityType::Crypto => derive_crypto_venue(contract), SecurityType::Stock => Venue::from("SMART"), SecurityType::Option | SecurityType::FuturesOption => { if !contract.exchange.as_str().is_empty() && contract.exchange.as_str() != "SMART" { @@ -234,7 +234,7 @@ pub fn ib_contract_to_instrument_id_raw( ) -> anyhow::Result { let venue = venue.unwrap_or_else(|| match contract.security_type { SecurityType::ForexPair => Venue::from("IDEALPRO"), - SecurityType::Crypto => Venue::from("PAXOS"), + SecurityType::Crypto => derive_crypto_venue(contract), SecurityType::Stock => Venue::from("SMART"), SecurityType::Option => Venue::from("SMART"), SecurityType::FuturesOption => Venue::from("SMART"), @@ -357,6 +357,30 @@ pub static VENUE_MEMBERS: LazyLock>> = map }); +/// Returns `true` if the contract is a cryptocurrency contract. +/// +/// Centralizes the crypto check used to gate crypto-specific request handling +/// (e.g. the `AGGTRADES` `whatToShow` rule — see +/// [`crate::data::convert::price_type_to_ib_what_to_show_for_security`]). +#[must_use] +pub fn is_crypto_contract(contract: &Contract) -> bool { + matches!(contract.security_type, SecurityType::Crypto) +} + +/// Derives the venue for a crypto contract. +/// +/// IB routes crypto to multiple venues; both PAXOS and ZEROHASH are live (which one +/// applies depends on the account/region). Uses the contract's actual exchange when +/// set (e.g. ZEROHASH or PAXOS), falling back to PAXOS when it is unspecified. +#[must_use] +pub fn derive_crypto_venue(contract: &Contract) -> Venue { + if !contract.exchange.as_str().is_empty() && contract.exchange.as_str() != "SMART" { + Venue::from(contract.exchange.as_str()) + } else { + Venue::from("PAXOS") + } +} + #[must_use] pub fn possible_exchanges_for_venue(venue: &str) -> Vec { if let Some(exchanges) = VENUE_MEMBERS.get(venue) { @@ -371,7 +395,9 @@ pub fn possible_exchanges_for_venue(venue: &str) -> Vec { /// Venue lists for different asset classes const VENUES_CASH: &[&str] = &["IDEALPRO"]; -const VENUES_CRYPTO: &[&str] = &["PAXOS"]; +// IB routes crypto to both PAXOS and ZEROHASH (which one applies depends on the +// account/region); accept both. +const VENUES_CRYPTO: &[&str] = &["PAXOS", "ZEROHASH"]; const VENUES_OPT: &[&str] = &["SMART", "EUREX"]; const VENUES_FUT: &[&str] = &[ "BELFOX", @@ -1365,6 +1391,86 @@ mod tests { assert_eq!(contract.local_symbol.as_str(), "DOGE.USD"); } + #[rstest] + fn test_instrument_id_to_ib_contract_parses_zerohash_crypto_symbol() { + // ZEROHASH is one of IB's crypto venues (alongside PAXOS). + let instrument_id = InstrumentId::from("BTC/USD.ZEROHASH"); + + let contract = instrument_id_to_ib_contract(instrument_id, None).unwrap(); + + assert_eq!(contract.security_type, SecurityType::Crypto); + assert_eq!(contract.exchange.as_str(), "ZEROHASH"); + assert_eq!(contract.symbol.as_str(), "BTC"); + assert_eq!(contract.currency.as_str(), "USD"); + assert_eq!(contract.local_symbol.as_str(), "BTC.USD"); + } + + #[rstest] + fn test_ib_contract_to_instrument_id_simplified_derives_zerohash_crypto_venue() { + // Contract -> InstrumentId derives the venue from the ZEROHASH exchange. + let contract = Contract { + symbol: Symbol::from("BTC"), + security_type: SecurityType::Crypto, + exchange: Exchange::from("ZEROHASH"), + currency: Currency::from("USD"), + local_symbol: "BTC.USD".to_string(), + ..Default::default() + }; + + let instrument_id = ib_contract_to_instrument_id_simplified(&contract, None).unwrap(); + + assert_eq!(instrument_id, InstrumentId::from("BTC/USD.ZEROHASH")); + } + + #[rstest] + fn test_ib_contract_to_instrument_id_simplified_falls_back_to_paxos_crypto_venue() { + // Contract -> InstrumentId falls back to PAXOS when no exchange is set, + // preserving backwards compatibility. + let contract = Contract { + symbol: Symbol::from("DOGE"), + security_type: SecurityType::Crypto, + currency: Currency::from("USD"), + local_symbol: "DOGE.USD".to_string(), + ..Default::default() + }; + + let instrument_id = ib_contract_to_instrument_id_simplified(&contract, None).unwrap(); + + assert_eq!(instrument_id, InstrumentId::from("DOGE/USD.PAXOS")); + } + + #[rstest] + fn test_instrument_id_to_ib_contract_parses_paxos_crypto_symbol() { + // PAXOS remains a live IB crypto venue alongside ZEROHASH. + let instrument_id = InstrumentId::from("BTC/USD.PAXOS"); + + let contract = instrument_id_to_ib_contract(instrument_id, None).unwrap(); + + assert_eq!(contract.security_type, SecurityType::Crypto); + assert_eq!(contract.exchange.as_str(), "PAXOS"); + assert_eq!(contract.symbol.as_str(), "BTC"); + assert_eq!(contract.currency.as_str(), "USD"); + assert_eq!(contract.local_symbol.as_str(), "BTC.USD"); + } + + #[rstest] + fn test_ib_contract_to_instrument_id_simplified_derives_paxos_crypto_venue() { + // A PAXOS contract derives the PAXOS venue from its exchange (not via the + // fallback), proving both venues are honored when explicitly set. + let contract = Contract { + symbol: Symbol::from("BTC"), + security_type: SecurityType::Crypto, + exchange: Exchange::from("PAXOS"), + currency: Currency::from("USD"), + local_symbol: "BTC.USD".to_string(), + ..Default::default() + }; + + let instrument_id = ib_contract_to_instrument_id_simplified(&contract, None).unwrap(); + + assert_eq!(instrument_id, InstrumentId::from("BTC/USD.PAXOS")); + } + #[rstest] fn test_instrument_id_to_ib_contract_uses_contfut_for_underlying() { let instrument_id = InstrumentId::from("ES.XCME"); diff --git a/crates/adapters/interactive_brokers/src/data/convert.rs b/crates/adapters/interactive_brokers/src/data/convert.rs index b89ec94978be..fa8b9d24c1ef 100644 --- a/crates/adapters/interactive_brokers/src/data/convert.rs +++ b/crates/adapters/interactive_brokers/src/data/convert.rs @@ -16,9 +16,12 @@ //! Conversion utilities for Interactive Brokers data types. use chrono::{DateTime, Utc}; -use ibapi::market_data::historical::{ - BarSize as HistoricalBarSize, BarTimestamp, Duration as IBDuration, ToDuration, - WhatToShow as HistoricalWhatToShow, +use ibapi::market_data::{ + historical::{ + BarSize as HistoricalBarSize, BarTimestamp, Duration as IBDuration, ToDuration, + WhatToShow as HistoricalWhatToShow, + }, + realtime::WhatToShow as RealtimeWhatToShow, }; use nautilus_core::UnixNanos; use nautilus_model::{ @@ -85,6 +88,59 @@ pub fn price_type_to_ib_what_to_show(price_type: PriceType) -> HistoricalWhatToS } } +/// Convert Nautilus PriceType to IB WhatToShow for historical bars, honoring the +/// crypto trade-price special case. +/// +/// IB rejects `TRADES` for crypto contracts (ZEROHASH/PAXOS) with error 10299 — +/// the venue serves trade-price data only under `AGGTRADES`. So for a crypto +/// contract requesting trade-price (`PriceType::Last`), map to +/// [`HistoricalWhatToShow::AggTrades`] instead of [`HistoricalWhatToShow::Trades`]. +/// Non-crypto contracts and non-trade price types are unaffected. This mirrors the +/// Java engine's `LiveOneMinBarIngestionService.whatToShowFor` rule exactly. +#[must_use] +pub fn price_type_to_ib_what_to_show_for_security( + price_type: PriceType, + is_crypto: bool, +) -> HistoricalWhatToShow { + if is_crypto && price_type == PriceType::Last { + return HistoricalWhatToShow::AggTrades; + } + price_type_to_ib_what_to_show(price_type) +} + +/// Convert Nautilus PriceType to IB WhatToShow for real-time (5-second) bars. +/// +/// Unmapped price types default to [`RealtimeWhatToShow::Trades`]. +#[must_use] +pub fn price_type_to_ib_realtime_what_to_show(price_type: PriceType) -> RealtimeWhatToShow { + match price_type { + PriceType::Last => RealtimeWhatToShow::Trades, + PriceType::Bid => RealtimeWhatToShow::Bid, + PriceType::Ask => RealtimeWhatToShow::Ask, + PriceType::Mid => RealtimeWhatToShow::MidPoint, + _ => RealtimeWhatToShow::Trades, // Default to trades + } +} + +/// Convert Nautilus PriceType to IB WhatToShow for real-time (5-second) bars, +/// honoring the crypto trade-price special case. +/// +/// TWS rejects `TRADES` for crypto contracts (ZEROHASH/PAXOS) with error 10299 on +/// `reqRealTimeBars` too — not just historical. So for a crypto contract requesting +/// trade-price (`PriceType::Last`), map to [`RealtimeWhatToShow::AggTrades`]. This +/// mirrors the Java engine, which passes `whatToShowFor(CRYPTO) = "AGGTRADES"` +/// straight into `subscribeRealTimeBars` (LiveOneMinBarIngestionService). +#[must_use] +pub fn price_type_to_ib_realtime_what_to_show_for_security( + price_type: PriceType, + is_crypto: bool, +) -> RealtimeWhatToShow { + if is_crypto && price_type == PriceType::Last { + return RealtimeWhatToShow::AggTrades; + } + price_type_to_ib_realtime_what_to_show(price_type) +} + #[must_use] pub fn apply_price_magnifier(price: f64, price_magnifier: i32) -> f64 { if price_magnifier > 0 { @@ -402,6 +458,102 @@ mod tests { ); } + #[rstest] + fn test_price_type_to_ib_what_to_show_for_security_crypto() { + // Crypto trade-price (Last) must map to AGGTRADES, not TRADES — TWS rejects + // TRADES for crypto (error 10299). Mirrors the Java whatToShowFor rule. + assert_eq!( + price_type_to_ib_what_to_show_for_security(PriceType::Last, true), + HistoricalWhatToShow::AggTrades + ); + // Non-trade price types are unaffected by the crypto special case. + assert_eq!( + price_type_to_ib_what_to_show_for_security(PriceType::Bid, true), + HistoricalWhatToShow::Bid + ); + assert_eq!( + price_type_to_ib_what_to_show_for_security(PriceType::Ask, true), + HistoricalWhatToShow::Ask + ); + assert_eq!( + price_type_to_ib_what_to_show_for_security(PriceType::Mid, true), + HistoricalWhatToShow::MidPoint + ); + } + + #[rstest] + fn test_price_type_to_ib_what_to_show_for_security_non_crypto() { + // Non-crypto: trade-price stays TRADES (equities/futures), everything else + // identical to the plain mapping. + assert_eq!( + price_type_to_ib_what_to_show_for_security(PriceType::Last, false), + HistoricalWhatToShow::Trades + ); + assert_eq!( + price_type_to_ib_what_to_show_for_security(PriceType::Bid, false), + HistoricalWhatToShow::Bid + ); + assert_eq!( + price_type_to_ib_what_to_show_for_security(PriceType::Mid, false), + HistoricalWhatToShow::MidPoint + ); + } + + #[rstest] + fn test_aggtrades_wire_string() { + // The vendored ibapi patch must serialize AggTrades as the exact IB wire + // token "AGGTRADES" on BOTH the historical and realtime enums. + assert_eq!(HistoricalWhatToShow::AggTrades.to_string(), "AGGTRADES"); + assert_eq!(RealtimeWhatToShow::AggTrades.to_string(), "AGGTRADES"); + } + + #[rstest] + fn test_price_type_to_ib_realtime_what_to_show() { + // `RealtimeWhatToShow` does not derive `PartialEq`, so match on the variants. + assert!(matches!( + price_type_to_ib_realtime_what_to_show(PriceType::Last), + RealtimeWhatToShow::Trades + )); + assert!(matches!( + price_type_to_ib_realtime_what_to_show(PriceType::Bid), + RealtimeWhatToShow::Bid + )); + assert!(matches!( + price_type_to_ib_realtime_what_to_show(PriceType::Ask), + RealtimeWhatToShow::Ask + )); + assert!(matches!( + price_type_to_ib_realtime_what_to_show(PriceType::Mid), + RealtimeWhatToShow::MidPoint + )); + } + + #[rstest] + fn test_price_type_to_ib_realtime_what_to_show_for_security_crypto() { + // Crypto trade-price (Last) 5-second bars must request AGGTRADES on the + // realtime path too — TWS rejects TRADES for crypto (error 10299) on + // reqRealTimeBars, exactly as on the historical path. Mirrors the Java + // engine passing whatToShowFor(CRYPTO)="AGGTRADES" to subscribeRealTimeBars. + assert!(matches!( + price_type_to_ib_realtime_what_to_show_for_security(PriceType::Last, true), + RealtimeWhatToShow::AggTrades + )); + // Non-trade price types unaffected by the crypto special case. + assert!(matches!( + price_type_to_ib_realtime_what_to_show_for_security(PriceType::Mid, true), + RealtimeWhatToShow::MidPoint + )); + assert!(matches!( + price_type_to_ib_realtime_what_to_show_for_security(PriceType::Bid, true), + RealtimeWhatToShow::Bid + )); + // Non-crypto trade-price stays TRADES. + assert!(matches!( + price_type_to_ib_realtime_what_to_show_for_security(PriceType::Last, false), + RealtimeWhatToShow::Trades + )); + } + #[rstest] fn test_ib_bar_to_nautilus_bar() { let ib_bar = ibapi::market_data::historical::Bar { diff --git a/crates/adapters/interactive_brokers/src/data/core.rs b/crates/adapters/interactive_brokers/src/data/core.rs index eef4075e2a5b..53b1a62d2ffd 100644 --- a/crates/adapters/interactive_brokers/src/data/core.rs +++ b/crates/adapters/interactive_brokers/src/data/core.rs @@ -71,7 +71,8 @@ use super::{ convert::{ apply_bar_price_magnifier, apply_price_magnifier, bar_type_to_ib_bar_size, calculate_duration, calculate_duration_segments, chrono_to_ib_datetime, - ib_bar_to_nautilus_bar, price_type_to_ib_what_to_show, + ib_bar_to_nautilus_bar, price_type_to_ib_realtime_what_to_show_for_security, + price_type_to_ib_what_to_show_for_security, }, }; use crate::{ @@ -310,10 +311,6 @@ impl InteractiveBrokersDataClient { }) } - fn venue_id(&self) -> Venue { - *IB_VENUE - } - fn cancel_active_subscriptions(&self) -> anyhow::Result<()> { { let mut subscriptions = self @@ -568,7 +565,12 @@ impl DataClient for InteractiveBrokersDataClient { } fn venue(&self) -> Option { - Some(self.venue_id()) + // Interactive Brokers is a multi-venue adapter (SMART, IDEALPRO, ZEROHASH, CME, etc.), + // so the data client must register for default routing rather than a single venue: + // subscriptions and requests are routed by the command's venue (derived from the + // instrument ID), which would otherwise never match and be dropped by the data engine. + // Mirrors the other multi-venue adapters (Tardis, Databento). + None } fn start(&mut self) -> anyhow::Result<()> { @@ -1260,6 +1262,12 @@ impl DataClient for InteractiveBrokersDataClient { let handle_revised_bars = self.config.handle_revised_bars; let use_rth = self.config.use_regular_trading_hours; let start_ns = parse_start_ns(cmd.params.as_ref()); + // Crypto (ZEROHASH/PAXOS) trade-price bars must request AGGTRADES, not + // TRADES (TWS rejects TRADES for crypto, error 10299) — on BOTH the + // realtime (reqRealTimeBars) and historical (reqHistoricalData) paths, per + // the Java engine's whatToShowFor rule. Capture the flag before `contract` + // is moved into the subscription task below. + let is_crypto = crate::common::parse::is_crypto_contract(&contract); // Create subscription-specific cancellation token let subscription_token = self.cancellation_token.child_token(); @@ -1276,6 +1284,10 @@ impl DataClient for InteractiveBrokersDataClient { bar_type, bar_type_str, instrument_id, + price_type_to_ib_realtime_what_to_show_for_security( + bar_type.spec().price_type, + is_crypto, + ), price_precision, size_precision, data_sender, @@ -1292,7 +1304,10 @@ impl DataClient for InteractiveBrokersDataClient { client_clone, contract, bar_type, - price_type_to_ib_what_to_show(bar_type.spec().price_type), + price_type_to_ib_what_to_show_for_security( + bar_type.spec().price_type, + is_crypto, + ), price_precision, size_precision, use_rth, @@ -2162,7 +2177,11 @@ impl DataClient for InteractiveBrokersDataClient { // Convert bar type to IB formats let ib_bar_size = bar_type_to_ib_bar_size(&cmd.bar_type) .context("Failed to convert bar type to IB bar size")?; - let ib_what_to_show = price_type_to_ib_what_to_show(cmd.bar_type.spec().price_type); + // Crypto trade-price bars require AGGTRADES (TWS rejects TRADES for crypto, + // error 10299); mirror the Java engine's whatToShowFor rule. + let is_crypto = crate::common::parse::is_crypto_contract(&contract); + let ib_what_to_show = + price_type_to_ib_what_to_show_for_security(cmd.bar_type.spec().price_type, is_crypto); // Calculate segments to break down the request if needed let segments = if let (Some(start), Some(end)) = (cmd.start, cmd.end) { @@ -2370,6 +2389,23 @@ mod tests { assert_eq!(datetime_to_unix_nanos(dt), UnixNanos::from(1_000_000_002)); } + #[rstest] + fn test_venue_is_none_for_default_routing() { + // IB is a multi-venue adapter: `venue()` must be `None` so the data engine registers + // the client for default routing. A `Some(venue)` here means venue-routed subscribe + // commands (e.g. `BTC/USD.ZEROHASH` bars, `AAPL.NASDAQ` quotes) never reach the client. + let (sender, _receiver) = tokio::sync::mpsc::unbounded_channel(); + nautilus_common::live::runner::replace_data_event_sender(sender); + + let config = InteractiveBrokersDataClientConfig::default(); + let provider = Arc::new(InteractiveBrokersInstrumentProvider::new( + config.instrument_provider.clone(), + )); + let client = InteractiveBrokersDataClient::new(*IB_CLIENT_ID, config, provider).unwrap(); + + assert_eq!(client.venue(), None); + } + #[rstest] fn test_stop_refreshes_cancellation_token_for_restart() { let (sender, _receiver) = tokio::sync::mpsc::unbounded_channel(); diff --git a/crates/adapters/interactive_brokers/src/data/core_streams.rs b/crates/adapters/interactive_brokers/src/data/core_streams.rs index 86fdf4c0b7ee..4f24505afaec 100644 --- a/crates/adapters/interactive_brokers/src/data/core_streams.rs +++ b/crates/adapters/interactive_brokers/src/data/core_streams.rs @@ -580,6 +580,7 @@ pub(super) async fn handle_realtime_bars_subscription( bar_type: BarType, bar_type_str: String, _instrument_id: InstrumentId, + what_to_show: RealtimeWhatToShow, price_precision: u8, size_precision: u8, data_sender: tokio::sync::mpsc::UnboundedSender, @@ -599,7 +600,7 @@ pub(super) async fn handle_realtime_bars_subscription( let mut subscription = client .realtime_bars(&contract) - .what_to_show(RealtimeWhatToShow::Trades) + .what_to_show(what_to_show) .trading_hours(trading_hours) .subscribe() .await diff --git a/crates/adapters/interactive_brokers/src/historical/client.rs b/crates/adapters/interactive_brokers/src/historical/client.rs index c3eb58c1a7c9..607d110a150f 100644 --- a/crates/adapters/interactive_brokers/src/historical/client.rs +++ b/crates/adapters/interactive_brokers/src/historical/client.rs @@ -42,7 +42,7 @@ use crate::{ data::convert::{ apply_bar_price_magnifier, apply_price_magnifier, bar_type_to_ib_bar_size, chrono_to_ib_datetime, ib_bar_to_nautilus_bar, ib_timestamp_to_unix_nanos, - price_type_to_ib_what_to_show, + price_type_to_ib_what_to_show_for_security, }, providers::instruments::InteractiveBrokersInstrumentProvider, }; @@ -357,9 +357,13 @@ impl HistoricalInteractiveBrokersClient { let bar_type_with_id = BarType::new(instrument_id, bar_spec, AggregationSource::External); - // Convert bar type to IB parameters + // Convert bar type to IB parameters. Crypto trade-price bars must + // request AGGTRADES, not TRADES (TWS rejects TRADES for crypto, + // error 10299) — same rule as the live data client's historical path. let ib_bar_size = bar_type_to_ib_bar_size(&bar_type_with_id)?; - let ib_what_to_show = price_type_to_ib_what_to_show(bar_spec.price_type); + let is_crypto = crate::common::parse::is_crypto_contract(&contract); + let ib_what_to_show = + price_type_to_ib_what_to_show_for_security(bar_spec.price_type, is_crypto); // Calculate duration segments let segments =