Skip to main content

nautilus_interactive_brokers/data/
convert.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//! Conversion utilities for Interactive Brokers data types.
17
18use std::str::FromStr;
19
20use ibapi::market_data::{
21    historical::{
22        BarSize as HistoricalBarSize, BarTimestamp, Duration as IBDuration, ToDuration,
23        WhatToShow as HistoricalWhatToShow,
24    },
25    realtime::WhatToShow as RealtimeWhatToShow,
26};
27use jiff::Timestamp;
28use nautilus_core::{DurationNanos, UnixNanos};
29use nautilus_model::{
30    data::{Bar, BarSpecification, BarType},
31    enums::{AssetClass, BarAggregation, PriceType},
32    instruments::{Instrument, InstrumentAny},
33    types::{Price, Quantity},
34};
35use rust_decimal::{Decimal, RoundingStrategy};
36use time::OffsetDateTime;
37
38use super::cache::is_sentinel_price;
39
40/// Converts the prices IB reports for an instrument into the instrument's prices.
41///
42/// IB reports a magnified contract's prices in a sub-unit of its currency (CBOT grains in cents,
43/// LSE stocks in pence), so these are divided by the contract's price magnifier. IB also reports
44/// an FX pair's prices in steps finer than the pair's price increment, its order tick, so these
45/// are rounded to the increment: a bid down, an ask up, and any other price to the nearest, ties to
46/// the even multiple. A quote then never locks or crosses unless IB's did, and an ask is never
47/// below IB's ask.
48#[derive(Debug, Clone, Copy, PartialEq, Eq)]
49pub struct IbPriceConverter {
50    precision: u8,
51    increment: Option<Price>,
52    magnifier: i32,
53}
54
55impl IbPriceConverter {
56    /// Creates a converter that applies the precision only, with no magnifier and no rounding to
57    /// an increment.
58    #[must_use]
59    pub const fn new(precision: u8) -> Self {
60        Self {
61            precision,
62            increment: None,
63            magnifier: 1,
64        }
65    }
66
67    /// Returns the converter with the contract's price magnifier, where a value below 1 means 1.
68    #[must_use]
69    pub const fn with_price_magnifier(mut self, magnifier: i32) -> Self {
70        self.magnifier = if magnifier < 1 { 1 } else { magnifier };
71        self
72    }
73
74    /// Creates the converter for `instrument` and its contract's `price_magnifier`, rounding to
75    /// the price increment for FX pairs.
76    #[must_use]
77    pub fn for_instrument(instrument: &InstrumentAny, price_magnifier: i32) -> Self {
78        let increment = match instrument {
79            InstrumentAny::CurrencyPair(pair) if pair.asset_class() == AssetClass::FX => {
80                Some(pair.price_increment())
81            }
82            _ => None,
83        };
84
85        Self {
86            precision: instrument.price_precision(),
87            increment,
88            magnifier: 1,
89        }
90        .with_price_magnifier(price_magnifier)
91    }
92
93    /// Returns the price precision.
94    #[must_use]
95    pub const fn precision(&self) -> u8 {
96        self.precision
97    }
98
99    /// Returns whether `ib_price` is IB's no-quote value for a side: -1 without a size, which IB
100    /// reports multiplied by the price magnifier for a magnified contract (-100 for a ZC
101    /// option on delayed data).
102    #[must_use]
103    pub fn is_sentinel(&self, ib_price: f64, size: Option<f64>) -> bool {
104        is_sentinel_price(ib_price, size)
105            || is_sentinel_price(ib_price / f64::from(self.magnifier), size)
106    }
107
108    /// Converts a price reported by IB into the instrument's price, rounding to the nearest
109    /// multiple of the price increment, ties to the even multiple.
110    ///
111    /// Use this for a trade, midpoint, last, or index price. A quote's bid and ask use
112    /// [`Self::bid_price`] and [`Self::ask_price`].
113    ///
114    /// # Errors
115    ///
116    /// Returns an error if the price is not finite or cannot be represented at the converter's
117    /// precision.
118    pub fn price(&self, ib_price: f64) -> anyhow::Result<Price> {
119        self.convert(ib_price, RoundingStrategy::MidpointNearestEven)
120    }
121
122    /// Converts a bid price reported by IB, rounding down to a multiple of the price increment.
123    ///
124    /// A published bid is never above IB's bid.
125    ///
126    /// # Errors
127    ///
128    /// Returns an error if the price is not finite or cannot be represented at the converter's
129    /// precision.
130    pub fn bid_price(&self, ib_price: f64) -> anyhow::Result<Price> {
131        self.convert(ib_price, RoundingStrategy::ToNegativeInfinity)
132    }
133
134    /// Converts an ask price reported by IB, rounding up to a multiple of the price increment.
135    ///
136    /// A published ask is never below IB's ask, so a limit order priced at it can fill, and the
137    /// published bid stays below the published ask whenever IB's does.
138    ///
139    /// # Errors
140    ///
141    /// Returns an error if the price is not finite or cannot be represented at the converter's
142    /// precision.
143    pub fn ask_price(&self, ib_price: f64) -> anyhow::Result<Price> {
144        self.convert(ib_price, RoundingStrategy::ToPositiveInfinity)
145    }
146
147    /// Converts a price of a bar of `price_type`: a bid bar rounds down, an ask bar rounds up,
148    /// and any other bar to the nearest multiple of the price increment.
149    ///
150    /// # Errors
151    ///
152    /// Returns an error if the price is not finite or cannot be represented at the converter's
153    /// precision.
154    pub fn bar_price(&self, price_type: PriceType, ib_price: f64) -> anyhow::Result<Price> {
155        match price_type {
156            PriceType::Bid => self.bid_price(ib_price),
157            PriceType::Ask => self.ask_price(ib_price),
158            _ => self.price(ib_price),
159        }
160    }
161
162    /// Converts `ib_price` once, in `Decimal`, so that a midpoint is rounded a single time.
163    ///
164    /// With no increment and no magnifier the price is built as is. Otherwise the shortest
165    /// round-trip decimal form of the `f64` is divided by the magnifier and rounded to the
166    /// increment with `strategy`, or to the precision when there is no increment.
167    fn convert(&self, ib_price: f64, strategy: RoundingStrategy) -> anyhow::Result<Price> {
168        if self.increment.is_none() && self.magnifier == 1 {
169            return Price::new_checked(ib_price, self.precision)
170                .map_err(|e| anyhow::anyhow!("invalid price={ib_price}: {e}"));
171        }
172
173        if !ib_price.is_finite() {
174            anyhow::bail!("invalid price={ib_price}: not finite");
175        }
176
177        let overflow = || anyhow::anyhow!("invalid price={ib_price}: out of range");
178        let value = Decimal::from_str(&ib_price.to_string())
179            .map_err(|e| anyhow::anyhow!("invalid price={ib_price}: {e}"))?
180            .checked_div(Decimal::from(self.magnifier))
181            .ok_or_else(overflow)?;
182
183        let value = match self.increment {
184            Some(increment) if increment.raw() > 0 => {
185                let step = increment.as_decimal();
186                value
187                    .checked_div(step)
188                    .ok_or_else(overflow)?
189                    .round_dp_with_strategy(0, strategy)
190                    .checked_mul(step)
191                    .ok_or_else(overflow)?
192            }
193            _ => value,
194        };
195
196        Price::from_decimal_dp(value, self.precision)
197            .map_err(|e| anyhow::anyhow!("invalid price={ib_price}: {e}"))
198    }
199}
200
201/// Convert Nautilus BarType to IB HistoricalBarSize.
202///
203/// # Errors
204///
205/// Returns an error if the bar aggregation/step combination is not supported by IB.
206pub fn bar_type_to_ib_bar_size(bar_type: &BarType) -> anyhow::Result<HistoricalBarSize> {
207    let spec = bar_type.spec();
208    let aggregation = spec.aggregation;
209    let step = spec.step.get();
210
211    let bar_size = match (aggregation, step) {
212        // Seconds
213        (BarAggregation::Second, 1) => HistoricalBarSize::Sec,
214        (BarAggregation::Second, 5) => HistoricalBarSize::Sec5,
215        (BarAggregation::Second, 15) => HistoricalBarSize::Sec15,
216        (BarAggregation::Second, 30) => HistoricalBarSize::Sec30,
217        // Minutes
218        (BarAggregation::Minute, 1) => HistoricalBarSize::Min,
219        (BarAggregation::Minute, 2) => HistoricalBarSize::Min2,
220        (BarAggregation::Minute, 3) => HistoricalBarSize::Min3,
221        (BarAggregation::Minute, 5) => HistoricalBarSize::Min5,
222        (BarAggregation::Minute, 10) => HistoricalBarSize::Min10,
223        (BarAggregation::Minute, 15) => HistoricalBarSize::Min15,
224        (BarAggregation::Minute, 20) => HistoricalBarSize::Min20,
225        (BarAggregation::Minute, 30) => HistoricalBarSize::Min30,
226        // Hours
227        (BarAggregation::Hour, 1) => HistoricalBarSize::Hour,
228        (BarAggregation::Hour, 2) => HistoricalBarSize::Hour2,
229        (BarAggregation::Hour, 3) => HistoricalBarSize::Hour3,
230        (BarAggregation::Hour, 4) => HistoricalBarSize::Hour4,
231        (BarAggregation::Hour, 8) => HistoricalBarSize::Hour8,
232        // Days
233        (BarAggregation::Day, 1) => HistoricalBarSize::Day,
234        // Weeks
235        (BarAggregation::Week, 1) => HistoricalBarSize::Week,
236        // Months
237        (BarAggregation::Month, 1) => HistoricalBarSize::Month,
238        _ => {
239            anyhow::bail!("Unsupported bar aggregation/step combination: {aggregation:?}/{step}",);
240        }
241    };
242
243    Ok(bar_size)
244}
245
246/// Convert Nautilus PriceType to IB WhatToShow.
247#[must_use]
248pub fn price_type_to_ib_what_to_show(price_type: PriceType) -> HistoricalWhatToShow {
249    match price_type {
250        PriceType::Last => HistoricalWhatToShow::Trades,
251        PriceType::Bid => HistoricalWhatToShow::Bid,
252        PriceType::Ask => HistoricalWhatToShow::Ask,
253        PriceType::Mid => HistoricalWhatToShow::MidPoint,
254        _ => HistoricalWhatToShow::Trades, // Default to trades
255    }
256}
257
258/// Whether IB requires `AGGTRADES` (not `TRADES`) for this request.
259///
260/// TWS rejects `TRADES` for crypto contracts (ZEROHASH/PAXOS) with error 10299 on
261/// both `reqHistoricalData` and `reqRealTimeBars`; crypto trade-price data is served
262/// only under `AGGTRADES`. Non-crypto contracts and non-trade price types are
263/// unaffected. Mirrors the Java engine's `LiveOneMinBarIngestionService.whatToShowFor`.
264#[must_use]
265fn uses_agg_trades(is_crypto: bool, price_type: PriceType) -> bool {
266    is_crypto && price_type == PriceType::Last
267}
268
269/// Convert Nautilus PriceType to IB WhatToShow for historical bars, mapping crypto
270/// trade-price (`PriceType::Last`) to `AGGTRADES` (see `uses_agg_trades`).
271#[must_use]
272pub fn price_type_to_ib_what_to_show_for_security(
273    price_type: PriceType,
274    is_crypto: bool,
275) -> HistoricalWhatToShow {
276    if uses_agg_trades(is_crypto, price_type) {
277        return HistoricalWhatToShow::AggTrades;
278    }
279
280    price_type_to_ib_what_to_show(price_type)
281}
282
283/// Convert Nautilus PriceType to IB WhatToShow for real-time (5-second) bars.
284///
285/// Unmapped price types default to [`RealtimeWhatToShow::Trades`].
286#[must_use]
287pub fn price_type_to_ib_realtime_what_to_show(price_type: PriceType) -> RealtimeWhatToShow {
288    match price_type {
289        PriceType::Last => RealtimeWhatToShow::Trades,
290        PriceType::Bid => RealtimeWhatToShow::Bid,
291        PriceType::Ask => RealtimeWhatToShow::Ask,
292        PriceType::Mid => RealtimeWhatToShow::MidPoint,
293        _ => RealtimeWhatToShow::Trades, // Default to trades
294    }
295}
296
297/// Convert Nautilus PriceType to IB WhatToShow for real-time (5-second) bars, mapping
298/// crypto trade-price (`PriceType::Last`) to `AGGTRADES` (see `uses_agg_trades`).
299#[must_use]
300pub fn price_type_to_ib_realtime_what_to_show_for_security(
301    price_type: PriceType,
302    is_crypto: bool,
303) -> RealtimeWhatToShow {
304    if uses_agg_trades(is_crypto, price_type) {
305        return RealtimeWhatToShow::AggTrades;
306    }
307
308    price_type_to_ib_realtime_what_to_show(price_type)
309}
310
311/// Implement bar price validation logic.
312/// Matches Python's `_validate_bar_prices` behavior.
313fn _validate_bar_prices(open: &mut f64, high: &mut f64, low: &mut f64, close: &f64) {
314    if *high < *low || *high < *open || *high < *close || *low > *open || *low > *close {
315        tracing::warn!(
316            "Invalid bar prices detected: O:{}, H:{}, L:{}, C:{}. Correcting using close price",
317            open,
318            high,
319            low,
320            close
321        );
322        *open = *close;
323        *high = *close;
324        *low = *close;
325    }
326}
327
328/// Convert IB Bar to Nautilus Bar.
329///
330/// `ts_event` and `ts_init` are set to the bar close ([`bar_close_from_open`]).
331///
332/// # Errors
333///
334/// Returns an error if conversion fails.
335pub fn ib_bar_to_nautilus_bar(
336    ib_bar: &ibapi::market_data::historical::Bar,
337    bar_type: BarType,
338    prices: IbPriceConverter,
339    size_precision: u8,
340) -> anyhow::Result<Bar> {
341    let ts_event = bar_close_from_open(
342        ib_bar_timestamp_to_unix_nanos(&ib_bar.date),
343        &bar_type.spec(),
344    );
345    let ts_init = ts_event;
346
347    // Validate and correct prices
348    let mut open = ib_bar.open;
349    let mut high = ib_bar.high;
350    let mut low = ib_bar.low;
351    let close = ib_bar.close;
352    _validate_bar_prices(&mut open, &mut high, &mut low, &close);
353
354    // Create prices
355    let price_type = bar_type.spec().price_type;
356    let open_price = prices.bar_price(price_type, open)?;
357    let high_price = prices.bar_price(price_type, high)?;
358    let low_price = prices.bar_price(price_type, low)?;
359    let close_price = prices.bar_price(price_type, close)?;
360
361    // Volume: IB uses -1 for unavailable volume, convert to 0
362    let volume = if ib_bar.volume < 0.0 {
363        Quantity::zero(size_precision)
364    } else {
365        Quantity::new(ib_bar.volume, size_precision)
366    };
367
368    Ok(Bar::new(
369        bar_type,
370        open_price,
371        high_price,
372        low_price,
373        close_price,
374        volume,
375        ts_event,
376        ts_init,
377    ))
378}
379
380/// Compute a bar's close timestamp from its open timestamp and [`BarSpecification`].
381///
382/// Weekly/monthly bars are returned unchanged (IB stamps these at the period end).
383#[must_use]
384pub fn bar_close_from_open(open: UnixNanos, spec: &BarSpecification) -> UnixNanos {
385    let is_day = spec.aggregation == BarAggregation::Day;
386
387    let duration = match spec.aggregation {
388        BarAggregation::Second
389        | BarAggregation::Minute
390        | BarAggregation::Hour
391        | BarAggregation::Day => spec.timedelta(),
392        _ => return open,
393    };
394
395    let Ok(duration) = DurationNanos::try_from(duration) else {
396        return open;
397    };
398
399    let close = open.saturating_add(duration);
400    if is_day {
401        close.saturating_sub(DurationNanos::new(1))
402    } else {
403        close
404    }
405}
406
407/// Convert IB historical bar timestamp to UnixNanos.
408#[must_use]
409pub fn ib_bar_timestamp_to_unix_nanos(dt: &BarTimestamp) -> UnixNanos {
410    match dt {
411        BarTimestamp::Date(date) => ib_timestamp_to_unix_nanos(&date.midnight().assume_utc()),
412        BarTimestamp::DateTime(dt) => ib_timestamp_to_unix_nanos(dt),
413    }
414}
415
416/// Convert IB timestamp (OffsetDateTime) to UnixNanos.
417#[must_use]
418pub fn ib_timestamp_to_unix_nanos(dt: &OffsetDateTime) -> UnixNanos {
419    let timestamp = dt.unix_timestamp_nanos();
420    UnixNanos::from(timestamp as u64)
421}
422
423/// Convert `Timestamp` to OffsetDateTime.
424pub fn jiff_to_ib_datetime(dt: &Timestamp) -> OffsetDateTime {
425    OffsetDateTime::from_unix_timestamp_nanos(dt.as_nanosecond())
426        .unwrap_or_else(|_| OffsetDateTime::now_utc())
427}
428
429/// Calculate duration segments for IB historical data request.
430///
431/// This is used to break down a large time range into multiple requests
432/// to comply with IB's duration limits for specific bar sizes.
433pub fn calculate_duration_segments(
434    start: Option<Timestamp>,
435    end: Option<Timestamp>,
436    duration: Option<IBDuration>,
437) -> Vec<(Timestamp, IBDuration)> {
438    let end = end.unwrap_or_else(Timestamp::now);
439
440    if let Some(duration) = duration {
441        return vec![(end, duration)];
442    }
443
444    let Some(start) = start else {
445        return vec![(end, 1.days())];
446    };
447
448    let mut results = Vec::new();
449    let duration = end.duration_since(start);
450    let mut total_seconds = duration.as_secs();
451    if duration.subsec_nanos() > 0 {
452        total_seconds += 1;
453    }
454
455    if total_seconds <= 0 {
456        return results;
457    }
458
459    let years = total_seconds / (365 * 24 * 3600);
460    total_seconds %= 365 * 24 * 3600;
461    let days = total_seconds / (24 * 3600);
462    total_seconds %= 24 * 3600;
463    let seconds = total_seconds;
464
465    if years > 0 {
466        results.push((end, (years as i32).years()));
467    }
468
469    if days > 0 {
470        let minus_years_duration = jiff::SignedDuration::from_hours(24 * (years * 365));
471        let minus_years_date = end - minus_years_duration;
472        results.push((minus_years_date, (days as i32).days()));
473    }
474
475    if seconds > 0 {
476        let minus_years_duration = jiff::SignedDuration::from_hours(24 * (years * 365));
477        let minus_days_duration = jiff::SignedDuration::from_hours(24 * (days));
478        let minus_days_date = end - minus_years_duration - minus_days_duration;
479        results.push((minus_days_date, (seconds as i32).seconds()));
480    }
481
482    results
483}
484
485/// Return whether a backward historical tick request needs another page.
486#[must_use]
487pub(crate) fn should_continue_historical_tick_pagination(
488    current_start_date: Option<Timestamp>,
489    current_end_date: Option<Timestamp>,
490    current_len: usize,
491    limit: Option<usize>,
492) -> bool {
493    limit.is_none_or(|limit| current_len < limit)
494        && current_start_date
495            .zip(current_end_date)
496            .is_none_or(|(start, end)| end > start)
497}
498
499/// Add a historical tick page and advance the next request before its earliest tick.
500#[allow(clippy::too_many_arguments)] // The parameters describe one pagination transition.
501pub(crate) fn extend_historical_tick_batch<T>(
502    all_ticks: &mut Vec<T>,
503    batch_ticks: Vec<T>,
504    current_start_date: Option<Timestamp>,
505    current_end_date: &mut Option<Timestamp>,
506    start_nanos: Option<UnixNanos>,
507    end_nanos: Option<UnixNanos>,
508    limit: Option<usize>,
509    ts_event: impl Fn(&T) -> UnixNanos,
510) -> bool {
511    if batch_ticks.is_empty() {
512        return false;
513    }
514
515    // The IB wire encodes the request end at second resolution and treats it as
516    // inclusive, so each page re-delivers ticks from the boundary second that a
517    // previous page already collected. Drop everything at or past the requested
518    // end before extending; an entirely re-delivered page means the end second
519    // cannot retreat (a single second holds a full page) and pagination must stop.
520    let mut batch_ticks = batch_ticks;
521
522    if let Some(end) = *current_end_date {
523        let end_bound_nanos = end.as_nanosecond();
524        batch_ticks.retain(|tick| i128::from(ts_event(tick).as_u64()) <= end_bound_nanos);
525        if batch_ticks.is_empty() {
526            tracing::warn!(
527                "Historical tick pagination pinned at {end}; a single second exceeds the page \
528                 size, earlier ticks within that second cannot be requested"
529            );
530            retain_historical_ticks_in_range(all_ticks, start_nanos, end_nanos, &ts_event);
531            return false;
532        }
533    }
534
535    let Some(min_ts_nanos) = batch_ticks.iter().map(&ts_event).min() else {
536        return false;
537    };
538
539    let new_end_nanos = min_ts_nanos.as_u64().saturating_sub(1);
540
541    let Ok(new_end) = Timestamp::from_nanosecond(i128::from(new_end_nanos)) else {
542        return false;
543    };
544
545    *current_end_date = Some(new_end);
546
547    all_ticks.extend(batch_ticks);
548    if current_start_date
549        .zip(*current_end_date)
550        .is_some_and(|(start, end)| end <= start)
551    {
552        retain_historical_ticks_in_range(all_ticks, start_nanos, end_nanos, &ts_event);
553        return false;
554    }
555
556    limit.is_none_or(|limit| all_ticks.len() < limit)
557}
558
559/// Retain historical ticks within the inclusive requested range.
560pub(crate) fn retain_historical_ticks_in_range<T>(
561    ticks: &mut Vec<T>,
562    start_nanos: Option<UnixNanos>,
563    end_nanos: Option<UnixNanos>,
564    ts_event: impl Fn(&T) -> UnixNanos,
565) {
566    ticks.retain(|tick| {
567        let ts_event = ts_event(tick);
568        start_nanos.is_none_or(|start| ts_event >= start)
569            && end_nanos.is_none_or(|end| ts_event <= end)
570    });
571}
572
573/// Adapt duration segments for an IB historical bars request.
574///
575/// For continuous futures the end date is dropped and only the first segment is
576/// kept (IB rejects an explicit end date with error 10339), logging a warning
577/// when the requested range cannot be honored.
578pub fn bar_request_segments(
579    segments: Vec<(Timestamp, IBDuration)>,
580    is_continuous_future: bool,
581) -> Vec<(Option<Timestamp>, IBDuration)> {
582    if is_continuous_future {
583        // Treat end dates within the last second as "now" so requests whose
584        // end defaults to the current time do not trigger a spurious warning.
585        let now = Timestamp::now();
586        let end_in_past = segments
587            .first()
588            .is_some_and(|(end, _)| *end < now - jiff::SignedDuration::from_secs(1));
589
590        if end_in_past || segments.len() > 1 {
591            tracing::warn!(
592                "Continuous futures cannot use an explicit end_date_time (IB error 10339); \
593                 the request is anchored to the current time using only the first duration \
594                 segment, so the returned bars may not cover the full requested range"
595            );
596        }
597
598        segments
599            .into_iter()
600            .take(1)
601            .map(|(_, d)| (None, d))
602            .collect()
603    } else {
604        segments
605            .into_iter()
606            .map(|(end, d)| (Some(end), d))
607            .collect()
608    }
609}
610
611#[cfg(test)]
612mod tests {
613    use std::cmp::Ordering;
614
615    use nautilus_model::{
616        data::{BarSpecification, BarType},
617        enums::{AggregationSource, BarAggregation, PriceType},
618        identifiers::{InstrumentId, Symbol, Venue},
619        instruments::Equity,
620        types::Currency,
621    };
622    use rstest::rstest;
623    use time::macros::datetime;
624
625    use super::*;
626    use crate::stubs::currency_pair;
627
628    fn create_test_instrument_id() -> InstrumentId {
629        InstrumentId::new(Symbol::from("AAPL"), Venue::from("NASDAQ"))
630    }
631
632    #[rstest]
633    fn test_bar_type_to_ib_bar_size_seconds() {
634        let instrument_id = create_test_instrument_id();
635
636        let bar_type = BarType::new(
637            instrument_id,
638            BarSpecification::new(1, BarAggregation::Second, PriceType::Last),
639            AggregationSource::External,
640        );
641        let result = bar_type_to_ib_bar_size(&bar_type);
642        assert!(result.is_ok());
643        assert_eq!(result.unwrap(), HistoricalBarSize::Sec);
644
645        let bar_type = BarType::new(
646            instrument_id,
647            BarSpecification::new(5, BarAggregation::Second, PriceType::Last),
648            AggregationSource::External,
649        );
650        let result = bar_type_to_ib_bar_size(&bar_type);
651        assert!(result.is_ok());
652        assert_eq!(result.unwrap(), HistoricalBarSize::Sec5);
653    }
654
655    #[rstest]
656    fn test_bar_type_to_ib_bar_size_minutes() {
657        let instrument_id = create_test_instrument_id();
658
659        let bar_type = BarType::new(
660            instrument_id,
661            BarSpecification::new(1, BarAggregation::Minute, PriceType::Last),
662            AggregationSource::External,
663        );
664        let result = bar_type_to_ib_bar_size(&bar_type);
665        assert!(result.is_ok());
666        assert_eq!(result.unwrap(), HistoricalBarSize::Min);
667
668        let bar_type = BarType::new(
669            instrument_id,
670            BarSpecification::new(15, BarAggregation::Minute, PriceType::Last),
671            AggregationSource::External,
672        );
673        let result = bar_type_to_ib_bar_size(&bar_type);
674        assert!(result.is_ok());
675        assert_eq!(result.unwrap(), HistoricalBarSize::Min15);
676    }
677
678    #[rstest]
679    fn test_bar_type_to_ib_bar_size_hours() {
680        let instrument_id = create_test_instrument_id();
681
682        let bar_type = BarType::new(
683            instrument_id,
684            BarSpecification::new(1, BarAggregation::Hour, PriceType::Last),
685            AggregationSource::External,
686        );
687        let result = bar_type_to_ib_bar_size(&bar_type);
688        assert!(result.is_ok());
689        assert_eq!(result.unwrap(), HistoricalBarSize::Hour);
690    }
691
692    #[rstest]
693    fn test_bar_type_to_ib_bar_size_days() {
694        let instrument_id = create_test_instrument_id();
695
696        let bar_type = BarType::new(
697            instrument_id,
698            BarSpecification::new(1, BarAggregation::Day, PriceType::Last),
699            AggregationSource::External,
700        );
701        let result = bar_type_to_ib_bar_size(&bar_type);
702        assert!(result.is_ok());
703        assert_eq!(result.unwrap(), HistoricalBarSize::Day);
704    }
705
706    #[rstest]
707    fn test_bar_type_to_ib_bar_size_unsupported() {
708        let instrument_id = create_test_instrument_id();
709
710        let bar_type = BarType::new(
711            instrument_id,
712            BarSpecification::new(12, BarAggregation::Minute, PriceType::Last),
713            AggregationSource::External,
714        );
715        let result = bar_type_to_ib_bar_size(&bar_type);
716        assert!(result.is_err());
717    }
718
719    #[rstest]
720    fn test_price_type_to_ib_what_to_show() {
721        assert_eq!(
722            price_type_to_ib_what_to_show(PriceType::Last),
723            HistoricalWhatToShow::Trades
724        );
725        assert_eq!(
726            price_type_to_ib_what_to_show(PriceType::Bid),
727            HistoricalWhatToShow::Bid
728        );
729        assert_eq!(
730            price_type_to_ib_what_to_show(PriceType::Ask),
731            HistoricalWhatToShow::Ask
732        );
733        assert_eq!(
734            price_type_to_ib_what_to_show(PriceType::Mid),
735            HistoricalWhatToShow::MidPoint
736        );
737    }
738
739    #[rstest]
740    fn test_price_type_to_ib_what_to_show_for_security_crypto() {
741        // Crypto trade-price (Last) must map to AGGTRADES, not TRADES - TWS rejects
742        // TRADES for crypto (error 10299). Mirrors the Java whatToShowFor rule.
743        assert_eq!(
744            price_type_to_ib_what_to_show_for_security(PriceType::Last, true),
745            HistoricalWhatToShow::AggTrades
746        );
747        // Non-trade price types are unaffected by the crypto special case.
748        assert_eq!(
749            price_type_to_ib_what_to_show_for_security(PriceType::Bid, true),
750            HistoricalWhatToShow::Bid
751        );
752        assert_eq!(
753            price_type_to_ib_what_to_show_for_security(PriceType::Ask, true),
754            HistoricalWhatToShow::Ask
755        );
756        assert_eq!(
757            price_type_to_ib_what_to_show_for_security(PriceType::Mid, true),
758            HistoricalWhatToShow::MidPoint
759        );
760    }
761
762    #[rstest]
763    fn test_price_type_to_ib_what_to_show_for_security_non_crypto() {
764        // Non-crypto: trade-price stays TRADES (equities/futures), everything else
765        // identical to the plain mapping.
766        assert_eq!(
767            price_type_to_ib_what_to_show_for_security(PriceType::Last, false),
768            HistoricalWhatToShow::Trades
769        );
770        assert_eq!(
771            price_type_to_ib_what_to_show_for_security(PriceType::Bid, false),
772            HistoricalWhatToShow::Bid
773        );
774        assert_eq!(
775            price_type_to_ib_what_to_show_for_security(PriceType::Mid, false),
776            HistoricalWhatToShow::MidPoint
777        );
778    }
779
780    #[rstest]
781    fn test_aggtrades_wire_string() {
782        // The vendored ibapi patch must serialize AggTrades as the exact IB wire
783        // token "AGGTRADES" on BOTH the historical and realtime enums.
784        assert_eq!(HistoricalWhatToShow::AggTrades.to_string(), "AGGTRADES");
785        assert_eq!(RealtimeWhatToShow::AggTrades.to_string(), "AGGTRADES");
786    }
787
788    #[rstest]
789    fn test_price_type_to_ib_realtime_what_to_show() {
790        // `RealtimeWhatToShow` does not derive `PartialEq`, so match on the variants.
791        assert!(matches!(
792            price_type_to_ib_realtime_what_to_show(PriceType::Last),
793            RealtimeWhatToShow::Trades
794        ));
795        assert!(matches!(
796            price_type_to_ib_realtime_what_to_show(PriceType::Bid),
797            RealtimeWhatToShow::Bid
798        ));
799        assert!(matches!(
800            price_type_to_ib_realtime_what_to_show(PriceType::Ask),
801            RealtimeWhatToShow::Ask
802        ));
803        assert!(matches!(
804            price_type_to_ib_realtime_what_to_show(PriceType::Mid),
805            RealtimeWhatToShow::MidPoint
806        ));
807    }
808
809    #[rstest]
810    fn test_price_type_to_ib_realtime_what_to_show_for_security_crypto() {
811        // Crypto trade-price (Last) 5-second bars must request AGGTRADES on the
812        // realtime path too - TWS rejects TRADES for crypto (error 10299) on
813        // reqRealTimeBars, exactly as on the historical path. Mirrors the Java
814        // engine passing whatToShowFor(CRYPTO)="AGGTRADES" to subscribeRealTimeBars.
815        assert!(matches!(
816            price_type_to_ib_realtime_what_to_show_for_security(PriceType::Last, true),
817            RealtimeWhatToShow::AggTrades
818        ));
819        // Non-trade price types unaffected by the crypto special case.
820        assert!(matches!(
821            price_type_to_ib_realtime_what_to_show_for_security(PriceType::Mid, true),
822            RealtimeWhatToShow::MidPoint
823        ));
824        assert!(matches!(
825            price_type_to_ib_realtime_what_to_show_for_security(PriceType::Bid, true),
826            RealtimeWhatToShow::Bid
827        ));
828        // Non-crypto trade-price stays TRADES.
829        assert!(matches!(
830            price_type_to_ib_realtime_what_to_show_for_security(PriceType::Last, false),
831            RealtimeWhatToShow::Trades
832        ));
833    }
834
835    #[rstest]
836    fn test_ib_bar_to_nautilus_bar() {
837        let ib_bar = ibapi::market_data::historical::Bar {
838            date: datetime!(2024-01-01 10:00:00 UTC).into(),
839            open: 150.0,
840            high: 151.0,
841            low: 149.0,
842            close: 150.5,
843            volume: 1000.0,
844            wap: 150.25,
845            count: 100,
846        };
847
848        let instrument_id = create_test_instrument_id();
849
850        let bar_type = BarType::new(
851            instrument_id,
852            BarSpecification::new(1, BarAggregation::Minute, PriceType::Last),
853            AggregationSource::External,
854        );
855        let result = ib_bar_to_nautilus_bar(&ib_bar, bar_type, IbPriceConverter::new(2), 0);
856        assert!(result.is_ok());
857        let bar = result.unwrap();
858        assert_eq!(bar.open.as_f64(), 150.0);
859        assert_eq!(bar.high.as_f64(), 151.0);
860        assert_eq!(bar.low.as_f64(), 149.0);
861        assert_eq!(bar.close.as_f64(), 150.5);
862        assert_eq!(bar.volume.as_f64(), 1000.0);
863        let close = ib_timestamp_to_unix_nanos(&datetime!(2024-01-01 10:01:00 UTC));
864        assert_eq!(bar.ts_event.as_u64(), close.as_u64());
865        assert_eq!(bar.ts_init.as_u64(), close.as_u64());
866    }
867
868    #[rstest]
869    fn test_ib_bar_to_nautilus_bar_negative_volume() {
870        let ib_bar = ibapi::market_data::historical::Bar {
871            date: datetime!(2024-01-01 10:00:00 UTC).into(),
872            open: 150.0,
873            high: 151.0,
874            low: 149.0,
875            close: 150.5,
876            volume: -1.0, // Unavailable volume
877            wap: 150.25,
878            count: 100,
879        };
880
881        let instrument_id = create_test_instrument_id();
882
883        let bar_type = BarType::new(
884            instrument_id,
885            BarSpecification::new(1, BarAggregation::Minute, PriceType::Last),
886            AggregationSource::External,
887        );
888        let result = ib_bar_to_nautilus_bar(&ib_bar, bar_type, IbPriceConverter::new(2), 0);
889        assert!(result.is_ok());
890        let bar = result.unwrap();
891        // Negative volume should be converted to 0
892        assert_eq!(bar.volume.as_f64(), 0.0);
893    }
894
895    #[rstest]
896    fn test_bar_close_from_open_intraday() {
897        let open = ib_timestamp_to_unix_nanos(&datetime!(2024-01-01 10:00:00 UTC));
898
899        let spec = BarSpecification::new(1, BarAggregation::Second, PriceType::Last);
900        assert_eq!(
901            bar_close_from_open(open, &spec).as_u64(),
902            ib_timestamp_to_unix_nanos(&datetime!(2024-01-01 10:00:01 UTC)).as_u64(),
903        );
904
905        let spec = BarSpecification::new(5, BarAggregation::Second, PriceType::Last);
906        assert_eq!(
907            bar_close_from_open(open, &spec).as_u64(),
908            ib_timestamp_to_unix_nanos(&datetime!(2024-01-01 10:00:05 UTC)).as_u64(),
909        );
910
911        let spec = BarSpecification::new(1, BarAggregation::Minute, PriceType::Last);
912        assert_eq!(
913            bar_close_from_open(open, &spec).as_u64(),
914            ib_timestamp_to_unix_nanos(&datetime!(2024-01-01 10:01:00 UTC)).as_u64(),
915        );
916
917        let spec = BarSpecification::new(1, BarAggregation::Hour, PriceType::Last);
918        assert_eq!(
919            bar_close_from_open(open, &spec).as_u64(),
920            ib_timestamp_to_unix_nanos(&datetime!(2024-01-01 11:00:00 UTC)).as_u64(),
921        );
922    }
923
924    #[rstest]
925    fn test_bar_close_from_open_day() {
926        let open = ib_timestamp_to_unix_nanos(&datetime!(2024-01-01 00:00:00 UTC));
927        let spec = BarSpecification::new(1, BarAggregation::Day, PriceType::Last);
928        assert_eq!(
929            bar_close_from_open(open, &spec).as_u64(),
930            open.as_u64() + 86_400_000_000_000 - 1,
931        );
932    }
933
934    #[rstest]
935    fn test_bar_close_from_open_week_month() {
936        let open = ib_timestamp_to_unix_nanos(&datetime!(2024-01-13 00:00:00 UTC));
937
938        let spec = BarSpecification::new(1, BarAggregation::Week, PriceType::Last);
939        assert_eq!(bar_close_from_open(open, &spec).as_u64(), open.as_u64());
940
941        let spec = BarSpecification::new(1, BarAggregation::Month, PriceType::Last);
942        assert_eq!(bar_close_from_open(open, &spec).as_u64(), open.as_u64());
943    }
944
945    #[rstest]
946    fn test_ib_timestamp_to_unix_nanos() {
947        let dt = datetime!(2024-01-01 10:00:00 UTC);
948        let result = ib_timestamp_to_unix_nanos(&dt);
949        assert!(result.as_i64() > 0);
950    }
951
952    #[rstest]
953    fn test_jiff_to_ib_datetime() {
954        let utc_dt = "2024-01-01T10:00:00Z".parse::<Timestamp>().unwrap();
955        let result = jiff_to_ib_datetime(&utc_dt);
956        assert_eq!(result.year(), 2024);
957        assert_eq!(result.month(), time::Month::January);
958        assert_eq!(result.day(), 1);
959    }
960
961    #[rstest]
962    fn test_calculate_duration_segments_with_start_and_end() {
963        let start = "2024-01-01T10:00:00Z".parse::<Timestamp>().unwrap();
964        let end = "2024-01-02T10:00:00Z".parse::<Timestamp>().unwrap();
965        let result = calculate_duration_segments(Some(start), Some(end), None);
966
967        assert_eq!(result, vec![(end, IBDuration::days(1))]);
968    }
969
970    #[rstest]
971    fn test_calculate_duration_segments_without_start_defaults_to_one_day() {
972        let end = "2024-01-02T10:00:00Z".parse::<Timestamp>().unwrap();
973        let result = calculate_duration_segments(None, Some(end), None);
974
975        assert_eq!(result, vec![(end, IBDuration::days(1))]);
976    }
977
978    #[rstest]
979    fn test_calculate_duration_segments_with_explicit_duration() {
980        let end = "2024-01-02T10:00:00Z".parse::<Timestamp>().unwrap();
981        let result = calculate_duration_segments(None, Some(end), Some(IBDuration::weeks(2)));
982
983        assert_eq!(result, vec![(end, IBDuration::weeks(2))]);
984    }
985
986    #[rstest]
987    fn test_calculate_duration_segments_splits_years_days_and_seconds() {
988        let end = "2025-07-03T10:00:01Z".parse::<Timestamp>().unwrap();
989        let start = "2024-01-01T10:00:00Z".parse::<Timestamp>().unwrap();
990        let segments = calculate_duration_segments(Some(start), Some(end), None);
991
992        assert_eq!(
993            segments,
994            vec![
995                (end, IBDuration::years(1)),
996                (
997                    "2024-07-03T10:00:01Z".parse::<Timestamp>().unwrap(),
998                    IBDuration::days(184),
999                ),
1000                (
1001                    "2024-01-01T10:00:01Z".parse::<Timestamp>().unwrap(),
1002                    IBDuration::seconds(1),
1003                ),
1004            ]
1005        );
1006    }
1007
1008    #[rstest]
1009    fn test_calculate_duration_segments_rounds_subsecond_up() {
1010        let start = Timestamp::new(1, 0).unwrap();
1011        let end = Timestamp::new(1, 1).unwrap();
1012        let segments = calculate_duration_segments(Some(start), Some(end), None);
1013
1014        assert_eq!(segments, vec![(end, IBDuration::seconds(1))]);
1015    }
1016
1017    #[rstest]
1018    #[case(None, Some(Timestamp::from_second(2).unwrap()), 0, Some(10), true)]
1019    #[case(Some(Timestamp::from_second(1).unwrap()), Some(Timestamp::from_second(2).unwrap()), 0, Some(10), true)]
1020    #[case(Some(Timestamp::from_second(2).unwrap()), Some(Timestamp::from_second(1).unwrap()), 0, Some(10), false)]
1021    #[case(Some(Timestamp::from_second(1).unwrap()), Some(Timestamp::from_second(2).unwrap()), 10, Some(10), false)]
1022    #[case(Some(Timestamp::from_second(1).unwrap()), Some(Timestamp::from_second(2).unwrap()), 10, None, true)]
1023    fn test_should_continue_historical_tick_pagination(
1024        #[case] start: Option<Timestamp>,
1025        #[case] end: Option<Timestamp>,
1026        #[case] current_len: usize,
1027        #[case] limit: Option<usize>,
1028        #[case] expected: bool,
1029    ) {
1030        assert_eq!(
1031            should_continue_historical_tick_pagination(start, end, current_len, limit),
1032            expected
1033        );
1034    }
1035
1036    #[rstest]
1037    fn test_extend_historical_tick_batch_retreats_one_nanosecond() {
1038        let mut ticks = vec![UnixNanos::from(30)];
1039        let mut end = Some(Timestamp::from_nanosecond(100).unwrap());
1040
1041        let should_continue = extend_historical_tick_batch(
1042            &mut ticks,
1043            vec![UnixNanos::from(20), UnixNanos::from(10)],
1044            Some(Timestamp::from_nanosecond(1).unwrap()),
1045            &mut end,
1046            Some(UnixNanos::from(1)),
1047            Some(UnixNanos::from(100)),
1048            Some(10),
1049            |ts| *ts,
1050        );
1051
1052        assert!(should_continue);
1053        assert_eq!(
1054            ticks,
1055            vec![
1056                UnixNanos::from(30),
1057                UnixNanos::from(20),
1058                UnixNanos::from(10)
1059            ]
1060        );
1061        assert_eq!(end, Some(Timestamp::from_nanosecond(9).unwrap()));
1062    }
1063
1064    #[rstest]
1065    fn test_extend_historical_tick_batch_stops_and_trims_at_start() {
1066        let mut ticks = Vec::new();
1067        let mut end = Some(Timestamp::from_nanosecond(100).unwrap());
1068
1069        let should_continue = extend_historical_tick_batch(
1070            &mut ticks,
1071            vec![UnixNanos::from(20), UnixNanos::from(10)],
1072            Some(Timestamp::from_nanosecond(10).unwrap()),
1073            &mut end,
1074            Some(UnixNanos::from(10)),
1075            Some(UnixNanos::from(15)),
1076            Some(10),
1077            |ts| *ts,
1078        );
1079
1080        assert!(!should_continue);
1081        assert_eq!(ticks, vec![UnixNanos::from(10)]);
1082        assert_eq!(end, Some(Timestamp::from_nanosecond(9).unwrap()));
1083    }
1084
1085    #[rstest]
1086    fn test_extend_historical_tick_batch_drops_boundary_duplicates() {
1087        let mut ticks = vec![UnixNanos::from(30)];
1088        let mut end = Some(Timestamp::from_nanosecond(29).unwrap());
1089
1090        let should_continue = extend_historical_tick_batch(
1091            &mut ticks,
1092            vec![
1093                UnixNanos::from(35),
1094                UnixNanos::from(30),
1095                UnixNanos::from(20),
1096            ],
1097            None,
1098            &mut end,
1099            None,
1100            None,
1101            Some(10),
1102            |ts| *ts,
1103        );
1104
1105        assert!(should_continue);
1106        assert_eq!(ticks, vec![UnixNanos::from(30), UnixNanos::from(20)]);
1107        assert_eq!(end, Some(Timestamp::from_nanosecond(19).unwrap()));
1108    }
1109
1110    #[rstest]
1111    fn test_extend_historical_tick_batch_stops_when_end_cannot_retreat() {
1112        let mut ticks = vec![UnixNanos::from(30)];
1113        let mut end = Some(Timestamp::from_nanosecond(29).unwrap());
1114
1115        let should_continue = extend_historical_tick_batch(
1116            &mut ticks,
1117            vec![UnixNanos::from(35), UnixNanos::from(30)],
1118            None,
1119            &mut end,
1120            None,
1121            None,
1122            Some(10),
1123            |ts| *ts,
1124        );
1125
1126        assert!(!should_continue);
1127        assert_eq!(ticks, vec![UnixNanos::from(30)]);
1128        assert_eq!(end, Some(Timestamp::from_nanosecond(29).unwrap()));
1129    }
1130
1131    #[rstest]
1132    fn test_bar_request_segments_attaches_end_dates_when_not_continuous() {
1133        let end = "2025-01-01T00:00:00Z".parse::<Timestamp>().unwrap();
1134        let earlier = "2024-06-01T00:00:00Z".parse::<Timestamp>().unwrap();
1135        let segments = vec![(end, IBDuration::years(1)), (earlier, IBDuration::days(30))];
1136
1137        let result = bar_request_segments(segments, false);
1138
1139        assert_eq!(result.len(), 2);
1140        assert_eq!(result[0].0, Some(end));
1141        assert_eq!(result[1].0, Some(earlier));
1142    }
1143
1144    #[rstest]
1145    fn test_bar_request_segments_drops_end_date_and_keeps_only_first_for_continuous() {
1146        let end = "2025-01-01T00:00:00Z".parse::<Timestamp>().unwrap();
1147        let earlier = "2024-06-01T00:00:00Z".parse::<Timestamp>().unwrap();
1148        let segments = vec![(end, IBDuration::years(1)), (earlier, IBDuration::days(30))];
1149
1150        let result = bar_request_segments(segments, true);
1151
1152        assert_eq!(result.len(), 1);
1153        assert_eq!(result[0].0, None);
1154        assert_eq!(result[0].1, IBDuration::years(1));
1155    }
1156
1157    #[rstest]
1158    fn test_bar_request_segments_empty_input_yields_nothing_for_continuous() {
1159        let result = bar_request_segments(vec![], true);
1160        assert!(result.is_empty());
1161    }
1162
1163    fn create_equity() -> InstrumentAny {
1164        let equity = Equity::builder()
1165            .instrument_id(create_test_instrument_id())
1166            .raw_symbol(Symbol::from("AAPL"))
1167            .currency(Currency::from("USD"))
1168            .price_precision(2)
1169            .price_increment(Price::from("0.05"))
1170            .lot_size(Quantity::from("1"))
1171            .ts_event(UnixNanos::default())
1172            .ts_init(UnixNanos::default())
1173            .build()
1174            .unwrap();
1175        InstrumentAny::from(equity)
1176    }
1177
1178    #[rstest]
1179    #[case(1.12097, "1.12095")]
1180    #[case(1.12098, "1.12100")]
1181    #[case(1.12100, "1.12100")]
1182    #[case(1.12095, "1.12095")]
1183    #[case(-1.12097, "-1.12095")]
1184    fn test_price_converter_rounds_fx_pair_to_increment(
1185        #[case] ib_price: f64,
1186        #[case] expected: &str,
1187    ) {
1188        let instrument = currency_pair("EUR", "USD", 5, "0.00005");
1189        let prices = IbPriceConverter::for_instrument(&instrument, 1);
1190
1191        let price = prices.price(ib_price).unwrap();
1192
1193        assert_eq!(price, Price::from(expected));
1194        assert_eq!(price.precision, 5);
1195    }
1196
1197    #[rstest]
1198    #[case(1e25)]
1199    #[case(-1e25)]
1200    #[case(7.9e28)]
1201    #[case(1e300)]
1202    fn test_price_converter_returns_error_for_out_of_range_price(#[case] ib_price: f64) {
1203        let fx = IbPriceConverter::for_instrument(&currency_pair("EUR", "USD", 5, "0.00005"), 1);
1204        let magnified = IbPriceConverter::new(4).with_price_magnifier(100);
1205
1206        assert!(fx.price(ib_price).is_err());
1207        assert!(fx.bid_price(ib_price).is_err());
1208        assert!(fx.ask_price(ib_price).is_err());
1209        assert!(magnified.price(ib_price).is_err());
1210    }
1211
1212    #[rstest]
1213    fn test_price_converter_rounds_jpy_pair_to_increment() {
1214        let instrument = currency_pair("USD", "JPY", 3, "0.005");
1215        let prices = IbPriceConverter::for_instrument(&instrument, 1);
1216
1217        assert_eq!(prices.price(151.234).unwrap(), Price::from("151.235"));
1218        assert_eq!(prices.price(151.232).unwrap(), Price::from("151.230"));
1219    }
1220
1221    #[rstest]
1222    #[case(1.000025, "1.00000")]
1223    #[case(1.000075, "1.00010")]
1224    #[case(1.000125, "1.00010")]
1225    #[case(1.000026, "1.00005")]
1226    #[case(1.120025, "1.12000")]
1227    #[case(1.120175, "1.12020")]
1228    fn test_price_converter_ties_go_to_even_multiple(
1229        #[case] ib_price: f64,
1230        #[case] expected: &str,
1231    ) {
1232        let instrument = currency_pair("EUR", "USD", 5, "0.00005");
1233        let prices = IbPriceConverter::for_instrument(&instrument, 1);
1234
1235        let price = prices.price(ib_price).unwrap();
1236
1237        assert_eq!(price, Price::from(expected));
1238        assert_eq!(price.precision, 5);
1239    }
1240
1241    #[rstest]
1242    #[case(151.0025, "151.000")]
1243    #[case(151.0075, "151.010")]
1244    #[case(151.0125, "151.010")]
1245    fn test_price_converter_ties_go_to_even_multiple_for_jpy_pair(
1246        #[case] ib_price: f64,
1247        #[case] expected: &str,
1248    ) {
1249        let instrument = currency_pair("USD", "JPY", 3, "0.005");
1250        let prices = IbPriceConverter::for_instrument(&instrument, 1);
1251
1252        assert_eq!(prices.price(ib_price).unwrap(), Price::from(expected));
1253    }
1254
1255    #[rstest]
1256    fn test_price_converter_rounds_every_fx_midpoint_like_exact_ties_to_even() {
1257        let instrument = currency_pair("EUR", "USD", 5, "0.00005");
1258        let prices = IbPriceConverter::for_instrument(&instrument, 1);
1259
1260        // IB prices in steps of 0.000005 around 1.12, half of the 0.00005 increment
1261        for n in 223_850_i64..=224_150 {
1262            let micros = n * 5;
1263            let ib_price: f64 = format!("{}.{:06}", micros / 1_000_000, micros % 1_000_000)
1264                .parse()
1265                .unwrap();
1266
1267            // The expectation is in integers: n steps of 5 micros against a step of 10 micros
1268            let (quotient, remainder) = (n / 10, n % 10);
1269            let steps = match remainder.cmp(&5) {
1270                Ordering::Less => quotient,
1271                Ordering::Greater => quotient + 1,
1272                Ordering::Equal => quotient + quotient % 2,
1273            };
1274            let hundred_thousandths = steps * 5;
1275            let expected = Price::from(
1276                format!(
1277                    "{}.{:05}",
1278                    hundred_thousandths / 100_000,
1279                    hundred_thousandths % 100_000
1280                )
1281                .as_str(),
1282            );
1283
1284            assert_eq!(
1285                prices.price(ib_price).unwrap(),
1286                expected,
1287                "ib_price={ib_price}"
1288            );
1289        }
1290    }
1291
1292    #[rstest]
1293    fn test_price_converter_does_not_round_crypto_pair() {
1294        let instrument = currency_pair("BTC", "USD", 2, "0.05");
1295        let prices = IbPriceConverter::for_instrument(&instrument, 1);
1296
1297        assert_eq!(prices.price(100.03).unwrap(), Price::from("100.03"));
1298    }
1299
1300    #[rstest]
1301    fn test_price_converter_does_not_round_equity() {
1302        let instrument = create_equity();
1303        let prices = IbPriceConverter::for_instrument(&instrument, 1);
1304
1305        assert_eq!(prices.precision(), 2);
1306        assert_eq!(prices.price(100.03).unwrap(), Price::from("100.03"));
1307    }
1308
1309    #[rstest]
1310    fn test_price_converter_new_applies_precision_only() {
1311        let prices = IbPriceConverter::new(5);
1312
1313        assert_eq!(prices.precision(), 5);
1314        assert_eq!(prices.price(1.12097).unwrap(), Price::from("1.12097"));
1315    }
1316
1317    #[rstest]
1318    fn test_price_converter_rejects_non_finite_price() {
1319        let prices = IbPriceConverter::new(2);
1320
1321        assert!(prices.price(f64::NAN).is_err());
1322    }
1323
1324    #[rstest]
1325    fn test_ib_bar_to_nautilus_bar_rounds_fx_prices_to_increment() {
1326        let ib_bar = ibapi::market_data::historical::Bar {
1327            date: datetime!(2024-01-01 10:00:00 UTC).into(),
1328            open: 1.12097,
1329            high: 1.12103,
1330            low: 1.12091,
1331            close: 1.12098,
1332            volume: 0.0,
1333            wap: 0.0,
1334            count: 0,
1335        };
1336        let instrument = currency_pair("EUR", "USD", 5, "0.00005");
1337        let bar_type = BarType::new(
1338            instrument.id(),
1339            BarSpecification::new(1, BarAggregation::Minute, PriceType::Mid),
1340            AggregationSource::External,
1341        );
1342
1343        let bar = ib_bar_to_nautilus_bar(
1344            &ib_bar,
1345            bar_type,
1346            IbPriceConverter::for_instrument(&instrument, 1),
1347            0,
1348        )
1349        .unwrap();
1350
1351        assert_eq!(bar.open, Price::from("1.12095"));
1352        assert_eq!(bar.high, Price::from("1.12105"));
1353        assert_eq!(bar.low, Price::from("1.12090"));
1354        assert_eq!(bar.close, Price::from("1.12100"));
1355    }
1356
1357    #[rstest]
1358    #[case(1.12098, "1.12095", "1.12100")]
1359    #[case(1.12099, "1.12095", "1.12100")]
1360    #[case(1.12097, "1.12095", "1.12100")]
1361    #[case(1.12095, "1.12095", "1.12095")]
1362    #[case(-1.12097, "-1.12100", "-1.12095")]
1363    fn test_price_converter_rounds_bid_down_and_ask_up(
1364        #[case] ib_price: f64,
1365        #[case] bid: &str,
1366        #[case] ask: &str,
1367    ) {
1368        let instrument = currency_pair("EUR", "USD", 5, "0.00005");
1369        let prices = IbPriceConverter::for_instrument(&instrument, 1);
1370
1371        assert_eq!(prices.bid_price(ib_price).unwrap(), Price::from(bid));
1372        assert_eq!(prices.ask_price(ib_price).unwrap(), Price::from(ask));
1373    }
1374
1375    #[rstest]
1376    fn test_price_converter_rounds_bid_down_and_ask_up_for_jpy_pair() {
1377        let instrument = currency_pair("USD", "JPY", 3, "0.005");
1378        let prices = IbPriceConverter::for_instrument(&instrument, 1);
1379
1380        assert_eq!(prices.bid_price(151.0049).unwrap(), Price::from("151.000"));
1381        assert_eq!(prices.ask_price(151.0001).unwrap(), Price::from("151.005"));
1382    }
1383
1384    #[rstest]
1385    #[case(PriceType::Bid, "1.12095")]
1386    #[case(PriceType::Ask, "1.12100")]
1387    #[case(PriceType::Mid, "1.12095")]
1388    #[case(PriceType::Last, "1.12095")]
1389    fn test_price_converter_bar_price_follows_price_type(
1390        #[case] price_type: PriceType,
1391        #[case] expected: &str,
1392    ) {
1393        let instrument = currency_pair("EUR", "USD", 5, "0.00005");
1394        let prices = IbPriceConverter::for_instrument(&instrument, 1);
1395
1396        assert_eq!(
1397            prices.bar_price(price_type, 1.12097).unwrap(),
1398            Price::from(expected)
1399        );
1400    }
1401
1402    #[rstest]
1403    fn test_price_converter_bid_and_ask_do_not_round_equity() {
1404        let instrument = create_equity();
1405        let prices = IbPriceConverter::for_instrument(&instrument, 1);
1406
1407        assert_eq!(prices.bid_price(100.03).unwrap(), Price::from("100.03"));
1408        assert_eq!(prices.ask_price(100.03).unwrap(), Price::from("100.03"));
1409    }
1410
1411    #[rstest]
1412    #[case(PriceType::Bid, "1.12095", "1.12105", "1.12090", "1.12095")]
1413    #[case(PriceType::Ask, "1.12100", "1.12110", "1.12095", "1.12100")]
1414    fn test_ib_bar_to_nautilus_bar_rounds_fx_bid_and_ask_bars(
1415        #[case] price_type: PriceType,
1416        #[case] open: &str,
1417        #[case] high: &str,
1418        #[case] low: &str,
1419        #[case] close: &str,
1420    ) {
1421        let ib_bar = ibapi::market_data::historical::Bar {
1422            date: datetime!(2024-01-01 10:00:00 UTC).into(),
1423            open: 1.12097,
1424            high: 1.12109,
1425            low: 1.12091,
1426            close: 1.12096,
1427            volume: 0.0,
1428            wap: 0.0,
1429            count: 0,
1430        };
1431        let instrument = currency_pair("EUR", "USD", 5, "0.00005");
1432        let bar_type = BarType::new(
1433            instrument.id(),
1434            BarSpecification::new(1, BarAggregation::Minute, price_type),
1435            AggregationSource::External,
1436        );
1437
1438        let bar = ib_bar_to_nautilus_bar(
1439            &ib_bar,
1440            bar_type,
1441            IbPriceConverter::for_instrument(&instrument, 1),
1442            0,
1443        )
1444        .unwrap();
1445
1446        assert_eq!(bar.open, Price::from(open));
1447        assert_eq!(bar.high, Price::from(high));
1448        assert_eq!(bar.low, Price::from(low));
1449        assert_eq!(bar.close, Price::from(close));
1450        assert!(bar.high >= bar.low);
1451    }
1452
1453    #[rstest]
1454    #[case(425.25, "4.2525")]
1455    #[case(486.25, "4.8625")]
1456    #[case(-100.0, "-1.0000")]
1457    fn test_price_converter_divides_by_price_magnifier(
1458        #[case] ib_price: f64,
1459        #[case] expected: &str,
1460    ) {
1461        let prices = IbPriceConverter::new(4).with_price_magnifier(100);
1462
1463        assert_eq!(prices.price(ib_price).unwrap(), Price::from(expected));
1464    }
1465
1466    #[rstest]
1467    fn test_price_converter_converts_magnified_quarter_cent_steps_exactly() {
1468        let prices = IbPriceConverter::new(4).with_price_magnifier(100);
1469
1470        // A quarter-cent step of 0.25 cents is 0.0025 in the contract's currency
1471        for n in 0_i64..=2000 {
1472            let ib_price = n as f64 * 0.25;
1473            let ten_thousandths = n * 25;
1474            let expected = Price::from(
1475                format!(
1476                    "{}.{:04}",
1477                    ten_thousandths / 10_000,
1478                    ten_thousandths % 10_000
1479                )
1480                .as_str(),
1481            );
1482
1483            assert_eq!(
1484                prices.price(ib_price).unwrap(),
1485                expected,
1486                "ib_price={ib_price}"
1487            );
1488        }
1489    }
1490
1491    #[rstest]
1492    #[case(0)]
1493    #[case(-5)]
1494    #[case(1)]
1495    fn test_price_converter_treats_magnifier_below_one_as_one(#[case] magnifier: i32) {
1496        let prices = IbPriceConverter::new(2).with_price_magnifier(magnifier);
1497
1498        assert_eq!(prices.price(150.25).unwrap(), Price::from("150.25"));
1499    }
1500
1501    #[rstest]
1502    #[case::magnified_sentinel(100, -100.0, None, true)]
1503    #[case::magnified_sentinel_zero_size(100, -100.0, Some(0.0), true)]
1504    #[case::magnified_price_with_size(100, -100.0, Some(5.0), false)]
1505    #[case::unmagnified_sentinel(100, -1.0, None, true)]
1506    #[case::magnified_price(100, 5.5, None, false)]
1507    #[case::other_negative_price(100, -1.25, None, false)]
1508    #[case::plain_sentinel(1, -1.0, None, true)]
1509    #[case::plain_negative_price(1, -100.0, None, false)]
1510    fn test_price_converter_detects_magnified_sentinel(
1511        #[case] magnifier: i32,
1512        #[case] ib_price: f64,
1513        #[case] size: Option<f64>,
1514        #[case] expected: bool,
1515    ) {
1516        let prices = IbPriceConverter::new(5).with_price_magnifier(magnifier);
1517
1518        assert_eq!(prices.is_sentinel(ib_price, size), expected);
1519    }
1520
1521    #[rstest]
1522    fn test_price_converter_divides_before_rounding_fx_to_increment() {
1523        let instrument = currency_pair("EUR", "USD", 5, "0.00005");
1524        let prices = IbPriceConverter::for_instrument(&instrument, 100);
1525
1526        assert_eq!(prices.price(112.097).unwrap(), Price::from("1.12095"));
1527    }
1528
1529    #[rstest]
1530    fn test_ib_bar_to_nautilus_bar_divides_magnified_prices() {
1531        let ib_bar = ibapi::market_data::historical::Bar {
1532            date: datetime!(2024-01-01 10:00:00 UTC).into(),
1533            open: 425.25,
1534            high: 430.0,
1535            low: 424.5,
1536            close: 428.5,
1537            volume: 10.0,
1538            wap: 0.0,
1539            count: 1,
1540        };
1541        let bar_type = BarType::new(
1542            create_test_instrument_id(),
1543            BarSpecification::new(1, BarAggregation::Minute, PriceType::Last),
1544            AggregationSource::External,
1545        );
1546
1547        let bar = ib_bar_to_nautilus_bar(
1548            &ib_bar,
1549            bar_type,
1550            IbPriceConverter::new(4).with_price_magnifier(100),
1551            0,
1552        )
1553        .unwrap();
1554
1555        assert_eq!(bar.open, Price::from("4.2525"));
1556        assert_eq!(bar.high, Price::from("4.3000"));
1557        assert_eq!(bar.low, Price::from("4.2450"));
1558        assert_eq!(bar.close, Price::from("4.2850"));
1559    }
1560}