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