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