Skip to main content

nautilus_interactive_brokers/python/
data.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//! Python bindings for the Interactive Brokers data client.
17
18use std::sync::Arc;
19
20use ibapi::contracts::{
21    Contract, Currency as IBCurrency, Exchange as IBExchange, SecurityType, Symbol,
22};
23use nautilus_common::{clients::DataClient, live::get_runtime};
24use nautilus_core::python::to_pyruntime_err;
25use nautilus_model::{
26    data::BarType,
27    identifiers::{ClientId, InstrumentId, Venue},
28    instruments::{Instrument, InstrumentAny},
29    python::instruments::instrument_any_to_pyobject,
30};
31use pyo3::{
32    IntoPyObjectExt,
33    prelude::*,
34    types::{PyDict, PyList},
35};
36
37use crate::{
38    data::InteractiveBrokersDataClient,
39    python::conversion::{contract_details_to_pyobject, py_list_to_contracts, py_to_contract},
40};
41
42#[cfg(feature = "python")]
43#[pymethods]
44impl InteractiveBrokersDataClient {
45    #[new]
46    #[pyo3(signature = (_msgbus, _cache, _clock, instrument_provider, config))]
47    fn py_new(
48        _msgbus: Py<PyAny>,
49        _cache: Py<PyAny>,
50        _clock: Py<PyAny>,
51        instrument_provider: crate::providers::instruments::InteractiveBrokersInstrumentProvider,
52        config: crate::config::InteractiveBrokersDataClientConfig,
53    ) -> PyResult<Self> {
54        Self::new_for_python(config, instrument_provider).map_err(to_pyruntime_err)
55    }
56
57    #[pyo3(name = "set_event_callback")]
58    fn py_set_event_callback(&self, callback: Py<PyAny>) {
59        self.register_python_event_callback(callback);
60    }
61
62    /// Returns the client ID.
63    #[getter]
64    pub fn client_id(&self) -> ClientId {
65        DataClient::client_id(self)
66    }
67
68    /// Returns whether the client is connected.
69    #[getter]
70    pub fn is_connected(&self) -> bool {
71        DataClient::is_connected(self)
72    }
73
74    /// Returns whether the client is disconnected.
75    #[getter]
76    pub fn is_disconnected(&self) -> bool {
77        DataClient::is_disconnected(self)
78    }
79
80    #[pyo3(name = "connect")]
81    fn py_connect(&mut self) -> PyResult<()> {
82        get_runtime()
83            .block_on(DataClient::connect(self))
84            .map_err(to_pyruntime_err)
85    }
86
87    #[pyo3(name = "disconnect")]
88    fn py_disconnect(&mut self) -> PyResult<()> {
89        get_runtime()
90            .block_on(DataClient::disconnect(self))
91            .map_err(to_pyruntime_err)
92    }
93
94    /// Get the instrument provider.
95    ///
96    /// # Errors
97    ///
98    /// Returns an error indicating the provider should be accessed through data client methods.
99    #[getter("get_instrument_provider")]
100    pub fn get_instrument_provider(
101        &self,
102    ) -> PyResult<crate::providers::instruments::InteractiveBrokersInstrumentProvider> {
103        // The provider is wrapped in Arc, so we just need to return it
104        // Since it doesn't implement Clone, we'll need to expose it differently
105        // For now, return an error indicating it should be accessed through methods
106        Err(to_pyruntime_err(
107            "instrument_provider should be accessed through the data client's methods that use it internally",
108        ))
109    }
110
111    /// Batch load multiple instrument IDs.
112    ///
113    /// This uses the data client's internal IB client to load instruments via the provider.
114    ///
115    /// # Arguments
116    ///
117    /// * `instrument_ids` - List of instrument IDs to load
118    ///
119    /// # Returns
120    ///
121    /// Returns the number of instruments successfully loaded.
122    ///
123    /// # Errors
124    ///
125    /// Returns an error if the client is not connected or loading fails.
126    #[pyo3(name = "batch_load")]
127    fn py_batch_load<'py>(
128        &self,
129        py: Python<'py>,
130        instrument_ids: Vec<InstrumentId>,
131    ) -> PyResult<Bound<'py, PyAny>> {
132        let provider = self.instrument_provider();
133        let ib_client_ref = self.get_ib_client().map(Arc::clone);
134
135        pyo3_async_runtimes::tokio::future_into_py(py, async move {
136            let client = ib_client_ref
137                .ok_or_else(|| anyhow::anyhow!("IB client not connected. Call connect() first"))
138                .map_err(to_pyruntime_err)?;
139
140            provider
141                .batch_load(&client, instrument_ids, None)
142                .await
143                .map_err(to_pyruntime_err)
144        })
145    }
146
147    /// Fetch option chain for an underlying contract with expiry filtering.
148    ///
149    /// This uses the data client's internal IB client to fetch options via the provider.
150    ///
151    /// # Arguments
152    ///
153    /// * `underlying_symbol` - The underlying symbol (e.g., "AAPL")
154    /// * `exchange` - The exchange (defaults to "SMART")
155    /// * `currency` - The currency (defaults to "USD")
156    /// * `expiry_min` - Minimum expiry date string (YYYYMMDD format, optional)
157    /// * `expiry_max` - Maximum expiry date string (YYYYMMDD format, optional)
158    ///
159    /// # Returns
160    ///
161    /// Returns the number of option instruments loaded.
162    ///
163    /// # Errors
164    ///
165    /// Returns an error if the client is not connected or fetching fails.
166    #[pyo3(signature = (underlying_symbol, exchange=None, currency=None, expiry_min=None, expiry_max=None))]
167    fn py_fetch_option_chain_by_range<'py>(
168        &self,
169        py: Python<'py>,
170        underlying_symbol: String,
171        exchange: Option<String>,
172        currency: Option<String>,
173        expiry_min: Option<String>,
174        expiry_max: Option<String>,
175    ) -> PyResult<Bound<'py, PyAny>> {
176        let provider = self.instrument_provider();
177        let ib_client_ref = self.get_ib_client().map(Arc::clone);
178
179        pyo3_async_runtimes::tokio::future_into_py(py, async move {
180            let client = ib_client_ref
181                .ok_or_else(|| anyhow::anyhow!("IB client not connected. Call connect() first"))
182                .map_err(to_pyruntime_err)?;
183
184            let underlying = Contract {
185                contract_id: 0,
186                symbol: Symbol::from(underlying_symbol.clone()),
187                security_type: SecurityType::Stock,
188                last_trade_date_or_contract_month: String::new(),
189                strike: 0.0,
190                right: String::new(),
191                multiplier: String::new(),
192                exchange: IBExchange::from(exchange.as_deref().unwrap_or("SMART")),
193                currency: IBCurrency::from(currency.as_deref().unwrap_or("USD")),
194                local_symbol: String::new(),
195                primary_exchange: IBExchange::default(),
196                trading_class: String::new(),
197                include_expired: false,
198                security_id_type: String::new(),
199                security_id: String::new(),
200                combo_legs_description: String::new(),
201                combo_legs: Vec::new(),
202                delta_neutral_contract: None,
203                issuer_id: String::new(),
204                description: String::new(),
205                last_trade_date: None,
206            };
207
208            provider
209                .fetch_option_chain_by_range(
210                    &client,
211                    &underlying,
212                    expiry_min.as_deref(),
213                    expiry_max.as_deref(),
214                )
215                .await
216                .map_err(to_pyruntime_err)
217        })
218    }
219
220    /// Fetch option chain for a fully specified underlying contract with expiry filtering.
221    ///
222    /// This variant preserves the source security type and contract fields, which is required
223    /// for futures options and other non-stock underliers.
224    #[pyo3(signature = (contract, expiry_min=None, expiry_max=None))]
225    #[allow(clippy::needless_pass_by_value)]
226    fn py_fetch_option_chain_by_range_for_contract<'py>(
227        &self,
228        py: Python<'py>,
229        contract: Py<PyAny>,
230        expiry_min: Option<String>,
231        expiry_max: Option<String>,
232    ) -> PyResult<Bound<'py, PyAny>> {
233        let provider = self.instrument_provider();
234        let ib_client_ref = self.get_ib_client().map(Arc::clone);
235        let rust_contract = py_to_contract(contract.bind(py))?;
236
237        pyo3_async_runtimes::tokio::future_into_py(py, async move {
238            let client = ib_client_ref
239                .ok_or_else(|| anyhow::anyhow!("IB client not connected. Call connect() first"))
240                .map_err(to_pyruntime_err)?;
241
242            provider
243                .fetch_option_chain_by_range(
244                    &client,
245                    &rust_contract,
246                    expiry_min.as_deref(),
247                    expiry_max.as_deref(),
248                )
249                .await
250                .map_err(to_pyruntime_err)
251        })
252    }
253
254    /// Fetch raw option chain metadata for a fully specified underlying contract.
255    ///
256    /// This is useful for debugging which exchanges, expiries, and strikes IB returns before
257    /// expanding into full option contract details.
258    #[pyo3(signature = (contract))]
259    #[allow(clippy::needless_pass_by_value)]
260    fn py_get_option_chain_metadata_for_contract<'py>(
261        &self,
262        py: Python<'py>,
263        contract: Py<PyAny>,
264    ) -> PyResult<Bound<'py, PyAny>> {
265        let ib_client_ref = self.get_ib_client().map(Arc::clone);
266        let rust_contract = py_to_contract(contract.bind(py))?;
267
268        pyo3_async_runtimes::tokio::future_into_py(py, async move {
269            let client = ib_client_ref
270                .ok_or_else(|| anyhow::anyhow!("IB client not connected. Call connect() first"))
271                .map_err(to_pyruntime_err)?;
272
273            let mut stream = client
274                .option_chain(
275                    rust_contract.symbol.as_str(),
276                    rust_contract.exchange.as_str(),
277                    rust_contract.security_type.clone(),
278                    rust_contract.contract_id,
279                )
280                .await
281                .map_err(to_pyruntime_err)?;
282
283            let mut chains = Vec::new();
284
285            while let Some(result) = stream.next().await {
286                match result {
287                    Ok(chain) => chains.push(chain),
288                    Err(e) => {
289                        return Err(to_pyruntime_err(format!(
290                            "Failed to receive option chain metadata: {e}",
291                        )));
292                    }
293                }
294            }
295
296            Python::attach(|py| -> PyResult<Py<PyAny>> {
297                let items = PyList::empty(py);
298                for chain in chains {
299                    let item = PyDict::new(py);
300                    item.set_item("underlying_contract_id", chain.underlying_contract_id)?;
301                    item.set_item("trading_class", chain.trading_class)?;
302                    item.set_item("multiplier", chain.multiplier)?;
303                    item.set_item("exchange", chain.exchange)?;
304                    item.set_item("expirations", chain.expirations)?;
305                    item.set_item("strikes", chain.strikes)?;
306                    items.append(item)?;
307                }
308                items.into_py_any(py)
309            })
310            .map_err(to_pyruntime_err)
311        })
312    }
313
314    /// Fetch raw IB contract details for a fully specified contract.
315    ///
316    /// This is useful for debugging exact contract queries such as FOP/OPT lookups and comparing
317    /// the Rust adapter output against the legacy Python adapter.
318    #[pyo3(signature = (contract))]
319    #[allow(clippy::needless_pass_by_value)]
320    fn py_get_contract_details_for_contract<'py>(
321        &self,
322        py: Python<'py>,
323        contract: Py<PyAny>,
324    ) -> PyResult<Bound<'py, PyAny>> {
325        let ib_client_ref = self.get_ib_client().map(Arc::clone);
326        let rust_contract = py_to_contract(contract.bind(py))?;
327
328        pyo3_async_runtimes::tokio::future_into_py(py, async move {
329            let client = ib_client_ref
330                .ok_or_else(|| anyhow::anyhow!("IB client not connected. Call connect() first"))
331                .map_err(to_pyruntime_err)?;
332
333            let details = client
334                .contract_details(&rust_contract)
335                .await
336                .map_err(to_pyruntime_err)?;
337
338            Python::attach(|py| -> PyResult<Py<PyAny>> {
339                let items = PyList::empty(py);
340                for detail in details {
341                    items.append(contract_details_to_pyobject(py, &detail)?)?;
342                }
343                items.into_py_any(py)
344            })
345            .map_err(to_pyruntime_err)
346        })
347    }
348
349    /// Resolve a contract through the Rust provider and return the Rust instrument kind and ID.
350    ///
351    /// This is intended only for debugging provider resolution issues.
352    #[pyo3(signature = (contract))]
353    #[allow(clippy::needless_pass_by_value)]
354    fn py_debug_resolve_instrument<'py>(
355        &self,
356        py: Python<'py>,
357        contract: Py<PyAny>,
358    ) -> PyResult<Bound<'py, PyAny>> {
359        let provider = self.instrument_provider().clone();
360        let ib_client_ref = self.get_ib_client().map(Arc::clone);
361        let rust_contract = py_to_contract(contract.bind(py))?;
362
363        pyo3_async_runtimes::tokio::future_into_py(py, async move {
364            let client = ib_client_ref
365                .ok_or_else(|| anyhow::anyhow!("IB client not connected. Call connect() first"))
366                .map_err(to_pyruntime_err)?;
367
368            let result = provider
369                .get_instrument(&client, &rust_contract)
370                .await
371                .map_err(to_pyruntime_err)?;
372
373            Python::attach(|py| -> PyResult<Py<PyAny>> {
374                let dict = PyDict::new(py);
375
376                if let Some(instrument) = result {
377                    let kind = match &instrument {
378                        InstrumentAny::Betting(_) => "Betting",
379                        InstrumentAny::BinaryOption(_) => "BinaryOption",
380                        InstrumentAny::Cfd(_) => "Cfd",
381                        InstrumentAny::Commodity(_) => "Commodity",
382                        InstrumentAny::CryptoFuture(_) => "CryptoFuture",
383                        InstrumentAny::CryptoOption(_) => "CryptoOption",
384                        InstrumentAny::CryptoPerpetual(_) => "CryptoPerpetual",
385                        InstrumentAny::CurrencyPair(_) => "CurrencyPair",
386                        InstrumentAny::Equity(_) => "Equity",
387                        InstrumentAny::FuturesContract(_) => "FuturesContract",
388                        InstrumentAny::FuturesSpread(_) => "FuturesSpread",
389                        InstrumentAny::IndexInstrument(_) => "IndexInstrument",
390                        InstrumentAny::OptionContract(_) => "OptionContract",
391                        InstrumentAny::OptionSpread(_) => "OptionSpread",
392                        InstrumentAny::PerpetualContract(_) => "PerpetualContract",
393                        InstrumentAny::TokenizedAsset(_) => "TokenizedAsset",
394                    };
395                    dict.set_item("kind", kind)?;
396                    dict.set_item("instrument_id", instrument.id().to_string())?;
397                } else {
398                    dict.set_item("kind", py.None())?;
399                    dict.set_item("instrument_id", py.None())?;
400                }
401                dict.into_py_any(py)
402            })
403            .map_err(to_pyruntime_err)
404        })
405    }
406
407    /// Fetch futures chain for a given underlying symbol.
408    ///
409    /// This uses the data client's internal IB client to fetch futures via the provider.
410    ///
411    /// # Arguments
412    ///
413    /// * `symbol` - The underlying symbol (e.g., "ES")
414    /// * `exchange` - The exchange (defaults to empty string for all exchanges)
415    /// * `currency` - The currency (defaults to "USD")
416    ///
417    /// # Returns
418    ///
419    /// Returns the number of futures instruments loaded.
420    ///
421    /// # Errors
422    ///
423    /// Returns an error if the client is not connected or fetching fails.
424    #[pyo3(signature = (symbol, exchange=None, currency=None, min_expiry_days=None, max_expiry_days=None))]
425    fn py_fetch_futures_chain<'py>(
426        &self,
427        py: Python<'py>,
428        symbol: String,
429        exchange: Option<String>,
430        currency: Option<String>,
431        min_expiry_days: Option<u32>,
432        max_expiry_days: Option<u32>,
433    ) -> PyResult<Bound<'py, PyAny>> {
434        let provider = self.instrument_provider();
435        let ib_client_ref = self.get_ib_client().map(Arc::clone);
436
437        pyo3_async_runtimes::tokio::future_into_py(py, async move {
438            let client = ib_client_ref
439                .ok_or_else(|| anyhow::anyhow!("IB client not connected. Call connect() first"))
440                .map_err(to_pyruntime_err)?;
441
442            provider
443                .fetch_futures_chain(
444                    &client,
445                    &symbol,
446                    exchange.as_deref().unwrap_or(""),
447                    currency.as_deref().unwrap_or("USD"),
448                    min_expiry_days,
449                    max_expiry_days,
450                )
451                .await
452                .map_err(to_pyruntime_err)
453        })
454    }
455
456    /// Get an instrument by IB Contract.
457    ///
458    /// This uses the data client's internal IB client to get instruments via the provider.
459    ///
460    /// # Arguments
461    ///
462    /// * `contract` - The IB contract (as a dict with contract fields)
463    ///
464    /// # Returns
465    ///
466    /// Returns the instrument if found, `None` otherwise.
467    ///
468    /// # Errors
469    ///
470    /// Returns an error if the client is not connected or fetching fails.
471    #[pyo3(name = "get_instrument")]
472    #[allow(clippy::needless_pass_by_value)]
473    #[allow(deprecated)]
474    fn py_get_instrument<'py>(
475        &self,
476        py: Python<'py>,
477        contract: Py<PyAny>,
478    ) -> PyResult<Bound<'py, PyAny>> {
479        let provider = self.instrument_provider().clone();
480        let ib_client_ref = self.get_ib_client().map(Arc::clone);
481        // Parse contract synchronously
482        let rust_contract = py_to_contract(contract.bind(py))?;
483
484        pyo3_async_runtimes::tokio::future_into_py(py, async move {
485            let client = ib_client_ref
486                .ok_or_else(|| anyhow::anyhow!("IB client not connected. Call connect() first"))
487                .map_err(to_pyruntime_err)?;
488
489            match provider
490                .get_instrument(&client, &rust_contract)
491                .await
492                .map_err(to_pyruntime_err)?
493            {
494                Some(instrument) => Python::attach(|gil| {
495                    instrument_any_to_pyobject(gil, instrument).map_err(to_pyruntime_err)
496                }),
497                None => Python::attach(|gil| Ok(gil.None())),
498            }
499        })
500    }
501
502    /// Load a single instrument (does not return loaded IDs).
503    ///
504    /// # Arguments
505    ///
506    /// * `instrument_id` - The instrument ID to load
507    /// * `force_instrument_update` - If true, force re-fetch even if already cached
508    ///
509    /// # Errors
510    ///
511    /// Returns an error if the client is not connected or loading fails.
512    #[pyo3(name = "load_async")]
513    fn py_load_async<'py>(
514        &self,
515        py: Python<'py>,
516        instrument_id: InstrumentId,
517        filters: Option<std::collections::HashMap<String, String>>,
518    ) -> PyResult<Bound<'py, PyAny>> {
519        let provider = self.instrument_provider();
520        let ib_client_ref = self.get_ib_client().map(Arc::clone);
521
522        pyo3_async_runtimes::tokio::future_into_py(py, async move {
523            let client = ib_client_ref
524                .ok_or_else(|| anyhow::anyhow!("IB client not connected. Call connect() first"))
525                .map_err(to_pyruntime_err)?;
526
527            provider
528                .load_async(&client, instrument_id, filters)
529                .await
530                .map_err(to_pyruntime_err)
531        })
532    }
533
534    /// Load a single instrument and return the loaded instrument ID.
535    ///
536    /// # Arguments
537    ///
538    /// * `instrument_id` - The instrument ID to load
539    /// * `force_instrument_update` - If true, force re-fetch even if already cached
540    ///
541    /// # Returns
542    ///
543    /// Returns the loaded instrument ID if successful, `None` otherwise.
544    ///
545    /// # Errors
546    ///
547    /// Returns an error if the client is not connected or loading fails.
548    #[pyo3(name = "load_with_return_async")]
549    fn py_load_with_return_async<'py>(
550        &self,
551        py: Python<'py>,
552        instrument_id: InstrumentId,
553        filters: Option<std::collections::HashMap<String, String>>,
554    ) -> PyResult<Bound<'py, PyAny>> {
555        let provider = self.instrument_provider();
556        let ib_client_ref = self.get_ib_client().map(Arc::clone);
557
558        pyo3_async_runtimes::tokio::future_into_py(py, async move {
559            let client = ib_client_ref
560                .ok_or_else(|| anyhow::anyhow!("IB client not connected. Call connect() first"))
561                .map_err(to_pyruntime_err)?;
562
563            provider
564                .load_with_return_async(&client, instrument_id, filters)
565                .await
566                .map_err(to_pyruntime_err)
567        })
568    }
569
570    /// Load multiple instruments (does not return loaded IDs).
571    ///
572    /// # Arguments
573    ///
574    /// * `instrument_ids` - List of instrument IDs to load
575    /// * `force_instrument_update` - If true, force re-fetch even if already cached
576    ///
577    /// # Errors
578    ///
579    /// Returns an error if the client is not connected or loading fails.
580    #[pyo3(name = "load_ids_async")]
581    fn py_load_ids_async<'py>(
582        &self,
583        py: Python<'py>,
584        instrument_ids: Vec<InstrumentId>,
585        filters: Option<std::collections::HashMap<String, String>>,
586    ) -> PyResult<Bound<'py, PyAny>> {
587        let provider = self.instrument_provider();
588        let ib_client_ref = self.get_ib_client().map(Arc::clone);
589
590        pyo3_async_runtimes::tokio::future_into_py(py, async move {
591            let client = ib_client_ref
592                .ok_or_else(|| anyhow::anyhow!("IB client not connected. Call connect() first"))
593                .map_err(to_pyruntime_err)?;
594
595            provider
596                .load_ids_async(&client, instrument_ids, filters)
597                .await
598                .map_err(to_pyruntime_err)
599        })
600    }
601
602    /// Load multiple instruments and return the loaded instrument IDs.
603    ///
604    /// # Arguments
605    ///
606    /// * `instrument_ids` - List of instrument IDs to load
607    /// * `force_instrument_update` - If true, force re-fetch even if already cached
608    ///
609    /// # Returns
610    ///
611    /// Returns a list of successfully loaded instrument IDs.
612    ///
613    /// # Errors
614    ///
615    /// Returns an error if the client is not connected or loading fails.
616    #[pyo3(name = "load_ids_with_return_async")]
617    fn py_load_ids_with_return_async<'py>(
618        &self,
619        py: Python<'py>,
620        instrument_ids: Vec<InstrumentId>,
621        filters: Option<std::collections::HashMap<String, String>>,
622    ) -> PyResult<Bound<'py, PyAny>> {
623        let provider = self.instrument_provider();
624        let ib_client_ref = self.get_ib_client().map(Arc::clone);
625
626        pyo3_async_runtimes::tokio::future_into_py(py, async move {
627            let client = ib_client_ref
628                .ok_or_else(|| anyhow::anyhow!("IB client not connected. Call connect() first"))
629                .map_err(to_pyruntime_err)?;
630
631            provider
632                .load_ids_with_return_async(&client, instrument_ids, filters)
633                .await
634                .map_err(to_pyruntime_err)
635        })
636    }
637
638    /// Get instrument ID by contract ID.
639    ///
640    /// # Arguments
641    ///
642    /// * `contract_id` - The IB contract ID
643    ///
644    /// # Returns
645    ///
646    /// Returns the instrument ID if found, `None` otherwise.
647    #[pyo3(name = "get_instrument_id_by_contract_id")]
648    fn py_get_instrument_id_by_contract_id(&self, contract_id: i32) -> Option<InstrumentId> {
649        self.instrument_provider()
650            .get_instrument_id_by_contract_id(contract_id)
651    }
652
653    /// Convert an instrument ID to IB contract details.
654    ///
655    /// # Arguments
656    ///
657    /// * `instrument_id` - The instrument ID to convert
658    ///
659    /// # Returns
660    ///
661    /// Returns the contract details if found, `None` otherwise.
662    #[pyo3(name = "instrument_id_to_ib_contract_details")]
663    fn py_instrument_id_to_ib_contract_details(
664        &self,
665        instrument_id: InstrumentId,
666    ) -> Option<Py<PyAny>> {
667        // TODO: Convert ContractDetails to Python object
668        // For now, return None as placeholder
669        let _details = self
670            .instrument_provider()
671            .instrument_id_to_ib_contract_details(&instrument_id);
672        // When ContractDetails PyO3 bindings are available, convert here
673        None
674    }
675
676    /// Determine venue from contract using provider configuration.
677    ///
678    /// # Arguments
679    ///
680    /// * `contract` - The IB contract (as a dict with contract fields)
681    ///
682    /// # Returns
683    ///
684    /// Returns the determined venue.
685    #[pyo3(name = "determine_venue")]
686    #[allow(clippy::needless_pass_by_value)]
687    fn py_determine_venue(&self, py: Python<'_>, contract: Py<PyAny>) -> PyResult<String> {
688        let rust_contract = py_to_contract(contract.bind(py))?;
689        let venue = self
690            .instrument_provider()
691            .determine_venue(&rust_contract, None);
692        Ok(venue.to_string())
693    }
694
695    /// Load all instruments from provided IDs and contracts.
696    ///
697    /// This is equivalent to Python's `load_all_async` method.
698    ///
699    /// # Arguments
700    ///
701    /// * `instrument_ids` - Optional list of instrument IDs to load
702    /// * `contracts` - Optional list of IB contracts (as dicts) to load
703    /// * `force_instrument_update` - If true, force re-fetch even if already cached
704    ///
705    /// # Returns
706    ///
707    /// Returns a list of successfully loaded instrument IDs.
708    ///
709    /// # Errors
710    ///
711    /// Returns an error if the client is not connected or loading fails.
712    #[pyo3(name = "load_all_async")]
713    fn py_load_all_async<'py>(
714        &self,
715        py: Python<'py>,
716        instrument_ids: Option<Vec<InstrumentId>>,
717        contracts: Option<Py<PyAny>>,
718        force_instrument_update: bool,
719    ) -> PyResult<Bound<'py, PyAny>> {
720        let provider = self.instrument_provider();
721        let ib_client_ref = self.get_ib_client().map(Arc::clone);
722
723        // Convert contracts synchronously
724        let contracts_rust: Option<Vec<ibapi::contracts::Contract>> = if let Some(c) = contracts {
725            Some(py_list_to_contracts(c.bind(py))?)
726        } else {
727            None
728        };
729
730        pyo3_async_runtimes::tokio::future_into_py(py, async move {
731            let client = ib_client_ref
732                .ok_or_else(|| anyhow::anyhow!("IB client not connected. Call connect() first"))
733                .map_err(to_pyruntime_err)?;
734
735            provider
736                .load_all_async(
737                    &client,
738                    instrument_ids,
739                    contracts_rust,
740                    force_instrument_update,
741                )
742                .await
743                .map_err(to_pyruntime_err)
744        })
745    }
746
747    /// Fetch a spread instrument by loading individual legs.
748    ///
749    /// This is equivalent to Python's `fetch_spread_instrument` method.
750    ///
751    /// # Arguments
752    ///
753    /// * `spread_instrument_id` - The spread instrument ID to fetch
754    /// * `force_instrument_update` - If true, force re-fetch even if already cached
755    ///
756    /// # Returns
757    ///
758    /// Returns `true` if the spread instrument was successfully loaded, `false` otherwise.
759    ///
760    /// # Errors
761    ///
762    /// Returns an error if the client is not connected or loading fails.
763    #[pyo3(name = "fetch_spread_instrument")]
764    fn py_fetch_spread_instrument<'py>(
765        &self,
766        py: Python<'py>,
767        spread_instrument_id: InstrumentId,
768        force_instrument_update: bool,
769        filters: Option<std::collections::HashMap<String, String>>,
770    ) -> PyResult<Bound<'py, PyAny>> {
771        let provider = self.instrument_provider();
772        let ib_client_ref = self.get_ib_client().map(Arc::clone);
773
774        pyo3_async_runtimes::tokio::future_into_py(py, async move {
775            let client = ib_client_ref
776                .ok_or_else(|| anyhow::anyhow!("IB client not connected. Call connect() first"))
777                .map_err(to_pyruntime_err)?;
778
779            provider
780                .fetch_spread_instrument(
781                    &client,
782                    spread_instrument_id,
783                    force_instrument_update,
784                    filters,
785                )
786                .await
787                .map_err(to_pyruntime_err)
788        })
789    }
790
791    /// Subscribe to quote ticks for an instrument.
792    ///
793    /// # Arguments
794    ///
795    /// * `instrument_id` - The instrument ID to subscribe to
796    /// * `params` - Optional parameters dict (e.g., {"batch_quotes": "true"})
797    ///
798    /// # Errors
799    ///
800    /// Returns an error if the subscription fails.
801    #[pyo3(name = "subscribe_quotes")]
802    fn py_subscribe_quotes(
803        &mut self,
804        instrument_id: InstrumentId,
805        params: Option<std::collections::HashMap<String, String>>,
806    ) -> PyResult<()> {
807        self.subscribe_quotes_for_python(instrument_id, params)
808            .map_err(to_pyruntime_err)
809    }
810
811    /// Subscribe to index prices for an instrument.
812    ///
813    /// # Arguments
814    ///
815    /// * `instrument_id` - The instrument ID to subscribe to
816    ///
817    /// # Errors
818    ///
819    /// Returns an error if the subscription fails.
820    #[pyo3(name = "subscribe_index_prices")]
821    fn py_subscribe_index_prices(&mut self, instrument_id: InstrumentId) -> PyResult<()> {
822        self.subscribe_index_prices_for_python(instrument_id)
823            .map_err(to_pyruntime_err)
824    }
825
826    /// Subscribe to option greeks for an instrument.
827    ///
828    /// # Arguments
829    ///
830    /// * `instrument_id` - The instrument ID to subscribe to
831    ///
832    /// # Errors
833    ///
834    /// Returns an error if the subscription fails.
835    #[pyo3(name = "subscribe_option_greeks")]
836    fn py_subscribe_option_greeks(&mut self, instrument_id: InstrumentId) -> PyResult<()> {
837        self.subscribe_option_greeks_for_python(instrument_id)
838            .map_err(to_pyruntime_err)
839    }
840
841    /// Subscribe to trade ticks for an instrument.
842    ///
843    /// # Arguments
844    ///
845    /// * `instrument_id` - The instrument ID to subscribe to
846    ///
847    /// # Errors
848    ///
849    /// Returns an error if the subscription fails.
850    #[pyo3(name = "subscribe_trades")]
851    fn py_subscribe_trades(&mut self, instrument_id: InstrumentId) -> PyResult<()> {
852        self.subscribe_trades_for_python(instrument_id)
853            .map_err(to_pyruntime_err)
854    }
855
856    /// Subscribe to bars for a bar type.
857    ///
858    /// # Arguments
859    ///
860    /// * `bar_type` - The bar type to subscribe to
861    ///
862    /// # Errors
863    ///
864    /// Returns an error if the subscription fails.
865    #[pyo3(name = "subscribe_bars", signature = (bar_type, params=None))]
866    fn py_subscribe_bars(
867        &mut self,
868        bar_type: BarType,
869        params: Option<std::collections::HashMap<String, String>>,
870    ) -> PyResult<()> {
871        self.subscribe_bars_for_python(bar_type, params)
872            .map_err(to_pyruntime_err)
873    }
874
875    /// Subscribe to order book deltas for an instrument.
876    ///
877    /// # Arguments
878    ///
879    /// * `instrument_id` - The instrument ID to subscribe to
880    /// * `depth` - The depth of the order book
881    /// * `params` - Optional parameters dict (e.g., {"is_smart_depth": "true"})
882    ///
883    /// # Errors
884    ///
885    /// Returns an error if the subscription fails.
886    #[pyo3(name = "subscribe_book_deltas")]
887    fn py_subscribe_book_deltas(
888        &mut self,
889        instrument_id: InstrumentId,
890        depth: Option<u16>,
891        params: Option<std::collections::HashMap<String, String>>,
892    ) -> PyResult<()> {
893        self.subscribe_book_deltas_for_python(instrument_id, depth, params)
894            .map_err(to_pyruntime_err)
895    }
896
897    /// Unsubscribe from quote ticks for an instrument.
898    ///
899    /// # Arguments
900    ///
901    /// * `instrument_id` - The instrument ID to unsubscribe from
902    ///
903    /// # Errors
904    ///
905    /// Returns an error if the unsubscription fails.
906    #[pyo3(name = "unsubscribe_quotes")]
907    fn py_unsubscribe_quotes(&mut self, instrument_id: InstrumentId) -> PyResult<()> {
908        self.unsubscribe_quotes_for_python(instrument_id)
909            .map_err(to_pyruntime_err)
910    }
911
912    /// Unsubscribe from index prices for an instrument.
913    ///
914    /// # Arguments
915    ///
916    /// * `instrument_id` - The instrument ID to unsubscribe from
917    ///
918    /// # Errors
919    ///
920    /// Returns an error if the unsubscription fails.
921    #[pyo3(name = "unsubscribe_index_prices")]
922    fn py_unsubscribe_index_prices(&mut self, instrument_id: InstrumentId) -> PyResult<()> {
923        self.unsubscribe_index_prices_for_python(instrument_id)
924            .map_err(to_pyruntime_err)
925    }
926
927    /// Unsubscribe from option greeks for an instrument.
928    ///
929    /// # Arguments
930    ///
931    /// * `instrument_id` - The instrument ID to unsubscribe from
932    ///
933    /// # Errors
934    ///
935    /// Returns an error if the unsubscription fails.
936    #[pyo3(name = "unsubscribe_option_greeks")]
937    fn py_unsubscribe_option_greeks(&mut self, instrument_id: InstrumentId) -> PyResult<()> {
938        self.unsubscribe_option_greeks_for_python(instrument_id)
939            .map_err(to_pyruntime_err)
940    }
941
942    /// Unsubscribe from trade ticks for an instrument.
943    ///
944    /// # Arguments
945    ///
946    /// * `instrument_id` - The instrument ID to unsubscribe from
947    ///
948    /// # Errors
949    ///
950    /// Returns an error if the unsubscription fails.
951    #[pyo3(name = "unsubscribe_trades")]
952    fn py_unsubscribe_trades(&mut self, instrument_id: InstrumentId) -> PyResult<()> {
953        self.unsubscribe_trades_for_python(instrument_id)
954            .map_err(to_pyruntime_err)
955    }
956
957    /// Unsubscribe from bars for a bar type.
958    ///
959    /// # Arguments
960    ///
961    /// * `bar_type` - The bar type to unsubscribe from
962    ///
963    /// # Errors
964    ///
965    /// Returns an error if the unsubscription fails.
966    #[pyo3(name = "unsubscribe_bars")]
967    fn py_unsubscribe_bars(&mut self, bar_type: BarType) -> PyResult<()> {
968        self.unsubscribe_bars_for_python(bar_type)
969            .map_err(to_pyruntime_err)
970    }
971
972    /// Unsubscribe from order book deltas for an instrument.
973    ///
974    /// # Arguments
975    ///
976    /// * `instrument_id` - The instrument ID to unsubscribe from
977    ///
978    /// # Errors
979    ///
980    /// Returns an error if the unsubscription fails.
981    #[pyo3(name = "unsubscribe_book_deltas")]
982    fn py_unsubscribe_book_deltas(&mut self, instrument_id: InstrumentId) -> PyResult<()> {
983        self.unsubscribe_book_deltas_for_python(instrument_id)
984            .map_err(to_pyruntime_err)
985    }
986
987    /// Request historical quote ticks for an instrument.
988    ///
989    /// # Arguments
990    ///
991    /// * `instrument_id` - The instrument ID to request quotes for
992    /// * `limit` - Maximum number of ticks to return
993    /// * `start` - Start timestamp (Unix nanoseconds, optional)
994    /// * `end` - End timestamp (Unix nanoseconds, optional)
995    ///
996    /// # Errors
997    ///
998    /// Returns an error if the request fails.
999    #[pyo3(name = "request_quotes", signature = (instrument_id, limit=None, start=None, end=None, request_id=None))]
1000    fn py_request_quotes(
1001        &self,
1002        instrument_id: InstrumentId,
1003        limit: Option<u64>,
1004        start: Option<u64>,
1005        end: Option<u64>,
1006        request_id: Option<String>,
1007    ) -> PyResult<()> {
1008        self.request_quotes_for_python(instrument_id, limit, start, end, request_id)
1009            .map_err(to_pyruntime_err)
1010    }
1011
1012    /// Request historical trade ticks for an instrument.
1013    ///
1014    /// # Arguments
1015    ///
1016    /// * `instrument_id` - The instrument ID to request trades for
1017    /// * `limit` - Maximum number of ticks to return
1018    /// * `start` - Start timestamp (Unix nanoseconds, optional)
1019    /// * `end` - End timestamp (Unix nanoseconds, optional)
1020    ///
1021    /// # Errors
1022    ///
1023    /// Returns an error if the request fails.
1024    #[pyo3(name = "request_trades", signature = (instrument_id, limit=None, start=None, end=None, request_id=None))]
1025    fn py_request_trades(
1026        &self,
1027        instrument_id: InstrumentId,
1028        limit: Option<u64>,
1029        start: Option<u64>,
1030        end: Option<u64>,
1031        request_id: Option<String>,
1032    ) -> PyResult<()> {
1033        self.request_trades_for_python(instrument_id, limit, start, end, request_id)
1034            .map_err(to_pyruntime_err)
1035    }
1036
1037    /// Request historical bars for a bar type.
1038    ///
1039    /// # Arguments
1040    ///
1041    /// * `bar_type` - The bar type to request
1042    /// * `limit` - Maximum number of bars to return
1043    /// * `start` - Start timestamp (Unix nanoseconds, optional)
1044    /// * `end` - End timestamp (Unix nanoseconds, optional)
1045    ///
1046    /// # Errors
1047    ///
1048    /// Returns an error if the request fails.
1049    #[pyo3(name = "request_bars", signature = (bar_type, limit=None, start=None, end=None, request_id=None))]
1050    fn py_request_bars(
1051        &self,
1052        bar_type: BarType,
1053        limit: Option<u64>,
1054        start: Option<u64>,
1055        end: Option<u64>,
1056        request_id: Option<String>,
1057    ) -> PyResult<()> {
1058        self.request_bars_for_python(bar_type, limit, start, end, request_id)
1059            .map_err(to_pyruntime_err)
1060    }
1061
1062    /// Request a single instrument.
1063    ///
1064    /// # Arguments
1065    ///
1066    /// * `instrument_id` - The instrument ID to request
1067    /// * `params` - Optional parameters dict (e.g., {"force_instrument_update": "true"})
1068    ///
1069    /// # Errors
1070    ///
1071    /// Returns an error if the request fails.
1072    #[pyo3(name = "request_instrument")]
1073    fn py_request_instrument(
1074        &self,
1075        instrument_id: InstrumentId,
1076        params: Option<std::collections::HashMap<String, String>>,
1077    ) -> PyResult<()> {
1078        self.request_instrument_for_python(instrument_id, params)
1079            .map_err(to_pyruntime_err)
1080    }
1081
1082    /// Request multiple instruments.
1083    ///
1084    /// # Arguments
1085    ///
1086    /// * `venue` - Optional venue to filter by
1087    /// * `params` - Optional parameters dict (e.g., {"force_instrument_update": "true", "ib_contracts": [...]})
1088    ///
1089    /// # Errors
1090    ///
1091    /// Returns an error if the request fails.
1092    #[pyo3(name = "request_instruments")]
1093    fn py_request_instruments(
1094        &self,
1095        venue: Option<Venue>,
1096        params: Option<std::collections::HashMap<String, String>>,
1097    ) -> PyResult<()> {
1098        self.request_instruments_for_python(venue, params)
1099            .map_err(to_pyruntime_err)
1100    }
1101}