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