nautilus_interactive_brokers/common/
contracts.rs1use std::str::FromStr;
19
20use anyhow::Context;
21use ibapi::contracts::{
22 Contract, Currency as IBCurrency, Exchange as IBExchange, OptionRight, SecurityIdType,
23 SecurityType, Symbol,
24};
25use nautilus_core::Params;
26use serde_json::Value;
27
28use crate::common::enums::{IbOptionRight, IbSecurityType};
29
30pub(crate) const KEY_BUILD_OPTIONS_CHAIN: &str = "build_options_chain";
31pub(crate) const KEY_BUILD_FUTURES_CHAIN: &str = "build_futures_chain";
32pub(crate) const KEY_MIN_EXPIRY_DAYS: &str = "min_expiry_days";
33pub(crate) const KEY_MAX_EXPIRY_DAYS: &str = "max_expiry_days";
34pub(crate) const KEY_OPTIONS_CHAIN_EXCHANGE: &str = "options_chain_exchange";
35pub(crate) const KEY_OPTIONS_CHAIN_EXCHANGE_ALT: &str = "optionsChainExchange";
36
37#[derive(Clone, Debug)]
39pub struct ConfiguredContract {
40 pub contract: Contract,
42 pub build_options_chain: Option<bool>,
44 pub build_futures_chain: Option<bool>,
46 pub min_expiry_days: Option<u32>,
48 pub max_expiry_days: Option<u32>,
50 pub options_chain_exchange: Option<String>,
52}
53
54impl From<Contract> for ConfiguredContract {
55 fn from(contract: Contract) -> Self {
56 Self {
57 contract,
58 build_options_chain: None,
59 build_futures_chain: None,
60 min_expiry_days: None,
61 max_expiry_days: None,
62 options_chain_exchange: None,
63 }
64 }
65}
66
67impl ConfiguredContract {
68 #[must_use]
70 pub fn to_json_value(&self) -> Value {
71 let mut json = contract_to_json_value(&self.contract);
72
73 if let Value::Object(map) = &mut json {
74 for (key, value) in self.chain_entries() {
75 map.insert(key.to_string(), value);
76 }
77 }
78
79 json
80 }
81
82 #[must_use]
84 pub fn chain_spec_json(&self) -> Option<Value> {
85 let entries = self.chain_entries();
86
87 if entries.is_empty() {
88 return None;
89 }
90
91 let mut map = serde_json::Map::new();
92 for (key, value) in entries {
93 map.insert(key.to_string(), value);
94 }
95
96 Some(Value::Object(map))
97 }
98
99 fn chain_entries(&self) -> Vec<(&'static str, Value)> {
100 let mut entries = Vec::new();
101 if let Some(value) = self.build_options_chain {
102 entries.push((KEY_BUILD_OPTIONS_CHAIN, Value::from(value)));
103 }
104
105 if let Some(value) = self.build_futures_chain {
106 entries.push((KEY_BUILD_FUTURES_CHAIN, Value::from(value)));
107 }
108
109 if let Some(value) = self.min_expiry_days {
110 entries.push((KEY_MIN_EXPIRY_DAYS, Value::from(value)));
111 }
112
113 if let Some(value) = self.max_expiry_days {
114 entries.push((KEY_MAX_EXPIRY_DAYS, Value::from(value)));
115 }
116
117 if let Some(value) = &self.options_chain_exchange {
118 entries.push((KEY_OPTIONS_CHAIN_EXCHANGE, Value::from(value.as_str())));
119 }
120
121 entries
122 }
123}
124
125#[must_use]
127pub fn contract_to_json_value(contract: &Contract) -> Value {
128 serde_json::json!({
129 "secType": security_type_to_code(&contract.security_type),
130 "conId": contract.contract_id,
131 "exchange": contract.exchange.to_string(),
132 "primaryExchange": contract.primary_exchange.to_string(),
133 "symbol": contract.symbol.to_string(),
134 "localSymbol": contract.local_symbol,
135 "currency": contract.currency.to_string(),
136 "tradingClass": contract.trading_class,
137 "lastTradeDateOrContractMonth": contract.last_trade_date_or_contract_month,
138 "multiplier": contract.multiplier,
139 "strike": contract.strike,
140 "right": contract.right,
141 "includeExpired": contract.include_expired,
142 "secIdType": contract.security_id_type,
143 "secId": contract.security_id,
144 "description": contract.description,
145 "issuerId": contract.issuer_id,
146 "comboLegsDescrip": contract.combo_legs_description,
147 })
148}
149
150#[must_use]
151pub fn contract_to_params(contract: &Contract) -> Params {
152 let mut params = Params::new();
153
154 if let Value::Object(map) = contract_to_json_value(contract) {
155 for (key, value) in map {
156 params.insert(key, value);
157 }
158 }
159
160 params
161}
162
163fn security_type_to_code(security_type: &SecurityType) -> String {
164 IbSecurityType::try_from(security_type).map_or_else(
165 |_| security_type.to_string(),
166 |security_type| security_type.to_string(),
167 )
168}
169
170pub fn parse_contract_from_json(json: &Value) -> anyhow::Result<Contract> {
179 let obj = json
180 .as_object()
181 .ok_or_else(|| anyhow::anyhow!("Expected JSON object for contract"))?;
182
183 let get_str = |key: &str| -> String {
184 obj.get(key)
185 .and_then(|v| v.as_str())
186 .unwrap_or_default()
187 .to_string()
188 };
189
190 let get_i32 = |key: &str| -> i32 {
191 obj.get(key)
192 .and_then(|v| v.as_i64())
193 .map_or(0, |n| n as i32)
194 };
195
196 let get_f64 = |key: &str| -> f64 { obj.get(key).and_then(|v| v.as_f64()).unwrap_or(0.0) };
197 let get_bool = |key: &str| -> bool { obj.get(key).and_then(|v| v.as_bool()).unwrap_or(false) };
198
199 let parse_option_right = |key: &str| -> Option<OptionRight> {
200 match IbOptionRight::from_str(&get_str(key)).ok()? {
201 IbOptionRight::Call => Some(OptionRight::Call),
202 IbOptionRight::Put => Some(OptionRight::Put),
203 }
204 };
205
206 let parse_security_id_type = |key: &str| -> Option<SecurityIdType> {
207 match get_str(key).to_ascii_uppercase().as_str() {
208 "CUSIP" => Some(SecurityIdType::Cusip),
209 "ISIN" => Some(SecurityIdType::Isin),
210 "SEDOL" => Some(SecurityIdType::Sedol),
211 "RIC" => Some(SecurityIdType::Ric),
212 "FIGI" => Some(SecurityIdType::Figi),
213 _ => None,
214 }
215 };
216
217 let sec_type_str = get_str("secType");
219
220 let security_type = if sec_type_str.is_empty() {
221 SecurityType::Stock
222 } else {
223 IbSecurityType::from_str(&sec_type_str).map_or_else(
224 |_| SecurityType::Other(sec_type_str.clone()),
225 IbSecurityType::ibapi_security_type,
226 )
227 };
228
229 Ok(Contract {
230 contract_id: get_i32("conId"),
231 symbol: Symbol::from(get_str("symbol")),
232 security_type,
233 last_trade_date_or_contract_month: get_str("lastTradeDateOrContractMonth"),
234 strike: get_f64("strike"),
235 right: parse_option_right("right"),
236 multiplier: get_str("multiplier"),
237 exchange: IBExchange::from(get_str("exchange")),
238 currency: IBCurrency::from(get_str("currency")),
239 local_symbol: get_str("localSymbol"),
240 primary_exchange: IBExchange::from(get_str("primaryExchange")),
241 trading_class: get_str("tradingClass"),
242 include_expired: get_bool("includeExpired"),
243 security_id_type: parse_security_id_type("secIdType"),
244 security_id: get_str("secId"),
245 last_trade_date: None,
246 combo_legs_description: get_str("comboLegsDescrip"),
247 combo_legs: Vec::new(), delta_neutral_contract: None, issuer_id: get_str("issuerId"),
250 description: get_str("description"),
251 })
252}
253
254pub fn parse_configured_contract_from_json(json: &Value) -> anyhow::Result<ConfiguredContract> {
261 let obj = json
262 .as_object()
263 .ok_or_else(|| anyhow::anyhow!("Expected JSON object for configured contract"))?;
264
265 for key in [
266 "symbol",
267 "lastTradeDateOrContractMonth",
268 "multiplier",
269 "exchange",
270 "currency",
271 "localSymbol",
272 "primaryExchange",
273 "tradingClass",
274 "secId",
275 "issuerId",
276 "description",
277 "comboLegsDescrip",
278 ] {
279 if let Some(value) = obj.get(key)
280 && !value.is_string()
281 {
282 anyhow::bail!("Configured contract field '{key}' must be a string");
283 }
284 }
285
286 if let Some(value) = obj.get("strike")
287 && !value.is_number()
288 {
289 anyhow::bail!("Configured contract field 'strike' must be a number");
290 }
291
292 if let Some(value) = obj.get("includeExpired")
293 && !value.is_boolean()
294 {
295 anyhow::bail!("Configured contract field 'includeExpired' must be a boolean");
296 }
297
298 let mut contract_id = 0;
299
300 if let Some(value) = obj.get("conId") {
301 let raw_contract_id = value
302 .as_i64()
303 .context("Configured contract field 'conId' must be an integer")?;
304 contract_id = i32::try_from(raw_contract_id)
305 .context("Configured contract field 'conId' exceeds i32")?;
306 }
307
308 match obj.get("secType").and_then(Value::as_str) {
309 Some(security_type) => {
310 IbSecurityType::from_str(security_type)?;
311 }
312 None if contract_id > 0 => {} None => anyhow::bail!(
314 "Configured contract requires 'secType' as a known IB security type string, or a positive 'conId'"
315 ),
316 }
317
318 if let Some(value) = obj.get("right")
319 && !value.is_null()
320 {
321 let right = value
322 .as_str()
323 .context("Configured contract field 'right' must be a string")?;
324 IbOptionRight::from_str(right)?;
325 }
326
327 if let Some(value) = obj.get("secIdType")
328 && !value.is_null()
329 {
330 let security_id_type = value
331 .as_str()
332 .context("Configured contract field 'secIdType' must be a string")?;
333
334 if !matches!(
335 security_id_type.to_ascii_uppercase().as_str(),
336 "CUSIP" | "ISIN" | "SEDOL" | "RIC" | "FIGI"
337 ) {
338 anyhow::bail!("Unknown IB security ID type: {security_id_type}");
339 }
340 }
341
342 let build_options_chain = configured_bool(obj, KEY_BUILD_OPTIONS_CHAIN)?;
343 let build_futures_chain = configured_bool(obj, KEY_BUILD_FUTURES_CHAIN)?;
344 let min_expiry_days = configured_u32(obj, KEY_MIN_EXPIRY_DAYS)?;
345 let max_expiry_days = configured_u32(obj, KEY_MAX_EXPIRY_DAYS)?;
346
347 let options_chain_exchange = match configured_string(obj, KEY_OPTIONS_CHAIN_EXCHANGE)? {
348 Some(exchange) => Some(exchange),
349 None => configured_string(obj, KEY_OPTIONS_CHAIN_EXCHANGE_ALT)?,
350 };
351
352 Ok(ConfiguredContract {
353 contract: parse_contract_from_json(json)?,
354 build_options_chain,
355 build_futures_chain,
356 min_expiry_days,
357 max_expiry_days,
358 options_chain_exchange,
359 })
360}
361
362fn configured_bool(
363 obj: &serde_json::Map<String, Value>,
364 key: &str,
365) -> anyhow::Result<Option<bool>> {
366 obj.get(key)
367 .map(|value| {
368 value
369 .as_bool()
370 .with_context(|| format!("Configured contract field '{key}' must be a boolean"))
371 })
372 .transpose()
373}
374
375fn configured_u32(obj: &serde_json::Map<String, Value>, key: &str) -> anyhow::Result<Option<u32>> {
376 obj.get(key)
377 .map(|value| {
378 value
379 .as_u64()
380 .and_then(|days| u32::try_from(days).ok())
381 .with_context(|| {
382 format!("Configured contract field '{key}' must be a non-negative integer")
383 })
384 })
385 .transpose()
386}
387
388fn configured_string(
389 obj: &serde_json::Map<String, Value>,
390 key: &str,
391) -> anyhow::Result<Option<String>> {
392 obj.get(key)
393 .map(|value| {
394 value
395 .as_str()
396 .map(ToString::to_string)
397 .with_context(|| format!("Configured contract field '{key}' must be a string"))
398 })
399 .transpose()
400}
401
402pub fn parse_contracts_from_json_array(json_str: &str) -> anyhow::Result<Vec<Contract>> {
408 let value: Value = serde_json::from_str(json_str).context("Failed to parse JSON string")?;
409
410 let array = value
411 .as_array()
412 .ok_or_else(|| anyhow::anyhow!("Expected JSON array for contracts"))?;
413
414 let mut contracts = Vec::new();
415
416 for (idx, item) in array.iter().enumerate() {
417 match parse_contract_from_json(item) {
418 Ok(contract) => contracts.push(contract),
419 Err(e) => {
420 tracing::warn!("Failed to parse contract at index {}: {}", idx, e);
421 }
422 }
423 }
424
425 Ok(contracts)
426}