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