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::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    price_type_to_ib_what_to_show(price_type)
113}
114
115/// Convert Nautilus PriceType to IB WhatToShow for real-time (5-second) bars.
116///
117/// Unmapped price types default to [`RealtimeWhatToShow::Trades`].
118#[must_use]
119pub fn price_type_to_ib_realtime_what_to_show(price_type: PriceType) -> RealtimeWhatToShow {
120    match price_type {
121        PriceType::Last => RealtimeWhatToShow::Trades,
122        PriceType::Bid => RealtimeWhatToShow::Bid,
123        PriceType::Ask => RealtimeWhatToShow::Ask,
124        PriceType::Mid => RealtimeWhatToShow::MidPoint,
125        _ => RealtimeWhatToShow::Trades, // Default to trades
126    }
127}
128
129/// Convert Nautilus PriceType to IB WhatToShow for real-time (5-second) bars, mapping
130/// crypto trade-price (`PriceType::Last`) to `AGGTRADES` (see `uses_agg_trades`).
131#[must_use]
132pub fn price_type_to_ib_realtime_what_to_show_for_security(
133    price_type: PriceType,
134    is_crypto: bool,
135) -> RealtimeWhatToShow {
136    if uses_agg_trades(is_crypto, price_type) {
137        return RealtimeWhatToShow::AggTrades;
138    }
139    price_type_to_ib_realtime_what_to_show(price_type)
140}
141
142#[must_use]
143pub fn apply_price_magnifier(price: f64, price_magnifier: i32) -> f64 {
144    if price_magnifier > 0 {
145        price / f64::from(price_magnifier)
146    } else {
147        price
148    }
149}
150
151#[must_use]
152pub fn apply_bar_price_magnifier(
153    ib_bar: &ibapi::market_data::historical::Bar,
154    price_magnifier: i32,
155) -> ibapi::market_data::historical::Bar {
156    ibapi::market_data::historical::Bar {
157        date: ib_bar.date,
158        open: apply_price_magnifier(ib_bar.open, price_magnifier),
159        high: apply_price_magnifier(ib_bar.high, price_magnifier),
160        low: apply_price_magnifier(ib_bar.low, price_magnifier),
161        close: apply_price_magnifier(ib_bar.close, price_magnifier),
162        volume: ib_bar.volume,
163        wap: apply_price_magnifier(ib_bar.wap, price_magnifier),
164        count: ib_bar.count,
165    }
166}
167
168/// Implement bar price validation logic.
169/// Matches Python's `_validate_bar_prices` behavior.
170fn _validate_bar_prices(open: &mut f64, high: &mut f64, low: &mut f64, close: &f64) {
171    if *high < *low || *high < *open || *high < *close || *low > *open || *low > *close {
172        tracing::warn!(
173            "Invalid bar prices detected: O:{}, H:{}, L:{}, C:{}. Correcting using close price",
174            open,
175            high,
176            low,
177            close
178        );
179        *open = *close;
180        *high = *close;
181        *low = *close;
182    }
183}
184
185/// Convert IB Bar to Nautilus Bar.
186///
187/// `ts_event` and `ts_init` are set to the bar close ([`bar_close_from_open`]).
188///
189/// # Errors
190///
191/// Returns an error if conversion fails.
192pub fn ib_bar_to_nautilus_bar(
193    ib_bar: &ibapi::market_data::historical::Bar,
194    bar_type: BarType,
195    price_precision: u8,
196    size_precision: u8,
197) -> anyhow::Result<Bar> {
198    let ts_event = bar_close_from_open(
199        ib_bar_timestamp_to_unix_nanos(&ib_bar.date),
200        &bar_type.spec(),
201    );
202    let ts_init = ts_event;
203
204    // Validate and correct prices
205    let mut open = ib_bar.open;
206    let mut high = ib_bar.high;
207    let mut low = ib_bar.low;
208    let close = ib_bar.close;
209    _validate_bar_prices(&mut open, &mut high, &mut low, &close);
210
211    // Create prices
212    let open_price = Price::new(open, price_precision);
213    let high_price = Price::new(high, price_precision);
214    let low_price = Price::new(low, price_precision);
215    let close_price = Price::new(close, price_precision);
216
217    // Volume: IB uses -1 for unavailable volume, convert to 0
218    let volume = if ib_bar.volume < 0.0 {
219        Quantity::zero(size_precision)
220    } else {
221        Quantity::new(ib_bar.volume, size_precision)
222    };
223
224    Ok(Bar::new(
225        bar_type,
226        open_price,
227        high_price,
228        low_price,
229        close_price,
230        volume,
231        ts_event,
232        ts_init,
233    ))
234}
235
236/// Compute a bar's close timestamp from its open timestamp and [`BarSpecification`].
237///
238/// Weekly/monthly bars are returned unchanged (IB stamps these at the period end).
239#[must_use]
240pub fn bar_close_from_open(open: UnixNanos, spec: &BarSpecification) -> UnixNanos {
241    let is_day = spec.aggregation == BarAggregation::Day;
242    let duration_ns = match spec.aggregation {
243        BarAggregation::Second
244        | BarAggregation::Minute
245        | BarAggregation::Hour
246        | BarAggregation::Day => spec.timedelta().as_nanos(),
247        _ => return open,
248    };
249    let Ok(duration_ns) = u64::try_from(duration_ns) else {
250        return open;
251    };
252    let close = open.saturating_add_ns(duration_ns);
253    if is_day {
254        close.saturating_sub_ns(1_u64)
255    } else {
256        close
257    }
258}
259
260/// Convert IB historical bar timestamp to UnixNanos.
261#[must_use]
262pub fn ib_bar_timestamp_to_unix_nanos(dt: &BarTimestamp) -> UnixNanos {
263    match dt {
264        BarTimestamp::Date(date) => ib_timestamp_to_unix_nanos(&date.midnight().assume_utc()),
265        BarTimestamp::DateTime(dt) => ib_timestamp_to_unix_nanos(dt),
266    }
267}
268
269/// Convert IB timestamp (OffsetDateTime) to UnixNanos.
270#[must_use]
271pub fn ib_timestamp_to_unix_nanos(dt: &OffsetDateTime) -> UnixNanos {
272    let timestamp = dt.unix_timestamp_nanos();
273    UnixNanos::from(timestamp as u64)
274}
275
276/// Convert `Timestamp` to OffsetDateTime.
277pub fn jiff_to_ib_datetime(dt: &Timestamp) -> OffsetDateTime {
278    OffsetDateTime::from_unix_timestamp_nanos(dt.as_nanosecond())
279        .unwrap_or_else(|_| OffsetDateTime::now_utc())
280}
281
282/// Calculate duration for IB historical data request.
283///
284/// # Errors
285///
286/// Returns an error if duration calculation fails.
287pub fn calculate_duration(
288    start: Option<Timestamp>,
289    end: Option<Timestamp>,
290) -> anyhow::Result<IBDuration> {
291    match (start, end) {
292        (Some(start_dt), Some(end_dt)) => {
293            let duration = end_dt.duration_since(start_dt);
294            let days = duration.as_secs() / (24 * 60 * 60);
295
296            if days > 0 && days <= i32::MAX as i64 {
297                Ok((days as i32).days())
298            } else {
299                // Fallback to seconds if less than a day or too large
300                let seconds = duration.as_secs();
301                if seconds > 0 && seconds <= i32::MAX as i64 {
302                    Ok((seconds as i32).seconds())
303                } else {
304                    // Default to 1 day if calculation fails
305                    Ok(1.days())
306                }
307            }
308        }
309        (None, Some(_)) => {
310            // Default to 1 day if only end is provided
311            Ok(1.days())
312        }
313        (Some(_), None) => {
314            // Default to 1 day if only start is provided
315            Ok(1.days())
316        }
317        (None, None) => {
318            // Default to 1 day if neither is provided
319            Ok(1.days())
320        }
321    }
322}
323
324/// Calculate duration segments for IB historical data request.
325///
326/// This is used to break down a large time range into multiple requests
327/// to comply with IB's duration limits for specific bar sizes.
328pub fn calculate_duration_segments(
329    start: Timestamp,
330    end: Timestamp,
331) -> Vec<(Timestamp, IBDuration)> {
332    let mut results = Vec::new();
333    let duration = end.duration_since(start);
334    let mut total_seconds = duration.as_secs();
335
336    if total_seconds <= 0 {
337        return results;
338    }
339
340    let years = total_seconds / (365 * 24 * 3600);
341    total_seconds %= 365 * 24 * 3600;
342    let days = total_seconds / (24 * 3600);
343    total_seconds %= 24 * 3600;
344    let seconds = total_seconds;
345
346    if years > 0 {
347        results.push((end, (years as i32).years()));
348    }
349
350    if days > 0 {
351        let minus_years_duration = jiff::SignedDuration::from_hours(24 * (years * 365));
352        let minus_years_date = end - minus_years_duration;
353        results.push((minus_years_date, (days as i32).days()));
354    }
355
356    if seconds > 0 {
357        let minus_years_duration = jiff::SignedDuration::from_hours(24 * (years * 365));
358        let minus_days_duration = jiff::SignedDuration::from_hours(24 * (days));
359        let minus_days_date = end - minus_years_duration - minus_days_duration;
360        results.push((minus_days_date, (seconds as i32).seconds()));
361    }
362
363    results
364}
365
366/// Adapt duration segments for an IB historical bars request.
367///
368/// For continuous futures the end date is dropped and only the first segment is
369/// kept (IB rejects an explicit end date with error 10339), logging a warning
370/// when the requested range cannot be honored.
371pub fn bar_request_segments(
372    segments: Vec<(Timestamp, IBDuration)>,
373    is_continuous_future: bool,
374) -> Vec<(Option<Timestamp>, IBDuration)> {
375    if is_continuous_future {
376        // Treat end dates within the last second as "now" so requests whose
377        // end defaults to the current time do not trigger a spurious warning.
378        let now = Timestamp::now();
379        let end_in_past = segments
380            .first()
381            .is_some_and(|(end, _)| *end < now - jiff::SignedDuration::from_secs(1));
382
383        if end_in_past || segments.len() > 1 {
384            tracing::warn!(
385                "Continuous futures cannot use an explicit end_date_time (IB error 10339); \
386                 the request is anchored to the current time using only the first duration \
387                 segment, so the returned bars may not cover the full requested range"
388            );
389        }
390
391        segments
392            .into_iter()
393            .take(1)
394            .map(|(_, d)| (None, d))
395            .collect()
396    } else {
397        segments
398            .into_iter()
399            .map(|(end, d)| (Some(end), d))
400            .collect()
401    }
402}
403
404#[cfg(test)]
405mod tests {
406    use nautilus_model::{
407        data::{BarSpecification, BarType},
408        enums::{AggregationSource, BarAggregation, PriceType},
409        identifiers::{InstrumentId, Symbol, Venue},
410    };
411    use rstest::rstest;
412    use time::macros::datetime;
413
414    use super::*;
415
416    fn create_test_instrument_id() -> InstrumentId {
417        InstrumentId::new(Symbol::from("AAPL"), Venue::from("NASDAQ"))
418    }
419
420    #[rstest]
421    fn test_bar_type_to_ib_bar_size_seconds() {
422        let instrument_id = create_test_instrument_id();
423        let bar_type = BarType::new(
424            instrument_id,
425            BarSpecification::new(1, BarAggregation::Second, PriceType::Last),
426            AggregationSource::External,
427        );
428        let result = bar_type_to_ib_bar_size(&bar_type);
429        assert!(result.is_ok());
430        assert_eq!(result.unwrap(), HistoricalBarSize::Sec);
431
432        let bar_type = BarType::new(
433            instrument_id,
434            BarSpecification::new(5, BarAggregation::Second, PriceType::Last),
435            AggregationSource::External,
436        );
437        let result = bar_type_to_ib_bar_size(&bar_type);
438        assert!(result.is_ok());
439        assert_eq!(result.unwrap(), HistoricalBarSize::Sec5);
440    }
441
442    #[rstest]
443    fn test_bar_type_to_ib_bar_size_minutes() {
444        let instrument_id = create_test_instrument_id();
445        let bar_type = BarType::new(
446            instrument_id,
447            BarSpecification::new(1, BarAggregation::Minute, PriceType::Last),
448            AggregationSource::External,
449        );
450        let result = bar_type_to_ib_bar_size(&bar_type);
451        assert!(result.is_ok());
452        assert_eq!(result.unwrap(), HistoricalBarSize::Min);
453
454        let bar_type = BarType::new(
455            instrument_id,
456            BarSpecification::new(15, BarAggregation::Minute, PriceType::Last),
457            AggregationSource::External,
458        );
459        let result = bar_type_to_ib_bar_size(&bar_type);
460        assert!(result.is_ok());
461        assert_eq!(result.unwrap(), HistoricalBarSize::Min15);
462    }
463
464    #[rstest]
465    fn test_bar_type_to_ib_bar_size_hours() {
466        let instrument_id = create_test_instrument_id();
467        let bar_type = BarType::new(
468            instrument_id,
469            BarSpecification::new(1, BarAggregation::Hour, PriceType::Last),
470            AggregationSource::External,
471        );
472        let result = bar_type_to_ib_bar_size(&bar_type);
473        assert!(result.is_ok());
474        assert_eq!(result.unwrap(), HistoricalBarSize::Hour);
475    }
476
477    #[rstest]
478    fn test_bar_type_to_ib_bar_size_days() {
479        let instrument_id = create_test_instrument_id();
480        let bar_type = BarType::new(
481            instrument_id,
482            BarSpecification::new(1, BarAggregation::Day, PriceType::Last),
483            AggregationSource::External,
484        );
485        let result = bar_type_to_ib_bar_size(&bar_type);
486        assert!(result.is_ok());
487        assert_eq!(result.unwrap(), HistoricalBarSize::Day);
488    }
489
490    #[rstest]
491    fn test_bar_type_to_ib_bar_size_unsupported() {
492        let instrument_id = create_test_instrument_id();
493        let bar_type = BarType::new(
494            instrument_id,
495            BarSpecification::new(12, BarAggregation::Minute, PriceType::Last),
496            AggregationSource::External,
497        );
498        let result = bar_type_to_ib_bar_size(&bar_type);
499        assert!(result.is_err());
500    }
501
502    #[rstest]
503    fn test_price_type_to_ib_what_to_show() {
504        assert_eq!(
505            price_type_to_ib_what_to_show(PriceType::Last),
506            HistoricalWhatToShow::Trades
507        );
508        assert_eq!(
509            price_type_to_ib_what_to_show(PriceType::Bid),
510            HistoricalWhatToShow::Bid
511        );
512        assert_eq!(
513            price_type_to_ib_what_to_show(PriceType::Ask),
514            HistoricalWhatToShow::Ask
515        );
516        assert_eq!(
517            price_type_to_ib_what_to_show(PriceType::Mid),
518            HistoricalWhatToShow::MidPoint
519        );
520    }
521
522    #[rstest]
523    fn test_price_type_to_ib_what_to_show_for_security_crypto() {
524        // Crypto trade-price (Last) must map to AGGTRADES, not TRADES - TWS rejects
525        // TRADES for crypto (error 10299). Mirrors the Java whatToShowFor rule.
526        assert_eq!(
527            price_type_to_ib_what_to_show_for_security(PriceType::Last, true),
528            HistoricalWhatToShow::AggTrades
529        );
530        // Non-trade price types are unaffected by the crypto special case.
531        assert_eq!(
532            price_type_to_ib_what_to_show_for_security(PriceType::Bid, true),
533            HistoricalWhatToShow::Bid
534        );
535        assert_eq!(
536            price_type_to_ib_what_to_show_for_security(PriceType::Ask, true),
537            HistoricalWhatToShow::Ask
538        );
539        assert_eq!(
540            price_type_to_ib_what_to_show_for_security(PriceType::Mid, true),
541            HistoricalWhatToShow::MidPoint
542        );
543    }
544
545    #[rstest]
546    fn test_price_type_to_ib_what_to_show_for_security_non_crypto() {
547        // Non-crypto: trade-price stays TRADES (equities/futures), everything else
548        // identical to the plain mapping.
549        assert_eq!(
550            price_type_to_ib_what_to_show_for_security(PriceType::Last, false),
551            HistoricalWhatToShow::Trades
552        );
553        assert_eq!(
554            price_type_to_ib_what_to_show_for_security(PriceType::Bid, false),
555            HistoricalWhatToShow::Bid
556        );
557        assert_eq!(
558            price_type_to_ib_what_to_show_for_security(PriceType::Mid, false),
559            HistoricalWhatToShow::MidPoint
560        );
561    }
562
563    #[rstest]
564    fn test_aggtrades_wire_string() {
565        // The vendored ibapi patch must serialize AggTrades as the exact IB wire
566        // token "AGGTRADES" on BOTH the historical and realtime enums.
567        assert_eq!(HistoricalWhatToShow::AggTrades.to_string(), "AGGTRADES");
568        assert_eq!(RealtimeWhatToShow::AggTrades.to_string(), "AGGTRADES");
569    }
570
571    #[rstest]
572    fn test_price_type_to_ib_realtime_what_to_show() {
573        // `RealtimeWhatToShow` does not derive `PartialEq`, so match on the variants.
574        assert!(matches!(
575            price_type_to_ib_realtime_what_to_show(PriceType::Last),
576            RealtimeWhatToShow::Trades
577        ));
578        assert!(matches!(
579            price_type_to_ib_realtime_what_to_show(PriceType::Bid),
580            RealtimeWhatToShow::Bid
581        ));
582        assert!(matches!(
583            price_type_to_ib_realtime_what_to_show(PriceType::Ask),
584            RealtimeWhatToShow::Ask
585        ));
586        assert!(matches!(
587            price_type_to_ib_realtime_what_to_show(PriceType::Mid),
588            RealtimeWhatToShow::MidPoint
589        ));
590    }
591
592    #[rstest]
593    fn test_price_type_to_ib_realtime_what_to_show_for_security_crypto() {
594        // Crypto trade-price (Last) 5-second bars must request AGGTRADES on the
595        // realtime path too - TWS rejects TRADES for crypto (error 10299) on
596        // reqRealTimeBars, exactly as on the historical path. Mirrors the Java
597        // engine passing whatToShowFor(CRYPTO)="AGGTRADES" to subscribeRealTimeBars.
598        assert!(matches!(
599            price_type_to_ib_realtime_what_to_show_for_security(PriceType::Last, true),
600            RealtimeWhatToShow::AggTrades
601        ));
602        // Non-trade price types unaffected by the crypto special case.
603        assert!(matches!(
604            price_type_to_ib_realtime_what_to_show_for_security(PriceType::Mid, true),
605            RealtimeWhatToShow::MidPoint
606        ));
607        assert!(matches!(
608            price_type_to_ib_realtime_what_to_show_for_security(PriceType::Bid, true),
609            RealtimeWhatToShow::Bid
610        ));
611        // Non-crypto trade-price stays TRADES.
612        assert!(matches!(
613            price_type_to_ib_realtime_what_to_show_for_security(PriceType::Last, false),
614            RealtimeWhatToShow::Trades
615        ));
616    }
617
618    #[rstest]
619    fn test_ib_bar_to_nautilus_bar() {
620        let ib_bar = ibapi::market_data::historical::Bar {
621            date: datetime!(2024-01-01 10:00:00 UTC).into(),
622            open: 150.0,
623            high: 151.0,
624            low: 149.0,
625            close: 150.5,
626            volume: 1000.0,
627            wap: 150.25,
628            count: 100,
629        };
630
631        let instrument_id = create_test_instrument_id();
632        let bar_type = BarType::new(
633            instrument_id,
634            BarSpecification::new(1, BarAggregation::Minute, PriceType::Last),
635            AggregationSource::External,
636        );
637        let result = ib_bar_to_nautilus_bar(&ib_bar, bar_type, 2, 0);
638        assert!(result.is_ok());
639        let bar = result.unwrap();
640        assert_eq!(bar.open.as_f64(), 150.0);
641        assert_eq!(bar.high.as_f64(), 151.0);
642        assert_eq!(bar.low.as_f64(), 149.0);
643        assert_eq!(bar.close.as_f64(), 150.5);
644        assert_eq!(bar.volume.as_f64(), 1000.0);
645        let close = ib_timestamp_to_unix_nanos(&datetime!(2024-01-01 10:01:00 UTC));
646        assert_eq!(bar.ts_event.as_u64(), close.as_u64());
647        assert_eq!(bar.ts_init.as_u64(), close.as_u64());
648    }
649
650    #[rstest]
651    fn test_ib_bar_to_nautilus_bar_negative_volume() {
652        let ib_bar = ibapi::market_data::historical::Bar {
653            date: datetime!(2024-01-01 10:00:00 UTC).into(),
654            open: 150.0,
655            high: 151.0,
656            low: 149.0,
657            close: 150.5,
658            volume: -1.0, // Unavailable volume
659            wap: 150.25,
660            count: 100,
661        };
662
663        let instrument_id = create_test_instrument_id();
664        let bar_type = BarType::new(
665            instrument_id,
666            BarSpecification::new(1, BarAggregation::Minute, PriceType::Last),
667            AggregationSource::External,
668        );
669        let result = ib_bar_to_nautilus_bar(&ib_bar, bar_type, 2, 0);
670        assert!(result.is_ok());
671        let bar = result.unwrap();
672        // Negative volume should be converted to 0
673        assert_eq!(bar.volume.as_f64(), 0.0);
674    }
675
676    #[rstest]
677    fn test_bar_close_from_open_intraday() {
678        let open = ib_timestamp_to_unix_nanos(&datetime!(2024-01-01 10:00:00 UTC));
679
680        let spec = BarSpecification::new(1, BarAggregation::Second, PriceType::Last);
681        assert_eq!(
682            bar_close_from_open(open, &spec).as_u64(),
683            ib_timestamp_to_unix_nanos(&datetime!(2024-01-01 10:00:01 UTC)).as_u64(),
684        );
685
686        let spec = BarSpecification::new(5, BarAggregation::Second, PriceType::Last);
687        assert_eq!(
688            bar_close_from_open(open, &spec).as_u64(),
689            ib_timestamp_to_unix_nanos(&datetime!(2024-01-01 10:00:05 UTC)).as_u64(),
690        );
691
692        let spec = BarSpecification::new(1, BarAggregation::Minute, PriceType::Last);
693        assert_eq!(
694            bar_close_from_open(open, &spec).as_u64(),
695            ib_timestamp_to_unix_nanos(&datetime!(2024-01-01 10:01:00 UTC)).as_u64(),
696        );
697
698        let spec = BarSpecification::new(1, BarAggregation::Hour, PriceType::Last);
699        assert_eq!(
700            bar_close_from_open(open, &spec).as_u64(),
701            ib_timestamp_to_unix_nanos(&datetime!(2024-01-01 11:00:00 UTC)).as_u64(),
702        );
703    }
704
705    #[rstest]
706    fn test_bar_close_from_open_day() {
707        let open = ib_timestamp_to_unix_nanos(&datetime!(2024-01-01 00:00:00 UTC));
708        let spec = BarSpecification::new(1, BarAggregation::Day, PriceType::Last);
709        assert_eq!(
710            bar_close_from_open(open, &spec).as_u64(),
711            open.as_u64() + 86_400_000_000_000 - 1,
712        );
713    }
714
715    #[rstest]
716    fn test_bar_close_from_open_week_month() {
717        let open = ib_timestamp_to_unix_nanos(&datetime!(2024-01-13 00:00:00 UTC));
718
719        let spec = BarSpecification::new(1, BarAggregation::Week, PriceType::Last);
720        assert_eq!(bar_close_from_open(open, &spec).as_u64(), open.as_u64());
721
722        let spec = BarSpecification::new(1, BarAggregation::Month, PriceType::Last);
723        assert_eq!(bar_close_from_open(open, &spec).as_u64(), open.as_u64());
724    }
725
726    #[rstest]
727    fn test_ib_timestamp_to_unix_nanos() {
728        let dt = datetime!(2024-01-01 10:00:00 UTC);
729        let result = ib_timestamp_to_unix_nanos(&dt);
730        assert!(result.as_i64() > 0);
731    }
732
733    #[rstest]
734    fn test_jiff_to_ib_datetime() {
735        let utc_dt = "2024-01-01T10:00:00Z".parse::<Timestamp>().unwrap();
736        let result = jiff_to_ib_datetime(&utc_dt);
737        assert_eq!(result.year(), 2024);
738        assert_eq!(result.month(), time::Month::January);
739        assert_eq!(result.day(), 1);
740    }
741
742    #[rstest]
743    fn test_calculate_duration_with_start_and_end() {
744        let start = "2024-01-01T10:00:00Z".parse::<Timestamp>().unwrap();
745        let end = "2024-01-02T10:00:00Z".parse::<Timestamp>().unwrap();
746        let result = calculate_duration(Some(start), Some(end));
747        assert!(result.is_ok());
748        // Should be 1 day
749        let duration = result.unwrap();
750        assert!(duration.to_string().contains("1 D") || duration.to_string().contains("1D"));
751    }
752
753    #[rstest]
754    fn test_calculate_duration_no_start() {
755        let end = "2024-01-02T10:00:00Z".parse::<Timestamp>().unwrap();
756        let result = calculate_duration(None, Some(end));
757        assert!(result.is_ok());
758        // Should default to 1 day
759        let duration = result.unwrap();
760        assert!(duration.to_string().contains("1 D") || duration.to_string().contains("1D"));
761    }
762
763    #[rstest]
764    fn test_calculate_duration_no_end() {
765        let start = "2024-01-01T10:00:00Z".parse::<Timestamp>().unwrap();
766        let result = calculate_duration(Some(start), None);
767        assert!(result.is_ok());
768        // Should default to 1 day
769        let duration = result.unwrap();
770        assert!(duration.to_string().contains("1 D") || duration.to_string().contains("1D"));
771    }
772
773    #[rstest]
774    fn test_calculate_duration_segments() {
775        // Test case: 1.5 years ago to now
776        let now = Timestamp::now();
777        let start = now - jiff::SignedDuration::from_hours(24 * (365 + 182)); // ~1.5 years
778        let segments = calculate_duration_segments(start, now);
779
780        assert!(!segments.is_empty());
781        // Should have at least one 1Y segment and one D/S segment
782        assert!(segments.len() >= 2);
783
784        // Check first segment is ~1Y
785        let dur1 = &segments[0].1;
786        assert!(dur1.to_string().contains("1 Y") || dur1.to_string().contains("1Y"));
787    }
788
789    #[rstest]
790    fn test_bar_request_segments_attaches_end_dates_when_not_continuous() {
791        let end = "2025-01-01T00:00:00Z".parse::<Timestamp>().unwrap();
792        let earlier = "2024-06-01T00:00:00Z".parse::<Timestamp>().unwrap();
793        let segments = vec![(end, IBDuration::years(1)), (earlier, IBDuration::days(30))];
794
795        let result = bar_request_segments(segments, false);
796
797        assert_eq!(result.len(), 2);
798        assert_eq!(result[0].0, Some(end));
799        assert_eq!(result[1].0, Some(earlier));
800    }
801
802    #[rstest]
803    fn test_bar_request_segments_drops_end_date_and_keeps_only_first_for_continuous() {
804        let end = "2025-01-01T00:00:00Z".parse::<Timestamp>().unwrap();
805        let earlier = "2024-06-01T00:00:00Z".parse::<Timestamp>().unwrap();
806        let segments = vec![(end, IBDuration::years(1)), (earlier, IBDuration::days(30))];
807
808        let result = bar_request_segments(segments, true);
809
810        assert_eq!(result.len(), 1);
811        assert_eq!(result[0].0, None);
812        assert_eq!(result[0].1, IBDuration::years(1));
813    }
814
815    #[rstest]
816    fn test_bar_request_segments_empty_input_yields_nothing_for_continuous() {
817        let result = bar_request_segments(vec![], true);
818        assert!(result.is_empty());
819    }
820}