Skip to main content

nautilus_interactive_brokers/data/
parse.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//! Parsing utilities for converting Interactive Brokers data to Nautilus types.
17
18use ibapi::contracts::{OptionComputation, tick_types::TickType};
19use nautilus_core::UnixNanos;
20use nautilus_model::{
21    data::{
22        Bar, BarType, IndexPriceUpdate, QuoteTick, TradeTick, greeks::OptionGreekValues,
23        option_chain::OptionGreeks,
24    },
25    enums::{AggressorSide, BookAction, GreeksConvention},
26    identifiers::{InstrumentId, TradeId},
27    types::{Price, Quantity},
28};
29
30fn checked_quantity(size: f64, precision: u8) -> anyhow::Result<Quantity> {
31    let quantity = Quantity::new_checked(size, precision)
32        .map_err(|e| anyhow::anyhow!("invalid quantity size={size}: {e}"))?;
33    let tolerance = 10_f64.powi(-i32::from(precision)) * 1e-9;
34    if (quantity.as_f64() - size).abs() > tolerance {
35        anyhow::bail!(
36            "quantity size={} cannot be represented with precision={}",
37            size,
38            precision
39        );
40    }
41    Ok(quantity)
42}
43
44/// Parse IB tick price and size data into a QuoteTick.
45///
46/// This builds a quote from individual tick updates. You typically need to accumulate
47/// bid/ask prices and sizes from multiple tick updates before creating a QuoteTick.
48///
49/// # Errors
50///
51/// Returns an error if price or size conversion fails.
52#[allow(clippy::too_many_arguments)]
53pub fn parse_quote_tick(
54    instrument_id: InstrumentId,
55    bid_price: Option<f64>,
56    ask_price: Option<f64>,
57    bid_size: Option<f64>,
58    ask_size: Option<f64>,
59    price_precision: u8,
60    size_precision: u8,
61    ts_event: UnixNanos,
62    ts_init: UnixNanos,
63) -> anyhow::Result<QuoteTick> {
64    let bid = bid_price.map(|p| Price::new(p, price_precision));
65    let ask = ask_price.map(|p| Price::new(p, price_precision));
66    let bid_qty = bid_size
67        .map(|s| checked_quantity(s, size_precision))
68        .transpose()?;
69    let ask_qty = ask_size
70        .map(|s| checked_quantity(s, size_precision))
71        .transpose()?;
72
73    Ok(QuoteTick::new(
74        instrument_id,
75        bid.unwrap_or_else(|| Price::zero(price_precision)),
76        ask.unwrap_or_else(|| Price::zero(price_precision)),
77        bid_qty.unwrap_or_else(|| Quantity::zero(size_precision)),
78        ask_qty.unwrap_or_else(|| Quantity::zero(size_precision)),
79        ts_event,
80        ts_init,
81    ))
82}
83
84/// Parse IB trade tick data into a TradeTick.
85///
86/// # Errors
87///
88/// Returns an error if price or size conversion fails.
89#[allow(clippy::too_many_arguments)]
90pub fn parse_trade_tick(
91    instrument_id: InstrumentId,
92    price: f64,
93    size: f64,
94    price_precision: u8,
95    size_precision: u8,
96    ts_event: UnixNanos,
97    ts_init: UnixNanos,
98    trade_id: Option<TradeId>,
99) -> anyhow::Result<TradeTick> {
100    let trade_price = Price::new(price, price_precision);
101    let trade_size = checked_quantity(size, size_precision)?;
102    let aggressor_side = AggressorSide::NoAggressor; // IB doesn't provide this directly
103    let trade_id = trade_id
104        .unwrap_or_else(|| crate::common::parse::generate_ib_trade_id(ts_event, price, size));
105
106    Ok(TradeTick::new(
107        instrument_id,
108        trade_price,
109        trade_size,
110        aggressor_side,
111        trade_id,
112        ts_event,
113        ts_init,
114    ))
115}
116
117/// Parse IB index price data into an [`IndexPriceUpdate`].
118///
119/// # Errors
120///
121/// Returns an error if the price conversion fails.
122pub fn parse_index_price(
123    instrument_id: InstrumentId,
124    price: f64,
125    price_precision: u8,
126    price_magnifier: i32,
127    ts_event: UnixNanos,
128    ts_init: UnixNanos,
129) -> anyhow::Result<IndexPriceUpdate> {
130    let converted_price = if price_magnifier > 0 {
131        price / price_magnifier as f64
132    } else {
133        price
134    };
135
136    Ok(IndexPriceUpdate::new(
137        instrument_id,
138        Price::new(converted_price, price_precision),
139        ts_event,
140        ts_init,
141    ))
142}
143
144/// Parse an IB option computation into Nautilus option greeks when the tick carries model greeks.
145#[must_use]
146pub fn parse_option_computation_to_option_greeks(
147    instrument_id: InstrumentId,
148    computation: &OptionComputation,
149    ts_event: UnixNanos,
150    ts_init: UnixNanos,
151) -> Option<OptionGreeks> {
152    match computation.field {
153        TickType::ModelOption | TickType::DelayedModelOption => Some(OptionGreeks {
154            instrument_id,
155            greeks: OptionGreekValues {
156                delta: computation.delta.unwrap_or_default(),
157                gamma: computation.gamma.unwrap_or_default(),
158                vega: computation.vega.unwrap_or_default(),
159                theta: computation.theta.unwrap_or_default(),
160                rho: 0.0, // IB does not publish rho in tickOptionComputation
161            },
162            convention: GreeksConvention::BlackScholes,
163            mark_iv: computation.implied_volatility,
164            bid_iv: None,
165            ask_iv: None,
166            underlying_price: computation.underlying_price,
167            open_interest: None,
168            ts_event,
169            ts_init,
170        }),
171        _ => None,
172    }
173}
174
175/// Parse open interest from an IB tick type when present.
176#[must_use]
177pub fn parse_option_open_interest(tick_type: &TickType, value: f64) -> Option<f64> {
178    match tick_type {
179        TickType::OpenInterest
180        | TickType::OptionCallOpenInterest
181        | TickType::OptionPutOpenInterest => Some(value),
182        _ => None,
183    }
184}
185
186/// Parse IB real-time bar data into a Bar.
187///
188/// # Errors
189///
190/// Returns an error if price or size conversion fails.
191#[allow(clippy::too_many_arguments)]
192pub fn parse_realtime_bar(
193    bar_type: BarType,
194    open: f64,
195    high: f64,
196    low: f64,
197    close: f64,
198    volume: f64,
199    price_precision: u8,
200    size_precision: u8,
201    ts_event: UnixNanos,
202    ts_init: UnixNanos,
203) -> anyhow::Result<Bar> {
204    let open_price = Price::new(open, price_precision);
205    let high_price = Price::new(high, price_precision);
206    let low_price = Price::new(low, price_precision);
207    let close_price = Price::new(close, price_precision);
208    let bar_volume = Quantity::new(volume, size_precision);
209
210    Ok(Bar::new(
211        bar_type,
212        open_price,
213        high_price,
214        low_price,
215        close_price,
216        bar_volume,
217        ts_event,
218        ts_init,
219    ))
220}
221
222/// Parse IB market depth operation to BookAction.
223#[must_use]
224pub fn parse_market_depth_operation(operation: i32) -> BookAction {
225    match operation {
226        0 => BookAction::Add,
227        1 => BookAction::Update,
228        2 => BookAction::Delete,
229        _ => BookAction::Add, // Default to Add for unknown operations
230    }
231}
232
233// Note: ib_timestamp_to_unix_nanos moved to convert.rs
234
235#[cfg(test)]
236mod tests {
237    use ibapi::contracts::{OptionComputation, tick_types::TickType};
238    use nautilus_core::UnixNanos;
239    use nautilus_model::{
240        data::{BarSpecification, BarType},
241        enums::{AggregationSource, BarAggregation, PriceType},
242        identifiers::{InstrumentId, Symbol, Venue},
243    };
244    use rstest::rstest;
245
246    use super::*;
247
248    fn create_test_instrument_id() -> InstrumentId {
249        InstrumentId::new(Symbol::from("AAPL"), Venue::from("NASDAQ"))
250    }
251
252    #[rstest]
253    fn test_parse_quote_tick_with_all_fields() {
254        let instrument_id = create_test_instrument_id();
255        let result = parse_quote_tick(
256            instrument_id,
257            Some(150.25),
258            Some(150.30),
259            Some(100.0),
260            Some(200.0),
261            2,
262            0,
263            UnixNanos::new(0),
264            UnixNanos::new(0),
265        );
266        assert!(result.is_ok());
267        let quote = result.unwrap();
268        assert_eq!(quote.bid_price.as_f64(), 150.25);
269        assert_eq!(quote.ask_price.as_f64(), 150.30);
270        assert_eq!(quote.bid_size.as_f64(), 100.0);
271        assert_eq!(quote.ask_size.as_f64(), 200.0);
272    }
273
274    #[rstest]
275    fn test_parse_quote_tick_with_partial_fields() {
276        let instrument_id = create_test_instrument_id();
277        let result = parse_quote_tick(
278            instrument_id,
279            Some(150.25),
280            None,
281            Some(100.0),
282            None,
283            2,
284            0,
285            UnixNanos::new(0),
286            UnixNanos::new(0),
287        );
288        assert!(result.is_ok());
289        let quote = result.unwrap();
290        assert_eq!(quote.bid_price.as_f64(), 150.25);
291        assert_eq!(quote.ask_price.as_f64(), 0.0); // Should default to zero
292        assert_eq!(quote.bid_size.as_f64(), 100.0);
293        assert_eq!(quote.ask_size.as_f64(), 0.0); // Should default to zero
294    }
295
296    #[rstest]
297    fn test_parse_quote_tick_with_no_fields() {
298        let instrument_id = create_test_instrument_id();
299        let result = parse_quote_tick(
300            instrument_id,
301            None,
302            None,
303            None,
304            None,
305            2,
306            0,
307            UnixNanos::new(0),
308            UnixNanos::new(0),
309        );
310        assert!(result.is_ok());
311        let quote = result.unwrap();
312        assert_eq!(quote.bid_price.as_f64(), 0.0);
313        assert_eq!(quote.ask_price.as_f64(), 0.0);
314    }
315
316    #[rstest]
317    fn test_parse_trade_tick_with_trade_id() {
318        let instrument_id = create_test_instrument_id();
319        let trade_id = TradeId::from("TRADE-001");
320        let result = parse_trade_tick(
321            instrument_id,
322            150.25,
323            100.0,
324            2,
325            0,
326            UnixNanos::new(0),
327            UnixNanos::new(0),
328            Some(trade_id),
329        );
330        assert!(result.is_ok());
331        let trade = result.unwrap();
332        assert_eq!(trade.price.as_f64(), 150.25);
333        assert_eq!(trade.size.as_f64(), 100.0);
334        assert_eq!(trade.trade_id, trade_id);
335    }
336
337    #[rstest]
338    fn test_parse_trade_tick_without_trade_id() {
339        let instrument_id = create_test_instrument_id();
340        let result = parse_trade_tick(
341            instrument_id,
342            150.25,
343            100.0,
344            2,
345            0,
346            UnixNanos::new(1000),
347            UnixNanos::new(1000),
348            None,
349        );
350        assert!(result.is_ok());
351        let trade = result.unwrap();
352        assert_eq!(trade.price.as_f64(), 150.25);
353        assert_eq!(trade.size.as_f64(), 100.0);
354        // Trade ID should be auto-generated
355        assert!(!trade.trade_id.to_string().is_empty());
356    }
357
358    #[rstest]
359    fn test_parse_trade_tick_rejects_unrepresentable_size() {
360        let instrument_id = create_test_instrument_id();
361        let result = parse_trade_tick(
362            instrument_id,
363            150.25,
364            0.5,
365            2,
366            0,
367            UnixNanos::new(1000),
368            UnixNanos::new(1000),
369            None,
370        );
371
372        assert!(result.is_err());
373    }
374
375    #[rstest]
376    fn test_parse_quote_tick_rejects_unrepresentable_size() {
377        let instrument_id = create_test_instrument_id();
378        let result = parse_quote_tick(
379            instrument_id,
380            Some(150.25),
381            Some(150.30),
382            Some(0.5),
383            Some(200.0),
384            2,
385            0,
386            UnixNanos::new(0),
387            UnixNanos::new(0),
388        );
389
390        assert!(result.is_err());
391    }
392
393    #[rstest]
394    fn test_parse_index_price_with_price_magnifier() {
395        let instrument_id = create_test_instrument_id();
396        let result = parse_index_price(
397            instrument_id,
398            452525.0,
399            2,
400            100,
401            UnixNanos::new(0),
402            UnixNanos::new(0),
403        );
404        assert!(result.is_ok());
405        let index_price = result.unwrap();
406        assert_eq!(index_price.value.as_f64(), 4525.25);
407    }
408
409    #[rstest]
410    fn test_parse_index_price_without_price_magnifier() {
411        let instrument_id = create_test_instrument_id();
412        let result = parse_index_price(
413            instrument_id,
414            4525.25,
415            2,
416            1,
417            UnixNanos::new(0),
418            UnixNanos::new(0),
419        );
420        assert!(result.is_ok());
421        let index_price = result.unwrap();
422        assert_eq!(index_price.value.as_f64(), 4525.25);
423    }
424
425    #[rstest]
426    fn test_parse_realtime_bar() {
427        let instrument_id = create_test_instrument_id();
428        let bar_type = BarType::new(
429            instrument_id,
430            BarSpecification::new(1, BarAggregation::Minute, PriceType::Last),
431            AggregationSource::External,
432        );
433        let result = parse_realtime_bar(
434            bar_type,
435            150.0,
436            151.0,
437            149.0,
438            150.5,
439            1000.0,
440            2,
441            0,
442            UnixNanos::new(0),
443            UnixNanos::new(0),
444        );
445        assert!(result.is_ok());
446        let bar = result.unwrap();
447        assert_eq!(bar.open.as_f64(), 150.0);
448        assert_eq!(bar.high.as_f64(), 151.0);
449        assert_eq!(bar.low.as_f64(), 149.0);
450        assert_eq!(bar.close.as_f64(), 150.5);
451        assert_eq!(bar.volume.as_f64(), 1000.0);
452    }
453
454    #[rstest]
455    fn test_parse_market_depth_operation_insert() {
456        let action = parse_market_depth_operation(0);
457        assert_eq!(action, BookAction::Add);
458    }
459
460    #[rstest]
461    fn test_parse_market_depth_operation_update() {
462        let action = parse_market_depth_operation(1);
463        assert_eq!(action, BookAction::Update);
464    }
465
466    #[rstest]
467    fn test_parse_market_depth_operation_delete() {
468        let action = parse_market_depth_operation(2);
469        assert_eq!(action, BookAction::Delete);
470    }
471
472    #[rstest]
473    fn test_parse_market_depth_operation_unknown() {
474        let action = parse_market_depth_operation(99);
475        // Should default to Add for unknown operations
476        assert_eq!(action, BookAction::Add);
477    }
478
479    #[rstest]
480    fn test_parse_quote_tick_precision() {
481        let instrument_id = create_test_instrument_id();
482        let result = parse_quote_tick(
483            instrument_id,
484            Some(150.255),
485            Some(150.305),
486            Some(100.0),
487            Some(200.0),
488            2, // Price precision: 2 decimal places
489            0, // Size precision: 0 decimal places
490            UnixNanos::new(0),
491            UnixNanos::new(0),
492        );
493        assert!(result.is_ok());
494        let quote = result.unwrap();
495        // Prices should be rounded to 2 decimal places
496        assert_eq!(quote.bid_price.as_f64(), 150.26); // Rounded up
497        assert_eq!(quote.ask_price.as_f64(), 150.31); // Rounded up
498    }
499
500    #[rstest]
501    fn test_parse_option_computation_to_option_greeks_from_model_tick() {
502        let instrument_id = create_test_instrument_id();
503        let greeks = parse_option_computation_to_option_greeks(
504            instrument_id,
505            &OptionComputation {
506                field: TickType::ModelOption,
507                implied_volatility: Some(0.25),
508                delta: Some(0.55),
509                gamma: Some(0.02),
510                vega: Some(0.15),
511                theta: Some(-0.05),
512                underlying_price: Some(155.0),
513                ..Default::default()
514            },
515            UnixNanos::new(10),
516            UnixNanos::new(11),
517        )
518        .unwrap();
519
520        assert_eq!(greeks.instrument_id, instrument_id);
521        assert_eq!(greeks.delta, 0.55);
522        assert_eq!(greeks.gamma, 0.02);
523        assert_eq!(greeks.vega, 0.15);
524        assert_eq!(greeks.theta, -0.05);
525        assert_eq!(greeks.rho, 0.0);
526        assert_eq!(greeks.mark_iv, Some(0.25));
527        assert_eq!(greeks.underlying_price, Some(155.0));
528        assert_eq!(greeks.open_interest, None);
529        assert_eq!(greeks.ts_event, UnixNanos::new(10));
530        assert_eq!(greeks.ts_init, UnixNanos::new(11));
531    }
532
533    #[rstest]
534    fn test_parse_option_computation_to_option_greeks_ignores_non_model_tick() {
535        let instrument_id = create_test_instrument_id();
536        let greeks = parse_option_computation_to_option_greeks(
537            instrument_id,
538            &OptionComputation {
539                field: TickType::BidOption,
540                implied_volatility: Some(0.24),
541                ..Default::default()
542            },
543            UnixNanos::new(10),
544            UnixNanos::new(11),
545        );
546
547        assert!(greeks.is_none());
548    }
549
550    #[rstest]
551    fn test_parse_option_open_interest_supports_option_interest_tick_types() {
552        assert_eq!(
553            parse_option_open_interest(&TickType::OptionCallOpenInterest, 1234.0),
554            Some(1234.0)
555        );
556        assert_eq!(
557            parse_option_open_interest(&TickType::OptionPutOpenInterest, 5678.0),
558            Some(5678.0)
559        );
560        assert_eq!(
561            parse_option_open_interest(&TickType::OpenInterest, 42.0),
562            Some(42.0)
563        );
564        assert_eq!(parse_option_open_interest(&TickType::Bid, 42.0), None);
565    }
566}