Skip to main content

rustrade_data/exchange/ibkr/
options.rs

1//! Option Greeks and chain data types for Interactive Brokers.
2//!
3//! This module provides IBKR-specific functionality for option analytics:
4//!
5//! - Conversion from ibapi's `OptionComputation` to [`OptionGreeks`]
6//! - [`OptionChainEntry`] for option chain metadata
7//!
8//! The core [`OptionGreeks`] type is defined in [`subscription::greeks`] and
9//! re-exported here for convenience.
10//!
11//! # Calculator vs Real-Time
12//!
13//! IB provides two ways to get Greeks:
14//!
15//! 1. **Calculators**: You provide volatility/price inputs, IB computes Greeks.
16//!    See [`IbkrHistoricalData::calculate_theoretical_greeks`] and
17//!    [`IbkrHistoricalData::calculate_implied_volatility`].
18//!
19//! 2. **Real-time ticks**: Subscribe to option market data, receive
20//!    Greeks computed from live prices via `TickTypes::OptionComputation`.
21//!
22//! # Subscription Requirements
23//!
24//! Option Greeks require OPRA subscription (paid) for US options.
25//!
26//! [`subscription::greeks`]: crate::subscription::greeks
27//! [`IbkrHistoricalData::calculate_theoretical_greeks`]: super::historical::IbkrHistoricalData::calculate_theoretical_greeks
28//! [`IbkrHistoricalData::calculate_implied_volatility`]: super::historical::IbkrHistoricalData::calculate_implied_volatility
29
30use chrono::NaiveDate;
31use ibapi::contracts::{OptionChain, OptionComputation};
32use rust_decimal::Decimal;
33use rustrade_instrument::{exchange::ExchangeId, instrument::market_data::OptionChainDescriptor};
34use serde::{Deserialize, Serialize};
35
36pub use crate::subscription::greeks::OptionGreeks;
37
38impl OptionGreeks {
39    /// Convert from ibapi's `OptionComputation` to [`OptionGreeks`].
40    pub(crate) fn from_ib(computation: &OptionComputation) -> Self {
41        Self {
42            delta: computation.delta,
43            gamma: computation.gamma,
44            theta: computation.theta,
45            vega: computation.vega,
46            // `OptionComputation` carries no rho.
47            rho: None,
48            implied_volatility: computation.implied_volatility,
49            theoretical_price: computation.option_price,
50            underlying_price: computation.underlying_price,
51        }
52    }
53}
54
55/// Option chain entry for a specific exchange.
56///
57/// Represents available option series for an underlying on a particular exchange.
58/// Use [`IbkrHistoricalData::fetch_option_chain`] to retrieve this data.
59///
60/// [`IbkrHistoricalData::fetch_option_chain`]: super::historical::IbkrHistoricalData::fetch_option_chain
61#[non_exhaustive]
62#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
63pub struct OptionChainEntry {
64    /// Contract ID of the underlying security.
65    pub underlying_contract_id: i32,
66    /// Option trading class (e.g., "AAPL" for standard, "AAPL7" for weekly).
67    pub trading_class: String,
68    /// Option multiplier (e.g., "100" for standard equity options).
69    pub multiplier: String,
70    /// Exchange where this option series trades.
71    pub exchange: String,
72    /// Available expiration dates.
73    pub expirations: Vec<NaiveDate>,
74    /// Available strike prices.
75    pub strikes: Vec<Decimal>,
76}
77
78impl OptionChainEntry {
79    /// Create OptionChainEntry from ibapi's OptionChain.
80    ///
81    /// Expirations that fail YYYYMMDD parsing and strike prices that fail
82    /// f64→Decimal conversion (NaN/Infinity/IB sentinel values) are skipped.
83    pub fn from_ib(chain: &OptionChain) -> Self {
84        Self {
85            underlying_contract_id: chain.underlying_contract_id,
86            trading_class: chain.trading_class.clone(),
87            multiplier: chain.multiplier.clone(),
88            exchange: chain.exchange.clone(),
89            expirations: chain
90                .expirations
91                .iter()
92                .filter_map(|s| NaiveDate::parse_from_str(s, "%Y%m%d").ok())
93                .collect(),
94            strikes: chain
95                .strikes
96                .iter()
97                .filter_map(|&s| super::decimal_from_f64(s))
98                .collect(),
99        }
100    }
101
102    /// Convert to unified [`OptionChainDescriptor`].
103    ///
104    /// # Errors
105    ///
106    /// Returns [`rust_decimal::Error`] if the multiplier string cannot be parsed
107    /// as a decimal. Surfacing the parse error lets callers log the offending
108    /// value instead of silently dropping the entry.
109    ///
110    /// # Notes
111    ///
112    /// - `exercise` is `None` because IBKR does not return exercise style in chain data
113    /// - `exchange` is always `ExchangeId::Ibkr` (the IBKR-specific exchange string is not mapped)
114    pub fn to_descriptor(&self) -> Result<OptionChainDescriptor, rust_decimal::Error> {
115        let multiplier = self.multiplier.parse::<Decimal>()?;
116
117        Ok(OptionChainDescriptor::new(
118            ExchangeId::Ibkr,
119            multiplier,
120            self.expirations.clone(),
121            self.strikes.clone(),
122            None,
123        ))
124    }
125}
126
127#[cfg(test)]
128#[allow(clippy::unwrap_used)] // Test code: panics on bad input are acceptable
129mod tests {
130    use super::*;
131
132    #[test]
133    fn option_greeks_from_ib_computation() {
134        use ibapi::contracts::{OptionComputation, tick_types::TickType};
135
136        let computation = OptionComputation {
137            field: TickType::ModelOption,
138            tick_attribute: Some(1),
139            delta: Some(0.55),
140            gamma: Some(0.02),
141            theta: Some(-0.05),
142            vega: Some(0.15),
143            implied_volatility: Some(0.25),
144            option_price: Some(5.50),
145            underlying_price: Some(150.0),
146            present_value_dividend: Some(0.0),
147        };
148
149        let greeks = OptionGreeks::from_ib(&computation);
150
151        assert_eq!(greeks.delta, Some(0.55));
152        assert_eq!(greeks.gamma, Some(0.02));
153        assert_eq!(greeks.theta, Some(-0.05));
154        assert_eq!(greeks.vega, Some(0.15));
155        assert_eq!(greeks.implied_volatility, Some(0.25));
156        assert_eq!(greeks.theoretical_price, Some(5.50));
157        assert_eq!(greeks.underlying_price, Some(150.0));
158        assert!(greeks.has_any_greek());
159    }
160
161    #[test]
162    fn option_greeks_empty_has_any_greek_false() {
163        let greeks = OptionGreeks::default();
164        assert!(!greeks.has_any_greek());
165    }
166
167    #[test]
168    fn option_chain_entry_from_ib() {
169        let chain = OptionChain {
170            underlying_contract_id: 265598,
171            trading_class: "AAPL".to_string(),
172            multiplier: "100".to_string(),
173            exchange: "SMART".to_string(),
174            expirations: vec!["20240119".to_string(), "20240216".to_string()],
175            strikes: vec![140.0, 145.0, 150.0, 155.0, 160.0],
176        };
177
178        let entry = OptionChainEntry::from_ib(&chain);
179
180        assert_eq!(entry.underlying_contract_id, 265598);
181        assert_eq!(entry.trading_class, "AAPL");
182        assert_eq!(entry.multiplier, "100");
183        assert_eq!(entry.exchange, "SMART");
184        assert_eq!(entry.expirations.len(), 2);
185        assert_eq!(
186            entry.expirations[0],
187            NaiveDate::from_ymd_opt(2024, 1, 19).unwrap()
188        );
189        assert_eq!(
190            entry.expirations[1],
191            NaiveDate::from_ymd_opt(2024, 2, 16).unwrap()
192        );
193        assert_eq!(entry.strikes.len(), 5);
194    }
195
196    #[test]
197    fn option_chain_entry_skips_invalid_expirations() {
198        let chain = OptionChain {
199            underlying_contract_id: 265598,
200            trading_class: "AAPL".to_string(),
201            multiplier: "100".to_string(),
202            exchange: "SMART".to_string(),
203            expirations: vec![
204                "20240119".to_string(),
205                "invalid".to_string(),
206                "20240216".to_string(),
207            ],
208            strikes: vec![150.0],
209        };
210
211        let entry = OptionChainEntry::from_ib(&chain);
212
213        assert_eq!(entry.expirations.len(), 2);
214    }
215
216    #[test]
217    fn option_chain_entry_to_descriptor() {
218        use rust_decimal_macros::dec;
219
220        let entry = OptionChainEntry {
221            underlying_contract_id: 265598,
222            trading_class: "AAPL".to_string(),
223            multiplier: "100".to_string(),
224            exchange: "SMART".to_string(),
225            expirations: vec![
226                NaiveDate::from_ymd_opt(2024, 1, 19).unwrap(),
227                NaiveDate::from_ymd_opt(2024, 2, 16).unwrap(),
228            ],
229            strikes: vec![dec!(145), dec!(150), dec!(155)],
230        };
231
232        let descriptor = entry.to_descriptor().unwrap();
233
234        assert_eq!(descriptor.exchange, ExchangeId::Ibkr);
235        assert_eq!(descriptor.multiplier, dec!(100));
236        assert_eq!(descriptor.expirations.len(), 2);
237        assert_eq!(descriptor.strikes.len(), 3);
238        assert!(descriptor.exercise.is_none());
239    }
240
241    #[test]
242    fn option_chain_entry_to_descriptor_invalid_multiplier() {
243        let entry = OptionChainEntry {
244            underlying_contract_id: 265598,
245            trading_class: "AAPL".to_string(),
246            multiplier: "not_a_number".to_string(),
247            exchange: "SMART".to_string(),
248            expirations: vec![],
249            strikes: vec![],
250        };
251
252        assert!(entry.to_descriptor().is_err());
253    }
254}