rustrade_data/exchange/ibkr/
options.rs1use 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 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 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#[non_exhaustive]
62#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
63pub struct OptionChainEntry {
64 pub underlying_contract_id: i32,
66 pub trading_class: String,
68 pub multiplier: String,
70 pub exchange: String,
72 pub expirations: Vec<NaiveDate>,
74 pub strikes: Vec<Decimal>,
76}
77
78impl OptionChainEntry {
79 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 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)] mod 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}