1use 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
34pub 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 (BarAggregation::Second, 1) => HistoricalBarSize::Sec,
47 (BarAggregation::Second, 5) => HistoricalBarSize::Sec5,
48 (BarAggregation::Second, 15) => HistoricalBarSize::Sec15,
49 (BarAggregation::Second, 30) => HistoricalBarSize::Sec30,
50 (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 (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 (BarAggregation::Day, 1) => HistoricalBarSize::Day,
67 (BarAggregation::Week, 1) => HistoricalBarSize::Week,
69 (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#[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, }
89}
90
91#[must_use]
98fn uses_agg_trades(is_crypto: bool, price_type: PriceType) -> bool {
99 is_crypto && price_type == PriceType::Last
100}
101
102#[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#[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, }
128}
129
130#[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
170fn _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
187pub 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 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 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 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#[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#[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#[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
281pub 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
287pub 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#[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#[allow(clippy::too_many_arguments)] pub(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 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
417pub(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
431pub 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 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 assert_eq!(
597 price_type_to_ib_what_to_show_for_security(PriceType::Last, true),
598 HistoricalWhatToShow::AggTrades
599 );
600 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 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 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 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 assert!(matches!(
669 price_type_to_ib_realtime_what_to_show_for_security(PriceType::Last, true),
670 RealtimeWhatToShow::AggTrades
671 ));
672 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 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, 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 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}