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 ibapi::market_data::{
19    historical::{
20        BarSize as HistoricalBarSize, BarTimestamp, Duration as IBDuration, ToDuration,
21        WhatToShow as HistoricalWhatToShow,
22    },
23    realtime::WhatToShow as RealtimeWhatToShow,
24};
25use jiff::Timestamp;
26use nautilus_core::{DurationNanos, UnixNanos};
27use nautilus_model::{
28    data::{Bar, BarSpecification, BarType},
29    enums::{BarAggregation, PriceType},
30    types::{Price, Quantity},
31};
32use time::OffsetDateTime;
33
34/// Convert Nautilus BarType to IB HistoricalBarSize.
35///
36/// # Errors
37///
38/// Returns an error if the bar aggregation/step combination is not supported by IB.
39pub fn bar_type_to_ib_bar_size(bar_type: &BarType) -> anyhow::Result<HistoricalBarSize> {
40    let spec = bar_type.spec();
41    let aggregation = spec.aggregation;
42    let step = spec.step.get();
43
44    let bar_size = match (aggregation, step) {
45        // Seconds
46        (BarAggregation::Second, 1) => HistoricalBarSize::Sec,
47        (BarAggregation::Second, 5) => HistoricalBarSize::Sec5,
48        (BarAggregation::Second, 15) => HistoricalBarSize::Sec15,
49        (BarAggregation::Second, 30) => HistoricalBarSize::Sec30,
50        // Minutes
51        (BarAggregation::Minute, 1) => HistoricalBarSize::Min,
52        (BarAggregation::Minute, 2) => HistoricalBarSize::Min2,
53        (BarAggregation::Minute, 3) => HistoricalBarSize::Min3,
54        (BarAggregation::Minute, 5) => HistoricalBarSize::Min5,
55        (BarAggregation::Minute, 10) => HistoricalBarSize::Min10,
56        (BarAggregation::Minute, 15) => HistoricalBarSize::Min15,
57        (BarAggregation::Minute, 20) => HistoricalBarSize::Min20,
58        (BarAggregation::Minute, 30) => HistoricalBarSize::Min30,
59        // Hours
60        (BarAggregation::Hour, 1) => HistoricalBarSize::Hour,
61        (BarAggregation::Hour, 2) => HistoricalBarSize::Hour2,
62        (BarAggregation::Hour, 3) => HistoricalBarSize::Hour3,
63        (BarAggregation::Hour, 4) => HistoricalBarSize::Hour4,
64        (BarAggregation::Hour, 8) => HistoricalBarSize::Hour8,
65        // Days
66        (BarAggregation::Day, 1) => HistoricalBarSize::Day,
67        // Weeks
68        (BarAggregation::Week, 1) => HistoricalBarSize::Week,
69        // Months
70        (BarAggregation::Month, 1) => HistoricalBarSize::Month,
71        _ => {
72            anyhow::bail!("Unsupported bar aggregation/step combination: {aggregation:?}/{step}",);
73        }
74    };
75
76    Ok(bar_size)
77}
78
79/// Convert Nautilus PriceType to IB WhatToShow.
80#[must_use]
81pub fn price_type_to_ib_what_to_show(price_type: PriceType) -> HistoricalWhatToShow {
82    match price_type {
83        PriceType::Last => HistoricalWhatToShow::Trades,
84        PriceType::Bid => HistoricalWhatToShow::Bid,
85        PriceType::Ask => HistoricalWhatToShow::Ask,
86        PriceType::Mid => HistoricalWhatToShow::MidPoint,
87        _ => HistoricalWhatToShow::Trades, // Default to trades
88    }
89}
90
91/// Whether IB requires `AGGTRADES` (not `TRADES`) for this request.
92///
93/// TWS rejects `TRADES` for crypto contracts (ZEROHASH/PAXOS) with error 10299 on
94/// both `reqHistoricalData` and `reqRealTimeBars`; crypto trade-price data is served
95/// only under `AGGTRADES`. Non-crypto contracts and non-trade price types are
96/// unaffected. Mirrors the Java engine's `LiveOneMinBarIngestionService.whatToShowFor`.
97#[must_use]
98fn uses_agg_trades(is_crypto: bool, price_type: PriceType) -> bool {
99    is_crypto && price_type == PriceType::Last
100}
101
102/// Convert Nautilus PriceType to IB WhatToShow for historical bars, mapping crypto
103/// trade-price (`PriceType::Last`) to `AGGTRADES` (see `uses_agg_trades`).
104#[must_use]
105pub fn price_type_to_ib_what_to_show_for_security(
106    price_type: PriceType,
107    is_crypto: bool,
108) -> HistoricalWhatToShow {
109    if uses_agg_trades(is_crypto, price_type) {
110        return HistoricalWhatToShow::AggTrades;
111    }
112
113    price_type_to_ib_what_to_show(price_type)
114}
115
116/// Convert Nautilus PriceType to IB WhatToShow for real-time (5-second) bars.
117///
118/// Unmapped price types default to [`RealtimeWhatToShow::Trades`].
119#[must_use]
120pub fn price_type_to_ib_realtime_what_to_show(price_type: PriceType) -> RealtimeWhatToShow {
121    match price_type {
122        PriceType::Last => RealtimeWhatToShow::Trades,
123        PriceType::Bid => RealtimeWhatToShow::Bid,
124        PriceType::Ask => RealtimeWhatToShow::Ask,
125        PriceType::Mid => RealtimeWhatToShow::MidPoint,
126        _ => RealtimeWhatToShow::Trades, // Default to trades
127    }
128}
129
130/// Convert Nautilus PriceType to IB WhatToShow for real-time (5-second) bars, mapping
131/// crypto trade-price (`PriceType::Last`) to `AGGTRADES` (see `uses_agg_trades`).
132#[must_use]
133pub fn price_type_to_ib_realtime_what_to_show_for_security(
134    price_type: PriceType,
135    is_crypto: bool,
136) -> RealtimeWhatToShow {
137    if uses_agg_trades(is_crypto, price_type) {
138        return RealtimeWhatToShow::AggTrades;
139    }
140
141    price_type_to_ib_realtime_what_to_show(price_type)
142}
143
144#[must_use]
145pub fn apply_price_magnifier(price: f64, price_magnifier: i32) -> f64 {
146    if price_magnifier > 0 {
147        price / f64::from(price_magnifier)
148    } else {
149        price
150    }
151}
152
153#[must_use]
154pub fn apply_bar_price_magnifier(
155    ib_bar: &ibapi::market_data::historical::Bar,
156    price_magnifier: i32,
157) -> ibapi::market_data::historical::Bar {
158    ibapi::market_data::historical::Bar {
159        date: ib_bar.date,
160        open: apply_price_magnifier(ib_bar.open, price_magnifier),
161        high: apply_price_magnifier(ib_bar.high, price_magnifier),
162        low: apply_price_magnifier(ib_bar.low, price_magnifier),
163        close: apply_price_magnifier(ib_bar.close, price_magnifier),
164        volume: ib_bar.volume,
165        wap: apply_price_magnifier(ib_bar.wap, price_magnifier),
166        count: ib_bar.count,
167    }
168}
169
170/// Implement bar price validation logic.
171/// Matches Python's `_validate_bar_prices` behavior.
172fn _validate_bar_prices(open: &mut f64, high: &mut f64, low: &mut f64, close: &f64) {
173    if *high < *low || *high < *open || *high < *close || *low > *open || *low > *close {
174        tracing::warn!(
175            "Invalid bar prices detected: O:{}, H:{}, L:{}, C:{}. Correcting using close price",
176            open,
177            high,
178            low,
179            close
180        );
181        *open = *close;
182        *high = *close;
183        *low = *close;
184    }
185}
186
187/// Convert IB Bar to Nautilus Bar.
188///
189/// `ts_event` and `ts_init` are set to the bar close ([`bar_close_from_open`]).
190///
191/// # Errors
192///
193/// Returns an error if conversion fails.
194pub fn ib_bar_to_nautilus_bar(
195    ib_bar: &ibapi::market_data::historical::Bar,
196    bar_type: BarType,
197    price_precision: u8,
198    size_precision: u8,
199) -> anyhow::Result<Bar> {
200    let ts_event = bar_close_from_open(
201        ib_bar_timestamp_to_unix_nanos(&ib_bar.date),
202        &bar_type.spec(),
203    );
204    let ts_init = ts_event;
205
206    // Validate and correct prices
207    let mut open = ib_bar.open;
208    let mut high = ib_bar.high;
209    let mut low = ib_bar.low;
210    let close = ib_bar.close;
211    _validate_bar_prices(&mut open, &mut high, &mut low, &close);
212
213    // Create prices
214    let open_price = Price::new(open, price_precision);
215    let high_price = Price::new(high, price_precision);
216    let low_price = Price::new(low, price_precision);
217    let close_price = Price::new(close, price_precision);
218
219    // Volume: IB uses -1 for unavailable volume, convert to 0
220    let volume = if ib_bar.volume < 0.0 {
221        Quantity::zero(size_precision)
222    } else {
223        Quantity::new(ib_bar.volume, size_precision)
224    };
225
226    Ok(Bar::new(
227        bar_type,
228        open_price,
229        high_price,
230        low_price,
231        close_price,
232        volume,
233        ts_event,
234        ts_init,
235    ))
236}
237
238/// Compute a bar's close timestamp from its open timestamp and [`BarSpecification`].
239///
240/// Weekly/monthly bars are returned unchanged (IB stamps these at the period end).
241#[must_use]
242pub fn bar_close_from_open(open: UnixNanos, spec: &BarSpecification) -> UnixNanos {
243    let is_day = spec.aggregation == BarAggregation::Day;
244
245    let duration = match spec.aggregation {
246        BarAggregation::Second
247        | BarAggregation::Minute
248        | BarAggregation::Hour
249        | BarAggregation::Day => spec.timedelta(),
250        _ => return open,
251    };
252
253    let Ok(duration) = DurationNanos::try_from(duration) else {
254        return open;
255    };
256
257    let close = open.saturating_add(duration);
258    if is_day {
259        close.saturating_sub(DurationNanos::new(1))
260    } else {
261        close
262    }
263}
264
265/// Convert IB historical bar timestamp to UnixNanos.
266#[must_use]
267pub fn ib_bar_timestamp_to_unix_nanos(dt: &BarTimestamp) -> UnixNanos {
268    match dt {
269        BarTimestamp::Date(date) => ib_timestamp_to_unix_nanos(&date.midnight().assume_utc()),
270        BarTimestamp::DateTime(dt) => ib_timestamp_to_unix_nanos(dt),
271    }
272}
273
274/// Convert IB timestamp (OffsetDateTime) to UnixNanos.
275#[must_use]
276pub fn ib_timestamp_to_unix_nanos(dt: &OffsetDateTime) -> UnixNanos {
277    let timestamp = dt.unix_timestamp_nanos();
278    UnixNanos::from(timestamp as u64)
279}
280
281/// Convert `Timestamp` to OffsetDateTime.
282pub fn jiff_to_ib_datetime(dt: &Timestamp) -> OffsetDateTime {
283    OffsetDateTime::from_unix_timestamp_nanos(dt.as_nanosecond())
284        .unwrap_or_else(|_| OffsetDateTime::now_utc())
285}
286
287/// Calculate duration segments for IB historical data request.
288///
289/// This is used to break down a large time range into multiple requests
290/// to comply with IB's duration limits for specific bar sizes.
291pub fn calculate_duration_segments(
292    start: Option<Timestamp>,
293    end: Option<Timestamp>,
294    duration: Option<IBDuration>,
295) -> Vec<(Timestamp, IBDuration)> {
296    let end = end.unwrap_or_else(Timestamp::now);
297
298    if let Some(duration) = duration {
299        return vec![(end, duration)];
300    }
301
302    let Some(start) = start else {
303        return vec![(end, 1.days())];
304    };
305
306    let mut results = Vec::new();
307    let duration = end.duration_since(start);
308    let mut total_seconds = duration.as_secs();
309    if duration.subsec_nanos() > 0 {
310        total_seconds += 1;
311    }
312
313    if total_seconds <= 0 {
314        return results;
315    }
316
317    let years = total_seconds / (365 * 24 * 3600);
318    total_seconds %= 365 * 24 * 3600;
319    let days = total_seconds / (24 * 3600);
320    total_seconds %= 24 * 3600;
321    let seconds = total_seconds;
322
323    if years > 0 {
324        results.push((end, (years as i32).years()));
325    }
326
327    if days > 0 {
328        let minus_years_duration = jiff::SignedDuration::from_hours(24 * (years * 365));
329        let minus_years_date = end - minus_years_duration;
330        results.push((minus_years_date, (days as i32).days()));
331    }
332
333    if seconds > 0 {
334        let minus_years_duration = jiff::SignedDuration::from_hours(24 * (years * 365));
335        let minus_days_duration = jiff::SignedDuration::from_hours(24 * (days));
336        let minus_days_date = end - minus_years_duration - minus_days_duration;
337        results.push((minus_days_date, (seconds as i32).seconds()));
338    }
339
340    results
341}
342
343/// Return whether a backward historical tick request needs another page.
344#[must_use]
345pub(crate) fn should_continue_historical_tick_pagination(
346    current_start_date: Option<Timestamp>,
347    current_end_date: Option<Timestamp>,
348    current_len: usize,
349    limit: Option<usize>,
350) -> bool {
351    limit.is_none_or(|limit| current_len < limit)
352        && current_start_date
353            .zip(current_end_date)
354            .is_none_or(|(start, end)| end > start)
355}
356
357/// Add a historical tick page and advance the next request before its earliest tick.
358#[allow(clippy::too_many_arguments)] // The parameters describe one pagination transition.
359pub(crate) fn extend_historical_tick_batch<T>(
360    all_ticks: &mut Vec<T>,
361    batch_ticks: Vec<T>,
362    current_start_date: Option<Timestamp>,
363    current_end_date: &mut Option<Timestamp>,
364    start_nanos: Option<UnixNanos>,
365    end_nanos: Option<UnixNanos>,
366    limit: Option<usize>,
367    ts_event: impl Fn(&T) -> UnixNanos,
368) -> bool {
369    if batch_ticks.is_empty() {
370        return false;
371    }
372
373    // The IB wire encodes the request end at second resolution and treats it as
374    // inclusive, so each page re-delivers ticks from the boundary second that a
375    // previous page already collected. Drop everything at or past the requested
376    // end before extending; an entirely re-delivered page means the end second
377    // cannot retreat (a single second holds a full page) and pagination must stop.
378    let mut batch_ticks = batch_ticks;
379
380    if let Some(end) = *current_end_date {
381        let end_bound_nanos = end.as_nanosecond();
382        batch_ticks.retain(|tick| i128::from(ts_event(tick).as_u64()) <= end_bound_nanos);
383        if batch_ticks.is_empty() {
384            tracing::warn!(
385                "Historical tick pagination pinned at {end}; a single second exceeds the page \
386                 size, earlier ticks within that second cannot be requested"
387            );
388            retain_historical_ticks_in_range(all_ticks, start_nanos, end_nanos, &ts_event);
389            return false;
390        }
391    }
392
393    let Some(min_ts_nanos) = batch_ticks.iter().map(&ts_event).min() else {
394        return false;
395    };
396
397    let new_end_nanos = min_ts_nanos.as_u64().saturating_sub(1);
398
399    let Ok(new_end) = Timestamp::from_nanosecond(i128::from(new_end_nanos)) else {
400        return false;
401    };
402
403    *current_end_date = Some(new_end);
404
405    all_ticks.extend(batch_ticks);
406    if current_start_date
407        .zip(*current_end_date)
408        .is_some_and(|(start, end)| end <= start)
409    {
410        retain_historical_ticks_in_range(all_ticks, start_nanos, end_nanos, &ts_event);
411        return false;
412    }
413
414    limit.is_none_or(|limit| all_ticks.len() < limit)
415}
416
417/// Retain historical ticks within the inclusive requested range.
418pub(crate) fn retain_historical_ticks_in_range<T>(
419    ticks: &mut Vec<T>,
420    start_nanos: Option<UnixNanos>,
421    end_nanos: Option<UnixNanos>,
422    ts_event: impl Fn(&T) -> UnixNanos,
423) {
424    ticks.retain(|tick| {
425        let ts_event = ts_event(tick);
426        start_nanos.is_none_or(|start| ts_event >= start)
427            && end_nanos.is_none_or(|end| ts_event <= end)
428    });
429}
430
431/// Adapt duration segments for an IB historical bars request.
432///
433/// For continuous futures the end date is dropped and only the first segment is
434/// kept (IB rejects an explicit end date with error 10339), logging a warning
435/// when the requested range cannot be honored.
436pub fn bar_request_segments(
437    segments: Vec<(Timestamp, IBDuration)>,
438    is_continuous_future: bool,
439) -> Vec<(Option<Timestamp>, IBDuration)> {
440    if is_continuous_future {
441        // Treat end dates within the last second as "now" so requests whose
442        // end defaults to the current time do not trigger a spurious warning.
443        let now = Timestamp::now();
444        let end_in_past = segments
445            .first()
446            .is_some_and(|(end, _)| *end < now - jiff::SignedDuration::from_secs(1));
447
448        if end_in_past || segments.len() > 1 {
449            tracing::warn!(
450                "Continuous futures cannot use an explicit end_date_time (IB error 10339); \
451                 the request is anchored to the current time using only the first duration \
452                 segment, so the returned bars may not cover the full requested range"
453            );
454        }
455
456        segments
457            .into_iter()
458            .take(1)
459            .map(|(_, d)| (None, d))
460            .collect()
461    } else {
462        segments
463            .into_iter()
464            .map(|(end, d)| (Some(end), d))
465            .collect()
466    }
467}
468
469#[cfg(test)]
470mod tests {
471    use nautilus_model::{
472        data::{BarSpecification, BarType},
473        enums::{AggregationSource, BarAggregation, PriceType},
474        identifiers::{InstrumentId, Symbol, Venue},
475    };
476    use rstest::rstest;
477    use time::macros::datetime;
478
479    use super::*;
480
481    fn create_test_instrument_id() -> InstrumentId {
482        InstrumentId::new(Symbol::from("AAPL"), Venue::from("NASDAQ"))
483    }
484
485    #[rstest]
486    fn test_bar_type_to_ib_bar_size_seconds() {
487        let instrument_id = create_test_instrument_id();
488
489        let bar_type = BarType::new(
490            instrument_id,
491            BarSpecification::new(1, BarAggregation::Second, PriceType::Last),
492            AggregationSource::External,
493        );
494        let result = bar_type_to_ib_bar_size(&bar_type);
495        assert!(result.is_ok());
496        assert_eq!(result.unwrap(), HistoricalBarSize::Sec);
497
498        let bar_type = BarType::new(
499            instrument_id,
500            BarSpecification::new(5, BarAggregation::Second, PriceType::Last),
501            AggregationSource::External,
502        );
503        let result = bar_type_to_ib_bar_size(&bar_type);
504        assert!(result.is_ok());
505        assert_eq!(result.unwrap(), HistoricalBarSize::Sec5);
506    }
507
508    #[rstest]
509    fn test_bar_type_to_ib_bar_size_minutes() {
510        let instrument_id = create_test_instrument_id();
511
512        let bar_type = BarType::new(
513            instrument_id,
514            BarSpecification::new(1, BarAggregation::Minute, PriceType::Last),
515            AggregationSource::External,
516        );
517        let result = bar_type_to_ib_bar_size(&bar_type);
518        assert!(result.is_ok());
519        assert_eq!(result.unwrap(), HistoricalBarSize::Min);
520
521        let bar_type = BarType::new(
522            instrument_id,
523            BarSpecification::new(15, BarAggregation::Minute, PriceType::Last),
524            AggregationSource::External,
525        );
526        let result = bar_type_to_ib_bar_size(&bar_type);
527        assert!(result.is_ok());
528        assert_eq!(result.unwrap(), HistoricalBarSize::Min15);
529    }
530
531    #[rstest]
532    fn test_bar_type_to_ib_bar_size_hours() {
533        let instrument_id = create_test_instrument_id();
534
535        let bar_type = BarType::new(
536            instrument_id,
537            BarSpecification::new(1, BarAggregation::Hour, PriceType::Last),
538            AggregationSource::External,
539        );
540        let result = bar_type_to_ib_bar_size(&bar_type);
541        assert!(result.is_ok());
542        assert_eq!(result.unwrap(), HistoricalBarSize::Hour);
543    }
544
545    #[rstest]
546    fn test_bar_type_to_ib_bar_size_days() {
547        let instrument_id = create_test_instrument_id();
548
549        let bar_type = BarType::new(
550            instrument_id,
551            BarSpecification::new(1, BarAggregation::Day, PriceType::Last),
552            AggregationSource::External,
553        );
554        let result = bar_type_to_ib_bar_size(&bar_type);
555        assert!(result.is_ok());
556        assert_eq!(result.unwrap(), HistoricalBarSize::Day);
557    }
558
559    #[rstest]
560    fn test_bar_type_to_ib_bar_size_unsupported() {
561        let instrument_id = create_test_instrument_id();
562
563        let bar_type = BarType::new(
564            instrument_id,
565            BarSpecification::new(12, BarAggregation::Minute, PriceType::Last),
566            AggregationSource::External,
567        );
568        let result = bar_type_to_ib_bar_size(&bar_type);
569        assert!(result.is_err());
570    }
571
572    #[rstest]
573    fn test_price_type_to_ib_what_to_show() {
574        assert_eq!(
575            price_type_to_ib_what_to_show(PriceType::Last),
576            HistoricalWhatToShow::Trades
577        );
578        assert_eq!(
579            price_type_to_ib_what_to_show(PriceType::Bid),
580            HistoricalWhatToShow::Bid
581        );
582        assert_eq!(
583            price_type_to_ib_what_to_show(PriceType::Ask),
584            HistoricalWhatToShow::Ask
585        );
586        assert_eq!(
587            price_type_to_ib_what_to_show(PriceType::Mid),
588            HistoricalWhatToShow::MidPoint
589        );
590    }
591
592    #[rstest]
593    fn test_price_type_to_ib_what_to_show_for_security_crypto() {
594        // Crypto trade-price (Last) must map to AGGTRADES, not TRADES - TWS rejects
595        // TRADES for crypto (error 10299). Mirrors the Java whatToShowFor rule.
596        assert_eq!(
597            price_type_to_ib_what_to_show_for_security(PriceType::Last, true),
598            HistoricalWhatToShow::AggTrades
599        );
600        // Non-trade price types are unaffected by the crypto special case.
601        assert_eq!(
602            price_type_to_ib_what_to_show_for_security(PriceType::Bid, true),
603            HistoricalWhatToShow::Bid
604        );
605        assert_eq!(
606            price_type_to_ib_what_to_show_for_security(PriceType::Ask, true),
607            HistoricalWhatToShow::Ask
608        );
609        assert_eq!(
610            price_type_to_ib_what_to_show_for_security(PriceType::Mid, true),
611            HistoricalWhatToShow::MidPoint
612        );
613    }
614
615    #[rstest]
616    fn test_price_type_to_ib_what_to_show_for_security_non_crypto() {
617        // Non-crypto: trade-price stays TRADES (equities/futures), everything else
618        // identical to the plain mapping.
619        assert_eq!(
620            price_type_to_ib_what_to_show_for_security(PriceType::Last, false),
621            HistoricalWhatToShow::Trades
622        );
623        assert_eq!(
624            price_type_to_ib_what_to_show_for_security(PriceType::Bid, false),
625            HistoricalWhatToShow::Bid
626        );
627        assert_eq!(
628            price_type_to_ib_what_to_show_for_security(PriceType::Mid, false),
629            HistoricalWhatToShow::MidPoint
630        );
631    }
632
633    #[rstest]
634    fn test_aggtrades_wire_string() {
635        // The vendored ibapi patch must serialize AggTrades as the exact IB wire
636        // token "AGGTRADES" on BOTH the historical and realtime enums.
637        assert_eq!(HistoricalWhatToShow::AggTrades.to_string(), "AGGTRADES");
638        assert_eq!(RealtimeWhatToShow::AggTrades.to_string(), "AGGTRADES");
639    }
640
641    #[rstest]
642    fn test_price_type_to_ib_realtime_what_to_show() {
643        // `RealtimeWhatToShow` does not derive `PartialEq`, so match on the variants.
644        assert!(matches!(
645            price_type_to_ib_realtime_what_to_show(PriceType::Last),
646            RealtimeWhatToShow::Trades
647        ));
648        assert!(matches!(
649            price_type_to_ib_realtime_what_to_show(PriceType::Bid),
650            RealtimeWhatToShow::Bid
651        ));
652        assert!(matches!(
653            price_type_to_ib_realtime_what_to_show(PriceType::Ask),
654            RealtimeWhatToShow::Ask
655        ));
656        assert!(matches!(
657            price_type_to_ib_realtime_what_to_show(PriceType::Mid),
658            RealtimeWhatToShow::MidPoint
659        ));
660    }
661
662    #[rstest]
663    fn test_price_type_to_ib_realtime_what_to_show_for_security_crypto() {
664        // Crypto trade-price (Last) 5-second bars must request AGGTRADES on the
665        // realtime path too - TWS rejects TRADES for crypto (error 10299) on
666        // reqRealTimeBars, exactly as on the historical path. Mirrors the Java
667        // engine passing whatToShowFor(CRYPTO)="AGGTRADES" to subscribeRealTimeBars.
668        assert!(matches!(
669            price_type_to_ib_realtime_what_to_show_for_security(PriceType::Last, true),
670            RealtimeWhatToShow::AggTrades
671        ));
672        // Non-trade price types unaffected by the crypto special case.
673        assert!(matches!(
674            price_type_to_ib_realtime_what_to_show_for_security(PriceType::Mid, true),
675            RealtimeWhatToShow::MidPoint
676        ));
677        assert!(matches!(
678            price_type_to_ib_realtime_what_to_show_for_security(PriceType::Bid, true),
679            RealtimeWhatToShow::Bid
680        ));
681        // Non-crypto trade-price stays TRADES.
682        assert!(matches!(
683            price_type_to_ib_realtime_what_to_show_for_security(PriceType::Last, false),
684            RealtimeWhatToShow::Trades
685        ));
686    }
687
688    #[rstest]
689    fn test_ib_bar_to_nautilus_bar() {
690        let ib_bar = ibapi::market_data::historical::Bar {
691            date: datetime!(2024-01-01 10:00:00 UTC).into(),
692            open: 150.0,
693            high: 151.0,
694            low: 149.0,
695            close: 150.5,
696            volume: 1000.0,
697            wap: 150.25,
698            count: 100,
699        };
700
701        let instrument_id = create_test_instrument_id();
702
703        let bar_type = BarType::new(
704            instrument_id,
705            BarSpecification::new(1, BarAggregation::Minute, PriceType::Last),
706            AggregationSource::External,
707        );
708        let result = ib_bar_to_nautilus_bar(&ib_bar, bar_type, 2, 0);
709        assert!(result.is_ok());
710        let bar = result.unwrap();
711        assert_eq!(bar.open.as_f64(), 150.0);
712        assert_eq!(bar.high.as_f64(), 151.0);
713        assert_eq!(bar.low.as_f64(), 149.0);
714        assert_eq!(bar.close.as_f64(), 150.5);
715        assert_eq!(bar.volume.as_f64(), 1000.0);
716        let close = ib_timestamp_to_unix_nanos(&datetime!(2024-01-01 10:01:00 UTC));
717        assert_eq!(bar.ts_event.as_u64(), close.as_u64());
718        assert_eq!(bar.ts_init.as_u64(), close.as_u64());
719    }
720
721    #[rstest]
722    fn test_ib_bar_to_nautilus_bar_negative_volume() {
723        let ib_bar = ibapi::market_data::historical::Bar {
724            date: datetime!(2024-01-01 10:00:00 UTC).into(),
725            open: 150.0,
726            high: 151.0,
727            low: 149.0,
728            close: 150.5,
729            volume: -1.0, // Unavailable volume
730            wap: 150.25,
731            count: 100,
732        };
733
734        let instrument_id = create_test_instrument_id();
735
736        let bar_type = BarType::new(
737            instrument_id,
738            BarSpecification::new(1, BarAggregation::Minute, PriceType::Last),
739            AggregationSource::External,
740        );
741        let result = ib_bar_to_nautilus_bar(&ib_bar, bar_type, 2, 0);
742        assert!(result.is_ok());
743        let bar = result.unwrap();
744        // Negative volume should be converted to 0
745        assert_eq!(bar.volume.as_f64(), 0.0);
746    }
747
748    #[rstest]
749    fn test_bar_close_from_open_intraday() {
750        let open = ib_timestamp_to_unix_nanos(&datetime!(2024-01-01 10:00:00 UTC));
751
752        let spec = BarSpecification::new(1, BarAggregation::Second, PriceType::Last);
753        assert_eq!(
754            bar_close_from_open(open, &spec).as_u64(),
755            ib_timestamp_to_unix_nanos(&datetime!(2024-01-01 10:00:01 UTC)).as_u64(),
756        );
757
758        let spec = BarSpecification::new(5, BarAggregation::Second, PriceType::Last);
759        assert_eq!(
760            bar_close_from_open(open, &spec).as_u64(),
761            ib_timestamp_to_unix_nanos(&datetime!(2024-01-01 10:00:05 UTC)).as_u64(),
762        );
763
764        let spec = BarSpecification::new(1, BarAggregation::Minute, PriceType::Last);
765        assert_eq!(
766            bar_close_from_open(open, &spec).as_u64(),
767            ib_timestamp_to_unix_nanos(&datetime!(2024-01-01 10:01:00 UTC)).as_u64(),
768        );
769
770        let spec = BarSpecification::new(1, BarAggregation::Hour, PriceType::Last);
771        assert_eq!(
772            bar_close_from_open(open, &spec).as_u64(),
773            ib_timestamp_to_unix_nanos(&datetime!(2024-01-01 11:00:00 UTC)).as_u64(),
774        );
775    }
776
777    #[rstest]
778    fn test_bar_close_from_open_day() {
779        let open = ib_timestamp_to_unix_nanos(&datetime!(2024-01-01 00:00:00 UTC));
780        let spec = BarSpecification::new(1, BarAggregation::Day, PriceType::Last);
781        assert_eq!(
782            bar_close_from_open(open, &spec).as_u64(),
783            open.as_u64() + 86_400_000_000_000 - 1,
784        );
785    }
786
787    #[rstest]
788    fn test_bar_close_from_open_week_month() {
789        let open = ib_timestamp_to_unix_nanos(&datetime!(2024-01-13 00:00:00 UTC));
790
791        let spec = BarSpecification::new(1, BarAggregation::Week, PriceType::Last);
792        assert_eq!(bar_close_from_open(open, &spec).as_u64(), open.as_u64());
793
794        let spec = BarSpecification::new(1, BarAggregation::Month, PriceType::Last);
795        assert_eq!(bar_close_from_open(open, &spec).as_u64(), open.as_u64());
796    }
797
798    #[rstest]
799    fn test_ib_timestamp_to_unix_nanos() {
800        let dt = datetime!(2024-01-01 10:00:00 UTC);
801        let result = ib_timestamp_to_unix_nanos(&dt);
802        assert!(result.as_i64() > 0);
803    }
804
805    #[rstest]
806    fn test_jiff_to_ib_datetime() {
807        let utc_dt = "2024-01-01T10:00:00Z".parse::<Timestamp>().unwrap();
808        let result = jiff_to_ib_datetime(&utc_dt);
809        assert_eq!(result.year(), 2024);
810        assert_eq!(result.month(), time::Month::January);
811        assert_eq!(result.day(), 1);
812    }
813
814    #[rstest]
815    fn test_calculate_duration_segments_with_start_and_end() {
816        let start = "2024-01-01T10:00:00Z".parse::<Timestamp>().unwrap();
817        let end = "2024-01-02T10:00:00Z".parse::<Timestamp>().unwrap();
818        let result = calculate_duration_segments(Some(start), Some(end), None);
819
820        assert_eq!(result, vec![(end, IBDuration::days(1))]);
821    }
822
823    #[rstest]
824    fn test_calculate_duration_segments_without_start_defaults_to_one_day() {
825        let end = "2024-01-02T10:00:00Z".parse::<Timestamp>().unwrap();
826        let result = calculate_duration_segments(None, Some(end), None);
827
828        assert_eq!(result, vec![(end, IBDuration::days(1))]);
829    }
830
831    #[rstest]
832    fn test_calculate_duration_segments_with_explicit_duration() {
833        let end = "2024-01-02T10:00:00Z".parse::<Timestamp>().unwrap();
834        let result = calculate_duration_segments(None, Some(end), Some(IBDuration::weeks(2)));
835
836        assert_eq!(result, vec![(end, IBDuration::weeks(2))]);
837    }
838
839    #[rstest]
840    fn test_calculate_duration_segments_splits_years_days_and_seconds() {
841        let end = "2025-07-03T10:00:01Z".parse::<Timestamp>().unwrap();
842        let start = "2024-01-01T10:00:00Z".parse::<Timestamp>().unwrap();
843        let segments = calculate_duration_segments(Some(start), Some(end), None);
844
845        assert_eq!(
846            segments,
847            vec![
848                (end, IBDuration::years(1)),
849                (
850                    "2024-07-03T10:00:01Z".parse::<Timestamp>().unwrap(),
851                    IBDuration::days(184),
852                ),
853                (
854                    "2024-01-01T10:00:01Z".parse::<Timestamp>().unwrap(),
855                    IBDuration::seconds(1),
856                ),
857            ]
858        );
859    }
860
861    #[rstest]
862    fn test_calculate_duration_segments_rounds_subsecond_up() {
863        let start = Timestamp::new(1, 0).unwrap();
864        let end = Timestamp::new(1, 1).unwrap();
865        let segments = calculate_duration_segments(Some(start), Some(end), None);
866
867        assert_eq!(segments, vec![(end, IBDuration::seconds(1))]);
868    }
869
870    #[rstest]
871    #[case(None, Some(Timestamp::from_second(2).unwrap()), 0, Some(10), true)]
872    #[case(Some(Timestamp::from_second(1).unwrap()), Some(Timestamp::from_second(2).unwrap()), 0, Some(10), true)]
873    #[case(Some(Timestamp::from_second(2).unwrap()), Some(Timestamp::from_second(1).unwrap()), 0, Some(10), false)]
874    #[case(Some(Timestamp::from_second(1).unwrap()), Some(Timestamp::from_second(2).unwrap()), 10, Some(10), false)]
875    #[case(Some(Timestamp::from_second(1).unwrap()), Some(Timestamp::from_second(2).unwrap()), 10, None, true)]
876    fn test_should_continue_historical_tick_pagination(
877        #[case] start: Option<Timestamp>,
878        #[case] end: Option<Timestamp>,
879        #[case] current_len: usize,
880        #[case] limit: Option<usize>,
881        #[case] expected: bool,
882    ) {
883        assert_eq!(
884            should_continue_historical_tick_pagination(start, end, current_len, limit),
885            expected
886        );
887    }
888
889    #[rstest]
890    fn test_extend_historical_tick_batch_retreats_one_nanosecond() {
891        let mut ticks = vec![UnixNanos::from(30)];
892        let mut end = Some(Timestamp::from_nanosecond(100).unwrap());
893
894        let should_continue = extend_historical_tick_batch(
895            &mut ticks,
896            vec![UnixNanos::from(20), UnixNanos::from(10)],
897            Some(Timestamp::from_nanosecond(1).unwrap()),
898            &mut end,
899            Some(UnixNanos::from(1)),
900            Some(UnixNanos::from(100)),
901            Some(10),
902            |ts| *ts,
903        );
904
905        assert!(should_continue);
906        assert_eq!(
907            ticks,
908            vec![
909                UnixNanos::from(30),
910                UnixNanos::from(20),
911                UnixNanos::from(10)
912            ]
913        );
914        assert_eq!(end, Some(Timestamp::from_nanosecond(9).unwrap()));
915    }
916
917    #[rstest]
918    fn test_extend_historical_tick_batch_stops_and_trims_at_start() {
919        let mut ticks = Vec::new();
920        let mut end = Some(Timestamp::from_nanosecond(100).unwrap());
921
922        let should_continue = extend_historical_tick_batch(
923            &mut ticks,
924            vec![UnixNanos::from(20), UnixNanos::from(10)],
925            Some(Timestamp::from_nanosecond(10).unwrap()),
926            &mut end,
927            Some(UnixNanos::from(10)),
928            Some(UnixNanos::from(15)),
929            Some(10),
930            |ts| *ts,
931        );
932
933        assert!(!should_continue);
934        assert_eq!(ticks, vec![UnixNanos::from(10)]);
935        assert_eq!(end, Some(Timestamp::from_nanosecond(9).unwrap()));
936    }
937
938    #[rstest]
939    fn test_extend_historical_tick_batch_drops_boundary_duplicates() {
940        let mut ticks = vec![UnixNanos::from(30)];
941        let mut end = Some(Timestamp::from_nanosecond(29).unwrap());
942
943        let should_continue = extend_historical_tick_batch(
944            &mut ticks,
945            vec![
946                UnixNanos::from(35),
947                UnixNanos::from(30),
948                UnixNanos::from(20),
949            ],
950            None,
951            &mut end,
952            None,
953            None,
954            Some(10),
955            |ts| *ts,
956        );
957
958        assert!(should_continue);
959        assert_eq!(ticks, vec![UnixNanos::from(30), UnixNanos::from(20)]);
960        assert_eq!(end, Some(Timestamp::from_nanosecond(19).unwrap()));
961    }
962
963    #[rstest]
964    fn test_extend_historical_tick_batch_stops_when_end_cannot_retreat() {
965        let mut ticks = vec![UnixNanos::from(30)];
966        let mut end = Some(Timestamp::from_nanosecond(29).unwrap());
967
968        let should_continue = extend_historical_tick_batch(
969            &mut ticks,
970            vec![UnixNanos::from(35), UnixNanos::from(30)],
971            None,
972            &mut end,
973            None,
974            None,
975            Some(10),
976            |ts| *ts,
977        );
978
979        assert!(!should_continue);
980        assert_eq!(ticks, vec![UnixNanos::from(30)]);
981        assert_eq!(end, Some(Timestamp::from_nanosecond(29).unwrap()));
982    }
983
984    #[rstest]
985    fn test_bar_request_segments_attaches_end_dates_when_not_continuous() {
986        let end = "2025-01-01T00:00:00Z".parse::<Timestamp>().unwrap();
987        let earlier = "2024-06-01T00:00:00Z".parse::<Timestamp>().unwrap();
988        let segments = vec![(end, IBDuration::years(1)), (earlier, IBDuration::days(30))];
989
990        let result = bar_request_segments(segments, false);
991
992        assert_eq!(result.len(), 2);
993        assert_eq!(result[0].0, Some(end));
994        assert_eq!(result[1].0, Some(earlier));
995    }
996
997    #[rstest]
998    fn test_bar_request_segments_drops_end_date_and_keeps_only_first_for_continuous() {
999        let end = "2025-01-01T00:00:00Z".parse::<Timestamp>().unwrap();
1000        let earlier = "2024-06-01T00:00:00Z".parse::<Timestamp>().unwrap();
1001        let segments = vec![(end, IBDuration::years(1)), (earlier, IBDuration::days(30))];
1002
1003        let result = bar_request_segments(segments, true);
1004
1005        assert_eq!(result.len(), 1);
1006        assert_eq!(result[0].0, None);
1007        assert_eq!(result[0].1, IBDuration::years(1));
1008    }
1009
1010    #[rstest]
1011    fn test_bar_request_segments_empty_input_yields_nothing_for_continuous() {
1012        let result = bar_request_segments(vec![], true);
1013        assert!(result.is_empty());
1014    }
1015}