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, PartialEq)]
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 map = match contract_to_json_value(contract) {
153 Value::Object(map) => map.into_iter().collect(),
154 _ => Default::default(),
155 };
156
157 Params::new(map)
158}
159
160fn security_type_to_code(security_type: &SecurityType) -> String {
161 IbSecurityType::try_from(security_type).map_or_else(
162 |_| security_type.to_string(),
163 |security_type| security_type.to_string(),
164 )
165}
166
167pub fn parse_contract_from_json(json: &Value) -> anyhow::Result<Contract> {
176 let obj = json
177 .as_object()
178 .ok_or_else(|| anyhow::anyhow!("Expected JSON object for contract"))?;
179
180 let get_str = |key: &str| -> String {
181 obj.get(key)
182 .and_then(|v| v.as_str())
183 .unwrap_or_default()
184 .to_string()
185 };
186
187 let get_i32 = |key: &str| -> i32 {
188 obj.get(key)
189 .and_then(|v| v.as_i64())
190 .map_or(0, |n| n as i32)
191 };
192
193 let get_f64 = |key: &str| -> f64 { obj.get(key).and_then(|v| v.as_f64()).unwrap_or(0.0) };
194 let get_bool = |key: &str| -> bool { obj.get(key).and_then(|v| v.as_bool()).unwrap_or(false) };
195
196 let parse_option_right = |key: &str| -> Option<OptionRight> {
197 match IbOptionRight::from_str(&get_str(key)).ok()? {
198 IbOptionRight::Call => Some(OptionRight::Call),
199 IbOptionRight::Put => Some(OptionRight::Put),
200 }
201 };
202
203 let parse_security_id_type = |key: &str| -> Option<SecurityIdType> {
204 match get_str(key).to_ascii_uppercase().as_str() {
205 "CUSIP" => Some(SecurityIdType::Cusip),
206 "ISIN" => Some(SecurityIdType::Isin),
207 "SEDOL" => Some(SecurityIdType::Sedol),
208 "RIC" => Some(SecurityIdType::Ric),
209 "FIGI" => Some(SecurityIdType::Figi),
210 _ => None,
211 }
212 };
213
214 let sec_type_str = get_str("secType");
216
217 let security_type = if sec_type_str.is_empty() {
218 SecurityType::Stock
219 } else {
220 IbSecurityType::from_str(&sec_type_str).map_or_else(
221 |_| SecurityType::Other(sec_type_str.clone()),
222 IbSecurityType::ibapi_security_type,
223 )
224 };
225
226 Ok(Contract {
227 contract_id: get_i32("conId"),
228 symbol: Symbol::from(get_str("symbol")),
229 security_type,
230 last_trade_date_or_contract_month: get_str("lastTradeDateOrContractMonth"),
231 strike: get_f64("strike"),
232 right: parse_option_right("right"),
233 multiplier: get_str("multiplier"),
234 exchange: IBExchange::from(get_str("exchange")),
235 currency: IBCurrency::from(get_str("currency")),
236 local_symbol: get_str("localSymbol"),
237 primary_exchange: IBExchange::from(get_str("primaryExchange")),
238 trading_class: get_str("tradingClass"),
239 include_expired: get_bool("includeExpired"),
240 security_id_type: parse_security_id_type("secIdType"),
241 security_id: get_str("secId"),
242 last_trade_date: None,
243 combo_legs_description: get_str("comboLegsDescrip"),
244 combo_legs: Vec::new(), delta_neutral_contract: None, issuer_id: get_str("issuerId"),
247 description: get_str("description"),
248 })
249}
250
251pub fn parse_configured_contract_from_json(json: &Value) -> anyhow::Result<ConfiguredContract> {
258 let obj = json
259 .as_object()
260 .ok_or_else(|| anyhow::anyhow!("Expected JSON object for configured contract"))?;
261
262 for key in [
263 "symbol",
264 "lastTradeDateOrContractMonth",
265 "multiplier",
266 "exchange",
267 "currency",
268 "localSymbol",
269 "primaryExchange",
270 "tradingClass",
271 "secId",
272 "issuerId",
273 "description",
274 "comboLegsDescrip",
275 ] {
276 if let Some(value) = obj.get(key)
277 && !value.is_string()
278 {
279 anyhow::bail!("Configured contract field '{key}' must be a string");
280 }
281 }
282
283 if let Some(value) = obj.get("strike")
284 && !value.is_number()
285 {
286 anyhow::bail!("Configured contract field 'strike' must be a number");
287 }
288
289 if let Some(value) = obj.get("includeExpired")
290 && !value.is_boolean()
291 {
292 anyhow::bail!("Configured contract field 'includeExpired' must be a boolean");
293 }
294
295 let mut contract_id = 0;
296
297 if let Some(value) = obj.get("conId") {
298 let raw_contract_id = value
299 .as_i64()
300 .context("Configured contract field 'conId' must be an integer")?;
301 contract_id = i32::try_from(raw_contract_id)
302 .context("Configured contract field 'conId' exceeds i32")?;
303 }
304
305 match obj.get("secType").and_then(Value::as_str) {
306 Some(security_type) => {
307 IbSecurityType::from_str(security_type)?;
308 }
309 None if contract_id > 0 => {} None => anyhow::bail!(
311 "Configured contract requires 'secType' as a known IB security type string, or a positive 'conId'"
312 ),
313 }
314
315 if let Some(value) = obj.get("right")
316 && !value.is_null()
317 {
318 let right = value
319 .as_str()
320 .context("Configured contract field 'right' must be a string")?;
321 IbOptionRight::from_str(right)?;
322 }
323
324 if let Some(value) = obj.get("secIdType")
325 && !value.is_null()
326 {
327 let security_id_type = value
328 .as_str()
329 .context("Configured contract field 'secIdType' must be a string")?;
330
331 if !matches!(
332 security_id_type.to_ascii_uppercase().as_str(),
333 "CUSIP" | "ISIN" | "SEDOL" | "RIC" | "FIGI"
334 ) {
335 anyhow::bail!("Unknown IB security ID type: {security_id_type}");
336 }
337 }
338
339 let build_options_chain = configured_bool(obj, KEY_BUILD_OPTIONS_CHAIN)?;
340 let build_futures_chain = configured_bool(obj, KEY_BUILD_FUTURES_CHAIN)?;
341 let min_expiry_days = configured_u32(obj, KEY_MIN_EXPIRY_DAYS)?;
342 let max_expiry_days = configured_u32(obj, KEY_MAX_EXPIRY_DAYS)?;
343
344 let options_chain_exchange = match configured_string(obj, KEY_OPTIONS_CHAIN_EXCHANGE)? {
345 Some(exchange) => Some(exchange),
346 None => configured_string(obj, KEY_OPTIONS_CHAIN_EXCHANGE_ALT)?,
347 };
348
349 Ok(ConfiguredContract {
350 contract: parse_contract_from_json(json)?,
351 build_options_chain,
352 build_futures_chain,
353 min_expiry_days,
354 max_expiry_days,
355 options_chain_exchange,
356 })
357}
358
359fn configured_bool(
360 obj: &serde_json::Map<String, Value>,
361 key: &str,
362) -> anyhow::Result<Option<bool>> {
363 obj.get(key)
364 .map(|value| {
365 value
366 .as_bool()
367 .with_context(|| format!("Configured contract field '{key}' must be a boolean"))
368 })
369 .transpose()
370}
371
372fn configured_u32(obj: &serde_json::Map<String, Value>, key: &str) -> anyhow::Result<Option<u32>> {
373 obj.get(key)
374 .map(|value| {
375 value
376 .as_u64()
377 .and_then(|days| u32::try_from(days).ok())
378 .with_context(|| {
379 format!("Configured contract field '{key}' must be a non-negative integer")
380 })
381 })
382 .transpose()
383}
384
385fn configured_string(
386 obj: &serde_json::Map<String, Value>,
387 key: &str,
388) -> anyhow::Result<Option<String>> {
389 obj.get(key)
390 .map(|value| {
391 value
392 .as_str()
393 .map(ToString::to_string)
394 .with_context(|| format!("Configured contract field '{key}' must be a string"))
395 })
396 .transpose()
397}
398
399pub fn parse_contracts_from_json_array(json_str: &str) -> anyhow::Result<Vec<Contract>> {
405 let value: Value = serde_json::from_str(json_str).context("Failed to parse JSON string")?;
406
407 let array = value
408 .as_array()
409 .ok_or_else(|| anyhow::anyhow!("Expected JSON array for contracts"))?;
410
411 let mut contracts = Vec::new();
412
413 for (idx, item) in array.iter().enumerate() {
414 match parse_contract_from_json(item) {
415 Ok(contract) => contracts.push(contract),
416 Err(e) => {
417 tracing::warn!("Failed to parse contract at index {}: {}", idx, e);
418 }
419 }
420 }
421
422 Ok(contracts)
423}