Skip to main content

nautilus_interactive_brokers/common/
contracts.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//! Contract parsing utilities for Interactive Brokers adapter.
17
18use 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/// A configured IB contract with optional per-contract chain-building settings.
38#[derive(Clone, Debug, PartialEq)]
39pub struct ConfiguredContract {
40    /// The IB contract to load.
41    pub contract: Contract,
42    /// Whether to build the options chain for this contract, overriding the global flag.
43    pub build_options_chain: Option<bool>,
44    /// Whether to build the futures chain for this contract, overriding the global flag.
45    pub build_futures_chain: Option<bool>,
46    /// Minimum expiry days for this contract's chains, overriding the global value.
47    pub min_expiry_days: Option<u32>,
48    /// Maximum expiry days for this contract's chains, overriding the global value.
49    pub max_expiry_days: Option<u32>,
50    /// Exchange override for options chain lookups.
51    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    /// Convert to the JSON representation used in configuration files.
69    #[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    /// Chain-building spec for `load_contract_spec`, when any per-contract setting is present.
83    #[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/// Convert an IB contract into JSON metadata suitable for instrument `info["contract"]`.
126#[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
167/// Parse IB contract from JSON dictionary.
168///
169/// This function parses a JSON object (dictionary) representing an IBContract
170/// and converts it to a rust-ibapi Contract struct.
171///
172/// # Errors
173///
174/// Returns an error if the JSON is not a valid object or if required fields are missing.
175pub 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    // Parse security type
215    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(),       // TODO: Parse combo_legs if needed
245        delta_neutral_contract: None, // TODO: Parse delta_neutral_contract if needed
246        issuer_id: get_str("issuerId"),
247        description: get_str("description"),
248    })
249}
250
251/// Parse a configured IB contract with strict field and enum validation.
252///
253/// # Errors
254///
255/// Returns an error if a known field has the wrong JSON type, a numeric field is out of range, or
256/// an enum value is unknown.
257pub 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 => {} // IB resolves the contract by conId
310        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
399/// Parse multiple IB contracts from JSON array.
400///
401/// # Errors
402///
403/// Returns an error if the JSON string is invalid or if any contract fails to parse.
404pub 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}