Skip to main content

nautilus_interactive_brokers/data/
core.rs

1// -------------------------------------------------------------------------------------------------
2//  Copyright (C) 2015-2026 Nautech Systems Pty Ltd. All rights reserved.
3//  https://nautechsystems.io
4//
5//  Licensed under the GNU Lesser General Public License Version 3.0 (the "License");
6//  You may not use this file except in compliance with the License.
7//  You may obtain a copy of the License at https://www.gnu.org/licenses/lgpl-3.0.en.html
8//
9//  Unless required by applicable law or agreed to in writing, software
10//  distributed under the License is distributed on an "AS IS" BASIS,
11//  WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12//  See the License for the specific language governing permissions and
13//  limitations under the License.
14// -------------------------------------------------------------------------------------------------
15
16//! Core data client implementation for Interactive Brokers.
17
18use std::{
19    collections::HashMap,
20    fmt::Debug,
21    sync::{
22        Arc, Mutex,
23        atomic::{AtomicBool, Ordering},
24    },
25    time::Duration,
26};
27
28use ahash::AHashMap;
29use anyhow::Context;
30use ibapi::{
31    contracts::{Contract, Currency as IBCurrency, Exchange as IBExchange, SecurityType, Symbol},
32    market_data::IgnoreSize,
33    prelude::{StreamExt, SubscriptionItemStreamExt},
34};
35use nautilus_common::{
36    clients::{DataClient, execute_bar_subscriptions, execute_instrument_subscriptions},
37    live::{runner::get_data_event_sender, sender::EventSender},
38    messages::{
39        DataEvent, DataResponse,
40        data::{
41            BarsResponse, InstrumentResponse, InstrumentsResponse, QuotesResponse, RequestBars,
42            RequestInstrument, RequestInstruments, RequestQuotes, RequestTrades, SubscribeBars,
43            SubscribeBookDeltas, SubscribeIndexPrices, SubscribeOptionGreeks, SubscribeQuotes,
44            SubscribeTrades, TradesResponse, UnsubscribeBars, UnsubscribeBookDeltas,
45            UnsubscribeIndexPrices, UnsubscribeOptionGreeks, UnsubscribeQuotes, UnsubscribeTrades,
46        },
47    },
48};
49use nautilus_core::{
50    UnixNanos,
51    params::Params,
52    time::{AtomicTime, get_atomic_clock_realtime},
53};
54use nautilus_live::task::{TaskGroup, TaskJoinOutcome, TaskSlot, finish_task};
55use nautilus_model::{
56    data::BarType,
57    enums::BookType,
58    identifiers::{ClientId, InstrumentId, Venue},
59    instruments::{Instrument, any::InstrumentAny},
60};
61use tokio_util::sync::CancellationToken;
62
63use super::{
64    cache::{OptionGreeksCache, QuoteCache},
65    convert::{
66        IbPriceConverter, bar_request_segments, bar_type_to_ib_bar_size,
67        calculate_duration_segments, extend_historical_tick_batch, ib_bar_to_nautilus_bar,
68        jiff_to_ib_datetime, price_type_to_ib_realtime_what_to_show_for_security,
69        price_type_to_ib_what_to_show_for_security, retain_historical_ticks_in_range,
70        should_continue_historical_tick_pagination,
71    },
72    parse::log_tick_parse_error,
73    streams::{
74        DataFarmConnectionState, StreamConfig, handle_historical_bars_subscription,
75        handle_index_price_subscription, handle_market_depth_subscription,
76        handle_option_greeks_subscription, handle_quote_subscription,
77        handle_realtime_bars_subscription, handle_tick_by_tick_quote_subscription,
78        handle_trade_subscription, monitor_data_farm_notices,
79    },
80};
81use crate::{
82    common::{
83        consts::IB_VENUE,
84        contracts::parse_contract_from_json,
85        shared_client::SharedClientHandle,
86        spreads::{is_spread_instrument_id, parse_spread_instrument_id_to_legs},
87        symbology::is_crypto_contract,
88    },
89    config::{InteractiveBrokersDataClientConfig, MarketDataType},
90    data_types::register_ib_custom_data,
91    providers::instruments::InteractiveBrokersInstrumentProvider,
92};
93
94/// Interactive Brokers data client.
95///
96/// This client provides market data functionality using the `rust-ibapi` library.
97/// It manages subscriptions, handles historical data requests, and streams
98/// market data to NautilusTrader.
99#[cfg_attr(
100    feature = "python",
101    pyo3::pyclass(module = "nautilus_trader.adapters.interactive_brokers")
102)]
103pub struct InteractiveBrokersDataClient {
104    client_id: ClientId,
105    config: InteractiveBrokersDataClientConfig,
106    instrument_provider: Arc<InteractiveBrokersInstrumentProvider>,
107    is_connected: Arc<AtomicBool>,
108    cancellation_token: CancellationToken,
109    session_tasks: TaskGroup,
110    command_tasks: TaskGroup,
111    data_sender: EventSender<DataEvent>,
112    subscriptions: Arc<Mutex<AHashMap<SubscriptionKey, SubscriptionInfo>>>,
113    option_greeks_subscriptions: Arc<Mutex<AHashMap<InstrumentId, CancellationToken>>>,
114    quote_cache: Arc<tokio::sync::Mutex<QuoteCache>>,
115    option_greeks_cache: Arc<tokio::sync::Mutex<OptionGreeksCache>>,
116    clock: &'static AtomicTime,
117    ib_client: Option<SharedClientHandle>,
118    last_bars: Arc<tokio::sync::Mutex<AHashMap<String, ibapi::market_data::realtime::Bar>>>,
119    bar_timeout_tasks: Arc<tokio::sync::Mutex<AHashMap<String, TaskSlot<()>>>>,
120    data_farm_state: Arc<DataFarmConnectionState>,
121}
122
123/// Information about an active subscription.
124#[derive(Debug)]
125struct SubscriptionInfo {
126    /// Cancellation token for this specific subscription.
127    cancellation_token: CancellationToken,
128    /// Unique id distinguishing this registration from later ones for the same key.
129    id: u64,
130}
131
132/// Type of subscription.
133#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
134enum SubscriptionType {
135    /// Quote subscription.
136    Quotes,
137    /// Index price subscription.
138    IndexPrices,
139    /// Trade subscription.
140    Trades,
141    /// Bar subscription, one stream per bar type.
142    Bars(BarType),
143    /// Order book delta subscription.
144    BookDeltas,
145}
146
147type SubscriptionKey = (InstrumentId, SubscriptionType);
148
149static SUBSCRIPTION_ID_SEQ: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(0);
150
151fn next_subscription_id() -> u64 {
152    SUBSCRIPTION_ID_SEQ.fetch_add(1, std::sync::atomic::Ordering::Relaxed)
153}
154
155/// Removes a subscription's registry entry when its stream task ends permanently,
156/// so a later re-subscribe is not blocked by a dead registration. The id check
157/// keeps a finishing task from removing a newer registration for the same key.
158struct SubscriptionGuard {
159    subscriptions: Arc<Mutex<AHashMap<SubscriptionKey, SubscriptionInfo>>>,
160    key: SubscriptionKey,
161    id: u64,
162}
163
164impl SubscriptionGuard {
165    fn release(self) {
166        // Dropping performs the removal; a panicking stream task reaches the same
167        // path through Drop, so a dead registration can never block a re-subscribe.
168    }
169}
170
171impl Drop for SubscriptionGuard {
172    fn drop(&mut self) {
173        if let Ok(mut subscriptions) = self.subscriptions.lock()
174            && subscriptions
175                .get(&self.key)
176                .is_some_and(|info| info.id == self.id)
177        {
178            subscriptions.remove(&self.key);
179        }
180    }
181}
182
183fn lock<T>(mutex: &Mutex<T>) -> anyhow::Result<std::sync::MutexGuard<'_, T>> {
184    mutex
185        .lock()
186        .map_err(|_| anyhow::anyhow!("Failed to lock IB data client state"))
187}
188
189fn parse_start_ns(params: Option<&nautilus_core::Params>) -> Option<UnixNanos> {
190    params
191        .and_then(|params| params.get_u64("start_ns"))
192        .or_else(|| {
193            params
194                .and_then(|params| params.get_str("start_ns"))
195                .and_then(|value| value.parse::<u64>().ok())
196        })
197        .map(UnixNanos::from)
198}
199
200fn parse_bool_param_value(value: &str) -> bool {
201    matches!(value, "true" | "True" | "1")
202}
203
204fn params_to_string_filters(params: Option<&Params>) -> Option<HashMap<String, String>> {
205    let filters: HashMap<String, String> = params?
206        .iter()
207        .filter_map(|(key, value)| value.as_str().map(|value| (key.clone(), value.to_string())))
208        .collect();
209    (!filters.is_empty()).then_some(filters)
210}
211
212fn datetime_to_unix_nanos(dt: jiff::Timestamp) -> UnixNanos {
213    UnixNanos::from(u64::try_from(dt.as_nanosecond()).unwrap_or_default())
214}
215
216fn request_trading_hours(use_regular_trading_hours: bool) -> ibapi::market_data::TradingHours {
217    if use_regular_trading_hours {
218        ibapi::market_data::TradingHours::Regular
219    } else {
220        ibapi::market_data::TradingHours::Extended
221    }
222}
223
224const HISTORICAL_TICK_DEFAULT_LIMIT: usize = 10_000;
225
226impl InteractiveBrokersDataClient {
227    /// Create a new `InteractiveBrokersDataClient`.
228    ///
229    /// # Errors
230    ///
231    /// Returns an error if client creation fails.
232    pub fn new(
233        client_id: ClientId,
234        config: InteractiveBrokersDataClientConfig,
235        instrument_provider: Arc<InteractiveBrokersInstrumentProvider>,
236    ) -> anyhow::Result<Self> {
237        let clock = get_atomic_clock_realtime();
238        config.validate()?;
239        register_ib_custom_data();
240        let data_sender = get_data_event_sender();
241
242        let session_tasks = TaskGroup::new();
243        let command_tasks = TaskGroup::new();
244
245        Ok(Self {
246            client_id,
247            config,
248            instrument_provider,
249            is_connected: Arc::new(AtomicBool::new(false)),
250            cancellation_token: session_tasks.cancellation_token(),
251            session_tasks,
252            command_tasks,
253            data_sender,
254            subscriptions: Arc::new(Mutex::new(AHashMap::new())),
255            option_greeks_subscriptions: Arc::new(Mutex::new(AHashMap::new())),
256            quote_cache: Arc::new(tokio::sync::Mutex::new(QuoteCache::new())),
257            option_greeks_cache: Arc::new(tokio::sync::Mutex::new(OptionGreeksCache::new())),
258            clock,
259            ib_client: None,
260            last_bars: Arc::new(tokio::sync::Mutex::new(AHashMap::new())),
261            bar_timeout_tasks: Arc::new(tokio::sync::Mutex::new(AHashMap::new())),
262            data_farm_state: Arc::new(DataFarmConnectionState::default()),
263        })
264    }
265
266    fn cancel_active_subscriptions(&self) -> anyhow::Result<()> {
267        {
268            let mut subscriptions = lock(&self.subscriptions)?;
269            for subscription in subscriptions.values() {
270                subscription.cancellation_token.cancel();
271            }
272
273            subscriptions.clear();
274        }
275
276        {
277            let mut subscriptions = lock(&self.option_greeks_subscriptions)?;
278            for cancellation_token in subscriptions.values() {
279                cancellation_token.cancel();
280            }
281
282            subscriptions.clear();
283        }
284
285        Ok(())
286    }
287
288    fn spawn_command<F>(&self, future: F)
289    where
290        F: std::future::Future<Output = ()> + Send + 'static,
291    {
292        if let Err(e) = self.command_tasks.spawn(future) {
293            tracing::warn!("Skipping IB data command after shutdown began: {e}");
294        }
295    }
296
297    async fn finish_tasks(&self) -> anyhow::Result<()> {
298        let (session_result, command_result) = tokio::join!(
299            self.session_tasks
300                .finish_shutdown(Duration::from_secs(1), Duration::from_secs(2)),
301            self.command_tasks
302                .finish_shutdown(Duration::from_secs(1), Duration::from_secs(2)),
303        );
304        self.finish_bar_timeout_tasks().await;
305        session_result.context("failed to finish IB data session tasks")?;
306        command_result.context("failed to finish IB data command tasks")?;
307        Ok(())
308    }
309
310    async fn prepare_task_groups(&mut self) -> anyhow::Result<()> {
311        if !self.session_tasks.is_open() || !self.command_tasks.is_open() {
312            self.session_tasks.begin_shutdown();
313            self.command_tasks.begin_shutdown();
314            self.finish_tasks().await?;
315            self.session_tasks
316                .start_generation()
317                .context("failed to start IB data session task generation")?;
318            self.command_tasks
319                .start_generation()
320                .context("failed to start IB data command task generation")?;
321            self.cancellation_token = self.session_tasks.cancellation_token();
322        }
323
324        Ok(())
325    }
326
327    async fn finish_bar_timeout_tasks(&self) {
328        let mut tasks = self.bar_timeout_tasks.lock().await;
329        let bar_types = tasks.keys().cloned().collect::<Vec<_>>();
330        for bar_type in bar_types {
331            let handle = tasks
332                .get_mut(&bar_type)
333                .expect("bar timeout task key collected from map");
334
335            match finish_task(handle, Duration::ZERO, Duration::from_secs(1)).await {
336                None => {}
337                Some(TaskJoinOutcome::Completed(()) | TaskJoinOutcome::Aborted) => {}
338                Some(TaskJoinOutcome::Failed(e)) => {
339                    tracing::warn!("IB bar timeout task join failed: {e}");
340                }
341                Some(TaskJoinOutcome::Incomplete) => {
342                    tracing::warn!("IB bar timeout task did not stop within one second");
343                }
344            }
345
346            if handle.is_none() {
347                tasks.remove(&bar_type);
348            }
349        }
350    }
351
352    /// Get a reference to the IB client if connected.
353    /// This is used internally for provider method calls.
354    #[allow(dead_code)] // Library API - may be used by other modules or PyO3 bindings
355    pub(crate) fn get_ib_client(&self) -> Option<&Arc<ibapi::Client>> {
356        self.ib_client.as_ref().map(|h| h.as_arc())
357    }
358
359    /// Get a reference to the instrument provider.
360    #[allow(dead_code)] // Library API - may be used by other modules or PyO3 bindings
361    pub(crate) fn instrument_provider(&self) -> Arc<InteractiveBrokersInstrumentProvider> {
362        Arc::clone(&self.instrument_provider)
363    }
364
365    /// Batch load multiple instrument IDs using the internal IB client.
366    ///
367    /// This method calls the provider's batch_load with the data client's IB client.
368    ///
369    /// # Errors
370    ///
371    /// Returns an error if:
372    /// - The client is not connected.
373    /// - The provider batch_load fails.
374    pub async fn batch_load_instruments(
375        &self,
376        instrument_ids: Vec<InstrumentId>,
377    ) -> anyhow::Result<Vec<InstrumentId>> {
378        log::debug!(
379            "Batch loading {} IB instruments through data client",
380            instrument_ids.len()
381        );
382        let client = self
383            .ib_client
384            .as_ref()
385            .context("IB client not connected. Call connect() first")?;
386
387        let loaded = self
388            .instrument_provider
389            .batch_load(client, instrument_ids, None)
390            .await?;
391        log::debug!("Batch loaded {} IB instruments", loaded.len());
392        Ok(loaded)
393    }
394
395    /// Fetch option chain for an underlying contract with expiry filtering.
396    ///
397    /// This method calls the provider's fetch_option_chain_by_range with the data client's IB client.
398    ///
399    /// # Errors
400    ///
401    /// Returns an error if:
402    /// - The client is not connected.
403    /// - The provider method fails.
404    pub async fn fetch_option_chain_by_range(
405        &self,
406        underlying_symbol: &str,
407        exchange: Option<&str>,
408        currency: Option<&str>,
409        expiry_min: Option<&str>,
410        expiry_max: Option<&str>,
411    ) -> anyhow::Result<usize> {
412        log::debug!(
413            "Fetching IB option chain by range (symbol={underlying_symbol}, exchange={exchange:?}, currency={currency:?}, expiry_min={expiry_min:?}, expiry_max={expiry_max:?})"
414        );
415        let client = self
416            .ib_client
417            .as_ref()
418            .context("IB client not connected. Call connect() first")?;
419
420        let underlying = Contract {
421            contract_id: 0,
422            symbol: Symbol::from(underlying_symbol.to_string()),
423            security_type: SecurityType::Stock,
424            last_trade_date_or_contract_month: String::new(),
425            strike: f64::MAX,
426            right: None,
427            multiplier: String::new(),
428            exchange: IBExchange::from(exchange.unwrap_or("SMART")),
429            currency: IBCurrency::from(currency.unwrap_or("USD")),
430            local_symbol: String::new(),
431            primary_exchange: IBExchange::from(""),
432            trading_class: String::new(),
433            include_expired: false,
434            security_id_type: None,
435            security_id: String::new(),
436            combo_legs_description: String::new(),
437            combo_legs: Vec::new(),
438            delta_neutral_contract: None,
439            issuer_id: String::new(),
440            description: String::new(),
441            last_trade_date: None,
442        };
443
444        let count = self
445            .instrument_provider
446            .fetch_option_chain_by_range(client, &underlying, expiry_min, expiry_max, None)
447            .await?;
448        log::debug!("Fetched {count} IB option instruments for {underlying_symbol}");
449        Ok(count)
450    }
451
452    /// Fetch futures chain for a given underlying symbol.
453    ///
454    /// This method calls the provider's fetch_futures_chain with the data client's IB client.
455    ///
456    /// # Errors
457    ///
458    /// Returns an error if:
459    /// - The client is not connected.
460    /// - The provider method fails.
461    pub async fn fetch_futures_chain(
462        &self,
463        symbol: &str,
464        exchange: Option<&str>,
465        currency: Option<&str>,
466        min_expiry_days: Option<u32>,
467        max_expiry_days: Option<u32>,
468    ) -> anyhow::Result<usize> {
469        log::debug!(
470            "Fetching IB futures chain (symbol={symbol}, exchange={exchange:?}, currency={currency:?}, min_days={min_expiry_days:?}, max_days={max_expiry_days:?})"
471        );
472        let client = self
473            .ib_client
474            .as_ref()
475            .context("IB client not connected. Call connect() first")?;
476
477        let count = self
478            .instrument_provider
479            .fetch_futures_chain(
480                client,
481                symbol,
482                exchange.unwrap_or(""),
483                currency.unwrap_or("USD"),
484                None,
485                false,
486                min_expiry_days,
487                max_expiry_days,
488            )
489            .await?;
490        log::debug!("Fetched {count} IB futures instruments for {symbol}");
491        Ok(count)
492    }
493
494    /// Fetch BAG (spread) contract details.
495    ///
496    /// This method calls the provider's fetch_bag_contract with the data client's IB client.
497    ///
498    /// # Errors
499    ///
500    /// Returns an error if:
501    /// - The client is not connected.
502    /// - The provider method fails.
503    pub async fn fetch_bag_contract(
504        &self,
505        bag_contract: &ibapi::contracts::Contract,
506    ) -> anyhow::Result<usize> {
507        log::debug!(
508            "Fetching IB BAG contract details (contract_id={}, exchange={}, symbol={})",
509            bag_contract.contract_id,
510            bag_contract.exchange.as_str(),
511            bag_contract.symbol.as_str()
512        );
513        let client = self
514            .ib_client
515            .as_ref()
516            .context("IB client not connected. Call connect() first")?;
517
518        let count = self
519            .instrument_provider
520            .fetch_bag_contract(client, bag_contract)
521            .await?;
522        log::debug!("Fetched {count} BAG instruments");
523        Ok(count)
524    }
525}
526
527impl Debug for InteractiveBrokersDataClient {
528    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
529        f.debug_struct(stringify!(InteractiveBrokersDataClient))
530            .field("client_id", &self.client_id)
531            .field("config", &self.config)
532            .field("is_connected", &self.is_connected.load(Ordering::Relaxed))
533            .field("has_ib_client", &self.ib_client.is_some())
534            .finish_non_exhaustive()
535    }
536}
537
538#[async_trait::async_trait(?Send)]
539impl DataClient for InteractiveBrokersDataClient {
540    fn client_id(&self) -> ClientId {
541        self.client_id
542    }
543
544    fn venue(&self) -> Option<Venue> {
545        // Interactive Brokers is a multi-venue adapter (SMART, IDEALPRO, ZEROHASH, CME, etc.),
546        // so the data client must register for default routing rather than a single venue:
547        // subscriptions and requests are routed by the command's venue (derived from the
548        // instrument ID), which would otherwise never match and be dropped by the data engine.
549        // Mirrors the other multi-venue adapters (Tardis, Databento).
550        None
551    }
552
553    fn start(&mut self) -> anyhow::Result<()> {
554        tracing::info!(
555            client_id = %self.client_id,
556            "Starting Interactive Brokers data client"
557        );
558        Ok(())
559    }
560
561    fn stop(&mut self) -> anyhow::Result<()> {
562        tracing::info!(
563            "Stopping Interactive Brokers data client {id}",
564            id = self.client_id
565        );
566        self.session_tasks.begin_shutdown();
567        self.command_tasks.begin_shutdown();
568        self.cancel_active_subscriptions()?;
569        self.is_connected.store(false, Ordering::Relaxed);
570
571        self.clear_bar_tracking_state();
572
573        Ok(())
574    }
575
576    fn reset(&mut self) -> anyhow::Result<()> {
577        tracing::debug!(
578            "Resetting Interactive Brokers data client {id}",
579            id = self.client_id
580        );
581        self.cancel_active_subscriptions()?;
582        self.session_tasks.begin_shutdown();
583        self.command_tasks.begin_shutdown();
584        self.is_connected.store(false, Ordering::Relaxed);
585        self.clear_bar_tracking_state();
586        self.quote_cache = Arc::new(tokio::sync::Mutex::new(QuoteCache::new()));
587        self.option_greeks_cache = Arc::new(tokio::sync::Mutex::new(OptionGreeksCache::new()));
588        self.cancellation_token = CancellationToken::new();
589
590        Ok(())
591    }
592
593    fn dispose(&mut self) -> anyhow::Result<()> {
594        self.stop()
595    }
596
597    async fn connect(&mut self) -> anyhow::Result<()> {
598        tracing::debug!("Connecting Interactive Brokers data client...");
599
600        self.prepare_task_groups().await?;
601
602        let handle = crate::common::shared_client::get_or_connect(
603            &self.config.host,
604            self.config.port,
605            self.config.client_id,
606            self.config.connection_timeout,
607            self.config.max_reconnect_attempts,
608        )
609        .await
610        .context("Failed to connect to IB Gateway/TWS")?;
611
612        let client = Arc::clone(handle.as_arc());
613
614        tracing::info!(
615            "Connected to IB Gateway/TWS at {}:{} (client_id: {})",
616            self.config.host,
617            self.config.port,
618            self.config.client_id
619        );
620
621        // Set market data type if not default
622        let ib_data_type = (self.config.market_data_type != MarketDataType::Realtime)
623            .then(|| self.config.market_data_type.into());
624
625        if let Some(ib_data_type) = ib_data_type {
626            client
627                .switch_market_data_type(ib_data_type)
628                .await
629                .context("Failed to switch market data type")?;
630            tracing::info!("Set market data type to {:?}", self.config.market_data_type);
631        }
632
633        // Initialize provider and load instruments from cache/config if configured
634        tracing::debug!("Initializing IB data instrument provider");
635
636        if let Err(e) = self
637            .instrument_provider
638            .initialize_with_client(client.as_ref())
639            .await
640        {
641            if !self.config.instrument_provider.load_ids.is_empty()
642                || !self.config.instrument_provider.load_contracts.is_empty()
643            {
644                return Err(e).context("Failed to load configured IB instruments on startup");
645            }
646
647            tracing::warn!("Failed to load instruments on startup: {}", e);
648        }
649
650        self.ib_client = Some(handle);
651
652        let data_farm_state = Arc::clone(&self.data_farm_state);
653        let is_connected = Arc::clone(&self.is_connected);
654        let cancellation_token = self.cancellation_token.child_token();
655
656        if let Err(e) = self.session_tasks.spawn(async move {
657            if let Err(e) = monitor_data_farm_notices(
658                client,
659                data_farm_state,
660                ib_data_type,
661                cancellation_token,
662                is_connected,
663            )
664            .await
665            {
666                tracing::warn!("IB data farm notice monitor stopped: {e:?}");
667            }
668        }) {
669            self.session_tasks.begin_shutdown();
670            self.command_tasks.begin_shutdown();
671            self.ib_client = None;
672
673            if let Err(teardown_error) = self.finish_tasks().await {
674                return Err(anyhow::Error::new(e)
675                    .context(format!("IB data startup teardown failed: {teardown_error}")));
676            }
677
678            return Err(anyhow::Error::new(e).context("failed to register IB data farm monitor"));
679        }
680
681        self.is_connected.store(true, Ordering::Relaxed);
682
683        let instrument_count = self.instrument_provider.count();
684        if instrument_count > 0 {
685            tracing::debug!(
686                "Data client connected with {} instruments in provider cache",
687                instrument_count
688            );
689
690            for instrument in self.instrument_provider.get_all() {
691                if let Err(e) = self.data_sender.send(DataEvent::Data(instrument.into())) {
692                    tracing::warn!("Failed to publish startup-loaded instrument: {e}");
693                    break;
694                }
695            }
696        }
697
698        tracing::info!("Connected Interactive Brokers data client");
699        Ok(())
700    }
701
702    async fn disconnect(&mut self) -> anyhow::Result<()> {
703        tracing::debug!("Disconnecting Interactive Brokers data client...");
704
705        self.session_tasks.begin_shutdown();
706        self.command_tasks.begin_shutdown();
707        self.cancel_active_subscriptions()?;
708        self.ib_client = None;
709        let tasks_result = self.finish_tasks().await;
710        self.is_connected.store(false, Ordering::Relaxed);
711        tracing::info!("Disconnected Interactive Brokers data client");
712        tasks_result
713    }
714
715    fn is_connected(&self) -> bool {
716        self.is_connected.load(Ordering::Relaxed)
717    }
718
719    fn is_disconnected(&self) -> bool {
720        !self.is_connected()
721    }
722
723    // Subscription handlers
724    fn subscribe_quotes(&mut self, cmd: SubscribeQuotes) -> anyhow::Result<()> {
725        execute_instrument_subscriptions(&cmd.instrument_ids, |instrument_id| {
726            self.subscribe_quotes_member(instrument_id, &cmd)
727        })
728    }
729
730    fn subscribe_index_prices(&mut self, cmd: SubscribeIndexPrices) -> anyhow::Result<()> {
731        execute_instrument_subscriptions(&cmd.instrument_ids, |instrument_id| {
732            tracing::debug!("Subscribing to index prices for {}", instrument_id);
733
734            if self.has_subscription(instrument_id, SubscriptionType::IndexPrices)? {
735                tracing::debug!(
736                    "Index price subscription already active for {}",
737                    instrument_id
738                );
739                return Ok(());
740            }
741
742            let client = self
743                .ib_client
744                .as_ref()
745                .context("IB client not connected. Call connect() first")?;
746
747            let instrument = self
748                .instrument_provider
749                .find(&instrument_id)
750                .context(format!(
751                    "Instrument {} not found in provider",
752                    instrument_id
753                ))?;
754
755            let contract = self
756                .instrument_provider
757                .resolve_contract_for_instrument(instrument_id)
758                .context("Failed to convert instrument_id to IB contract")?;
759
760            if !matches!(contract.security_type, SecurityType::Index) {
761                tracing::warn!(
762                    "Index price subscription not supported for security type {:?} on {}",
763                    contract.security_type,
764                    instrument_id
765                );
766                return Ok(());
767            }
768
769            let prices = IbPriceConverter::for_instrument(
770                &instrument,
771                self.instrument_provider.get_price_magnifier(&instrument_id),
772            );
773            let data_sender = self.data_sender.clone();
774            let clock = self.clock;
775
776            let subscription_token = self.cancellation_token.child_token();
777
778            let subscription_id = next_subscription_id();
779            lock(&self.subscriptions)?.insert(
780                (instrument_id, SubscriptionType::IndexPrices),
781                SubscriptionInfo {
782                    cancellation_token: subscription_token.clone(),
783                    id: subscription_id,
784                },
785            );
786
787            let guard = SubscriptionGuard {
788                subscriptions: Arc::clone(&self.subscriptions),
789                key: (instrument_id, SubscriptionType::IndexPrices),
790                id: subscription_id,
791            };
792
793            let client_clone = client.as_arc().clone();
794            let data_farm_state = Arc::clone(&self.data_farm_state);
795            let stream_config = StreamConfig::new(self.client_id, &self.config);
796
797            let task = async move {
798                if let Err(e) = handle_index_price_subscription(
799                    client_clone,
800                    contract,
801                    instrument_id,
802                    prices,
803                    data_sender,
804                    clock,
805                    subscription_token,
806                    data_farm_state,
807                    stream_config,
808                )
809                .await
810                {
811                    tracing::error!(
812                        "Index price subscription error for {}: {:?}",
813                        instrument_id,
814                        e
815                    );
816                }
817
818                guard.release();
819            };
820
821            self.session_tasks
822                .spawn(task)
823                .context("failed to register IB data subscription task")?;
824
825            tracing::debug!("Index price subscription started for {}", instrument_id);
826            Ok(())
827        })
828    }
829
830    fn subscribe_option_greeks(&mut self, cmd: SubscribeOptionGreeks) -> anyhow::Result<()> {
831        execute_instrument_subscriptions(&cmd.instrument_ids, |instrument_id| {
832            tracing::debug!("Subscribing to option greeks for {}", instrument_id);
833
834            if lock(&self.option_greeks_subscriptions)?.contains_key(&instrument_id) {
835                tracing::debug!(
836                    "Option greeks subscription already active for {}",
837                    instrument_id
838                );
839                return Ok(());
840            }
841
842            let client = self
843                .ib_client
844                .as_ref()
845                .context("IB client not connected. Call connect() first")?;
846
847            let instrument = self
848                .instrument_provider
849                .find(&instrument_id)
850                .context(format!(
851                    "Instrument {} not found in provider",
852                    instrument_id
853                ))?;
854
855            if !matches!(
856                instrument,
857                InstrumentAny::OptionContract(_)
858                    | InstrumentAny::FuturesContract(_)
859                    | InstrumentAny::CryptoOption(_)
860            ) && !matches!(
861                self.instrument_provider
862                    .resolve_contract_for_instrument(instrument_id)?
863                    .security_type,
864                SecurityType::Option | SecurityType::FuturesOption
865            ) {
866                tracing::warn!(
867                    "Option greeks subscription is only supported for option instruments: {}",
868                    instrument_id
869                );
870                return Ok(());
871            }
872
873            let contract = self
874                .instrument_provider
875                .resolve_contract_for_instrument(instrument_id)
876                .context("Failed to convert instrument_id to IB contract")?;
877
878            let data_sender = self.data_sender.clone();
879            let option_greeks_cache = Arc::clone(&self.option_greeks_cache);
880            let clock = self.clock;
881            let subscription_token = self.cancellation_token.child_token();
882            let subscription_token_clone = subscription_token.clone();
883            let client_clone = client.as_arc().clone();
884            let data_farm_state = Arc::clone(&self.data_farm_state);
885            let stream_config = StreamConfig::new(self.client_id, &self.config);
886
887            let task = async move {
888                if let Err(e) = handle_option_greeks_subscription(
889                    client_clone,
890                    contract,
891                    instrument_id,
892                    data_sender,
893                    option_greeks_cache,
894                    clock,
895                    subscription_token_clone,
896                    data_farm_state,
897                    stream_config,
898                )
899                .await
900                {
901                    tracing::error!(
902                        "Option greeks subscription error for {}: {:?}",
903                        instrument_id,
904                        e
905                    );
906                }
907            };
908
909            self.session_tasks
910                .spawn(task)
911                .context("failed to register IB data subscription task")?;
912
913            let mut subscriptions = lock(&self.option_greeks_subscriptions)?;
914            subscriptions.insert(instrument_id, subscription_token);
915
916            tracing::debug!("Option greeks subscription started for {}", instrument_id);
917            Ok(())
918        })
919    }
920
921    fn unsubscribe_quotes(&mut self, cmd: &UnsubscribeQuotes) -> anyhow::Result<()> {
922        execute_instrument_subscriptions(&cmd.instrument_ids, |instrument_id| {
923            tracing::debug!("Unsubscribing from quotes for {instrument_id}");
924
925            let mut subscriptions = lock(&self.subscriptions)?;
926            if let Some(sub_info) = subscriptions.remove(&(instrument_id, SubscriptionType::Quotes))
927            {
928                sub_info.cancellation_token.cancel();
929                tracing::debug!("Unsubscribed from quotes for {instrument_id}");
930            } else {
931                tracing::warn!("No active quote subscription found for {instrument_id}");
932            }
933
934            // Clear quote cache for this instrument
935            {
936                // Quote cache doesn't have per-instrument clear, but we can clear all
937                // In practice, the cache will naturally expire as new quotes arrive
938            }
939
940            Ok(())
941        })
942    }
943
944    fn unsubscribe_index_prices(&mut self, cmd: &UnsubscribeIndexPrices) -> anyhow::Result<()> {
945        execute_instrument_subscriptions(&cmd.instrument_ids, |instrument_id| {
946            tracing::debug!("Unsubscribing from index prices for {}", instrument_id);
947
948            let mut subscriptions = lock(&self.subscriptions)?;
949
950            if let Some(sub_info) =
951                subscriptions.remove(&(instrument_id, SubscriptionType::IndexPrices))
952            {
953                sub_info.cancellation_token.cancel();
954                tracing::debug!("Unsubscribed from index prices for {}", instrument_id);
955            } else {
956                tracing::warn!(
957                    "No active index price subscription found for {}",
958                    instrument_id
959                );
960            }
961
962            Ok(())
963        })
964    }
965
966    fn unsubscribe_option_greeks(&mut self, cmd: &UnsubscribeOptionGreeks) -> anyhow::Result<()> {
967        execute_instrument_subscriptions(&cmd.instrument_ids, |instrument_id| {
968            tracing::debug!("Unsubscribing from option greeks for {}", instrument_id);
969
970            let mut subscriptions = lock(&self.option_greeks_subscriptions)?;
971            if let Some(subscription_token) = subscriptions.remove(&instrument_id) {
972                subscription_token.cancel();
973                tracing::debug!("Unsubscribed from option greeks for {}", instrument_id);
974            } else {
975                tracing::warn!(
976                    "No active option greeks subscription found for {}",
977                    instrument_id
978                );
979            }
980
981            Ok(())
982        })
983    }
984
985    fn subscribe_trades(&mut self, cmd: SubscribeTrades) -> anyhow::Result<()> {
986        execute_instrument_subscriptions(&cmd.instrument_ids, |instrument_id| {
987            tracing::debug!("Subscribing to trades for {}", instrument_id);
988
989            if self.has_subscription(instrument_id, SubscriptionType::Trades)? {
990                tracing::debug!("Trade subscription already active for {}", instrument_id);
991                return Ok(());
992            }
993
994            let client = self
995                .ib_client
996                .as_ref()
997                .context("IB client not connected. Call connect() first")?;
998
999            // Get instrument from provider
1000            let instrument = self
1001                .instrument_provider
1002                .find(&instrument_id)
1003                .context(format!(
1004                    "Instrument {} not found in provider",
1005                    instrument_id
1006                ))?;
1007
1008            // Check if instrument is a CurrencyPair (IB doesn't support trades for CurrencyPair)
1009            if matches!(instrument, InstrumentAny::CurrencyPair(_)) {
1010                tracing::error!(
1011                    "Interactive Brokers does not support trades for CurrencyPair instruments: {}",
1012                    instrument_id
1013                );
1014                return Ok(());
1015            }
1016
1017            let prices = IbPriceConverter::for_instrument(
1018                &instrument,
1019                self.instrument_provider.get_price_magnifier(&instrument_id),
1020            );
1021            let size_precision = instrument.size_precision();
1022
1023            // Convert instrument_id to IB contract
1024            let contract = self
1025                .instrument_provider
1026                .resolve_contract_for_instrument(instrument_id)
1027                .context("Failed to convert instrument_id to IB contract")?;
1028
1029            let data_sender = self.data_sender.clone();
1030            let clock = self.clock;
1031
1032            // Create subscription-specific cancellation token
1033            let subscription_token = self.cancellation_token.child_token();
1034
1035            let subscription_id = next_subscription_id();
1036            lock(&self.subscriptions)?.insert(
1037                (instrument_id, SubscriptionType::Trades),
1038                SubscriptionInfo {
1039                    cancellation_token: subscription_token.clone(),
1040                    id: subscription_id,
1041                },
1042            );
1043
1044            let guard = SubscriptionGuard {
1045                subscriptions: Arc::clone(&self.subscriptions),
1046                key: (instrument_id, SubscriptionType::Trades),
1047                id: subscription_id,
1048            };
1049
1050            // Spawn subscription task
1051            let client_clone = client.as_arc().clone();
1052            let data_farm_state = Arc::clone(&self.data_farm_state);
1053            let stream_config = StreamConfig::new(self.client_id, &self.config);
1054
1055            let task = async move {
1056                if let Err(e) = handle_trade_subscription(
1057                    client_clone,
1058                    contract,
1059                    instrument_id,
1060                    prices,
1061                    size_precision,
1062                    data_sender,
1063                    clock,
1064                    subscription_token,
1065                    data_farm_state,
1066                    stream_config,
1067                )
1068                .await
1069                {
1070                    tracing::error!("Trade subscription error for {}: {:?}", instrument_id, e);
1071                }
1072
1073                guard.release();
1074            };
1075
1076            self.session_tasks
1077                .spawn(task)
1078                .context("failed to register IB data subscription task")?;
1079
1080            tracing::debug!("Trade subscription started for {}", instrument_id);
1081            Ok(())
1082        })
1083    }
1084
1085    fn unsubscribe_trades(&mut self, cmd: &UnsubscribeTrades) -> anyhow::Result<()> {
1086        execute_instrument_subscriptions(&cmd.instrument_ids, |instrument_id| {
1087            tracing::debug!("Unsubscribing from trades for {}", instrument_id);
1088
1089            let mut subscriptions = lock(&self.subscriptions)?;
1090            if let Some(sub_info) = subscriptions.remove(&(instrument_id, SubscriptionType::Trades))
1091            {
1092                sub_info.cancellation_token.cancel();
1093                tracing::debug!("Unsubscribed from trades for {}", instrument_id);
1094            } else {
1095                tracing::warn!("No active trade subscription found for {}", instrument_id);
1096            }
1097
1098            Ok(())
1099        })
1100    }
1101
1102    fn subscribe_bars(&mut self, cmd: SubscribeBars) -> anyhow::Result<()> {
1103        execute_bar_subscriptions(&cmd.bar_types, |bar_type| {
1104            let cmd = cmd.with_bar_types(bar_type);
1105            tracing::debug!("Subscribing to bars for {}", bar_type);
1106
1107            let client = self
1108                .ib_client
1109                .as_ref()
1110                .context("IB client not connected. Call connect() first")?;
1111
1112            // Get instrument from provider
1113            let instrument_id = bar_type.instrument_id();
1114            if self.has_subscription(instrument_id, SubscriptionType::Bars(bar_type))? {
1115                tracing::debug!("Bar subscription already active for {}", bar_type);
1116                return Ok(());
1117            }
1118
1119            let instrument = self
1120                .instrument_provider
1121                .find(&instrument_id)
1122                .context(format!("Instrument {instrument_id} not found in provider"))?;
1123
1124            let prices = IbPriceConverter::for_instrument(
1125                &instrument,
1126                self.instrument_provider.get_price_magnifier(&instrument_id),
1127            );
1128            let size_precision = instrument.size_precision();
1129
1130            // Convert instrument_id to IB contract
1131            let contract = self
1132                .instrument_provider
1133                .resolve_contract_for_instrument(instrument_id)
1134                .context("Failed to convert instrument_id to IB contract")?;
1135
1136            let bar_type_str = bar_type.to_string();
1137            let data_sender = self.data_sender.clone();
1138            let clock = self.clock;
1139            let last_bars = Arc::clone(&self.last_bars);
1140            let bar_timeout_tasks = Arc::clone(&self.bar_timeout_tasks);
1141            let handle_revised_bars = self.config.handle_revised_bars;
1142            let use_rth = self.config.use_regular_trading_hours;
1143            let start_ns = parse_start_ns(cmd.params.as_ref());
1144            // Crypto (ZEROHASH/PAXOS) trade-price bars must request AGGTRADES, not
1145            // TRADES (TWS rejects TRADES for crypto, error 10299) - on BOTH the
1146            // realtime (reqRealTimeBars) and historical (reqHistoricalData) paths, per
1147            // the Java engine's whatToShowFor rule. Capture the flag before `contract`
1148            // is moved into the subscription task below.
1149            let is_crypto = is_crypto_contract(&contract);
1150
1151            // Create subscription-specific cancellation token
1152            let subscription_token = self.cancellation_token.child_token();
1153
1154            let subscription_id = next_subscription_id();
1155            lock(&self.subscriptions)?.insert(
1156                (instrument_id, SubscriptionType::Bars(bar_type)),
1157                SubscriptionInfo {
1158                    cancellation_token: subscription_token.clone(),
1159                    id: subscription_id,
1160                },
1161            );
1162
1163            let guard = SubscriptionGuard {
1164                subscriptions: Arc::clone(&self.subscriptions),
1165                key: (instrument_id, SubscriptionType::Bars(bar_type)),
1166                id: subscription_id,
1167            };
1168
1169            // Spawn subscription task
1170            let client_clone = client.as_arc().clone();
1171            let data_farm_state = Arc::clone(&self.data_farm_state);
1172            let stream_config = StreamConfig::new(self.client_id, &self.config);
1173
1174            let task = async move {
1175                let result = if bar_type.spec().timedelta().as_secs() == 5 {
1176                    handle_realtime_bars_subscription(
1177                        client_clone,
1178                        contract,
1179                        bar_type,
1180                        bar_type_str,
1181                        instrument_id,
1182                        price_type_to_ib_realtime_what_to_show_for_security(
1183                            bar_type.spec().price_type,
1184                            is_crypto,
1185                        ),
1186                        prices,
1187                        size_precision,
1188                        data_sender,
1189                        clock,
1190                        last_bars,
1191                        bar_timeout_tasks,
1192                        handle_revised_bars,
1193                        use_rth,
1194                        subscription_token,
1195                        Arc::clone(&data_farm_state),
1196                        stream_config,
1197                    )
1198                    .await
1199                } else {
1200                    handle_historical_bars_subscription(
1201                        client_clone,
1202                        contract,
1203                        bar_type,
1204                        price_type_to_ib_what_to_show_for_security(
1205                            bar_type.spec().price_type,
1206                            is_crypto,
1207                        ),
1208                        prices,
1209                        size_precision,
1210                        use_rth,
1211                        start_ns,
1212                        data_sender,
1213                        handle_revised_bars,
1214                        clock,
1215                        subscription_token,
1216                        Arc::clone(&data_farm_state),
1217                        stream_config,
1218                    )
1219                    .await
1220                };
1221
1222                if let Err(e) = result {
1223                    tracing::error!("Bars subscription error for {}: {:?}", bar_type, e);
1224                }
1225
1226                guard.release();
1227            };
1228
1229            self.session_tasks
1230                .spawn(task)
1231                .context("failed to register IB data subscription task")?;
1232
1233            tracing::debug!("Real-time bars subscription started for {}", bar_type);
1234            Ok(())
1235        })
1236    }
1237
1238    fn unsubscribe_bars(&mut self, cmd: &UnsubscribeBars) -> anyhow::Result<()> {
1239        execute_bar_subscriptions(&cmd.bar_types, |bar_type| {
1240            tracing::debug!("Unsubscribing from bars for {}", bar_type);
1241
1242            let instrument_id = bar_type.instrument_id();
1243            let mut subscriptions = lock(&self.subscriptions)?;
1244
1245            if let Some(sub_info) =
1246                subscriptions.remove(&(instrument_id, SubscriptionType::Bars(bar_type)))
1247            {
1248                sub_info.cancellation_token.cancel();
1249                tracing::debug!("Unsubscribed from bars for {}", bar_type);
1250            } else {
1251                tracing::warn!("No active bar subscription found for {}", bar_type);
1252            }
1253
1254            Ok(())
1255        })
1256    }
1257
1258    fn subscribe_book_deltas(&mut self, cmd: SubscribeBookDeltas) -> anyhow::Result<()> {
1259        execute_instrument_subscriptions(&cmd.instrument_ids, |instrument_id| {
1260            let cmd = cmd.with_instrument_ids(instrument_id);
1261            tracing::debug!("Subscribing to book deltas for {}", instrument_id);
1262
1263            if self.has_subscription(instrument_id, SubscriptionType::BookDeltas)? {
1264                tracing::debug!(
1265                    "Book delta subscription already active for {}",
1266                    instrument_id
1267                );
1268                return Ok(());
1269            }
1270
1271            // Validate book type (IB doesn't support L3_MBO)
1272            if cmd.book_type == BookType::L3_MBO {
1273                tracing::error!(
1274                    "Cannot subscribe to order book deltas: L3_MBO data is not published by Interactive Brokers. Valid book types are L1_MBP, L2_MBP"
1275                );
1276                return Ok(());
1277            }
1278
1279            let client = self
1280                .ib_client
1281                .as_ref()
1282                .context("IB client not connected. Call connect() first")?;
1283
1284            // Get instrument from provider
1285            let instrument = self
1286                .instrument_provider
1287                .find(&instrument_id)
1288                .context(format!(
1289                    "Instrument {} not found in provider",
1290                    instrument_id
1291                ))?;
1292
1293            // Book levels are not rounded to the increment: two IB levels could round to one price
1294            let prices = IbPriceConverter::new(instrument.price_precision())
1295                .with_price_magnifier(self.instrument_provider.get_price_magnifier(&instrument_id));
1296            let size_precision = instrument.size_precision();
1297
1298            // Convert instrument_id to IB contract
1299            let contract = self
1300                .instrument_provider
1301                .resolve_contract_for_instrument(instrument_id)
1302                .context("Failed to convert instrument_id to IB contract")?;
1303
1304            let data_sender = self.data_sender.clone();
1305            let clock = self.clock;
1306
1307            // Create subscription-specific cancellation token
1308            let subscription_token = self.cancellation_token.child_token();
1309
1310            // Get depth from command or default to 20 (Python default)
1311            let depth_rows = cmd.depth.map_or(20, |d| d.get() as i32);
1312
1313            // Get is_smart_depth from params or default to true
1314            let is_smart_depth = cmd
1315                .params
1316                .as_ref()
1317                .and_then(|params| params.get_str("is_smart_depth"))
1318                .is_none_or(parse_bool_param_value);
1319
1320            let subscription_id = next_subscription_id();
1321            lock(&self.subscriptions)?.insert(
1322                (instrument_id, SubscriptionType::BookDeltas),
1323                SubscriptionInfo {
1324                    cancellation_token: subscription_token.clone(),
1325                    id: subscription_id,
1326                },
1327            );
1328
1329            let guard = SubscriptionGuard {
1330                subscriptions: Arc::clone(&self.subscriptions),
1331                key: (instrument_id, SubscriptionType::BookDeltas),
1332                id: subscription_id,
1333            };
1334
1335            // Spawn subscription task
1336            let client_clone = client.as_arc().clone();
1337            let data_farm_state = Arc::clone(&self.data_farm_state);
1338            let stream_config = StreamConfig::new(self.client_id, &self.config);
1339
1340            let task = async move {
1341                if let Err(e) = handle_market_depth_subscription(
1342                    client_clone,
1343                    contract,
1344                    instrument_id,
1345                    prices,
1346                    size_precision,
1347                    depth_rows,
1348                    is_smart_depth,
1349                    data_sender,
1350                    clock,
1351                    subscription_token,
1352                    data_farm_state,
1353                    stream_config,
1354                )
1355                .await
1356                {
1357                    tracing::error!(
1358                        "Market depth subscription error for {}: {:?}",
1359                        instrument_id,
1360                        e
1361                    );
1362                }
1363
1364                guard.release();
1365            };
1366
1367            self.session_tasks
1368                .spawn(task)
1369                .context("failed to register IB data subscription task")?;
1370
1371            tracing::debug!("Market depth subscription started for {}", instrument_id);
1372            Ok(())
1373        })
1374    }
1375
1376    fn unsubscribe_book_deltas(&mut self, cmd: &UnsubscribeBookDeltas) -> anyhow::Result<()> {
1377        execute_instrument_subscriptions(&cmd.instrument_ids, |instrument_id| {
1378            tracing::debug!("Unsubscribing from book deltas for {}", instrument_id);
1379
1380            let mut subscriptions = lock(&self.subscriptions)?;
1381
1382            if let Some(sub_info) =
1383                subscriptions.remove(&(instrument_id, SubscriptionType::BookDeltas))
1384            {
1385                sub_info.cancellation_token.cancel();
1386                tracing::debug!("Unsubscribed from book deltas for {}", instrument_id);
1387            } else {
1388                tracing::warn!(
1389                    "No active book delta subscription found for {}",
1390                    instrument_id
1391                );
1392            }
1393
1394            Ok(())
1395        })
1396    }
1397
1398    // Request handlers
1399    fn request_instrument(&self, cmd: RequestInstrument) -> anyhow::Result<()> {
1400        let instrument_id = cmd.single_instrument_id()?;
1401
1402        tracing::debug!("Requesting instrument: {instrument_id}");
1403
1404        if cmd.start.is_some() {
1405            tracing::warn!(
1406                "Requesting instrument {instrument_id} with specified `start` which has no effect"
1407            );
1408        }
1409
1410        if cmd.end.is_some() {
1411            tracing::warn!(
1412                "Requesting instrument {instrument_id} with specified `end` which has no effect"
1413            );
1414        }
1415
1416        // Check if force_instrument_update is requested
1417        let force_update = cmd
1418            .params
1419            .as_ref()
1420            .and_then(|params| params.get_str("force_instrument_update"))
1421            .is_some_and(parse_bool_param_value);
1422
1423        // Get instrument from provider (or load if not found or force_update)
1424        let instrument = if force_update || self.instrument_provider.find(&instrument_id).is_none()
1425        {
1426            // Need to load instrument - spawn async task
1427            let client = self
1428                .ib_client
1429                .as_ref()
1430                .context("IB client not connected. Call connect() first")?;
1431            let instrument_provider = Arc::clone(&self.instrument_provider);
1432            let data_sender = self.data_sender.clone();
1433            let clock = self.clock;
1434            let request_id = cmd.request_id;
1435            let client_id = cmd.client_id.unwrap_or(self.client_id);
1436            let params = cmd.params.clone();
1437            let start_nanos = cmd.start.map(datetime_to_unix_nanos);
1438            let end_nanos = cmd.end.map(datetime_to_unix_nanos);
1439
1440            let client_clone = client.as_arc().clone();
1441
1442            self.spawn_command(async move {
1443                let filters = params_to_string_filters(params.as_ref());
1444
1445                if let Err(e) = load_requested_instrument(
1446                    &instrument_provider,
1447                    &client_clone,
1448                    instrument_id,
1449                    force_update,
1450                    filters,
1451                    &data_sender,
1452                )
1453                .await
1454                {
1455                    tracing::error!(
1456                        "Failed to fetch contract details for {}: {:?}",
1457                        instrument_id,
1458                        e
1459                    );
1460                    return;
1461                }
1462
1463                if let Some(instrument) = instrument_provider.find(&instrument_id) {
1464                    let response = DataResponse::Instrument(Box::new(InstrumentResponse::new(
1465                        request_id,
1466                        client_id,
1467                        instrument_id,
1468                        instrument,
1469                        start_nanos,
1470                        end_nanos,
1471                        clock.get_time_ns(),
1472                        params,
1473                    )));
1474
1475                    if let Err(e) = data_sender.send(DataEvent::Response(response)) {
1476                        tracing::error!("Failed to send instrument response: {e}");
1477                    }
1478                }
1479            });
1480
1481            // Return early, response will be sent async
1482            return Ok(());
1483        } else {
1484            // Instrument already in provider
1485            self.instrument_provider
1486                .find(&instrument_id)
1487                .context(format!("Instrument {instrument_id} not found in provider"))?
1488        };
1489
1490        let start_nanos = cmd.start.map(datetime_to_unix_nanos);
1491        let end_nanos = cmd.end.map(datetime_to_unix_nanos);
1492
1493        let response = DataResponse::Instrument(Box::new(InstrumentResponse::new(
1494            cmd.request_id,
1495            cmd.client_id.unwrap_or(self.client_id),
1496            instrument_id,
1497            instrument,
1498            start_nanos,
1499            end_nanos,
1500            self.clock.get_time_ns(),
1501            cmd.params,
1502        )));
1503
1504        if let Err(e) = self.data_sender.send(DataEvent::Response(response)) {
1505            tracing::error!("Failed to send instrument response: {e}");
1506        }
1507
1508        Ok(())
1509    }
1510
1511    fn request_instruments(&self, cmd: RequestInstruments) -> anyhow::Result<()> {
1512        tracing::debug!("Requesting all instruments for venue: {:?}", cmd.venue);
1513
1514        let client = self
1515            .ib_client
1516            .as_ref()
1517            .context("IB client not connected. Call connect() first")?;
1518
1519        // Check for force_instrument_update
1520        let force_update = cmd
1521            .params
1522            .as_ref()
1523            .and_then(|params| params.get_str("force_instrument_update"))
1524            .is_some_and(parse_bool_param_value);
1525
1526        // Check if ib_contracts parameter is provided for batch loading
1527        let mut contract_specs_to_load: Vec<serde_json::Value> = Vec::new();
1528
1529        if let Some(params) = &cmd.params
1530            && let Some(ib_contracts_value) = params.get("ib_contracts")
1531        {
1532            match ib_contracts_value {
1533                serde_json::Value::Array(contract_specs) => {
1534                    tracing::debug!(
1535                        "Parsed {} structured contract specs from ib_contracts",
1536                        contract_specs.len()
1537                    );
1538                    contract_specs_to_load = contract_specs.clone();
1539                }
1540                serde_json::Value::String(ib_contracts_json_str) => {
1541                    match serde_json::from_str::<serde_json::Value>(ib_contracts_json_str) {
1542                        Ok(serde_json::Value::Array(contract_specs)) => {
1543                            tracing::debug!(
1544                                "Parsed {} contract specs from ib_contracts JSON",
1545                                contract_specs.len()
1546                            );
1547                            log::debug!("Parsed ib_contracts payload: {ib_contracts_json_str}");
1548                            contract_specs_to_load = contract_specs;
1549                        }
1550                        Ok(value) => {
1551                            tracing::warn!(
1552                                "Expected ib_contracts JSON array, received {}. Continuing without contracts",
1553                                value
1554                            );
1555                        }
1556                        Err(e) => {
1557                            tracing::warn!(
1558                                "Failed to parse ib_contracts JSON: {}. Continuing without contracts",
1559                                e
1560                            );
1561                        }
1562                    }
1563                }
1564                value => {
1565                    tracing::warn!(
1566                        "Expected ib_contracts array or JSON string, received {}. Continuing without contracts",
1567                        value
1568                    );
1569                }
1570            }
1571        }
1572
1573        // If force_update is requested or we need to batch load, spawn async task
1574        let instrument_provider = Arc::clone(&self.instrument_provider);
1575        let client_clone = client.as_arc().clone();
1576        let data_sender = self.data_sender.clone();
1577        let clock = self.clock;
1578        let request_id = cmd.request_id;
1579        let client_id = cmd.client_id.unwrap_or(self.client_id);
1580        let venue = cmd.venue.unwrap_or(*IB_VENUE);
1581        let params = cmd.params.clone();
1582        let start_nanos = cmd.start.map(datetime_to_unix_nanos);
1583        let end_nanos = cmd.end.map(datetime_to_unix_nanos);
1584
1585        // Handle batch loading if contracts are provided or force_update is requested
1586        if !contract_specs_to_load.is_empty() || force_update {
1587            let contract_specs_to_load_clone = contract_specs_to_load;
1588            let return_loaded_only = !contract_specs_to_load_clone.is_empty();
1589
1590            self.spawn_command(async move {
1591                let mut loaded_instrument_ids = Vec::new();
1592
1593                // Load instruments from contracts if provided
1594                if !contract_specs_to_load_clone.is_empty() {
1595                    for contract_spec in contract_specs_to_load_clone {
1596                        let contract =
1597                            match parse_contract_from_json(&contract_spec)
1598                            {
1599                                Ok(contract) => contract,
1600                                Err(e) => {
1601                                    tracing::warn!(
1602                                        "Failed to parse IB contract spec {:?}: {}",
1603                                        contract_spec,
1604                                        e
1605                                    );
1606                                    continue;
1607                                }
1608                            };
1609
1610                        log::debug!(
1611                            "Loading instrument from IB contract spec (sec_type={:?}, symbol={}, local_symbol={}, exchange={}, expiry={})",
1612                            contract.security_type,
1613                            contract.symbol.as_str(),
1614                            contract.local_symbol.as_str(),
1615                            contract.exchange.as_str(),
1616                            contract.last_trade_date_or_contract_month.as_str()
1617                        );
1618
1619                        match instrument_provider
1620                            .load_contract_spec(&client_clone, &contract, Some(&contract_spec))
1621                            .await
1622                        {
1623                            Ok(mut instrument_ids) => {
1624                                loaded_instrument_ids.append(&mut instrument_ids);
1625                            }
1626                            Err(e) => {
1627                                tracing::warn!(
1628                                    "Failed to load IB contract spec {:?}: {}",
1629                                    contract_spec,
1630                                    e
1631                                );
1632                            }
1633                        }
1634                    }
1635                }
1636
1637                // If force_update, also reload all existing instruments
1638                if force_update && !return_loaded_only {
1639                    let all_instrument_ids: Vec<InstrumentId> = instrument_provider
1640                        .get_all()
1641                        .into_iter()
1642                        .map(|inst| inst.id())
1643                        .collect();
1644
1645                    if !all_instrument_ids.is_empty()
1646                        && let Ok(mut reloaded_ids) = instrument_provider
1647                            .batch_load(&client_clone, all_instrument_ids, None)
1648                            .await
1649                    {
1650                        loaded_instrument_ids.append(&mut reloaded_ids);
1651                    }
1652                }
1653
1654                let instruments = if return_loaded_only {
1655                    instrument_provider.find_all(&loaded_instrument_ids)
1656                } else {
1657                    Vec::new()
1658                };
1659
1660                let instruments_count = instruments.len();
1661
1662                let response = DataResponse::Instruments(InstrumentsResponse::new(
1663                    request_id,
1664                    client_id,
1665                    venue,
1666                    instruments,
1667                    start_nanos,
1668                    end_nanos,
1669                    clock.get_time_ns(),
1670                    params,
1671                ));
1672
1673                if let Err(e) = data_sender.send(DataEvent::Response(response)) {
1674                    tracing::error!("Failed to send instruments response: {e}");
1675                } else {
1676                    tracing::debug!(
1677                        "Successfully sent {} instruments response (loaded {} new instruments)",
1678                        instruments_count,
1679                        loaded_instrument_ids.len()
1680                    );
1681                }
1682            });
1683        } else {
1684            let response = DataResponse::Instruments(InstrumentsResponse::new(
1685                cmd.request_id,
1686                cmd.client_id.unwrap_or(self.client_id),
1687                venue,
1688                Vec::new(),
1689                start_nanos,
1690                end_nanos,
1691                self.clock.get_time_ns(),
1692                cmd.params,
1693            ));
1694
1695            if let Err(e) = self.data_sender.send(DataEvent::Response(response)) {
1696                tracing::error!("Failed to send instruments response: {e}");
1697            } else {
1698                tracing::debug!("Successfully sent empty instruments response");
1699            }
1700        }
1701
1702        Ok(())
1703    }
1704
1705    fn request_quotes(&self, cmd: RequestQuotes) -> anyhow::Result<()> {
1706        let instrument_id = cmd.single_instrument_id()?;
1707
1708        tracing::debug!("Requesting quotes for {instrument_id}");
1709
1710        let client = self
1711            .ib_client
1712            .as_ref()
1713            .context("IB client not connected. Call connect() first")?;
1714
1715        // Get instrument from provider
1716        let instrument = self
1717            .instrument_provider
1718            .find(&instrument_id)
1719            .context(format!("Instrument {instrument_id} not found in provider"))?;
1720
1721        let prices = IbPriceConverter::for_instrument(
1722            &instrument,
1723            self.instrument_provider.get_price_magnifier(&instrument_id),
1724        );
1725        let size_precision = instrument.size_precision();
1726
1727        // Convert instrument_id to IB contract
1728        let contract = self
1729            .instrument_provider
1730            .resolve_contract_for_instrument(instrument_id)
1731            .context("Failed to convert instrument_id to IB contract")?;
1732
1733        let limit = cmd
1734            .limit
1735            .map(|limit| limit.get())
1736            .or_else(|| cmd.start.is_none().then_some(HISTORICAL_TICK_DEFAULT_LIMIT));
1737        let number_of_ticks = limit.unwrap_or(1000).min(1000) as i32;
1738
1739        let data_sender = self.data_sender.clone();
1740        let clock = self.clock;
1741        let request_id = cmd.request_id;
1742        let client_id = cmd.client_id.unwrap_or(self.client_id);
1743        let params = cmd.params.clone();
1744        let start_nanos = cmd.start.map(datetime_to_unix_nanos);
1745        let end_nanos = cmd.end.map(datetime_to_unix_nanos);
1746
1747        // Spawn async task to handle the request with pagination
1748        let client_clone = client.as_arc().clone();
1749        let start_nanos_clone = start_nanos;
1750        let end_nanos_clone = end_nanos;
1751        let cmd_start = cmd.start;
1752        let cmd_end = cmd.end;
1753        let trading_hours = request_trading_hours(self.config.use_regular_trading_hours);
1754
1755        self.spawn_command(async move {
1756            let mut all_quotes = Vec::new();
1757            // Work backwards from end_date, updating end to the earliest tick received
1758            let mut current_end_date = cmd_end;
1759            if current_end_date.is_none() {
1760                current_end_date = Some(jiff::Timestamp::now());
1761            }
1762
1763            let current_start_date = cmd_start;
1764
1765            loop {
1766                if !should_continue_historical_tick_pagination(
1767                    current_start_date,
1768                    current_end_date,
1769                    all_quotes.len(),
1770                    limit,
1771                ) {
1772                    break;
1773                }
1774
1775                let current_end_ib = current_end_date.as_ref().map(jiff_to_ib_datetime);
1776
1777                // Make request for this batch
1778                let mut builder = client_clone
1779                    .historical_ticks(&contract, number_of_ticks)
1780                    .trading_hours(trading_hours);
1781
1782                // IB accepts start or end on reqHistoricalTicks, not both; backward
1783                // pagination anchors on the end and filters the start client-side.
1784                if let Some(end) = current_end_ib {
1785                    builder = builder.ending(end);
1786                }
1787
1788                match builder.bid_ask(IgnoreSize::No).await {
1789                    Ok(subscription) => {
1790                        let mut subscription = subscription.filter_data();
1791                        let mut batch_quotes = Vec::new();
1792
1793                        while let Some(tick_result) = subscription.next().await {
1794                            let tick = match tick_result {
1795                                Ok(tick) => tick,
1796                                Err(e) => {
1797                                    tracing::warn!("Historical quote ticks stream error: {e:?}");
1798                                    continue;
1799                                }
1800                            };
1801
1802                            let ts_event =
1803                                super::convert::ib_timestamp_to_unix_nanos(&tick.timestamp);
1804                            let ts_init = clock.get_time_ns();
1805
1806                            match super::parse::parse_quote_tick(
1807                                instrument_id,
1808                                Some(tick.price_bid),
1809                                Some(tick.price_ask),
1810                                tick.size_bid,
1811                                tick.size_ask,
1812                                prices,
1813                                size_precision,
1814                                ts_event,
1815                                ts_init,
1816                            ) {
1817                                Ok(quote_tick) => batch_quotes.push(quote_tick),
1818                                Err(e) => {
1819                                    tracing::warn!("Failed to parse quote tick: {:?}", e);
1820                                }
1821                            }
1822                        }
1823
1824                        if !extend_historical_tick_batch(
1825                            &mut all_quotes,
1826                            batch_quotes,
1827                            current_start_date,
1828                            &mut current_end_date,
1829                            start_nanos_clone,
1830                            end_nanos_clone,
1831                            limit,
1832                            |quote| quote.ts_event,
1833                        ) {
1834                            break;
1835                        }
1836                    }
1837                    Err(e) => {
1838                        tracing::error!(
1839                            "Historical quotes request failed for {}: {:?}",
1840                            instrument_id,
1841                            e
1842                        );
1843                        break;
1844                    }
1845                }
1846            }
1847
1848            retain_historical_ticks_in_range(
1849                &mut all_quotes,
1850                start_nanos_clone,
1851                end_nanos_clone,
1852                |quote| quote.ts_event,
1853            );
1854
1855            all_quotes.sort_by_key(|q| q.ts_event);
1856            if let Some(limit) = limit
1857                && all_quotes.len() > limit
1858            {
1859                all_quotes = all_quotes.split_off(all_quotes.len() - limit);
1860            }
1861
1862            let quotes_count = all_quotes.len();
1863            let response = DataResponse::Quotes(QuotesResponse::new(
1864                request_id,
1865                client_id,
1866                instrument_id,
1867                all_quotes,
1868                start_nanos_clone,
1869                end_nanos_clone,
1870                clock.get_time_ns(),
1871                params,
1872            ));
1873
1874            if let Err(e) = data_sender.send(DataEvent::Response(response)) {
1875                tracing::error!("Failed to send quotes response: {e}");
1876            } else {
1877                tracing::debug!(
1878                    "Successfully sent {} quotes for {}",
1879                    quotes_count,
1880                    instrument_id
1881                );
1882            }
1883        });
1884
1885        Ok(())
1886    }
1887
1888    fn request_trades(&self, cmd: RequestTrades) -> anyhow::Result<()> {
1889        let instrument_id = cmd.single_instrument_id()?;
1890
1891        tracing::debug!("Requesting trades for {instrument_id}");
1892
1893        let client = self
1894            .ib_client
1895            .as_ref()
1896            .context("IB client not connected. Call connect() first")?;
1897
1898        // Get instrument from provider
1899        let instrument = self
1900            .instrument_provider
1901            .find(&instrument_id)
1902            .context(format!("Instrument {instrument_id} not found in provider"))?;
1903
1904        // Check if instrument is a CurrencyPair (IB doesn't support trades for CurrencyPair)
1905        if matches!(instrument, InstrumentAny::CurrencyPair(_)) {
1906            tracing::error!(
1907                "Interactive Brokers does not support trades for CurrencyPair instruments: {instrument_id}"
1908            );
1909            return Ok(());
1910        }
1911
1912        let prices = IbPriceConverter::for_instrument(
1913            &instrument,
1914            self.instrument_provider.get_price_magnifier(&instrument_id),
1915        );
1916        let size_precision = instrument.size_precision();
1917
1918        // Convert instrument_id to IB contract
1919        let contract = self
1920            .instrument_provider
1921            .resolve_contract_for_instrument(instrument_id)
1922            .context("Failed to convert instrument_id to IB contract")?;
1923
1924        let limit = cmd
1925            .limit
1926            .map(|limit| limit.get())
1927            .or_else(|| cmd.start.is_none().then_some(HISTORICAL_TICK_DEFAULT_LIMIT));
1928        let number_of_ticks = limit.unwrap_or(1000).min(1000) as i32;
1929
1930        let data_sender = self.data_sender.clone();
1931        let clock = self.clock;
1932        let request_id = cmd.request_id;
1933        let client_id = cmd.client_id.unwrap_or(self.client_id);
1934        let params = cmd.params.clone();
1935        let start_nanos = cmd.start.map(datetime_to_unix_nanos);
1936        let end_nanos = cmd.end.map(datetime_to_unix_nanos);
1937
1938        // Spawn async task to handle the request with pagination
1939        let client_clone = client.as_arc().clone();
1940        let start_nanos_clone = start_nanos;
1941        let end_nanos_clone = end_nanos;
1942        let cmd_start = cmd.start;
1943        let cmd_end = cmd.end;
1944        let trading_hours = request_trading_hours(self.config.use_regular_trading_hours);
1945
1946        self.spawn_command(async move {
1947            let mut all_trades = Vec::new();
1948            // Work backwards from end_date, updating end to the earliest tick received
1949            let mut current_end_date = cmd_end;
1950            if current_end_date.is_none() {
1951                current_end_date = Some(jiff::Timestamp::now());
1952            }
1953
1954            let current_start_date = cmd_start;
1955
1956            loop {
1957                if !should_continue_historical_tick_pagination(
1958                    current_start_date,
1959                    current_end_date,
1960                    all_trades.len(),
1961                    limit,
1962                ) {
1963                    break;
1964                }
1965
1966                let current_end_ib = current_end_date.as_ref().map(jiff_to_ib_datetime);
1967
1968                // Make request for this batch
1969                let mut builder = client_clone
1970                    .historical_ticks(&contract, number_of_ticks)
1971                    .trading_hours(trading_hours);
1972
1973                // IB accepts start or end on reqHistoricalTicks, not both; backward
1974                // pagination anchors on the end and filters the start client-side.
1975                if let Some(end) = current_end_ib {
1976                    builder = builder.ending(end);
1977                }
1978
1979                match builder.trade().await {
1980                    Ok(subscription) => {
1981                        let mut subscription = subscription.filter_data();
1982                        let mut batch_trades = Vec::new();
1983
1984                        while let Some(tick_result) = subscription.next().await {
1985                            let tick = match tick_result {
1986                                Ok(tick) => tick,
1987                                Err(e) => {
1988                                    tracing::warn!("Historical trade ticks stream error: {e:?}");
1989                                    continue;
1990                                }
1991                            };
1992
1993                            let ts_event =
1994                                super::convert::ib_timestamp_to_unix_nanos(&tick.timestamp);
1995                            let ts_init = clock.get_time_ns();
1996
1997                            // Generate trade ID from exchange and special conditions if available
1998                            let trade_id = None;
1999
2000                            let Some(size) = tick.size else {
2001                                tracing::warn!(
2002                                    "Skipping historical trade tick with no size for {}",
2003                                    instrument_id
2004                                );
2005                                continue;
2006                            };
2007
2008                            match super::parse::parse_trade_tick(
2009                                instrument_id,
2010                                tick.price,
2011                                size,
2012                                prices,
2013                                size_precision,
2014                                ts_event,
2015                                ts_init,
2016                                trade_id,
2017                            ) {
2018                                Ok(trade_tick) => batch_trades.push(trade_tick),
2019                                Err(e) => {
2020                                    log_tick_parse_error("trade", instrument_id, &e);
2021                                }
2022                            }
2023                        }
2024
2025                        if !extend_historical_tick_batch(
2026                            &mut all_trades,
2027                            batch_trades,
2028                            current_start_date,
2029                            &mut current_end_date,
2030                            start_nanos_clone,
2031                            end_nanos_clone,
2032                            limit,
2033                            |trade| trade.ts_event,
2034                        ) {
2035                            break;
2036                        }
2037                    }
2038                    Err(e) => {
2039                        tracing::error!(
2040                            "Historical trades request failed for {}: {:?}",
2041                            instrument_id,
2042                            e
2043                        );
2044                        break;
2045                    }
2046                }
2047            }
2048
2049            retain_historical_ticks_in_range(
2050                &mut all_trades,
2051                start_nanos_clone,
2052                end_nanos_clone,
2053                |trade| trade.ts_event,
2054            );
2055
2056            all_trades.sort_by_key(|t| t.ts_event);
2057            if let Some(limit) = limit
2058                && all_trades.len() > limit
2059            {
2060                all_trades = all_trades.split_off(all_trades.len() - limit);
2061            }
2062
2063            let trades_count = all_trades.len();
2064            let response = DataResponse::Trades(TradesResponse::new(
2065                request_id,
2066                client_id,
2067                instrument_id,
2068                all_trades,
2069                start_nanos_clone,
2070                end_nanos_clone,
2071                clock.get_time_ns(),
2072                params,
2073            ));
2074
2075            if let Err(e) = data_sender.send(DataEvent::Response(response)) {
2076                tracing::error!("Failed to send trades response: {e}");
2077            } else {
2078                tracing::debug!(
2079                    "Successfully sent {} trades for {}",
2080                    trades_count,
2081                    instrument_id
2082                );
2083            }
2084        });
2085
2086        Ok(())
2087    }
2088
2089    fn request_bars(&self, cmd: RequestBars) -> anyhow::Result<()> {
2090        let bar_type = cmd.single_bar_type()?;
2091
2092        tracing::debug!("Requesting bars for {bar_type}");
2093
2094        // Validate bar spec (only time-aggregated bars are supported)
2095        if !bar_type.spec().is_time_aggregated() {
2096            tracing::error!(
2097                "Cannot request {bar_type} bars: only time bars are aggregated by Interactive Brokers"
2098            );
2099            return Ok(());
2100        }
2101
2102        let client = self
2103            .ib_client
2104            .as_ref()
2105            .context("IB client not connected. Call connect() first")?;
2106
2107        // Get instrument from provider
2108        let instrument_id = bar_type.instrument_id();
2109        let instrument = self
2110            .instrument_provider
2111            .find(&instrument_id)
2112            .context(format!("Instrument {instrument_id} not found in provider"))?;
2113
2114        let prices = IbPriceConverter::for_instrument(
2115            &instrument,
2116            self.instrument_provider.get_price_magnifier(&instrument_id),
2117        );
2118        let size_precision = instrument.size_precision();
2119
2120        // Convert instrument_id to IB contract
2121        let contract = self
2122            .instrument_provider
2123            .resolve_contract_for_instrument(instrument_id)
2124            .context("Failed to convert instrument_id to IB contract")?;
2125
2126        // Convert bar type to IB formats
2127        let ib_bar_size = bar_type_to_ib_bar_size(&bar_type)
2128            .context("Failed to convert bar type to IB bar size")?;
2129        // Crypto trade-price bars require AGGTRADES (TWS rejects TRADES for crypto,
2130        // error 10299); mirror the Java engine's whatToShowFor rule.
2131        let is_crypto = is_crypto_contract(&contract);
2132        let ib_what_to_show =
2133            price_type_to_ib_what_to_show_for_security(bar_type.spec().price_type, is_crypto);
2134
2135        // Calculate segments to break down the request if needed.
2136        // Omit the end date for continuous futures (IB error 10339).
2137        let is_continuous_future = contract.security_type == SecurityType::ContinuousFuture;
2138        let segments = calculate_duration_segments(cmd.start, cmd.end, None);
2139        let segments = bar_request_segments(segments, is_continuous_future);
2140
2141        let data_sender = self.data_sender.clone();
2142        let clock = self.clock;
2143        let request_id = cmd.request_id;
2144        let client_id = cmd.client_id.unwrap_or(self.client_id);
2145        let params = cmd.params.clone();
2146        let start_nanos = cmd.start.map(datetime_to_unix_nanos);
2147        let end_nanos = cmd.end.map(datetime_to_unix_nanos);
2148        let limit = cmd.limit.map(|limit| limit.get());
2149
2150        // Spawn async task to handle the request with segmentation
2151        let client_clone = client.as_arc().clone();
2152        let trading_hours = request_trading_hours(self.config.use_regular_trading_hours);
2153
2154        self.spawn_command(async move {
2155            let mut all_bars = Vec::new();
2156
2157            for (seg_end, seg_duration) in segments {
2158                let mut request = client_clone
2159                    .historical_data(&contract, ib_bar_size)
2160                    .duration(seg_duration)
2161                    .what_to_show(ib_what_to_show)
2162                    .trading_hours(trading_hours);
2163
2164                if let Some(end) = seg_end {
2165                    request = request.ending(jiff_to_ib_datetime(&end));
2166                }
2167
2168                match request.fetch().await {
2169                    Ok(historical_data) => {
2170                        // Convert IB bars to Nautilus bars
2171                        for ib_bar in &historical_data.bars {
2172                            match ib_bar_to_nautilus_bar(
2173                                ib_bar,
2174                                bar_type,
2175                                prices,
2176                                size_precision,
2177                            ) {
2178                                Ok(bar) => all_bars.push(bar),
2179                                Err(e) => {
2180                                    tracing::warn!(
2181                                        "Failed to convert IB bar to Nautilus bar: {:?}",
2182                                        e
2183                                    );
2184                                }
2185                            }
2186                        }
2187                    }
2188                    Err(e) => {
2189                        tracing::warn!(
2190                            "Historical data request failed for {bar_type} segment ending at {seg_end:?}: {e}, emitting the bars already retrieved"
2191                        );
2192                        break;
2193                    }
2194                }
2195            }
2196
2197            // Return aggregated results
2198            if all_bars.is_empty() {
2199                tracing::warn!("No bar data received for {}", bar_type);
2200            }
2201
2202            // Sort and deduplicate bars as segments might overlap or be out of order from IB.
2203            all_bars.sort_by_key(|b| b.ts_event);
2204            all_bars.dedup();
2205            if let Some(limit) = limit
2206                && all_bars.len() > limit
2207            {
2208                all_bars = all_bars.split_off(all_bars.len() - limit);
2209            }
2210
2211            let bars_count = all_bars.len();
2212
2213            let response = DataResponse::Bars(BarsResponse::new(
2214                request_id,
2215                client_id,
2216                bar_type,
2217                all_bars,
2218                start_nanos,
2219                end_nanos,
2220                clock.get_time_ns(),
2221                params,
2222            ));
2223
2224            if let Err(e) = data_sender.send(DataEvent::Response(response)) {
2225                tracing::error!("Failed to send bars response: {e}");
2226            } else {
2227                tracing::debug!(
2228                    "Successfully sent {} bars for {} (segmented)",
2229                    bars_count,
2230                    bar_type
2231                );
2232            }
2233        });
2234
2235        Ok(())
2236    }
2237}
2238
2239impl InteractiveBrokersDataClient {
2240    fn has_subscription(
2241        &self,
2242        instrument_id: InstrumentId,
2243        subscription_type: SubscriptionType,
2244    ) -> anyhow::Result<bool> {
2245        let subscriptions = lock(&self.subscriptions)?;
2246        Ok(subscriptions.contains_key(&(instrument_id, subscription_type)))
2247    }
2248
2249    fn clear_bar_tracking_state(&self) {
2250        if let Ok(mut tasks) = self.bar_timeout_tasks.try_lock() {
2251            for task in tasks.values_mut() {
2252                task.abort();
2253            }
2254        } else {
2255            tracing::warn!("Failed to lock IB bar timeout tasks for cleanup");
2256        }
2257
2258        if let Ok(mut last_bars) = self.last_bars.try_lock() {
2259            last_bars.clear();
2260        } else {
2261            tracing::warn!("Failed to lock IB last bars for cleanup");
2262        }
2263    }
2264}
2265
2266impl Drop for InteractiveBrokersDataClient {
2267    fn drop(&mut self) {
2268        let _ = self.stop();
2269    }
2270}
2271
2272impl InteractiveBrokersDataClient {
2273    fn subscribe_quotes_member(
2274        &self,
2275        instrument_id: InstrumentId,
2276        cmd: &SubscribeQuotes,
2277    ) -> anyhow::Result<()> {
2278        tracing::debug!("Subscribing to quotes for {instrument_id}");
2279
2280        if self.has_subscription(instrument_id, SubscriptionType::Quotes)? {
2281            tracing::debug!("Quote subscription already active for {instrument_id}");
2282            return Ok(());
2283        }
2284
2285        let client = self
2286            .ib_client
2287            .as_ref()
2288            .context("IB client not connected. Call connect() first")?;
2289
2290        // Get instrument from provider
2291        let instrument = self
2292            .instrument_provider
2293            .find(&instrument_id)
2294            .context(format!("Instrument {instrument_id} not found in provider"))?;
2295
2296        let prices = IbPriceConverter::for_instrument(
2297            &instrument,
2298            self.instrument_provider.get_price_magnifier(&instrument_id),
2299        );
2300        let size_precision = instrument.size_precision();
2301
2302        // Convert instrument_id to IB contract
2303        let contract = self
2304            .instrument_provider
2305            .resolve_contract_for_instrument(instrument_id)
2306            .context("Failed to convert instrument_id to IB contract")?;
2307
2308        // Check if contract is BAG (spread) or if batch_quotes parameter is set
2309        // BAG contracts have SecurityType::Spread or combo_legs populated
2310        let is_bag = matches!(
2311            contract.security_type,
2312            ibapi::contracts::SecurityType::Spread
2313        ) || !contract.combo_legs.is_empty();
2314        let batch_quotes = cmd
2315            .params
2316            .as_ref()
2317            .and_then(|params| params.get_str("batch_quotes"))
2318            .map_or(self.config.batch_quotes, parse_bool_param_value);
2319
2320        let use_market_data = is_bag || batch_quotes;
2321
2322        let data_sender = self.data_sender.clone();
2323        let quote_cache = Arc::clone(&self.quote_cache);
2324        let clock = self.clock;
2325
2326        let subscription_token = self.cancellation_token.child_token();
2327
2328        // Record the subscription before spawning so a fast-failing task cannot
2329        // finish before its registry entry exists.
2330        let subscription_id = next_subscription_id();
2331        lock(&self.subscriptions)?.insert(
2332            (instrument_id, SubscriptionType::Quotes),
2333            SubscriptionInfo {
2334                cancellation_token: subscription_token.clone(),
2335                id: subscription_id,
2336            },
2337        );
2338
2339        let guard = SubscriptionGuard {
2340            subscriptions: Arc::clone(&self.subscriptions),
2341            key: (instrument_id, SubscriptionType::Quotes),
2342            id: subscription_id,
2343        };
2344
2345        // Spawn subscription task
2346        let client_clone = client.as_arc().clone();
2347        let ignore_size_updates = self.config.ignore_quote_tick_size_updates;
2348        let data_farm_state = Arc::clone(&self.data_farm_state);
2349        let stream_config = StreamConfig::new(self.client_id, &self.config);
2350
2351        let task = async move {
2352            if use_market_data {
2353                // Use market_data (reqMktData) for BAG contracts or when batch_quotes is requested
2354                tracing::debug!(
2355                    "Using market_data subscription for {} (BAG: {}, batch_quotes: {})",
2356                    instrument_id,
2357                    is_bag,
2358                    batch_quotes
2359                );
2360
2361                if let Err(e) = handle_quote_subscription(
2362                    client_clone,
2363                    contract,
2364                    instrument_id,
2365                    prices,
2366                    size_precision,
2367                    data_sender,
2368                    quote_cache,
2369                    clock,
2370                    subscription_token,
2371                    ignore_size_updates,
2372                    Arc::clone(&data_farm_state),
2373                    stream_config,
2374                )
2375                .await
2376                {
2377                    tracing::error!("Quote subscription error for {}: {:?}", instrument_id, e);
2378                }
2379            } else {
2380                // Try tick_by_tick_bid_ask first for regular contracts (better performance)
2381                // Fallback to market_data if it fails (e.g., for BAG contracts not detected upfront)
2382                tracing::debug!(
2383                    "Attempting tick_by_tick_bid_ask subscription for {}",
2384                    instrument_id
2385                );
2386
2387                match handle_tick_by_tick_quote_subscription(
2388                    client_clone.clone(),
2389                    contract.clone(),
2390                    instrument_id,
2391                    prices,
2392                    size_precision,
2393                    data_sender.clone(),
2394                    clock,
2395                    subscription_token.clone(),
2396                    Arc::clone(&data_farm_state),
2397                    stream_config,
2398                )
2399                .await
2400                {
2401                    Ok(()) => {
2402                        // Success - subscription is active
2403                    }
2404                    Err(e) => {
2405                        tracing::warn!(
2406                            "tick_by_tick_bid_ask failed for {} (may be BAG contract), falling back to market_data: {:?}",
2407                            instrument_id,
2408                            e
2409                        );
2410
2411                        // Fallback to market_data (reqMktData) - works for BAG contracts
2412                        if let Err(fallback_err) = handle_quote_subscription(
2413                            client_clone,
2414                            contract,
2415                            instrument_id,
2416                            prices,
2417                            size_precision,
2418                            data_sender,
2419                            quote_cache,
2420                            clock,
2421                            subscription_token,
2422                            ignore_size_updates,
2423                            Arc::clone(&data_farm_state),
2424                            stream_config,
2425                        )
2426                        .await
2427                        {
2428                            tracing::error!(
2429                                "Quote subscription fallback also failed for {}: {:?}",
2430                                instrument_id,
2431                                fallback_err
2432                            );
2433                        } else {
2434                            tracing::debug!(
2435                                "Successfully subscribed to {} using market_data fallback",
2436                                instrument_id
2437                            );
2438                        }
2439                    }
2440                }
2441            }
2442
2443            guard.release();
2444        };
2445
2446        self.session_tasks
2447            .spawn(task)
2448            .context("failed to register IB data subscription task")?;
2449
2450        tracing::debug!(
2451            "Quote subscription started for {instrument_id} (method: {})",
2452            if use_market_data {
2453                "market_data"
2454            } else {
2455                "tick_by_tick_bid_ask"
2456            }
2457        );
2458        Ok(())
2459    }
2460}
2461
2462// Loads a requested instrument into the provider. A generic spread is built as an IB BAG from its
2463// qualified legs, as `load_ids` does, and its legs are published first because spread fills update
2464// the leg positions.
2465async fn load_requested_instrument(
2466    instrument_provider: &InteractiveBrokersInstrumentProvider,
2467    client: &ibapi::Client,
2468    instrument_id: InstrumentId,
2469    force_update: bool,
2470    filters: Option<HashMap<String, String>>,
2471    data_sender: &EventSender<DataEvent>,
2472) -> anyhow::Result<()> {
2473    if !is_spread_instrument_id(&instrument_id) {
2474        return instrument_provider
2475            .fetch_contract_details(client, instrument_id, force_update, filters)
2476            .await;
2477    }
2478
2479    instrument_provider
2480        .fetch_spread_instrument(client, instrument_id, force_update, filters)
2481        .await?;
2482    let leg_ids = parse_spread_instrument_id_to_legs(&instrument_id)?
2483        .into_iter()
2484        .map(|(leg_id, _)| leg_id)
2485        .collect::<Vec<_>>();
2486
2487    for leg in instrument_provider.find_all(&leg_ids) {
2488        if let Err(e) = data_sender.send(DataEvent::Data(leg.into())) {
2489            tracing::error!("Failed to send spread leg instrument: {e}");
2490        }
2491    }
2492
2493    Ok(())
2494}
2495
2496#[cfg(test)]
2497mod tests {
2498    use nautilus_common::live::get_runtime;
2499    use rstest::rstest;
2500
2501    use super::*;
2502    use crate::common::consts::IB_CLIENT_ID;
2503
2504    #[rstest]
2505    #[case(true, ibapi::market_data::TradingHours::Regular)]
2506    #[case(false, ibapi::market_data::TradingHours::Extended)]
2507    fn test_request_trading_hours_uses_config(
2508        #[case] use_regular_trading_hours: bool,
2509        #[case] expected: ibapi::market_data::TradingHours,
2510    ) {
2511        assert_eq!(request_trading_hours(use_regular_trading_hours), expected);
2512    }
2513
2514    #[rstest]
2515    #[case("true", true)]
2516    #[case("True", true)]
2517    #[case("1", true)]
2518    #[case("false", false)]
2519    #[case("False", false)]
2520    #[case("0", false)]
2521    fn test_parse_bool_param_value(#[case] value: &str, #[case] expected: bool) {
2522        assert_eq!(parse_bool_param_value(value), expected);
2523    }
2524
2525    #[rstest]
2526    fn test_datetime_to_unix_nanos() {
2527        let dt = jiff::Timestamp::new(1, 2).unwrap();
2528
2529        assert_eq!(datetime_to_unix_nanos(dt), UnixNanos::from(1_000_000_002));
2530    }
2531
2532    #[rstest]
2533    fn test_subscription_keys_keep_quote_and_trade_streams_independent() {
2534        let instrument_id = InstrumentId::from("AAPL.NASDAQ");
2535        let quote_token = CancellationToken::new();
2536        let trade_token = CancellationToken::new();
2537        let mut subscriptions = AHashMap::new();
2538        subscriptions.insert(
2539            (instrument_id, SubscriptionType::Quotes),
2540            SubscriptionInfo {
2541                cancellation_token: quote_token.clone(),
2542                id: next_subscription_id(),
2543            },
2544        );
2545
2546        subscriptions.insert(
2547            (instrument_id, SubscriptionType::Trades),
2548            SubscriptionInfo {
2549                cancellation_token: trade_token.clone(),
2550                id: next_subscription_id(),
2551            },
2552        );
2553
2554        let quote = subscriptions
2555            .remove(&(instrument_id, SubscriptionType::Quotes))
2556            .unwrap();
2557        quote.cancellation_token.cancel();
2558
2559        assert!(quote_token.is_cancelled());
2560        assert!(!trade_token.is_cancelled());
2561        assert_eq!(subscriptions.len(), 1);
2562        assert!(subscriptions.contains_key(&(instrument_id, SubscriptionType::Trades)));
2563    }
2564
2565    #[rstest]
2566    fn test_subscription_keys_keep_bar_types_of_one_instrument_independent() {
2567        let minute = BarType::from("AAPL.NASDAQ-1-MINUTE-LAST-EXTERNAL");
2568        let five_minute = BarType::from("AAPL.NASDAQ-5-MINUTE-LAST-EXTERNAL");
2569        let instrument_id = minute.instrument_id();
2570        let minute_token = CancellationToken::new();
2571        let five_minute_token = CancellationToken::new();
2572        let mut subscriptions = AHashMap::new();
2573        subscriptions.insert(
2574            (instrument_id, SubscriptionType::Bars(minute)),
2575            SubscriptionInfo {
2576                cancellation_token: minute_token.clone(),
2577                id: next_subscription_id(),
2578            },
2579        );
2580
2581        subscriptions.insert(
2582            (instrument_id, SubscriptionType::Bars(five_minute)),
2583            SubscriptionInfo {
2584                cancellation_token: five_minute_token.clone(),
2585                id: next_subscription_id(),
2586            },
2587        );
2588
2589        let five = subscriptions
2590            .remove(&(instrument_id, SubscriptionType::Bars(five_minute)))
2591            .unwrap();
2592        five.cancellation_token.cancel();
2593
2594        assert!(five_minute_token.is_cancelled());
2595        assert!(!minute_token.is_cancelled());
2596        assert_eq!(subscriptions.len(), 1);
2597        assert!(subscriptions.contains_key(&(instrument_id, SubscriptionType::Bars(minute))));
2598    }
2599
2600    #[rstest]
2601    fn test_subscription_guard_release_removes_only_matching_registration() {
2602        let subscriptions = Arc::new(Mutex::new(AHashMap::new()));
2603        let instrument_id = InstrumentId::from("AAPL.NASDAQ");
2604        let key = (instrument_id, SubscriptionType::Quotes);
2605
2606        let first_id = next_subscription_id();
2607        subscriptions.lock().unwrap().insert(
2608            key,
2609            SubscriptionInfo {
2610                cancellation_token: CancellationToken::new(),
2611                id: first_id,
2612            },
2613        );
2614
2615        let stale_guard = SubscriptionGuard {
2616            subscriptions: Arc::clone(&subscriptions),
2617            key,
2618            id: first_id,
2619        };
2620
2621        // A newer registration for the same key must survive the stale guard.
2622        let second_id = next_subscription_id();
2623        subscriptions.lock().unwrap().insert(
2624            key,
2625            SubscriptionInfo {
2626                cancellation_token: CancellationToken::new(),
2627                id: second_id,
2628            },
2629        );
2630
2631        stale_guard.release();
2632        assert!(subscriptions.lock().unwrap().contains_key(&key));
2633
2634        let current_guard = SubscriptionGuard {
2635            subscriptions: Arc::clone(&subscriptions),
2636            key,
2637            id: second_id,
2638        };
2639
2640        current_guard.release();
2641        assert!(!subscriptions.lock().unwrap().contains_key(&key));
2642    }
2643
2644    #[rstest]
2645    fn test_subscription_guard_drop_removes_registration_without_release() {
2646        let subscriptions = Arc::new(Mutex::new(AHashMap::new()));
2647        let instrument_id = InstrumentId::from("AAPL.NASDAQ");
2648        let key = (instrument_id, SubscriptionType::Quotes);
2649
2650        let id = next_subscription_id();
2651        subscriptions.lock().unwrap().insert(
2652            key,
2653            SubscriptionInfo {
2654                cancellation_token: CancellationToken::new(),
2655                id,
2656            },
2657        );
2658
2659        {
2660            let _guard = SubscriptionGuard {
2661                subscriptions: Arc::clone(&subscriptions),
2662                key,
2663                id,
2664            };
2665            // Dropped without release(), as when a stream task panics.
2666        }
2667
2668        assert!(!subscriptions.lock().unwrap().contains_key(&key));
2669    }
2670
2671    #[rstest]
2672    fn test_venue_is_none_for_default_routing() {
2673        // IB is a multi-venue adapter: `venue()` must be `None` so the data engine registers
2674        // the client for default routing. A `Some(venue)` here means venue-routed subscribe
2675        // commands (e.g. `BTC/USD.ZEROHASH` bars, `AAPL.NASDAQ` quotes) never reach the client.
2676        let (sender, _receiver) = tokio::sync::mpsc::unbounded_channel();
2677        nautilus_common::live::runner::replace_data_event_sender(sender);
2678
2679        let config = InteractiveBrokersDataClientConfig::default();
2680
2681        let provider = Arc::new(
2682            InteractiveBrokersInstrumentProvider::new(config.instrument_provider.clone()).unwrap(),
2683        );
2684        let client = InteractiveBrokersDataClient::new(*IB_CLIENT_ID, config, provider).unwrap();
2685
2686        assert_eq!(client.venue(), None);
2687    }
2688
2689    #[rstest]
2690    fn test_stop_closes_tasks_until_async_generation_preparation() {
2691        let (sender, _receiver) = tokio::sync::mpsc::unbounded_channel();
2692        nautilus_common::live::runner::replace_data_event_sender(sender);
2693
2694        let config = InteractiveBrokersDataClientConfig::default();
2695
2696        let provider = Arc::new(
2697            InteractiveBrokersInstrumentProvider::new(config.instrument_provider.clone()).unwrap(),
2698        );
2699        let mut client =
2700            InteractiveBrokersDataClient::new(*IB_CLIENT_ID, config, provider).unwrap();
2701
2702        client
2703            .session_tasks
2704            .spawn(async { std::future::pending::<()>().await })
2705            .unwrap();
2706
2707        client.stop().unwrap();
2708
2709        assert!(client.cancellation_token.is_cancelled());
2710        assert!(client.cancellation_token.child_token().is_cancelled());
2711        assert!(!client.session_tasks.is_open());
2712        assert!(!client.command_tasks.is_open());
2713
2714        get_runtime()
2715            .block_on(client.prepare_task_groups())
2716            .expect("prepare replacement task generation");
2717
2718        assert!(!client.cancellation_token.is_cancelled());
2719        assert!(!client.cancellation_token.child_token().is_cancelled());
2720        assert!(client.session_tasks.is_open());
2721        assert!(client.command_tasks.is_open());
2722    }
2723
2724    #[rstest]
2725    fn test_stop_retains_bar_timeout_handle_until_async_finish() {
2726        let (sender, _receiver) = tokio::sync::mpsc::unbounded_channel();
2727        nautilus_common::live::runner::replace_data_event_sender(sender);
2728
2729        let config = InteractiveBrokersDataClientConfig::default();
2730
2731        let provider = Arc::new(
2732            InteractiveBrokersInstrumentProvider::new(config.instrument_provider.clone()).unwrap(),
2733        );
2734        let mut client =
2735            InteractiveBrokersDataClient::new(*IB_CLIENT_ID, config, provider).unwrap();
2736        let bar_type = "AAPL.NASDAQ-1-MINUTE-LAST-EXTERNAL".to_string();
2737
2738        let task = get_runtime().spawn(async {
2739            std::future::pending::<()>().await;
2740        });
2741
2742        get_runtime().block_on(async {
2743            client
2744                .bar_timeout_tasks
2745                .lock()
2746                .await
2747                .insert(bar_type.clone(), TaskSlot::from_handle(task));
2748            client.last_bars.lock().await.insert(
2749                bar_type.clone(),
2750                ibapi::market_data::realtime::Bar {
2751                    date: time::OffsetDateTime::from_unix_timestamp(1).unwrap(),
2752                    open: 1.0,
2753                    high: 1.0,
2754                    low: 1.0,
2755                    close: 1.0,
2756                    volume: 1.0,
2757                    wap: 1.0,
2758                    count: 1,
2759                },
2760            );
2761        });
2762
2763        client.stop().unwrap();
2764
2765        get_runtime().block_on(async {
2766            assert_eq!(client.bar_timeout_tasks.lock().await.len(), 1);
2767            assert!(client.last_bars.lock().await.is_empty());
2768
2769            client
2770                .prepare_task_groups()
2771                .await
2772                .expect("finish stopped task generation");
2773
2774            assert!(client.bar_timeout_tasks.lock().await.is_empty());
2775        });
2776    }
2777}