Skip to main content

rustrade_execution/client/ibkr/
connect.rs

1//! What [`IbkrClient::connect_sync`](super::IbkrClient::connect_sync) and
2//! [`IbkrClient::connect_sync_lenient`](super::IbkrClient::connect_sync_lenient) report about
3//! the contracts listed in [`IbkrConfig::contracts`](super::IbkrConfig::contracts).
4
5use super::{
6    ContractConfig, IbkrClient,
7    contract::{ContractConfigError, ResolveContractError},
8};
9use crate::error::UnindexedClientError;
10use itertools::Itertools;
11use rustrade_instrument::{
12    ibkr::{ContractRegistry, ContractRegistryError},
13    instrument::name::InstrumentNameExchange,
14};
15use thiserror::Error;
16use tracing::{debug, warn};
17
18/// Why [`IbkrClient::connect_sync`](super::IbkrClient::connect_sync) failed.
19#[derive(Debug, Clone, PartialEq, Error)]
20#[non_exhaustive]
21pub enum IbkrConnectError {
22    /// TWS/Gateway could not be reached, or refused the connection, such as for a client ID
23    /// already in use.
24    #[error(transparent)]
25    Connect(#[from] UnindexedClientError),
26
27    /// The connection succeeded, but at least one contract in
28    /// [`IbkrConfig::contracts`](super::IbkrConfig::contracts) could not be registered. Every
29    /// such contract is listed, not just the first. The connection was dropped, so its client
30    /// ID is free again.
31    #[error(
32        "{} configured contract(s) not registered: {}",
33        .0.len(),
34        .0.iter().format("; ")
35    )]
36    Contracts(Vec<SkippedContract>),
37}
38
39/// The result of [`IbkrClient::connect_sync_lenient`](super::IbkrClient::connect_sync_lenient):
40/// the connected client, and the configured contracts it connected without.
41#[derive(Debug)]
42#[must_use = "a configured contract in `skipped` is not registered, and its orders are refused"]
43#[non_exhaustive]
44pub struct ConnectOutcome {
45    /// The connected client. Every configured contract not in `skipped` is registered.
46    pub client: IbkrClient,
47    /// Each configured contract that could not be registered, in config order. Empty when all
48    /// of them were.
49    pub skipped: Vec<SkippedContract>,
50}
51
52/// A contract in [`IbkrConfig::contracts`](super::IbkrConfig::contracts) that could not be
53/// registered when the client connected.
54#[derive(Debug, Clone, PartialEq)]
55#[non_exhaustive]
56pub struct SkippedContract {
57    /// The instrument the contract was to be registered under, from
58    /// [`ContractConfig::name`](super::ContractConfig::name).
59    pub name: InstrumentNameExchange,
60    /// Why it could not be registered.
61    pub reason: ContractSkipReason,
62}
63
64impl SkippedContract {
65    /// A skipped contract, for code that handles [`ConnectOutcome::skipped`] or
66    /// [`IbkrConnectError::Contracts`] to be tested without IB.
67    pub fn new(name: InstrumentNameExchange, reason: ContractSkipReason) -> Self {
68        Self { name, reason }
69    }
70}
71
72impl std::fmt::Display for SkippedContract {
73    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
74        write!(f, "{}: {}", self.name, self.reason)
75    }
76}
77
78/// Why a configured contract could not be registered. Each variant carries the error of the
79/// step that failed: building, resolving or registering it.
80#[derive(Debug, Clone, PartialEq, Error)]
81#[non_exhaustive]
82pub enum ContractSkipReason {
83    /// The config does not describe a contract: it is incomplete or names an unsupported
84    /// security type. Fix the config.
85    #[error("invalid contract config: {0}")]
86    Config(ContractConfigError),
87
88    /// IB could not resolve the contract to exactly one of its own, as
89    /// [`IbkrClient::resolve_contract`](super::IbkrClient::resolve_contract) reports.
90    #[error("contract did not resolve: {0}")]
91    Resolve(ResolveContractError),
92
93    /// The resolved contract could not be registered, as
94    /// [`IbkrClient::register_contract`](super::IbkrClient::register_contract) reports, such as
95    /// for a contract already registered under another configured name.
96    #[error("contract did not register: {0}")]
97    Register(ContractRegistryError),
98}
99
100impl ContractSkipReason {
101    /// Whether the same contract may register if tried again: only a
102    /// [`Resolve`](Self::Resolve) failure that
103    /// [`ResolveContractError::is_transient`] calls transient, such as a dropped connection.
104    ///
105    /// The library does not retry. To retry one contract on a connected client, call
106    /// [`resolve_contract`](super::IbkrClient::resolve_contract) and then
107    /// [`register_contract`](super::IbkrClient::register_contract).
108    pub fn is_transient(&self) -> bool {
109        match self {
110            Self::Resolve(error) => error.is_transient(),
111            Self::Config(_) | Self::Register(_) => false,
112        }
113    }
114}
115
116/// Build, resolve and register each configured contract into `registry`, in config order, and
117/// return the ones that failed. `resolve` asks IB for the one contract a description matches.
118///
119/// Every failure is logged at `warn!` as it happens and collected; one failure does not stop the
120/// rest from being tried.
121pub(super) fn register_configured(
122    configs: &[ContractConfig],
123    registry: &ContractRegistry,
124    mut resolve: impl FnMut(
125        &ibapi::contracts::Contract,
126    ) -> Result<ibapi::contracts::Contract, ResolveContractError>,
127) -> Vec<SkippedContract> {
128    configs
129        .iter()
130        .filter_map(|config| {
131            let name = InstrumentNameExchange::from(config.name.as_str());
132            let registered = config
133                .to_contract()
134                .map_err(ContractSkipReason::Config)
135                .and_then(|contract| resolve(&contract).map_err(ContractSkipReason::Resolve))
136                .and_then(|resolved| {
137                    let con_id = resolved.contract_id;
138                    registry
139                        .register(name.clone(), resolved)
140                        .map(|()| con_id)
141                        .map_err(ContractSkipReason::Register)
142                });
143            match registered {
144                Ok(con_id) => {
145                    debug!(name = %name, con_id, "Registered contract");
146                    None
147                }
148                Err(reason) => {
149                    warn!(name = %name, error = %reason, "Configured contract not registered");
150                    Some(SkippedContract { name, reason })
151                }
152            }
153        })
154        .collect()
155}
156
157#[cfg(test)]
158#[allow(clippy::unwrap_used, clippy::expect_used)] // Test code: panics are the correct failure mode
159mod tests {
160    use super::*;
161    use crate::error::ConnectivityError;
162    use ibapi::contracts::Contract;
163
164    fn config(name: &str, security_type: &str) -> ContractConfig {
165        ContractConfig {
166            name: name.to_string(),
167            symbol: name.to_string(),
168            security_type: security_type.to_string(),
169            exchange: "SMART".to_string(),
170            currency: "USD".to_string(),
171            last_trade_date: None,
172            strike: None,
173            right: None,
174        }
175    }
176
177    /// Resolves a description to a copy carrying `contract_id`, looked up by symbol.
178    fn resolve_by_symbol<'a>(
179        ids: &'a [(&'a str, Result<i32, ResolveContractError>)],
180    ) -> impl FnMut(&Contract) -> Result<Contract, ResolveContractError> + 'a {
181        move |contract| {
182            let (_, outcome) = ids
183                .iter()
184                .find(|(symbol, _)| *symbol == contract.symbol.as_str())
185                .expect("every resolved symbol has an outcome");
186            outcome.clone().map(|contract_id| Contract {
187                contract_id,
188                ..contract.clone()
189            })
190        }
191    }
192
193    #[test]
194    fn register_configured_registers_every_contract_that_resolves() {
195        let registry = ContractRegistry::new();
196        let ids = [("AAPL", Ok(265_598)), ("MSFT", Ok(272_093))];
197
198        let skipped = register_configured(
199            &[config("AAPL", "STK"), config("MSFT", "STK")],
200            &registry,
201            resolve_by_symbol(&ids),
202        );
203
204        assert!(skipped.is_empty(), "{skipped:?}");
205        assert_eq!(registry.len(), 2);
206    }
207
208    #[test]
209    fn register_configured_collects_every_failure_in_config_order() {
210        let registry = ContractRegistry::new();
211        let ids = [
212            ("AAPL", Ok(265_598)),
213            ("GONE", Err(ResolveContractError::NoMatch)),
214            // Resolves to AAPL's contract id, which is already registered under "AAPL".
215            ("DUP", Ok(265_598)),
216            ("MSFT", Ok(272_093)),
217        ];
218        let mut missing_date = config("ES", "FUT");
219        missing_date.last_trade_date = None;
220
221        let skipped = register_configured(
222            &[
223                config("AAPL", "STK"),
224                missing_date,
225                config("GONE", "STK"),
226                config("DUP", "STK"),
227                config("MSFT", "STK"),
228            ],
229            &registry,
230            resolve_by_symbol(&ids),
231        );
232
233        let names: Vec<_> = skipped
234            .iter()
235            .map(|skip| skip.name.name().as_str())
236            .collect();
237        assert_eq!(names, ["ES", "GONE", "DUP"]);
238        assert!(matches!(
239            skipped[0].reason,
240            ContractSkipReason::Config(ContractConfigError::MissingLastTradeDate)
241        ));
242        assert_eq!(
243            skipped[1].reason,
244            ContractSkipReason::Resolve(ResolveContractError::NoMatch)
245        );
246        assert!(matches!(
247            skipped[2].reason,
248            ContractSkipReason::Register(ContractRegistryError::ContractIdTaken { .. })
249        ));
250        // The failures did not stop the contracts after them from registering.
251        assert_eq!(registry.len(), 2);
252    }
253
254    #[test]
255    fn register_configured_does_not_resolve_an_invalid_config() {
256        let registry = ContractRegistry::new();
257        let mut resolved = 0;
258
259        let skipped = register_configured(&[config("X", "BOND")], &registry, |_| {
260            resolved += 1;
261            Err(ResolveContractError::NoMatch)
262        });
263
264        assert_eq!(resolved, 0);
265        assert!(matches!(
266            skipped[0].reason,
267            ContractSkipReason::Config(ContractConfigError::UnrecognizedSecurityType { .. })
268        ));
269    }
270
271    #[test]
272    fn only_a_transient_resolve_failure_is_transient() {
273        let transient = ContractSkipReason::Resolve(ResolveContractError::Connectivity(
274            ConnectivityError::Timeout,
275        ));
276        let no_match = ContractSkipReason::Resolve(ResolveContractError::NoMatch);
277        let config = ContractSkipReason::Config(ContractConfigError::MissingStrike);
278        let register = ContractSkipReason::Register(ContractRegistryError::Unresolved {
279            name: InstrumentNameExchange::new("X"),
280        });
281
282        assert!(transient.is_transient());
283        assert!(!no_match.is_transient());
284        assert!(!config.is_transient());
285        assert!(!register.is_transient());
286    }
287
288    #[test]
289    fn contracts_error_names_every_skipped_contract() {
290        let error = IbkrConnectError::Contracts(vec![
291            SkippedContract {
292                name: InstrumentNameExchange::new("ES"),
293                reason: ContractSkipReason::Config(ContractConfigError::MissingLastTradeDate),
294            },
295            SkippedContract {
296                name: InstrumentNameExchange::new("GONE"),
297                reason: ContractSkipReason::Resolve(ResolveContractError::NoMatch),
298            },
299        ]);
300
301        let message = error.to_string();
302        assert!(message.starts_with("2 configured contract(s)"), "{message}");
303        assert!(message.contains("ES: invalid contract config"), "{message}");
304        assert!(
305            message.contains("GONE: contract did not resolve"),
306            "{message}"
307        );
308    }
309}