Skip to content

Commit b3b5c62

Browse files
bebop23claude
andcommitted
Fix Interactive Brokers v2 crypto market data (support PAXOS and ZeroHash)
IBKR routes retail crypto to both PAXOS and ZeroHash (which venue applies depends on the account/region). The v2 IB adapter hardcoded PAXOS, so ZeroHash-routed instruments like BTC/USD.ZEROHASH were unusable, and several stacked defects broke crypto market data: - Crypto venue was hardcoded to PAXOS, so BTC/USD.ZEROHASH failed contract resolution ([200] No security definition). Derive the venue from the contract's exchange (ZEROHASH or PAXOS), fall back to PAXOS when unspecified, and accept both in VENUES_CRYPTO. PAXOS continues to work unchanged. - The data client registered for a single venue, so venue-routed subscriptions (e.g. BTC/USD.ZEROHASH) were silently dropped by the data engine. Return None from venue() for default multi-venue routing, matching Tardis/Databento. - Real-time (5s) bars hardcoded whatToShow=TRADES, ignoring the bar's price type. Honor the requested price type. - TWS rejects whatToShow=TRADES for crypto with error 10299 on both reqHistoricalData and reqRealTimeBars (for PAXOS and ZeroHash alike); the venue serves trade-price data only under AGGTRADES. Map crypto trade-price bars to AGGTRADES on the realtime path, the live-client historical path, and the standalone HistoricalInteractiveBrokersClient path. AGGTRADES requires a WhatToShow::AggTrades variant that stock ibapi 3.0.1 lacks; this is added via a temporary [patch.crates-io] ibapi (submitted upstream to wboayue/rust-ibapi), to be replaced by a version bump once released. Adds Rust unit tests for both PAXOS and ZeroHash venue derivation (explicit and fallback), the crypto AGGTRADES mapping on all bar paths, and default routing. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1 parent 5406595 commit b3b5c62

7 files changed

Lines changed: 320 additions & 20 deletions

File tree

Cargo.lock

Lines changed: 1 addition & 2 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

Cargo.toml

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -390,6 +390,11 @@ walkdir = "2.5.0"
390390
[patch.crates-io]
391391
# Keep the pinned pyo3-stub-gen buildable with PyO3 0.29 API and exception alias changes.
392392
pyo3-stub-gen = { path = "patches/pyo3-stub-gen" }
393+
# IB requires whatToShow=AGGTRADES for crypto (TWS rejects TRADES with error 10299),
394+
# but stock ibapi 3.0.1's WhatToShow enums have no AggTrades variant. This patch adds it,
395+
# pending upstream merge/release (see https://github.com/wboayue/rust-ibapi/pull/693).
396+
# When that lands in a released ibapi, delete this patch and bump the `ibapi` version.
397+
ibapi = { git = "https://github.com/bebop23/rust-ibapi", branch = "feat/aggtrades-whattoshow" }
393398

394399
# -----------------------------------------------------------------------------
395400
# Tools (for cargo install, used by Makefile and CI)

crates/adapters/interactive_brokers/src/common/parse.rs

Lines changed: 105 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -73,7 +73,17 @@ pub fn ib_contract_to_instrument_id_simplified(
7373
}
7474
}
7575
SecurityType::ForexPair => Venue::from("IDEALPRO"),
76-
SecurityType::Crypto => Venue::from("PAXOS"),
76+
// IB routes crypto to multiple venues; both PAXOS and ZEROHASH are live
77+
// (which one applies depends on the account/region). Derive the venue from
78+
// the contract's actual exchange when set (e.g. ZEROHASH or PAXOS), falling
79+
// back to PAXOS when it is unspecified.
80+
SecurityType::Crypto => {
81+
if !contract.exchange.as_str().is_empty() && contract.exchange.as_str() != "SMART" {
82+
Venue::from(contract.exchange.as_str())
83+
} else {
84+
Venue::from("PAXOS")
85+
}
86+
}
7787
SecurityType::Stock => Venue::from("SMART"),
7888
SecurityType::Option | SecurityType::FuturesOption => {
7989
if !contract.exchange.as_str().is_empty() && contract.exchange.as_str() != "SMART" {
@@ -234,7 +244,17 @@ pub fn ib_contract_to_instrument_id_raw(
234244
) -> anyhow::Result<InstrumentId> {
235245
let venue = venue.unwrap_or_else(|| match contract.security_type {
236246
SecurityType::ForexPair => Venue::from("IDEALPRO"),
237-
SecurityType::Crypto => Venue::from("PAXOS"),
247+
// IB routes crypto to multiple venues; both PAXOS and ZEROHASH are live
248+
// (which one applies depends on the account/region). Derive the venue from
249+
// the contract's actual exchange when set (e.g. ZEROHASH or PAXOS), falling
250+
// back to PAXOS when it is unspecified.
251+
SecurityType::Crypto => {
252+
if !contract.exchange.as_str().is_empty() && contract.exchange.as_str() != "SMART" {
253+
Venue::from(contract.exchange.as_str())
254+
} else {
255+
Venue::from("PAXOS")
256+
}
257+
}
238258
SecurityType::Stock => Venue::from("SMART"),
239259
SecurityType::Option => Venue::from("SMART"),
240260
SecurityType::FuturesOption => Venue::from("SMART"),
@@ -371,7 +391,9 @@ pub fn possible_exchanges_for_venue(venue: &str) -> Vec<String> {
371391

372392
/// Venue lists for different asset classes
373393
const VENUES_CASH: &[&str] = &["IDEALPRO"];
374-
const VENUES_CRYPTO: &[&str] = &["PAXOS"];
394+
// IB routes crypto to both PAXOS and ZEROHASH (which one applies depends on the
395+
// account/region); accept both.
396+
const VENUES_CRYPTO: &[&str] = &["PAXOS", "ZEROHASH"];
375397
const VENUES_OPT: &[&str] = &["SMART", "EUREX"];
376398
const VENUES_FUT: &[&str] = &[
377399
"BELFOX",
@@ -1365,6 +1387,86 @@ mod tests {
13651387
assert_eq!(contract.local_symbol.as_str(), "DOGE.USD");
13661388
}
13671389

1390+
#[rstest]
1391+
fn test_instrument_id_to_ib_contract_parses_zerohash_crypto_symbol() {
1392+
// ZEROHASH is one of IB's crypto venues (alongside PAXOS).
1393+
let instrument_id = InstrumentId::from("BTC/USD.ZEROHASH");
1394+
1395+
let contract = instrument_id_to_ib_contract(instrument_id, None).unwrap();
1396+
1397+
assert_eq!(contract.security_type, SecurityType::Crypto);
1398+
assert_eq!(contract.exchange.as_str(), "ZEROHASH");
1399+
assert_eq!(contract.symbol.as_str(), "BTC");
1400+
assert_eq!(contract.currency.as_str(), "USD");
1401+
assert_eq!(contract.local_symbol.as_str(), "BTC.USD");
1402+
}
1403+
1404+
#[rstest]
1405+
fn test_ib_contract_to_instrument_id_simplified_derives_zerohash_crypto_venue() {
1406+
// Contract -> InstrumentId derives the venue from the ZEROHASH exchange.
1407+
let contract = Contract {
1408+
symbol: Symbol::from("BTC"),
1409+
security_type: SecurityType::Crypto,
1410+
exchange: Exchange::from("ZEROHASH"),
1411+
currency: Currency::from("USD"),
1412+
local_symbol: "BTC.USD".to_string(),
1413+
..Default::default()
1414+
};
1415+
1416+
let instrument_id = ib_contract_to_instrument_id_simplified(&contract, None).unwrap();
1417+
1418+
assert_eq!(instrument_id, InstrumentId::from("BTC/USD.ZEROHASH"));
1419+
}
1420+
1421+
#[rstest]
1422+
fn test_ib_contract_to_instrument_id_simplified_falls_back_to_paxos_crypto_venue() {
1423+
// Contract -> InstrumentId falls back to PAXOS when no exchange is set,
1424+
// preserving backwards compatibility.
1425+
let contract = Contract {
1426+
symbol: Symbol::from("DOGE"),
1427+
security_type: SecurityType::Crypto,
1428+
currency: Currency::from("USD"),
1429+
local_symbol: "DOGE.USD".to_string(),
1430+
..Default::default()
1431+
};
1432+
1433+
let instrument_id = ib_contract_to_instrument_id_simplified(&contract, None).unwrap();
1434+
1435+
assert_eq!(instrument_id, InstrumentId::from("DOGE/USD.PAXOS"));
1436+
}
1437+
1438+
#[rstest]
1439+
fn test_instrument_id_to_ib_contract_parses_paxos_crypto_symbol() {
1440+
// PAXOS remains a live IB crypto venue alongside ZEROHASH.
1441+
let instrument_id = InstrumentId::from("BTC/USD.PAXOS");
1442+
1443+
let contract = instrument_id_to_ib_contract(instrument_id, None).unwrap();
1444+
1445+
assert_eq!(contract.security_type, SecurityType::Crypto);
1446+
assert_eq!(contract.exchange.as_str(), "PAXOS");
1447+
assert_eq!(contract.symbol.as_str(), "BTC");
1448+
assert_eq!(contract.currency.as_str(), "USD");
1449+
assert_eq!(contract.local_symbol.as_str(), "BTC.USD");
1450+
}
1451+
1452+
#[rstest]
1453+
fn test_ib_contract_to_instrument_id_simplified_derives_paxos_crypto_venue() {
1454+
// A PAXOS contract derives the PAXOS venue from its exchange (not via the
1455+
// fallback), proving both venues are honored when explicitly set.
1456+
let contract = Contract {
1457+
symbol: Symbol::from("BTC"),
1458+
security_type: SecurityType::Crypto,
1459+
exchange: Exchange::from("PAXOS"),
1460+
currency: Currency::from("USD"),
1461+
local_symbol: "BTC.USD".to_string(),
1462+
..Default::default()
1463+
};
1464+
1465+
let instrument_id = ib_contract_to_instrument_id_simplified(&contract, None).unwrap();
1466+
1467+
assert_eq!(instrument_id, InstrumentId::from("BTC/USD.PAXOS"));
1468+
}
1469+
13681470
#[rstest]
13691471
fn test_instrument_id_to_ib_contract_uses_contfut_for_underlying() {
13701472
let instrument_id = InstrumentId::from("ES.XCME");

crates/adapters/interactive_brokers/src/data/convert.rs

Lines changed: 153 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -16,9 +16,12 @@
1616
//! Conversion utilities for Interactive Brokers data types.
1717
1818
use chrono::{DateTime, Utc};
19-
use ibapi::market_data::historical::{
20-
BarSize as HistoricalBarSize, BarTimestamp, Duration as IBDuration, ToDuration,
21-
WhatToShow as HistoricalWhatToShow,
19+
use ibapi::market_data::{
20+
historical::{
21+
BarSize as HistoricalBarSize, BarTimestamp, Duration as IBDuration, ToDuration,
22+
WhatToShow as HistoricalWhatToShow,
23+
},
24+
realtime::WhatToShow as RealtimeWhatToShow,
2225
};
2326
use nautilus_core::UnixNanos;
2427
use nautilus_model::{
@@ -85,6 +88,57 @@ pub fn price_type_to_ib_what_to_show(price_type: PriceType) -> HistoricalWhatToS
8588
}
8689
}
8790

91+
/// Convert Nautilus PriceType to IB WhatToShow for historical bars, honoring the
92+
/// crypto trade-price special case.
93+
///
94+
/// IB rejects `TRADES` for crypto contracts (ZEROHASH/PAXOS) with error 10299 —
95+
/// the venue serves trade-price data only under `AGGTRADES`. So for a crypto
96+
/// contract requesting trade-price (`PriceType::Last`), map to
97+
/// [`HistoricalWhatToShow::AggTrades`] instead of [`HistoricalWhatToShow::Trades`].
98+
/// Non-crypto contracts and non-trade price types are unaffected. This mirrors the
99+
/// Java engine's `LiveOneMinBarIngestionService.whatToShowFor` rule exactly.
100+
#[must_use]
101+
pub fn price_type_to_ib_what_to_show_for_security(
102+
price_type: PriceType,
103+
is_crypto: bool,
104+
) -> HistoricalWhatToShow {
105+
if is_crypto && price_type == PriceType::Last {
106+
return HistoricalWhatToShow::AggTrades;
107+
}
108+
price_type_to_ib_what_to_show(price_type)
109+
}
110+
111+
/// Convert Nautilus PriceType to IB WhatToShow for real-time (5-second) bars.
112+
#[must_use]
113+
pub fn price_type_to_ib_realtime_what_to_show(price_type: PriceType) -> RealtimeWhatToShow {
114+
match price_type {
115+
PriceType::Last => RealtimeWhatToShow::Trades,
116+
PriceType::Bid => RealtimeWhatToShow::Bid,
117+
PriceType::Ask => RealtimeWhatToShow::Ask,
118+
PriceType::Mid => RealtimeWhatToShow::MidPoint,
119+
_ => RealtimeWhatToShow::Trades, // Default to trades
120+
}
121+
}
122+
123+
/// Convert Nautilus PriceType to IB WhatToShow for real-time (5-second) bars,
124+
/// honoring the crypto trade-price special case.
125+
///
126+
/// TWS rejects `TRADES` for crypto contracts (ZEROHASH/PAXOS) with error 10299 on
127+
/// `reqRealTimeBars` too — not just historical. So for a crypto contract requesting
128+
/// trade-price (`PriceType::Last`), map to [`RealtimeWhatToShow::AggTrades`]. This
129+
/// mirrors the Java engine, which passes `whatToShowFor(CRYPTO) = "AGGTRADES"`
130+
/// straight into `subscribeRealTimeBars` (LiveOneMinBarIngestionService).
131+
#[must_use]
132+
pub fn price_type_to_ib_realtime_what_to_show_for_security(
133+
price_type: PriceType,
134+
is_crypto: bool,
135+
) -> RealtimeWhatToShow {
136+
if is_crypto && price_type == PriceType::Last {
137+
return RealtimeWhatToShow::AggTrades;
138+
}
139+
price_type_to_ib_realtime_what_to_show(price_type)
140+
}
141+
88142
#[must_use]
89143
pub fn apply_price_magnifier(price: f64, price_magnifier: i32) -> f64 {
90144
if price_magnifier > 0 {
@@ -402,6 +456,102 @@ mod tests {
402456
);
403457
}
404458

459+
#[rstest]
460+
fn test_price_type_to_ib_what_to_show_for_security_crypto() {
461+
// Crypto trade-price (Last) must map to AGGTRADES, not TRADES — TWS rejects
462+
// TRADES for crypto (error 10299). Mirrors the Java whatToShowFor rule.
463+
assert_eq!(
464+
price_type_to_ib_what_to_show_for_security(PriceType::Last, true),
465+
HistoricalWhatToShow::AggTrades
466+
);
467+
// Non-trade price types are unaffected by the crypto special case.
468+
assert_eq!(
469+
price_type_to_ib_what_to_show_for_security(PriceType::Bid, true),
470+
HistoricalWhatToShow::Bid
471+
);
472+
assert_eq!(
473+
price_type_to_ib_what_to_show_for_security(PriceType::Ask, true),
474+
HistoricalWhatToShow::Ask
475+
);
476+
assert_eq!(
477+
price_type_to_ib_what_to_show_for_security(PriceType::Mid, true),
478+
HistoricalWhatToShow::MidPoint
479+
);
480+
}
481+
482+
#[rstest]
483+
fn test_price_type_to_ib_what_to_show_for_security_non_crypto() {
484+
// Non-crypto: trade-price stays TRADES (equities/futures), everything else
485+
// identical to the plain mapping.
486+
assert_eq!(
487+
price_type_to_ib_what_to_show_for_security(PriceType::Last, false),
488+
HistoricalWhatToShow::Trades
489+
);
490+
assert_eq!(
491+
price_type_to_ib_what_to_show_for_security(PriceType::Bid, false),
492+
HistoricalWhatToShow::Bid
493+
);
494+
assert_eq!(
495+
price_type_to_ib_what_to_show_for_security(PriceType::Mid, false),
496+
HistoricalWhatToShow::MidPoint
497+
);
498+
}
499+
500+
#[rstest]
501+
fn test_aggtrades_wire_string() {
502+
// The vendored ibapi patch must serialize AggTrades as the exact IB wire
503+
// token "AGGTRADES" on BOTH the historical and realtime enums.
504+
assert_eq!(HistoricalWhatToShow::AggTrades.to_string(), "AGGTRADES");
505+
assert_eq!(RealtimeWhatToShow::AggTrades.to_string(), "AGGTRADES");
506+
}
507+
508+
#[rstest]
509+
fn test_price_type_to_ib_realtime_what_to_show() {
510+
// `RealtimeWhatToShow` does not derive `PartialEq`, so match on the variants.
511+
assert!(matches!(
512+
price_type_to_ib_realtime_what_to_show(PriceType::Last),
513+
RealtimeWhatToShow::Trades
514+
));
515+
assert!(matches!(
516+
price_type_to_ib_realtime_what_to_show(PriceType::Bid),
517+
RealtimeWhatToShow::Bid
518+
));
519+
assert!(matches!(
520+
price_type_to_ib_realtime_what_to_show(PriceType::Ask),
521+
RealtimeWhatToShow::Ask
522+
));
523+
assert!(matches!(
524+
price_type_to_ib_realtime_what_to_show(PriceType::Mid),
525+
RealtimeWhatToShow::MidPoint
526+
));
527+
}
528+
529+
#[rstest]
530+
fn test_price_type_to_ib_realtime_what_to_show_for_security_crypto() {
531+
// Crypto trade-price (Last) 5-second bars must request AGGTRADES on the
532+
// realtime path too — TWS rejects TRADES for crypto (error 10299) on
533+
// reqRealTimeBars, exactly as on the historical path. Mirrors the Java
534+
// engine passing whatToShowFor(CRYPTO)="AGGTRADES" to subscribeRealTimeBars.
535+
assert!(matches!(
536+
price_type_to_ib_realtime_what_to_show_for_security(PriceType::Last, true),
537+
RealtimeWhatToShow::AggTrades
538+
));
539+
// Non-trade price types unaffected by the crypto special case.
540+
assert!(matches!(
541+
price_type_to_ib_realtime_what_to_show_for_security(PriceType::Mid, true),
542+
RealtimeWhatToShow::MidPoint
543+
));
544+
assert!(matches!(
545+
price_type_to_ib_realtime_what_to_show_for_security(PriceType::Bid, true),
546+
RealtimeWhatToShow::Bid
547+
));
548+
// Non-crypto trade-price stays TRADES.
549+
assert!(matches!(
550+
price_type_to_ib_realtime_what_to_show_for_security(PriceType::Last, false),
551+
RealtimeWhatToShow::Trades
552+
));
553+
}
554+
405555
#[rstest]
406556
fn test_ib_bar_to_nautilus_bar() {
407557
let ib_bar = ibapi::market_data::historical::Bar {

0 commit comments

Comments
 (0)