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
31use super::convert::IbPriceConverter;
32
33#[derive(Debug, PartialEq, Error)]
34#[error("quantity size={size} cannot be represented with precision={precision}")]
35struct QuantityPrecisionError {
36    size: f64,
37    precision: u8,
38}
39
40fn checked_quantity(size: f64, precision: u8) -> anyhow::Result<Quantity> {
41    let quantity = Quantity::new_checked(size, precision)
42        .map_err(|e| anyhow::anyhow!("invalid quantity size={size}: {e}"))?;
43    if !is_representable_size(quantity, size, precision) {
44        anyhow::bail!(QuantityPrecisionError { size, precision });
45    }
46
47    Ok(quantity)
48}
49
50/// Returns whether `quantity`, built from `size` at `precision`, kept `size` without rounding.
51///
52/// The tolerance grows with the size: `Quantity::as_f64` divides a 128-bit raw by the fixed
53/// scalar, so it can be an ulp or two off the size (2.3e-10 for 1975139), which a fixed
54/// tolerance rejects.
55pub(super) fn is_representable_size(quantity: Quantity, size: f64, precision: u8) -> bool {
56    let tolerance =
57        (10_f64.powi(-i32::from(precision)) * 1e-9).max(size.abs() * 4.0 * f64::EPSILON);
58    (quantity.as_f64() - size).abs() <= tolerance
59}
60
61/// Generates the stable fallback trade ID used when IB omits an execution identifier.
62pub(crate) fn generate_ib_trade_id(ts_event: UnixNanos, price: f64, size: f64) -> TradeId {
63    let ts_secs = ts_event.as_i64() / 1_000_000_000;
64    TradeId::new(format!("{ts_secs}-{price}-{size}"))
65}
66
67/// Parse IB tick price and size data into a QuoteTick.
68///
69/// This builds a quote from individual tick updates. You typically need to accumulate
70/// bid/ask prices and sizes from multiple tick updates before creating a QuoteTick.
71///
72/// # Errors
73///
74/// Returns an error if price or size conversion fails.
75#[allow(clippy::too_many_arguments)]
76pub fn parse_quote_tick(
77    instrument_id: InstrumentId,
78    bid_price: Option<f64>,
79    ask_price: Option<f64>,
80    bid_size: Option<f64>,
81    ask_size: Option<f64>,
82    prices: IbPriceConverter,
83    size_precision: u8,
84    ts_event: UnixNanos,
85    ts_init: UnixNanos,
86) -> anyhow::Result<QuoteTick> {
87    let bid = bid_price.map(|p| prices.bid_price(p)).transpose()?;
88    let ask = ask_price.map(|p| prices.ask_price(p)).transpose()?;
89    let bid_qty = bid_size
90        .map(|s| checked_quantity(s, size_precision))
91        .transpose()?;
92    let ask_qty = ask_size
93        .map(|s| checked_quantity(s, size_precision))
94        .transpose()?;
95
96    Ok(QuoteTick::new(
97        instrument_id,
98        bid.unwrap_or_else(|| Price::zero(prices.precision())),
99        ask.unwrap_or_else(|| Price::zero(prices.precision())),
100        bid_qty.unwrap_or_else(|| Quantity::zero(size_precision)),
101        ask_qty.unwrap_or_else(|| Quantity::zero(size_precision)),
102        ts_event,
103        ts_init,
104    ))
105}
106
107/// Parse IB trade tick data into a TradeTick.
108///
109/// # Errors
110///
111/// Returns an error if price or size conversion fails.
112#[allow(clippy::too_many_arguments)]
113pub fn parse_trade_tick(
114    instrument_id: InstrumentId,
115    price: f64,
116    size: f64,
117    prices: IbPriceConverter,
118    size_precision: u8,
119    ts_event: UnixNanos,
120    ts_init: UnixNanos,
121    trade_id: Option<TradeId>,
122) -> anyhow::Result<TradeTick> {
123    let trade_price = prices
124        .price(price)
125        .map_err(|e| anyhow::anyhow!("invalid trade price={price}: {e}"))?;
126    let trade_size = checked_quantity(size, size_precision)?;
127    let aggressor_side = AggressorSide::NoAggressor; // IB doesn't provide this directly
128    let trade_id = trade_id.unwrap_or_else(|| generate_ib_trade_id(ts_event, price, size));
129
130    TradeTick::new_checked(
131        instrument_id,
132        trade_price,
133        trade_size,
134        aggressor_side,
135        trade_id,
136        ts_event,
137        ts_init,
138    )
139    .map_err(|e| anyhow::anyhow!("invalid trade tick: {e}"))
140}
141
142pub(super) fn log_tick_parse_error(kind: &str, instrument_id: InstrumentId, e: &anyhow::Error) {
143    if e.is::<QuantityPrecisionError>() {
144        tracing::debug!("Dropping IB {kind} tick for {instrument_id}: {e}");
145    } else {
146        tracing::warn!("Failed to parse IB {kind} tick for {instrument_id}: {e}");
147    }
148}
149
150/// Parse IB index price data into an [`IndexPriceUpdate`].
151///
152/// # Errors
153///
154/// Returns an error if the price conversion fails.
155pub fn parse_index_price(
156    instrument_id: InstrumentId,
157    price: f64,
158    prices: IbPriceConverter,
159    ts_event: UnixNanos,
160    ts_init: UnixNanos,
161) -> anyhow::Result<IndexPriceUpdate> {
162    Ok(IndexPriceUpdate::new(
163        instrument_id,
164        prices.price(price)?,
165        ts_event,
166        ts_init,
167    ))
168}
169
170/// Parse an IB option computation into Nautilus option greeks when the tick carries model greeks.
171#[must_use]
172pub fn parse_option_computation_to_option_greeks(
173    instrument_id: InstrumentId,
174    computation: &OptionComputation,
175    ts_event: UnixNanos,
176    ts_init: UnixNanos,
177) -> Option<OptionGreeks> {
178    match computation.field {
179        TickType::ModelOption | TickType::DelayedModelOption => Some(OptionGreeks {
180            instrument_id,
181            greeks: OptionGreekValues {
182                delta: computation.delta.unwrap_or_default(),
183                gamma: computation.gamma.unwrap_or_default(),
184                vega: computation.vega.unwrap_or_default(),
185                theta: computation.theta.unwrap_or_default(),
186                rho: 0.0, // IB does not publish rho in tickOptionComputation
187            },
188            convention: GreeksConvention::BlackScholes,
189            mark_iv: computation.implied_volatility,
190            bid_iv: None,
191            ask_iv: None,
192            underlying_price: computation.underlying_price,
193            open_interest: None,
194            ts_event,
195            ts_init,
196        }),
197        _ => None,
198    }
199}
200
201/// Parse open interest from an IB tick type when present.
202#[must_use]
203pub fn parse_option_open_interest(tick_type: &TickType, value: f64) -> Option<f64> {
204    match tick_type {
205        TickType::OpenInterest
206        | TickType::OptionCallOpenInterest
207        | TickType::OptionPutOpenInterest => Some(value),
208        _ => None,
209    }
210}
211
212/// Parse IB real-time bar data into a Bar.
213///
214/// # Errors
215///
216/// Returns an error if price or size conversion fails.
217#[allow(clippy::too_many_arguments)]
218pub fn parse_realtime_bar(
219    bar_type: BarType,
220    open: f64,
221    high: f64,
222    low: f64,
223    close: f64,
224    volume: f64,
225    prices: IbPriceConverter,
226    size_precision: u8,
227    ts_event: UnixNanos,
228    ts_init: UnixNanos,
229) -> anyhow::Result<Bar> {
230    let price_type = bar_type.spec().price_type;
231    let open_price = prices.bar_price(price_type, open)?;
232    let high_price = prices.bar_price(price_type, high)?;
233    let low_price = prices.bar_price(price_type, low)?;
234    let close_price = prices.bar_price(price_type, close)?;
235    let bar_volume = Quantity::new(volume, size_precision);
236
237    Ok(Bar::new(
238        bar_type,
239        open_price,
240        high_price,
241        low_price,
242        close_price,
243        bar_volume,
244        ts_event,
245        ts_init,
246    ))
247}
248
249/// Parse IB market depth operation to BookAction.
250#[must_use]
251pub fn parse_market_depth_operation(operation: i32) -> Option<BookAction> {
252    match operation {
253        0 => Some(BookAction::Add),
254        1 => Some(BookAction::Update),
255        2 => Some(BookAction::Delete),
256        _ => None,
257    }
258}
259
260// Note: ib_timestamp_to_unix_nanos moved to convert.rs
261
262#[cfg(test)]
263mod tests {
264    use ibapi::contracts::{OptionComputation, tick_types::TickType};
265    use nautilus_core::UnixNanos;
266    use nautilus_model::{
267        data::{BarSpecification, BarType},
268        enums::{AggregationSource, BarAggregation, PriceType},
269        identifiers::{InstrumentId, Symbol, Venue},
270        instruments::Instrument,
271    };
272    use rstest::rstest;
273    use rust_decimal::Decimal;
274
275    use super::*;
276    use crate::stubs::currency_pair;
277
278    fn create_test_instrument_id() -> InstrumentId {
279        InstrumentId::new(Symbol::from("AAPL"), Venue::from("NASDAQ"))
280    }
281
282    #[rstest]
283    fn test_parse_quote_tick_with_all_fields() {
284        let instrument_id = create_test_instrument_id();
285        let result = parse_quote_tick(
286            instrument_id,
287            Some(150.25),
288            Some(150.30),
289            Some(100.0),
290            Some(200.0),
291            IbPriceConverter::new(2),
292            0,
293            UnixNanos::new(0),
294            UnixNanos::new(0),
295        );
296        assert!(result.is_ok());
297        let quote = result.unwrap();
298        assert_eq!(quote.bid_price.as_f64(), 150.25);
299        assert_eq!(quote.ask_price.as_f64(), 150.30);
300        assert_eq!(quote.bid_size.as_f64(), 100.0);
301        assert_eq!(quote.ask_size.as_f64(), 200.0);
302    }
303
304    #[rstest]
305    fn test_parse_quote_tick_with_partial_fields() {
306        let instrument_id = create_test_instrument_id();
307        let result = parse_quote_tick(
308            instrument_id,
309            Some(150.25),
310            None,
311            Some(100.0),
312            None,
313            IbPriceConverter::new(2),
314            0,
315            UnixNanos::new(0),
316            UnixNanos::new(0),
317        );
318        assert!(result.is_ok());
319        let quote = result.unwrap();
320        assert_eq!(quote.bid_price.as_f64(), 150.25);
321        assert_eq!(quote.ask_price.as_f64(), 0.0); // Should default to zero
322        assert_eq!(quote.bid_size.as_f64(), 100.0);
323        assert_eq!(quote.ask_size.as_f64(), 0.0); // Should default to zero
324    }
325
326    #[rstest]
327    fn test_parse_quote_tick_with_no_fields() {
328        let instrument_id = create_test_instrument_id();
329        let result = parse_quote_tick(
330            instrument_id,
331            None,
332            None,
333            None,
334            None,
335            IbPriceConverter::new(2),
336            0,
337            UnixNanos::new(0),
338            UnixNanos::new(0),
339        );
340        assert!(result.is_ok());
341        let quote = result.unwrap();
342        assert_eq!(quote.bid_price.as_f64(), 0.0);
343        assert_eq!(quote.ask_price.as_f64(), 0.0);
344    }
345
346    #[rstest]
347    fn test_parse_trade_tick_with_trade_id() {
348        let instrument_id = create_test_instrument_id();
349        let trade_id = TradeId::from("TRADE-001");
350        let result = parse_trade_tick(
351            instrument_id,
352            150.25,
353            100.0,
354            IbPriceConverter::new(2),
355            0,
356            UnixNanos::new(0),
357            UnixNanos::new(0),
358            Some(trade_id),
359        );
360        assert!(result.is_ok());
361        let trade = result.unwrap();
362        assert_eq!(trade.price.as_f64(), 150.25);
363        assert_eq!(trade.size.as_f64(), 100.0);
364        assert_eq!(trade.trade_id, trade_id);
365    }
366
367    #[rstest]
368    fn test_parse_trade_tick_without_trade_id() {
369        let instrument_id = create_test_instrument_id();
370        let result = parse_trade_tick(
371            instrument_id,
372            150.25,
373            100.0,
374            IbPriceConverter::new(2),
375            0,
376            UnixNanos::new(1000),
377            UnixNanos::new(1000),
378            None,
379        );
380        assert!(result.is_ok());
381        let trade = result.unwrap();
382        assert_eq!(trade.price.as_f64(), 150.25);
383        assert_eq!(trade.size.as_f64(), 100.0);
384        // Trade ID should be auto-generated
385        assert!(!trade.trade_id.to_string().is_empty());
386    }
387
388    #[rstest]
389    fn test_parse_trade_tick_rejects_unrepresentable_size() {
390        let instrument_id = create_test_instrument_id();
391        let result = parse_trade_tick(
392            instrument_id,
393            150.25,
394            0.5,
395            IbPriceConverter::new(2),
396            0,
397            UnixNanos::new(1000),
398            UnixNanos::new(1000),
399            None,
400        );
401
402        let e = result.unwrap_err();
403        assert_eq!(
404            e.downcast_ref::<QuantityPrecisionError>(),
405            Some(&QuantityPrecisionError {
406                size: 0.5,
407                precision: 0
408            })
409        );
410    }
411
412    #[rstest]
413    #[case(0.63462, 0)]
414    #[case(1.025, 2)]
415    #[case(1_975_139.005, 2)]
416    fn test_quantity_precision_error_preserves_size(#[case] size: f64, #[case] precision: u8) {
417        let e = checked_quantity(size, precision).unwrap_err();
418        assert_eq!(
419            e.downcast_ref::<QuantityPrecisionError>(),
420            Some(&QuantityPrecisionError { size, precision })
421        );
422    }
423
424    #[rstest]
425    #[case(1_975_139.0, 2)]
426    #[case(1_975_139.25, 2)]
427    #[case(123_456_789.0, 0)]
428    fn test_checked_quantity_accepts_large_sizes(#[case] size: f64, #[case] precision: u8) {
429        let quantity = checked_quantity(size, precision).unwrap();
430
431        assert_eq!(quantity, Quantity::new(size, precision));
432    }
433
434    #[rstest]
435    #[case(-1.0)]
436    #[case(f64::NAN)]
437    #[case(f64::INFINITY)]
438    fn test_invalid_quantities_are_not_expected_precision_drops(#[case] size: f64) {
439        let e = checked_quantity(size, 0).unwrap_err();
440        assert!(!e.is::<QuantityPrecisionError>());
441    }
442
443    #[rstest]
444    fn test_parse_trade_tick_rejects_zero_size() {
445        let result = parse_trade_tick(
446            create_test_instrument_id(),
447            150.25,
448            0.0,
449            IbPriceConverter::new(2),
450            0,
451            UnixNanos::new(1000),
452            UnixNanos::new(1000),
453            None,
454        );
455
456        assert_eq!(
457            result.unwrap_err().to_string(),
458            "invalid trade tick: invalid `Quantity` for 'size' not positive, was 0",
459        );
460    }
461
462    #[rstest]
463    fn test_parse_quote_tick_rejects_unrepresentable_size() {
464        let instrument_id = create_test_instrument_id();
465        let result = parse_quote_tick(
466            instrument_id,
467            Some(150.25),
468            Some(150.30),
469            Some(0.5),
470            Some(200.0),
471            IbPriceConverter::new(2),
472            0,
473            UnixNanos::new(0),
474            UnixNanos::new(0),
475        );
476
477        assert!(result.is_err());
478    }
479
480    #[rstest]
481    fn test_parse_index_price_with_price_magnifier() {
482        let instrument_id = create_test_instrument_id();
483        let result = parse_index_price(
484            instrument_id,
485            452525.0,
486            IbPriceConverter::new(2).with_price_magnifier(100),
487            UnixNanos::new(0),
488            UnixNanos::new(0),
489        );
490        assert!(result.is_ok());
491        let index_price = result.unwrap();
492        assert_eq!(index_price.value.as_f64(), 4525.25);
493    }
494
495    #[rstest]
496    fn test_parse_index_price_without_price_magnifier() {
497        let instrument_id = create_test_instrument_id();
498        let result = parse_index_price(
499            instrument_id,
500            4525.25,
501            IbPriceConverter::new(2),
502            UnixNanos::new(0),
503            UnixNanos::new(0),
504        );
505        assert!(result.is_ok());
506        let index_price = result.unwrap();
507        assert_eq!(index_price.value.as_f64(), 4525.25);
508    }
509
510    #[rstest]
511    fn test_parse_realtime_bar() {
512        let instrument_id = create_test_instrument_id();
513
514        let bar_type = BarType::new(
515            instrument_id,
516            BarSpecification::new(1, BarAggregation::Minute, PriceType::Last),
517            AggregationSource::External,
518        );
519        let result = parse_realtime_bar(
520            bar_type,
521            150.0,
522            151.0,
523            149.0,
524            150.5,
525            1000.0,
526            IbPriceConverter::new(2),
527            0,
528            UnixNanos::new(0),
529            UnixNanos::new(0),
530        );
531        assert!(result.is_ok());
532        let bar = result.unwrap();
533        assert_eq!(bar.open.as_f64(), 150.0);
534        assert_eq!(bar.high.as_f64(), 151.0);
535        assert_eq!(bar.low.as_f64(), 149.0);
536        assert_eq!(bar.close.as_f64(), 150.5);
537        assert_eq!(bar.volume.as_f64(), 1000.0);
538    }
539
540    #[rstest]
541    fn test_parse_market_depth_operation_insert() {
542        let action = parse_market_depth_operation(0);
543        assert_eq!(action, Some(BookAction::Add));
544    }
545
546    #[rstest]
547    fn test_parse_market_depth_operation_update() {
548        let action = parse_market_depth_operation(1);
549        assert_eq!(action, Some(BookAction::Update));
550    }
551
552    #[rstest]
553    fn test_parse_market_depth_operation_delete() {
554        let action = parse_market_depth_operation(2);
555        assert_eq!(action, Some(BookAction::Delete));
556    }
557
558    #[rstest]
559    fn test_parse_market_depth_operation_unknown() {
560        let action = parse_market_depth_operation(99);
561        assert_eq!(action, None);
562    }
563
564    #[rstest]
565    fn test_parse_quote_tick_precision() {
566        let instrument_id = create_test_instrument_id();
567        let result = parse_quote_tick(
568            instrument_id,
569            Some(150.255),
570            Some(150.305),
571            Some(100.0),
572            Some(200.0),
573            IbPriceConverter::new(2), // Price precision: 2 decimal places
574            0,                        // Size precision: 0 decimal places
575            UnixNanos::new(0),
576            UnixNanos::new(0),
577        );
578        assert!(result.is_ok());
579        let quote = result.unwrap();
580        // Prices should be rounded to 2 decimal places
581        assert_eq!(quote.bid_price.as_f64(), 150.26); // Rounded up
582        assert_eq!(quote.ask_price.as_f64(), 150.31); // Rounded up
583    }
584
585    #[rstest]
586    fn test_parse_option_computation_to_option_greeks_from_model_tick() {
587        let instrument_id = create_test_instrument_id();
588
589        let greeks = parse_option_computation_to_option_greeks(
590            instrument_id,
591            &OptionComputation {
592                field: TickType::ModelOption,
593                implied_volatility: Some(0.25),
594                delta: Some(0.55),
595                gamma: Some(0.02),
596                vega: Some(0.15),
597                theta: Some(-0.05),
598                underlying_price: Some(155.0),
599                ..Default::default()
600            },
601            UnixNanos::new(10),
602            UnixNanos::new(11),
603        )
604        .unwrap();
605
606        assert_eq!(greeks.instrument_id, instrument_id);
607        assert_eq!(greeks.delta, 0.55);
608        assert_eq!(greeks.gamma, 0.02);
609        assert_eq!(greeks.vega, 0.15);
610        assert_eq!(greeks.theta, -0.05);
611        assert_eq!(greeks.rho, 0.0);
612        assert_eq!(greeks.mark_iv, Some(0.25));
613        assert_eq!(greeks.underlying_price, Some(155.0));
614        assert_eq!(greeks.open_interest, None);
615        assert_eq!(greeks.ts_event, UnixNanos::new(10));
616        assert_eq!(greeks.ts_init, UnixNanos::new(11));
617    }
618
619    #[rstest]
620    fn test_parse_option_computation_to_option_greeks_ignores_non_model_tick() {
621        let instrument_id = create_test_instrument_id();
622
623        let greeks = parse_option_computation_to_option_greeks(
624            instrument_id,
625            &OptionComputation {
626                field: TickType::BidOption,
627                implied_volatility: Some(0.24),
628                ..Default::default()
629            },
630            UnixNanos::new(10),
631            UnixNanos::new(11),
632        );
633
634        assert!(greeks.is_none());
635    }
636
637    #[rstest]
638    fn test_parse_option_open_interest_supports_option_interest_tick_types() {
639        assert_eq!(
640            parse_option_open_interest(&TickType::OptionCallOpenInterest, 1234.0),
641            Some(1234.0)
642        );
643        assert_eq!(
644            parse_option_open_interest(&TickType::OptionPutOpenInterest, 5678.0),
645            Some(5678.0)
646        );
647        assert_eq!(
648            parse_option_open_interest(&TickType::OpenInterest, 42.0),
649            Some(42.0)
650        );
651        assert_eq!(parse_option_open_interest(&TickType::Bid, 42.0), None);
652    }
653
654    #[rstest]
655    fn test_parse_quote_tick_rounds_fx_prices_to_increment() {
656        let instrument = currency_pair("EUR", "USD", 5, "0.00005");
657
658        let quote = parse_quote_tick(
659            instrument.id(),
660            Some(1.12097),
661            Some(1.12098),
662            Some(1_000_000.0),
663            Some(2_000_000.0),
664            IbPriceConverter::for_instrument(&instrument, 1),
665            0,
666            UnixNanos::new(0),
667            UnixNanos::new(0),
668        )
669        .unwrap();
670
671        assert_eq!(quote.bid_price, Price::from("1.12095"));
672        assert_eq!(quote.ask_price, Price::from("1.12100"));
673    }
674
675    #[rstest]
676    #[case(1.12098, 1.12099, "1.12095", "1.12100")]
677    #[case(1.12097, 1.12097, "1.12095", "1.12100")]
678    #[case(1.12100, 1.12105, "1.12100", "1.12105")]
679    fn test_parse_quote_tick_rounds_fx_bid_down_and_ask_up(
680        #[case] ib_bid: f64,
681        #[case] ib_ask: f64,
682        #[case] bid: &str,
683        #[case] ask: &str,
684    ) {
685        let instrument = currency_pair("EUR", "USD", 5, "0.00005");
686
687        let quote = parse_quote_tick(
688            instrument.id(),
689            Some(ib_bid),
690            Some(ib_ask),
691            Some(1_000_000.0),
692            Some(2_000_000.0),
693            IbPriceConverter::for_instrument(&instrument, 1),
694            0,
695            UnixNanos::new(0),
696            UnixNanos::new(0),
697        )
698        .unwrap();
699
700        assert_eq!(quote.bid_price, Price::from(bid));
701        assert_eq!(quote.ask_price, Price::from(ask));
702    }
703
704    #[rstest]
705    fn test_parse_quote_tick_never_locks_or_crosses_fx_quote_across_sub_pip_offsets() {
706        let instrument = currency_pair("EUR", "USD", 5, "0.00005");
707        let increment = Price::from("0.00005").as_decimal();
708        let to_ib = |units: i64| -> f64 {
709            format!("{}.{:05}", units / 100_000, units % 100_000)
710                .parse()
711                .unwrap()
712        };
713
714        // Every bid below every ask over 40 steps of 0.00001, two pips
715        for bid_units in 112_000_i64..=112_040 {
716            for ask_units in (bid_units + 1)..=112_041 {
717                let quote = parse_quote_tick(
718                    instrument.id(),
719                    Some(to_ib(bid_units)),
720                    Some(to_ib(ask_units)),
721                    Some(1.0),
722                    Some(1.0),
723                    IbPriceConverter::for_instrument(&instrument, 1),
724                    0,
725                    UnixNanos::new(0),
726                    UnixNanos::new(0),
727                )
728                .unwrap();
729
730                let (bid, ask) = (quote.bid_price.as_decimal(), quote.ask_price.as_decimal());
731                let (ib_bid, ib_ask) = (Decimal::new(bid_units, 5), Decimal::new(ask_units, 5));
732                let case = format!("bid={ib_bid} ask={ib_ask} published {bid}/{ask}");
733
734                assert!((bid % increment).is_zero(), "bid off grid: {case}");
735                assert!((ask % increment).is_zero(), "ask off grid: {case}");
736                assert!(bid <= ib_bid && ib_bid - bid < increment, "{case}");
737                assert!(ask >= ib_ask && ask - ib_ask < increment, "{case}");
738                assert!(bid < ask, "locked or crossed: {case}");
739            }
740        }
741    }
742
743    #[rstest]
744    fn test_parse_quote_tick_leaves_equity_quote_prices_unrounded() {
745        let quote = parse_quote_tick(
746            create_test_instrument_id(),
747            Some(150.25),
748            Some(150.31),
749            Some(100.0),
750            Some(200.0),
751            IbPriceConverter::new(2),
752            0,
753            UnixNanos::new(0),
754            UnixNanos::new(0),
755        )
756        .unwrap();
757
758        assert_eq!(quote.bid_price, Price::from("150.25"));
759        assert_eq!(quote.ask_price, Price::from("150.31"));
760    }
761
762    #[rstest]
763    #[case(PriceType::Bid, "1.12095", "1.12105", "1.12090", "1.12095")]
764    #[case(PriceType::Ask, "1.12100", "1.12110", "1.12095", "1.12100")]
765    #[case(PriceType::Mid, "1.12095", "1.12110", "1.12090", "1.12095")]
766    fn test_parse_realtime_bar_rounds_fx_prices_by_price_type(
767        #[case] price_type: PriceType,
768        #[case] open: &str,
769        #[case] high: &str,
770        #[case] low: &str,
771        #[case] close: &str,
772    ) {
773        let instrument = currency_pair("EUR", "USD", 5, "0.00005");
774        let bar_type = BarType::new(
775            instrument.id(),
776            BarSpecification::new(5, BarAggregation::Second, price_type),
777            AggregationSource::External,
778        );
779
780        let bar = parse_realtime_bar(
781            bar_type,
782            1.12097,
783            1.12109,
784            1.12091,
785            1.12096,
786            0.0,
787            IbPriceConverter::for_instrument(&instrument, 1),
788            0,
789            UnixNanos::new(0),
790            UnixNanos::new(0),
791        )
792        .unwrap();
793
794        assert_eq!(bar.open, Price::from(open));
795        assert_eq!(bar.high, Price::from(high));
796        assert_eq!(bar.low, Price::from(low));
797        assert_eq!(bar.close, Price::from(close));
798    }
799
800    #[rstest]
801    fn test_parse_quote_tick_divides_magnified_prices() {
802        let quote = parse_quote_tick(
803            create_test_instrument_id(),
804            Some(425.25),
805            Some(425.5),
806            Some(10.0),
807            Some(20.0),
808            IbPriceConverter::new(4).with_price_magnifier(100),
809            0,
810            UnixNanos::new(0),
811            UnixNanos::new(0),
812        )
813        .unwrap();
814
815        assert_eq!(quote.bid_price, Price::from("4.2525"));
816        assert_eq!(quote.ask_price, Price::from("4.2550"));
817    }
818
819    #[rstest]
820    fn test_parse_trade_tick_divides_magnified_price() {
821        let trade = parse_trade_tick(
822            create_test_instrument_id(),
823            425.25,
824            3.0,
825            IbPriceConverter::new(4).with_price_magnifier(100),
826            0,
827            UnixNanos::new(0),
828            UnixNanos::new(0),
829            Some(TradeId::from("TRADE-001")),
830        )
831        .unwrap();
832
833        assert_eq!(trade.price, Price::from("4.2525"));
834    }
835
836    #[rstest]
837    fn test_parse_realtime_bar_divides_magnified_prices() {
838        let bar_type = BarType::new(
839            create_test_instrument_id(),
840            BarSpecification::new(5, BarAggregation::Second, PriceType::Last),
841            AggregationSource::External,
842        );
843
844        let bar = parse_realtime_bar(
845            bar_type,
846            425.25,
847            430.0,
848            424.5,
849            428.5,
850            10.0,
851            IbPriceConverter::new(4).with_price_magnifier(100),
852            0,
853            UnixNanos::new(0),
854            UnixNanos::new(0),
855        )
856        .unwrap();
857
858        assert_eq!(bar.open, Price::from("4.2525"));
859        assert_eq!(bar.close, Price::from("4.2850"));
860    }
861}