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 chrono::{DateTime, Utc};
19use ibapi::market_data::historical::{
20    BarSize as HistoricalBarSize, BarTimestamp, Duration as IBDuration, ToDuration,
21    WhatToShow as HistoricalWhatToShow,
22};
23use nautilus_core::UnixNanos;
24use nautilus_model::{
25    data::{Bar, BarType},
26    enums::{BarAggregation, PriceType},
27    types::{Price, Quantity},
28};
29use time::OffsetDateTime;
30
31/// Convert Nautilus BarType to IB HistoricalBarSize.
32///
33/// # Errors
34///
35/// Returns an error if the bar aggregation/step combination is not supported by IB.
36pub fn bar_type_to_ib_bar_size(bar_type: &BarType) -> anyhow::Result<HistoricalBarSize> {
37    let spec = bar_type.spec();
38    let aggregation = spec.aggregation;
39    let step = spec.step.get();
40
41    let bar_size = match (aggregation, step) {
42        // Seconds
43        (BarAggregation::Second, 1) => HistoricalBarSize::Sec,
44        (BarAggregation::Second, 5) => HistoricalBarSize::Sec5,
45        (BarAggregation::Second, 15) => HistoricalBarSize::Sec15,
46        (BarAggregation::Second, 30) => HistoricalBarSize::Sec30,
47        // Minutes
48        (BarAggregation::Minute, 1) => HistoricalBarSize::Min,
49        (BarAggregation::Minute, 2) => HistoricalBarSize::Min2,
50        (BarAggregation::Minute, 3) => HistoricalBarSize::Min3,
51        (BarAggregation::Minute, 5) => HistoricalBarSize::Min5,
52        (BarAggregation::Minute, 10) => HistoricalBarSize::Min10,
53        (BarAggregation::Minute, 15) => HistoricalBarSize::Min15,
54        (BarAggregation::Minute, 20) => HistoricalBarSize::Min20,
55        (BarAggregation::Minute, 30) => HistoricalBarSize::Min30,
56        // Hours
57        (BarAggregation::Hour, 1) => HistoricalBarSize::Hour,
58        (BarAggregation::Hour, 2) => HistoricalBarSize::Hour2,
59        (BarAggregation::Hour, 3) => HistoricalBarSize::Hour3,
60        (BarAggregation::Hour, 4) => HistoricalBarSize::Hour4,
61        (BarAggregation::Hour, 8) => HistoricalBarSize::Hour8,
62        // Days
63        (BarAggregation::Day, 1) => HistoricalBarSize::Day,
64        // Weeks
65        (BarAggregation::Week, 1) => HistoricalBarSize::Week,
66        // Months
67        (BarAggregation::Month, 1) => HistoricalBarSize::Month,
68        _ => {
69            anyhow::bail!("Unsupported bar aggregation/step combination: {aggregation:?}/{step}",);
70        }
71    };
72
73    Ok(bar_size)
74}
75
76/// Convert Nautilus PriceType to IB WhatToShow.
77#[must_use]
78pub fn price_type_to_ib_what_to_show(price_type: PriceType) -> HistoricalWhatToShow {
79    match price_type {
80        PriceType::Last => HistoricalWhatToShow::Trades,
81        PriceType::Bid => HistoricalWhatToShow::Bid,
82        PriceType::Ask => HistoricalWhatToShow::Ask,
83        PriceType::Mid => HistoricalWhatToShow::MidPoint,
84        _ => HistoricalWhatToShow::Trades, // Default to trades
85    }
86}
87
88#[must_use]
89pub fn apply_price_magnifier(price: f64, price_magnifier: i32) -> f64 {
90    if price_magnifier > 0 {
91        price / f64::from(price_magnifier)
92    } else {
93        price
94    }
95}
96
97#[must_use]
98pub fn apply_bar_price_magnifier(
99    ib_bar: &ibapi::market_data::historical::Bar,
100    price_magnifier: i32,
101) -> ibapi::market_data::historical::Bar {
102    ibapi::market_data::historical::Bar {
103        date: ib_bar.date,
104        open: apply_price_magnifier(ib_bar.open, price_magnifier),
105        high: apply_price_magnifier(ib_bar.high, price_magnifier),
106        low: apply_price_magnifier(ib_bar.low, price_magnifier),
107        close: apply_price_magnifier(ib_bar.close, price_magnifier),
108        volume: ib_bar.volume,
109        wap: apply_price_magnifier(ib_bar.wap, price_magnifier),
110        count: ib_bar.count,
111    }
112}
113
114/// Implement bar price validation logic.
115/// Matches Python's `_validate_bar_prices` behavior.
116fn _validate_bar_prices(open: &mut f64, high: &mut f64, low: &mut f64, close: &f64) {
117    if *high < *low || *high < *open || *high < *close || *low > *open || *low > *close {
118        tracing::warn!(
119            "Invalid bar prices detected: O:{}, H:{}, L:{}, C:{}. Correcting using close price",
120            open,
121            high,
122            low,
123            close
124        );
125        *open = *close;
126        *high = *close;
127        *low = *close;
128    }
129}
130
131/// Convert IB Bar to Nautilus Bar.
132///
133/// # Errors
134///
135/// Returns an error if conversion fails.
136pub fn ib_bar_to_nautilus_bar(
137    ib_bar: &ibapi::market_data::historical::Bar,
138    bar_type: BarType,
139    price_precision: u8,
140    size_precision: u8,
141) -> anyhow::Result<Bar> {
142    // Convert IB timestamp to UnixNanos
143    let ts_event = ib_bar_timestamp_to_unix_nanos(&ib_bar.date);
144    let ts_init = ts_event; // Use same timestamp for init
145
146    // Validate and correct prices
147    let mut open = ib_bar.open;
148    let mut high = ib_bar.high;
149    let mut low = ib_bar.low;
150    let close = ib_bar.close;
151    _validate_bar_prices(&mut open, &mut high, &mut low, &close);
152
153    // Create prices
154    let open_price = Price::new(open, price_precision);
155    let high_price = Price::new(high, price_precision);
156    let low_price = Price::new(low, price_precision);
157    let close_price = Price::new(close, price_precision);
158
159    // Volume: IB uses -1 for unavailable volume, convert to 0
160    let volume = if ib_bar.volume < 0.0 {
161        Quantity::zero(size_precision)
162    } else {
163        Quantity::new(ib_bar.volume, size_precision)
164    };
165
166    Ok(Bar::new(
167        bar_type,
168        open_price,
169        high_price,
170        low_price,
171        close_price,
172        volume,
173        ts_event,
174        ts_init,
175    ))
176}
177
178/// Convert IB historical bar timestamp to UnixNanos.
179#[must_use]
180pub fn ib_bar_timestamp_to_unix_nanos(dt: &BarTimestamp) -> UnixNanos {
181    match dt {
182        BarTimestamp::Date(date) => ib_timestamp_to_unix_nanos(&date.midnight().assume_utc()),
183        BarTimestamp::DateTime(dt) => ib_timestamp_to_unix_nanos(dt),
184    }
185}
186
187/// Convert IB timestamp (OffsetDateTime) to UnixNanos.
188#[must_use]
189pub fn ib_timestamp_to_unix_nanos(dt: &OffsetDateTime) -> UnixNanos {
190    let timestamp = dt.unix_timestamp_nanos();
191    UnixNanos::from(timestamp as u64)
192}
193
194/// Convert `DateTime<Utc>` to OffsetDateTime.
195pub fn chrono_to_ib_datetime(dt: &DateTime<Utc>) -> OffsetDateTime {
196    let timestamp = dt.timestamp();
197    let nanos = dt.timestamp_subsec_nanos();
198    let total_nanos = timestamp as i128 * 1_000_000_000 + nanos as i128;
199    OffsetDateTime::from_unix_timestamp_nanos(total_nanos)
200        .unwrap_or_else(|_| OffsetDateTime::now_utc())
201}
202
203/// Calculate duration for IB historical data request.
204///
205/// # Errors
206///
207/// Returns an error if duration calculation fails.
208pub fn calculate_duration(
209    start: Option<DateTime<Utc>>,
210    end: Option<DateTime<Utc>>,
211) -> anyhow::Result<IBDuration> {
212    match (start, end) {
213        (Some(start_dt), Some(end_dt)) => {
214            let duration = end_dt.signed_duration_since(start_dt);
215            let days = duration.num_days();
216
217            if days > 0 && days <= i32::MAX as i64 {
218                Ok((days as i32).days())
219            } else {
220                // Fallback to seconds if less than a day or too large
221                let seconds = duration.num_seconds();
222                if seconds > 0 && seconds <= i32::MAX as i64 {
223                    Ok((seconds as i32).seconds())
224                } else {
225                    // Default to 1 day if calculation fails
226                    Ok(1.days())
227                }
228            }
229        }
230        (None, Some(_)) => {
231            // Default to 1 day if only end is provided
232            Ok(1.days())
233        }
234        (Some(_), None) => {
235            // Default to 1 day if only start is provided
236            Ok(1.days())
237        }
238        (None, None) => {
239            // Default to 1 day if neither is provided
240            Ok(1.days())
241        }
242    }
243}
244
245/// Calculate duration segments for IB historical data request.
246///
247/// This is used to break down a large time range into multiple requests
248/// to comply with IB's duration limits for specific bar sizes.
249pub fn calculate_duration_segments(
250    start: DateTime<Utc>,
251    end: DateTime<Utc>,
252) -> Vec<(DateTime<Utc>, IBDuration)> {
253    let mut results = Vec::new();
254    let duration = end.signed_duration_since(start);
255    let mut total_seconds = duration.num_seconds();
256
257    if total_seconds <= 0 {
258        return results;
259    }
260
261    let years = total_seconds / (365 * 24 * 3600);
262    total_seconds %= 365 * 24 * 3600;
263    let days = total_seconds / (24 * 3600);
264    total_seconds %= 24 * 3600;
265    let seconds = total_seconds;
266
267    if years > 0 {
268        results.push((end, (years as i32).years()));
269    }
270
271    if days > 0 {
272        let minus_years_duration = chrono::Duration::days(years * 365);
273        let minus_years_date = end - minus_years_duration;
274        results.push((minus_years_date, (days as i32).days()));
275    }
276
277    if seconds > 0 {
278        let minus_years_duration = chrono::Duration::days(years * 365);
279        let minus_days_duration = chrono::Duration::days(days);
280        let minus_days_date = end - minus_years_duration - minus_days_duration;
281        results.push((minus_days_date, (seconds as i32).seconds()));
282    }
283
284    results
285}
286
287#[cfg(test)]
288mod tests {
289    use nautilus_model::{
290        data::{BarSpecification, BarType},
291        enums::{AggregationSource, BarAggregation, PriceType},
292        identifiers::{InstrumentId, Symbol, Venue},
293    };
294    use rstest::rstest;
295    use time::macros::datetime;
296
297    use super::*;
298
299    fn create_test_instrument_id() -> InstrumentId {
300        InstrumentId::new(Symbol::from("AAPL"), Venue::from("NASDAQ"))
301    }
302
303    #[rstest]
304    fn test_bar_type_to_ib_bar_size_seconds() {
305        let instrument_id = create_test_instrument_id();
306        let bar_type = BarType::new(
307            instrument_id,
308            BarSpecification::new(1, BarAggregation::Second, PriceType::Last),
309            AggregationSource::External,
310        );
311        let result = bar_type_to_ib_bar_size(&bar_type);
312        assert!(result.is_ok());
313        assert_eq!(result.unwrap(), HistoricalBarSize::Sec);
314
315        let bar_type = BarType::new(
316            instrument_id,
317            BarSpecification::new(5, BarAggregation::Second, PriceType::Last),
318            AggregationSource::External,
319        );
320        let result = bar_type_to_ib_bar_size(&bar_type);
321        assert!(result.is_ok());
322        assert_eq!(result.unwrap(), HistoricalBarSize::Sec5);
323    }
324
325    #[rstest]
326    fn test_bar_type_to_ib_bar_size_minutes() {
327        let instrument_id = create_test_instrument_id();
328        let bar_type = BarType::new(
329            instrument_id,
330            BarSpecification::new(1, BarAggregation::Minute, PriceType::Last),
331            AggregationSource::External,
332        );
333        let result = bar_type_to_ib_bar_size(&bar_type);
334        assert!(result.is_ok());
335        assert_eq!(result.unwrap(), HistoricalBarSize::Min);
336
337        let bar_type = BarType::new(
338            instrument_id,
339            BarSpecification::new(15, BarAggregation::Minute, PriceType::Last),
340            AggregationSource::External,
341        );
342        let result = bar_type_to_ib_bar_size(&bar_type);
343        assert!(result.is_ok());
344        assert_eq!(result.unwrap(), HistoricalBarSize::Min15);
345    }
346
347    #[rstest]
348    fn test_bar_type_to_ib_bar_size_hours() {
349        let instrument_id = create_test_instrument_id();
350        let bar_type = BarType::new(
351            instrument_id,
352            BarSpecification::new(1, BarAggregation::Hour, PriceType::Last),
353            AggregationSource::External,
354        );
355        let result = bar_type_to_ib_bar_size(&bar_type);
356        assert!(result.is_ok());
357        assert_eq!(result.unwrap(), HistoricalBarSize::Hour);
358    }
359
360    #[rstest]
361    fn test_bar_type_to_ib_bar_size_days() {
362        let instrument_id = create_test_instrument_id();
363        let bar_type = BarType::new(
364            instrument_id,
365            BarSpecification::new(1, BarAggregation::Day, PriceType::Last),
366            AggregationSource::External,
367        );
368        let result = bar_type_to_ib_bar_size(&bar_type);
369        assert!(result.is_ok());
370        assert_eq!(result.unwrap(), HistoricalBarSize::Day);
371    }
372
373    #[rstest]
374    fn test_bar_type_to_ib_bar_size_unsupported() {
375        let instrument_id = create_test_instrument_id();
376        let bar_type = BarType::new(
377            instrument_id,
378            BarSpecification::new(12, BarAggregation::Minute, PriceType::Last),
379            AggregationSource::External,
380        );
381        let result = bar_type_to_ib_bar_size(&bar_type);
382        assert!(result.is_err());
383    }
384
385    #[rstest]
386    fn test_price_type_to_ib_what_to_show() {
387        assert_eq!(
388            price_type_to_ib_what_to_show(PriceType::Last),
389            HistoricalWhatToShow::Trades
390        );
391        assert_eq!(
392            price_type_to_ib_what_to_show(PriceType::Bid),
393            HistoricalWhatToShow::Bid
394        );
395        assert_eq!(
396            price_type_to_ib_what_to_show(PriceType::Ask),
397            HistoricalWhatToShow::Ask
398        );
399        assert_eq!(
400            price_type_to_ib_what_to_show(PriceType::Mid),
401            HistoricalWhatToShow::MidPoint
402        );
403    }
404
405    #[rstest]
406    fn test_ib_bar_to_nautilus_bar() {
407        let ib_bar = ibapi::market_data::historical::Bar {
408            date: datetime!(2024-01-01 10:00:00 UTC).into(),
409            open: 150.0,
410            high: 151.0,
411            low: 149.0,
412            close: 150.5,
413            volume: 1000.0,
414            wap: 150.25,
415            count: 100,
416        };
417
418        let instrument_id = create_test_instrument_id();
419        let bar_type = BarType::new(
420            instrument_id,
421            BarSpecification::new(1, BarAggregation::Minute, PriceType::Last),
422            AggregationSource::External,
423        );
424        let result = ib_bar_to_nautilus_bar(&ib_bar, bar_type, 2, 0);
425        assert!(result.is_ok());
426        let bar = result.unwrap();
427        assert_eq!(bar.open.as_f64(), 150.0);
428        assert_eq!(bar.high.as_f64(), 151.0);
429        assert_eq!(bar.low.as_f64(), 149.0);
430        assert_eq!(bar.close.as_f64(), 150.5);
431        assert_eq!(bar.volume.as_f64(), 1000.0);
432    }
433
434    #[rstest]
435    fn test_ib_bar_to_nautilus_bar_negative_volume() {
436        let ib_bar = ibapi::market_data::historical::Bar {
437            date: datetime!(2024-01-01 10:00:00 UTC).into(),
438            open: 150.0,
439            high: 151.0,
440            low: 149.0,
441            close: 150.5,
442            volume: -1.0, // Unavailable volume
443            wap: 150.25,
444            count: 100,
445        };
446
447        let instrument_id = create_test_instrument_id();
448        let bar_type = BarType::new(
449            instrument_id,
450            BarSpecification::new(1, BarAggregation::Minute, PriceType::Last),
451            AggregationSource::External,
452        );
453        let result = ib_bar_to_nautilus_bar(&ib_bar, bar_type, 2, 0);
454        assert!(result.is_ok());
455        let bar = result.unwrap();
456        // Negative volume should be converted to 0
457        assert_eq!(bar.volume.as_f64(), 0.0);
458    }
459
460    #[rstest]
461    fn test_ib_timestamp_to_unix_nanos() {
462        let dt = datetime!(2024-01-01 10:00:00 UTC);
463        let result = ib_timestamp_to_unix_nanos(&dt);
464        assert!(result.as_i64() > 0);
465    }
466
467    #[rstest]
468    fn test_chrono_to_ib_datetime() {
469        let dt = DateTime::parse_from_rfc3339("2024-01-01T10:00:00Z").unwrap();
470        let utc_dt = dt.with_timezone(&Utc);
471        let result = chrono_to_ib_datetime(&utc_dt);
472        assert_eq!(result.year(), 2024);
473        assert_eq!(result.month(), time::Month::January);
474        assert_eq!(result.day(), 1);
475    }
476
477    #[rstest]
478    fn test_calculate_duration_with_start_and_end() {
479        let start = DateTime::parse_from_rfc3339("2024-01-01T10:00:00Z")
480            .unwrap()
481            .with_timezone(&Utc);
482        let end = DateTime::parse_from_rfc3339("2024-01-02T10:00:00Z")
483            .unwrap()
484            .with_timezone(&Utc);
485        let result = calculate_duration(Some(start), Some(end));
486        assert!(result.is_ok());
487        // Should be 1 day
488        let duration = result.unwrap();
489        assert!(duration.to_string().contains("1 D") || duration.to_string().contains("1D"));
490    }
491
492    #[rstest]
493    fn test_calculate_duration_no_start() {
494        let end = DateTime::parse_from_rfc3339("2024-01-02T10:00:00Z")
495            .unwrap()
496            .with_timezone(&Utc);
497        let result = calculate_duration(None, Some(end));
498        assert!(result.is_ok());
499        // Should default to 1 day
500        let duration = result.unwrap();
501        assert!(duration.to_string().contains("1 D") || duration.to_string().contains("1D"));
502    }
503
504    #[rstest]
505    fn test_calculate_duration_no_end() {
506        let start = DateTime::parse_from_rfc3339("2024-01-01T10:00:00Z")
507            .unwrap()
508            .with_timezone(&Utc);
509        let result = calculate_duration(Some(start), None);
510        assert!(result.is_ok());
511        // Should default to 1 day
512        let duration = result.unwrap();
513        assert!(duration.to_string().contains("1 D") || duration.to_string().contains("1D"));
514    }
515
516    #[rstest]
517    fn test_calculate_duration_segments() {
518        // Test case: 1.5 years ago to now
519        let now = Utc::now();
520        let start = now - chrono::Duration::days(365 + 182); // ~1.5 years
521        let segments = calculate_duration_segments(start, now);
522
523        assert!(!segments.is_empty());
524        // Should have at least one 1Y segment and one D/S segment
525        assert!(segments.len() >= 2);
526
527        // Check first segment is ~1Y
528        let dur1 = &segments[0].1;
529        assert!(dur1.to_string().contains("1 Y") || dur1.to_string().contains("1Y"));
530    }
531}