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)]
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 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
170/// Parse IB contract from JSON dictionary.
171///
172/// This function parses a JSON object (dictionary) representing an IBContract
173/// and converts it to a rust-ibapi Contract struct.
174///
175/// # Errors
176///
177/// Returns an error if the JSON is not a valid object or if required fields are missing.
178pub 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    // Parse security type
218    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(),       // TODO: Parse combo_legs if needed
248        delta_neutral_contract: None, // TODO: Parse delta_neutral_contract if needed
249        issuer_id: get_str("issuerId"),
250        description: get_str("description"),
251    })
252}
253
254/// Parse a configured IB contract with strict field and enum validation.
255///
256/// # Errors
257///
258/// Returns an error if a known field has the wrong JSON type, a numeric field is out of range, or
259/// an enum value is unknown.
260pub 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 => {} // IB resolves the contract by conId
313        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
402/// Parse multiple IB contracts from JSON array.
403///
404/// # Errors
405///
406/// Returns an error if the JSON string is invalid or if any contract fails to parse.
407pub 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}