rustrade_execution/client/ibkr/
connect.rs1use 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#[derive(Debug, Clone, PartialEq, Error)]
20#[non_exhaustive]
21pub enum IbkrConnectError {
22 #[error(transparent)]
25 Connect(#[from] UnindexedClientError),
26
27 #[error(
32 "{} configured contract(s) not registered: {}",
33 .0.len(),
34 .0.iter().format("; ")
35 )]
36 Contracts(Vec<SkippedContract>),
37}
38
39#[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 pub client: IbkrClient,
47 pub skipped: Vec<SkippedContract>,
50}
51
52#[derive(Debug, Clone, PartialEq)]
55#[non_exhaustive]
56pub struct SkippedContract {
57 pub name: InstrumentNameExchange,
60 pub reason: ContractSkipReason,
62}
63
64impl SkippedContract {
65 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#[derive(Debug, Clone, PartialEq, Error)]
81#[non_exhaustive]
82pub enum ContractSkipReason {
83 #[error("invalid contract config: {0}")]
86 Config(ContractConfigError),
87
88 #[error("contract did not resolve: {0}")]
91 Resolve(ResolveContractError),
92
93 #[error("contract did not register: {0}")]
97 Register(ContractRegistryError),
98}
99
100impl ContractSkipReason {
101 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
116pub(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)] mod 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 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 ®istry,
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 ("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 ®istry,
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 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")], ®istry, |_| {
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}