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