Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 1 addition & 2 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

5 changes: 5 additions & 0 deletions Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
112 changes: 109 additions & 3 deletions crates/adapters/interactive_brokers/src/common/parse.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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" {
Expand Down Expand Up @@ -234,7 +234,7 @@ pub fn ib_contract_to_instrument_id_raw(
) -> anyhow::Result<InstrumentId> {
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"),
Expand Down Expand Up @@ -357,6 +357,30 @@ pub static VENUE_MEMBERS: LazyLock<HashMap<&'static str, Vec<&'static str>>> =
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<String> {
if let Some(exchanges) = VENUE_MEMBERS.get(venue) {
Expand All @@ -371,7 +395,9 @@ pub fn possible_exchanges_for_venue(venue: &str) -> Vec<String> {

/// 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",
Expand Down Expand Up @@ -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");
Expand Down
158 changes: 155 additions & 3 deletions crates/adapters/interactive_brokers/src/data/convert.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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::{
Expand Down Expand Up @@ -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 {
Expand Down Expand Up @@ -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 {
Expand Down
Loading
Loading