1use std::str::FromStr;
19
20use ibapi::market_data::{
21 historical::{
22 BarSize as HistoricalBarSize, BarTimestamp, Duration as IBDuration, ToDuration,
23 WhatToShow as HistoricalWhatToShow,
24 },
25 realtime::WhatToShow as RealtimeWhatToShow,
26};
27use jiff::Timestamp;
28use nautilus_core::{DurationNanos, UnixNanos};
29use nautilus_model::{
30 data::{Bar, BarSpecification, BarType},
31 enums::{AssetClass, BarAggregation, PriceType},
32 instruments::{Instrument, InstrumentAny},
33 types::{Price, Quantity},
34};
35use rust_decimal::{Decimal, RoundingStrategy};
36use time::OffsetDateTime;
37
38use super::cache::is_sentinel_price;
39
40#[derive(Debug, Clone, Copy, PartialEq, Eq)]
49pub struct IbPriceConverter {
50 precision: u8,
51 increment: Option<Price>,
52 magnifier: i32,
53}
54
55impl IbPriceConverter {
56 #[must_use]
59 pub const fn new(precision: u8) -> Self {
60 Self {
61 precision,
62 increment: None,
63 magnifier: 1,
64 }
65 }
66
67 #[must_use]
69 pub const fn with_price_magnifier(mut self, magnifier: i32) -> Self {
70 self.magnifier = if magnifier < 1 { 1 } else { magnifier };
71 self
72 }
73
74 #[must_use]
77 pub fn for_instrument(instrument: &InstrumentAny, price_magnifier: i32) -> Self {
78 let increment = match instrument {
79 InstrumentAny::CurrencyPair(pair) if pair.asset_class() == AssetClass::FX => {
80 Some(pair.price_increment())
81 }
82 _ => None,
83 };
84
85 Self {
86 precision: instrument.price_precision(),
87 increment,
88 magnifier: 1,
89 }
90 .with_price_magnifier(price_magnifier)
91 }
92
93 #[must_use]
95 pub const fn precision(&self) -> u8 {
96 self.precision
97 }
98
99 #[must_use]
103 pub fn is_sentinel(&self, ib_price: f64, size: Option<f64>) -> bool {
104 is_sentinel_price(ib_price, size)
105 || is_sentinel_price(ib_price / f64::from(self.magnifier), size)
106 }
107
108 pub fn price(&self, ib_price: f64) -> anyhow::Result<Price> {
119 self.convert(ib_price, RoundingStrategy::MidpointNearestEven)
120 }
121
122 pub fn bid_price(&self, ib_price: f64) -> anyhow::Result<Price> {
131 self.convert(ib_price, RoundingStrategy::ToNegativeInfinity)
132 }
133
134 pub fn ask_price(&self, ib_price: f64) -> anyhow::Result<Price> {
144 self.convert(ib_price, RoundingStrategy::ToPositiveInfinity)
145 }
146
147 pub fn bar_price(&self, price_type: PriceType, ib_price: f64) -> anyhow::Result<Price> {
155 match price_type {
156 PriceType::Bid => self.bid_price(ib_price),
157 PriceType::Ask => self.ask_price(ib_price),
158 _ => self.price(ib_price),
159 }
160 }
161
162 fn convert(&self, ib_price: f64, strategy: RoundingStrategy) -> anyhow::Result<Price> {
168 if self.increment.is_none() && self.magnifier == 1 {
169 return Price::new_checked(ib_price, self.precision)
170 .map_err(|e| anyhow::anyhow!("invalid price={ib_price}: {e}"));
171 }
172
173 if !ib_price.is_finite() {
174 anyhow::bail!("invalid price={ib_price}: not finite");
175 }
176
177 let overflow = || anyhow::anyhow!("invalid price={ib_price}: out of range");
178 let value = Decimal::from_str(&ib_price.to_string())
179 .map_err(|e| anyhow::anyhow!("invalid price={ib_price}: {e}"))?
180 .checked_div(Decimal::from(self.magnifier))
181 .ok_or_else(overflow)?;
182
183 let value = match self.increment {
184 Some(increment) if increment.raw() > 0 => {
185 let step = increment.as_decimal();
186 value
187 .checked_div(step)
188 .ok_or_else(overflow)?
189 .round_dp_with_strategy(0, strategy)
190 .checked_mul(step)
191 .ok_or_else(overflow)?
192 }
193 _ => value,
194 };
195
196 Price::from_decimal_dp(value, self.precision)
197 .map_err(|e| anyhow::anyhow!("invalid price={ib_price}: {e}"))
198 }
199}
200
201pub fn bar_type_to_ib_bar_size(bar_type: &BarType) -> anyhow::Result<HistoricalBarSize> {
207 let spec = bar_type.spec();
208 let aggregation = spec.aggregation;
209 let step = spec.step.get();
210
211 let bar_size = match (aggregation, step) {
212 (BarAggregation::Second, 1) => HistoricalBarSize::Sec,
214 (BarAggregation::Second, 5) => HistoricalBarSize::Sec5,
215 (BarAggregation::Second, 15) => HistoricalBarSize::Sec15,
216 (BarAggregation::Second, 30) => HistoricalBarSize::Sec30,
217 (BarAggregation::Minute, 1) => HistoricalBarSize::Min,
219 (BarAggregation::Minute, 2) => HistoricalBarSize::Min2,
220 (BarAggregation::Minute, 3) => HistoricalBarSize::Min3,
221 (BarAggregation::Minute, 5) => HistoricalBarSize::Min5,
222 (BarAggregation::Minute, 10) => HistoricalBarSize::Min10,
223 (BarAggregation::Minute, 15) => HistoricalBarSize::Min15,
224 (BarAggregation::Minute, 20) => HistoricalBarSize::Min20,
225 (BarAggregation::Minute, 30) => HistoricalBarSize::Min30,
226 (BarAggregation::Hour, 1) => HistoricalBarSize::Hour,
228 (BarAggregation::Hour, 2) => HistoricalBarSize::Hour2,
229 (BarAggregation::Hour, 3) => HistoricalBarSize::Hour3,
230 (BarAggregation::Hour, 4) => HistoricalBarSize::Hour4,
231 (BarAggregation::Hour, 8) => HistoricalBarSize::Hour8,
232 (BarAggregation::Day, 1) => HistoricalBarSize::Day,
234 (BarAggregation::Week, 1) => HistoricalBarSize::Week,
236 (BarAggregation::Month, 1) => HistoricalBarSize::Month,
238 _ => {
239 anyhow::bail!("Unsupported bar aggregation/step combination: {aggregation:?}/{step}",);
240 }
241 };
242
243 Ok(bar_size)
244}
245
246#[must_use]
248pub fn price_type_to_ib_what_to_show(price_type: PriceType) -> HistoricalWhatToShow {
249 match price_type {
250 PriceType::Last => HistoricalWhatToShow::Trades,
251 PriceType::Bid => HistoricalWhatToShow::Bid,
252 PriceType::Ask => HistoricalWhatToShow::Ask,
253 PriceType::Mid => HistoricalWhatToShow::MidPoint,
254 _ => HistoricalWhatToShow::Trades, }
256}
257
258#[must_use]
265fn uses_agg_trades(is_crypto: bool, price_type: PriceType) -> bool {
266 is_crypto && price_type == PriceType::Last
267}
268
269#[must_use]
272pub fn price_type_to_ib_what_to_show_for_security(
273 price_type: PriceType,
274 is_crypto: bool,
275) -> HistoricalWhatToShow {
276 if uses_agg_trades(is_crypto, price_type) {
277 return HistoricalWhatToShow::AggTrades;
278 }
279
280 price_type_to_ib_what_to_show(price_type)
281}
282
283#[must_use]
287pub fn price_type_to_ib_realtime_what_to_show(price_type: PriceType) -> RealtimeWhatToShow {
288 match price_type {
289 PriceType::Last => RealtimeWhatToShow::Trades,
290 PriceType::Bid => RealtimeWhatToShow::Bid,
291 PriceType::Ask => RealtimeWhatToShow::Ask,
292 PriceType::Mid => RealtimeWhatToShow::MidPoint,
293 _ => RealtimeWhatToShow::Trades, }
295}
296
297#[must_use]
300pub fn price_type_to_ib_realtime_what_to_show_for_security(
301 price_type: PriceType,
302 is_crypto: bool,
303) -> RealtimeWhatToShow {
304 if uses_agg_trades(is_crypto, price_type) {
305 return RealtimeWhatToShow::AggTrades;
306 }
307
308 price_type_to_ib_realtime_what_to_show(price_type)
309}
310
311fn _validate_bar_prices(open: &mut f64, high: &mut f64, low: &mut f64, close: &f64) {
314 if *high < *low || *high < *open || *high < *close || *low > *open || *low > *close {
315 tracing::warn!(
316 "Invalid bar prices detected: O:{}, H:{}, L:{}, C:{}. Correcting using close price",
317 open,
318 high,
319 low,
320 close
321 );
322 *open = *close;
323 *high = *close;
324 *low = *close;
325 }
326}
327
328pub fn ib_bar_to_nautilus_bar(
336 ib_bar: &ibapi::market_data::historical::Bar,
337 bar_type: BarType,
338 prices: IbPriceConverter,
339 size_precision: u8,
340) -> anyhow::Result<Bar> {
341 let ts_event = bar_close_from_open(
342 ib_bar_timestamp_to_unix_nanos(&ib_bar.date),
343 &bar_type.spec(),
344 );
345 let ts_init = ts_event;
346
347 let mut open = ib_bar.open;
349 let mut high = ib_bar.high;
350 let mut low = ib_bar.low;
351 let close = ib_bar.close;
352 _validate_bar_prices(&mut open, &mut high, &mut low, &close);
353
354 let price_type = bar_type.spec().price_type;
356 let open_price = prices.bar_price(price_type, open)?;
357 let high_price = prices.bar_price(price_type, high)?;
358 let low_price = prices.bar_price(price_type, low)?;
359 let close_price = prices.bar_price(price_type, close)?;
360
361 let volume = if ib_bar.volume < 0.0 {
363 Quantity::zero(size_precision)
364 } else {
365 Quantity::new(ib_bar.volume, size_precision)
366 };
367
368 Ok(Bar::new(
369 bar_type,
370 open_price,
371 high_price,
372 low_price,
373 close_price,
374 volume,
375 ts_event,
376 ts_init,
377 ))
378}
379
380#[must_use]
384pub fn bar_close_from_open(open: UnixNanos, spec: &BarSpecification) -> UnixNanos {
385 let is_day = spec.aggregation == BarAggregation::Day;
386
387 let duration = match spec.aggregation {
388 BarAggregation::Second
389 | BarAggregation::Minute
390 | BarAggregation::Hour
391 | BarAggregation::Day => spec.timedelta(),
392 _ => return open,
393 };
394
395 let Ok(duration) = DurationNanos::try_from(duration) else {
396 return open;
397 };
398
399 let close = open.saturating_add(duration);
400 if is_day {
401 close.saturating_sub(DurationNanos::new(1))
402 } else {
403 close
404 }
405}
406
407#[must_use]
409pub fn ib_bar_timestamp_to_unix_nanos(dt: &BarTimestamp) -> UnixNanos {
410 match dt {
411 BarTimestamp::Date(date) => ib_timestamp_to_unix_nanos(&date.midnight().assume_utc()),
412 BarTimestamp::DateTime(dt) => ib_timestamp_to_unix_nanos(dt),
413 }
414}
415
416#[must_use]
418pub fn ib_timestamp_to_unix_nanos(dt: &OffsetDateTime) -> UnixNanos {
419 let timestamp = dt.unix_timestamp_nanos();
420 UnixNanos::from(timestamp as u64)
421}
422
423pub fn jiff_to_ib_datetime(dt: &Timestamp) -> OffsetDateTime {
425 OffsetDateTime::from_unix_timestamp_nanos(dt.as_nanosecond())
426 .unwrap_or_else(|_| OffsetDateTime::now_utc())
427}
428
429pub fn calculate_duration_segments(
434 start: Option<Timestamp>,
435 end: Option<Timestamp>,
436 duration: Option<IBDuration>,
437) -> Vec<(Timestamp, IBDuration)> {
438 let end = end.unwrap_or_else(Timestamp::now);
439
440 if let Some(duration) = duration {
441 return vec![(end, duration)];
442 }
443
444 let Some(start) = start else {
445 return vec![(end, 1.days())];
446 };
447
448 let mut results = Vec::new();
449 let duration = end.duration_since(start);
450 let mut total_seconds = duration.as_secs();
451 if duration.subsec_nanos() > 0 {
452 total_seconds += 1;
453 }
454
455 if total_seconds <= 0 {
456 return results;
457 }
458
459 let years = total_seconds / (365 * 24 * 3600);
460 total_seconds %= 365 * 24 * 3600;
461 let days = total_seconds / (24 * 3600);
462 total_seconds %= 24 * 3600;
463 let seconds = total_seconds;
464
465 if years > 0 {
466 results.push((end, (years as i32).years()));
467 }
468
469 if days > 0 {
470 let minus_years_duration = jiff::SignedDuration::from_hours(24 * (years * 365));
471 let minus_years_date = end - minus_years_duration;
472 results.push((minus_years_date, (days as i32).days()));
473 }
474
475 if seconds > 0 {
476 let minus_years_duration = jiff::SignedDuration::from_hours(24 * (years * 365));
477 let minus_days_duration = jiff::SignedDuration::from_hours(24 * (days));
478 let minus_days_date = end - minus_years_duration - minus_days_duration;
479 results.push((minus_days_date, (seconds as i32).seconds()));
480 }
481
482 results
483}
484
485#[must_use]
487pub(crate) fn should_continue_historical_tick_pagination(
488 current_start_date: Option<Timestamp>,
489 current_end_date: Option<Timestamp>,
490 current_len: usize,
491 limit: Option<usize>,
492) -> bool {
493 limit.is_none_or(|limit| current_len < limit)
494 && current_start_date
495 .zip(current_end_date)
496 .is_none_or(|(start, end)| end > start)
497}
498
499#[allow(clippy::too_many_arguments)] pub(crate) fn extend_historical_tick_batch<T>(
502 all_ticks: &mut Vec<T>,
503 batch_ticks: Vec<T>,
504 current_start_date: Option<Timestamp>,
505 current_end_date: &mut Option<Timestamp>,
506 start_nanos: Option<UnixNanos>,
507 end_nanos: Option<UnixNanos>,
508 limit: Option<usize>,
509 ts_event: impl Fn(&T) -> UnixNanos,
510) -> bool {
511 if batch_ticks.is_empty() {
512 return false;
513 }
514
515 let mut batch_ticks = batch_ticks;
521
522 if let Some(end) = *current_end_date {
523 let end_bound_nanos = end.as_nanosecond();
524 batch_ticks.retain(|tick| i128::from(ts_event(tick).as_u64()) <= end_bound_nanos);
525 if batch_ticks.is_empty() {
526 tracing::warn!(
527 "Historical tick pagination pinned at {end}; a single second exceeds the page \
528 size, earlier ticks within that second cannot be requested"
529 );
530 retain_historical_ticks_in_range(all_ticks, start_nanos, end_nanos, &ts_event);
531 return false;
532 }
533 }
534
535 let Some(min_ts_nanos) = batch_ticks.iter().map(&ts_event).min() else {
536 return false;
537 };
538
539 let new_end_nanos = min_ts_nanos.as_u64().saturating_sub(1);
540
541 let Ok(new_end) = Timestamp::from_nanosecond(i128::from(new_end_nanos)) else {
542 return false;
543 };
544
545 *current_end_date = Some(new_end);
546
547 all_ticks.extend(batch_ticks);
548 if current_start_date
549 .zip(*current_end_date)
550 .is_some_and(|(start, end)| end <= start)
551 {
552 retain_historical_ticks_in_range(all_ticks, start_nanos, end_nanos, &ts_event);
553 return false;
554 }
555
556 limit.is_none_or(|limit| all_ticks.len() < limit)
557}
558
559pub(crate) fn retain_historical_ticks_in_range<T>(
561 ticks: &mut Vec<T>,
562 start_nanos: Option<UnixNanos>,
563 end_nanos: Option<UnixNanos>,
564 ts_event: impl Fn(&T) -> UnixNanos,
565) {
566 ticks.retain(|tick| {
567 let ts_event = ts_event(tick);
568 start_nanos.is_none_or(|start| ts_event >= start)
569 && end_nanos.is_none_or(|end| ts_event <= end)
570 });
571}
572
573pub fn bar_request_segments(
579 segments: Vec<(Timestamp, IBDuration)>,
580 is_continuous_future: bool,
581) -> Vec<(Option<Timestamp>, IBDuration)> {
582 if is_continuous_future {
583 let now = Timestamp::now();
586 let end_in_past = segments
587 .first()
588 .is_some_and(|(end, _)| *end < now - jiff::SignedDuration::from_secs(1));
589
590 if end_in_past || segments.len() > 1 {
591 tracing::warn!(
592 "Continuous futures cannot use an explicit end_date_time (IB error 10339); \
593 the request is anchored to the current time using only the first duration \
594 segment, so the returned bars may not cover the full requested range"
595 );
596 }
597
598 segments
599 .into_iter()
600 .take(1)
601 .map(|(_, d)| (None, d))
602 .collect()
603 } else {
604 segments
605 .into_iter()
606 .map(|(end, d)| (Some(end), d))
607 .collect()
608 }
609}
610
611#[cfg(test)]
612mod tests {
613 use std::cmp::Ordering;
614
615 use nautilus_model::{
616 data::{BarSpecification, BarType},
617 enums::{AggregationSource, BarAggregation, PriceType},
618 identifiers::{InstrumentId, Symbol, Venue},
619 instruments::Equity,
620 types::Currency,
621 };
622 use rstest::rstest;
623 use time::macros::datetime;
624
625 use super::*;
626 use crate::stubs::currency_pair;
627
628 fn create_test_instrument_id() -> InstrumentId {
629 InstrumentId::new(Symbol::from("AAPL"), Venue::from("NASDAQ"))
630 }
631
632 #[rstest]
633 fn test_bar_type_to_ib_bar_size_seconds() {
634 let instrument_id = create_test_instrument_id();
635
636 let bar_type = BarType::new(
637 instrument_id,
638 BarSpecification::new(1, BarAggregation::Second, PriceType::Last),
639 AggregationSource::External,
640 );
641 let result = bar_type_to_ib_bar_size(&bar_type);
642 assert!(result.is_ok());
643 assert_eq!(result.unwrap(), HistoricalBarSize::Sec);
644
645 let bar_type = BarType::new(
646 instrument_id,
647 BarSpecification::new(5, BarAggregation::Second, PriceType::Last),
648 AggregationSource::External,
649 );
650 let result = bar_type_to_ib_bar_size(&bar_type);
651 assert!(result.is_ok());
652 assert_eq!(result.unwrap(), HistoricalBarSize::Sec5);
653 }
654
655 #[rstest]
656 fn test_bar_type_to_ib_bar_size_minutes() {
657 let instrument_id = create_test_instrument_id();
658
659 let bar_type = BarType::new(
660 instrument_id,
661 BarSpecification::new(1, BarAggregation::Minute, PriceType::Last),
662 AggregationSource::External,
663 );
664 let result = bar_type_to_ib_bar_size(&bar_type);
665 assert!(result.is_ok());
666 assert_eq!(result.unwrap(), HistoricalBarSize::Min);
667
668 let bar_type = BarType::new(
669 instrument_id,
670 BarSpecification::new(15, BarAggregation::Minute, PriceType::Last),
671 AggregationSource::External,
672 );
673 let result = bar_type_to_ib_bar_size(&bar_type);
674 assert!(result.is_ok());
675 assert_eq!(result.unwrap(), HistoricalBarSize::Min15);
676 }
677
678 #[rstest]
679 fn test_bar_type_to_ib_bar_size_hours() {
680 let instrument_id = create_test_instrument_id();
681
682 let bar_type = BarType::new(
683 instrument_id,
684 BarSpecification::new(1, BarAggregation::Hour, PriceType::Last),
685 AggregationSource::External,
686 );
687 let result = bar_type_to_ib_bar_size(&bar_type);
688 assert!(result.is_ok());
689 assert_eq!(result.unwrap(), HistoricalBarSize::Hour);
690 }
691
692 #[rstest]
693 fn test_bar_type_to_ib_bar_size_days() {
694 let instrument_id = create_test_instrument_id();
695
696 let bar_type = BarType::new(
697 instrument_id,
698 BarSpecification::new(1, BarAggregation::Day, PriceType::Last),
699 AggregationSource::External,
700 );
701 let result = bar_type_to_ib_bar_size(&bar_type);
702 assert!(result.is_ok());
703 assert_eq!(result.unwrap(), HistoricalBarSize::Day);
704 }
705
706 #[rstest]
707 fn test_bar_type_to_ib_bar_size_unsupported() {
708 let instrument_id = create_test_instrument_id();
709
710 let bar_type = BarType::new(
711 instrument_id,
712 BarSpecification::new(12, BarAggregation::Minute, PriceType::Last),
713 AggregationSource::External,
714 );
715 let result = bar_type_to_ib_bar_size(&bar_type);
716 assert!(result.is_err());
717 }
718
719 #[rstest]
720 fn test_price_type_to_ib_what_to_show() {
721 assert_eq!(
722 price_type_to_ib_what_to_show(PriceType::Last),
723 HistoricalWhatToShow::Trades
724 );
725 assert_eq!(
726 price_type_to_ib_what_to_show(PriceType::Bid),
727 HistoricalWhatToShow::Bid
728 );
729 assert_eq!(
730 price_type_to_ib_what_to_show(PriceType::Ask),
731 HistoricalWhatToShow::Ask
732 );
733 assert_eq!(
734 price_type_to_ib_what_to_show(PriceType::Mid),
735 HistoricalWhatToShow::MidPoint
736 );
737 }
738
739 #[rstest]
740 fn test_price_type_to_ib_what_to_show_for_security_crypto() {
741 assert_eq!(
744 price_type_to_ib_what_to_show_for_security(PriceType::Last, true),
745 HistoricalWhatToShow::AggTrades
746 );
747 assert_eq!(
749 price_type_to_ib_what_to_show_for_security(PriceType::Bid, true),
750 HistoricalWhatToShow::Bid
751 );
752 assert_eq!(
753 price_type_to_ib_what_to_show_for_security(PriceType::Ask, true),
754 HistoricalWhatToShow::Ask
755 );
756 assert_eq!(
757 price_type_to_ib_what_to_show_for_security(PriceType::Mid, true),
758 HistoricalWhatToShow::MidPoint
759 );
760 }
761
762 #[rstest]
763 fn test_price_type_to_ib_what_to_show_for_security_non_crypto() {
764 assert_eq!(
767 price_type_to_ib_what_to_show_for_security(PriceType::Last, false),
768 HistoricalWhatToShow::Trades
769 );
770 assert_eq!(
771 price_type_to_ib_what_to_show_for_security(PriceType::Bid, false),
772 HistoricalWhatToShow::Bid
773 );
774 assert_eq!(
775 price_type_to_ib_what_to_show_for_security(PriceType::Mid, false),
776 HistoricalWhatToShow::MidPoint
777 );
778 }
779
780 #[rstest]
781 fn test_aggtrades_wire_string() {
782 assert_eq!(HistoricalWhatToShow::AggTrades.to_string(), "AGGTRADES");
785 assert_eq!(RealtimeWhatToShow::AggTrades.to_string(), "AGGTRADES");
786 }
787
788 #[rstest]
789 fn test_price_type_to_ib_realtime_what_to_show() {
790 assert!(matches!(
792 price_type_to_ib_realtime_what_to_show(PriceType::Last),
793 RealtimeWhatToShow::Trades
794 ));
795 assert!(matches!(
796 price_type_to_ib_realtime_what_to_show(PriceType::Bid),
797 RealtimeWhatToShow::Bid
798 ));
799 assert!(matches!(
800 price_type_to_ib_realtime_what_to_show(PriceType::Ask),
801 RealtimeWhatToShow::Ask
802 ));
803 assert!(matches!(
804 price_type_to_ib_realtime_what_to_show(PriceType::Mid),
805 RealtimeWhatToShow::MidPoint
806 ));
807 }
808
809 #[rstest]
810 fn test_price_type_to_ib_realtime_what_to_show_for_security_crypto() {
811 assert!(matches!(
816 price_type_to_ib_realtime_what_to_show_for_security(PriceType::Last, true),
817 RealtimeWhatToShow::AggTrades
818 ));
819 assert!(matches!(
821 price_type_to_ib_realtime_what_to_show_for_security(PriceType::Mid, true),
822 RealtimeWhatToShow::MidPoint
823 ));
824 assert!(matches!(
825 price_type_to_ib_realtime_what_to_show_for_security(PriceType::Bid, true),
826 RealtimeWhatToShow::Bid
827 ));
828 assert!(matches!(
830 price_type_to_ib_realtime_what_to_show_for_security(PriceType::Last, false),
831 RealtimeWhatToShow::Trades
832 ));
833 }
834
835 #[rstest]
836 fn test_ib_bar_to_nautilus_bar() {
837 let ib_bar = ibapi::market_data::historical::Bar {
838 date: datetime!(2024-01-01 10:00:00 UTC).into(),
839 open: 150.0,
840 high: 151.0,
841 low: 149.0,
842 close: 150.5,
843 volume: 1000.0,
844 wap: 150.25,
845 count: 100,
846 };
847
848 let instrument_id = create_test_instrument_id();
849
850 let bar_type = BarType::new(
851 instrument_id,
852 BarSpecification::new(1, BarAggregation::Minute, PriceType::Last),
853 AggregationSource::External,
854 );
855 let result = ib_bar_to_nautilus_bar(&ib_bar, bar_type, IbPriceConverter::new(2), 0);
856 assert!(result.is_ok());
857 let bar = result.unwrap();
858 assert_eq!(bar.open.as_f64(), 150.0);
859 assert_eq!(bar.high.as_f64(), 151.0);
860 assert_eq!(bar.low.as_f64(), 149.0);
861 assert_eq!(bar.close.as_f64(), 150.5);
862 assert_eq!(bar.volume.as_f64(), 1000.0);
863 let close = ib_timestamp_to_unix_nanos(&datetime!(2024-01-01 10:01:00 UTC));
864 assert_eq!(bar.ts_event.as_u64(), close.as_u64());
865 assert_eq!(bar.ts_init.as_u64(), close.as_u64());
866 }
867
868 #[rstest]
869 fn test_ib_bar_to_nautilus_bar_negative_volume() {
870 let ib_bar = ibapi::market_data::historical::Bar {
871 date: datetime!(2024-01-01 10:00:00 UTC).into(),
872 open: 150.0,
873 high: 151.0,
874 low: 149.0,
875 close: 150.5,
876 volume: -1.0, wap: 150.25,
878 count: 100,
879 };
880
881 let instrument_id = create_test_instrument_id();
882
883 let bar_type = BarType::new(
884 instrument_id,
885 BarSpecification::new(1, BarAggregation::Minute, PriceType::Last),
886 AggregationSource::External,
887 );
888 let result = ib_bar_to_nautilus_bar(&ib_bar, bar_type, IbPriceConverter::new(2), 0);
889 assert!(result.is_ok());
890 let bar = result.unwrap();
891 assert_eq!(bar.volume.as_f64(), 0.0);
893 }
894
895 #[rstest]
896 fn test_bar_close_from_open_intraday() {
897 let open = ib_timestamp_to_unix_nanos(&datetime!(2024-01-01 10:00:00 UTC));
898
899 let spec = BarSpecification::new(1, BarAggregation::Second, PriceType::Last);
900 assert_eq!(
901 bar_close_from_open(open, &spec).as_u64(),
902 ib_timestamp_to_unix_nanos(&datetime!(2024-01-01 10:00:01 UTC)).as_u64(),
903 );
904
905 let spec = BarSpecification::new(5, BarAggregation::Second, PriceType::Last);
906 assert_eq!(
907 bar_close_from_open(open, &spec).as_u64(),
908 ib_timestamp_to_unix_nanos(&datetime!(2024-01-01 10:00:05 UTC)).as_u64(),
909 );
910
911 let spec = BarSpecification::new(1, BarAggregation::Minute, PriceType::Last);
912 assert_eq!(
913 bar_close_from_open(open, &spec).as_u64(),
914 ib_timestamp_to_unix_nanos(&datetime!(2024-01-01 10:01:00 UTC)).as_u64(),
915 );
916
917 let spec = BarSpecification::new(1, BarAggregation::Hour, PriceType::Last);
918 assert_eq!(
919 bar_close_from_open(open, &spec).as_u64(),
920 ib_timestamp_to_unix_nanos(&datetime!(2024-01-01 11:00:00 UTC)).as_u64(),
921 );
922 }
923
924 #[rstest]
925 fn test_bar_close_from_open_day() {
926 let open = ib_timestamp_to_unix_nanos(&datetime!(2024-01-01 00:00:00 UTC));
927 let spec = BarSpecification::new(1, BarAggregation::Day, PriceType::Last);
928 assert_eq!(
929 bar_close_from_open(open, &spec).as_u64(),
930 open.as_u64() + 86_400_000_000_000 - 1,
931 );
932 }
933
934 #[rstest]
935 fn test_bar_close_from_open_week_month() {
936 let open = ib_timestamp_to_unix_nanos(&datetime!(2024-01-13 00:00:00 UTC));
937
938 let spec = BarSpecification::new(1, BarAggregation::Week, PriceType::Last);
939 assert_eq!(bar_close_from_open(open, &spec).as_u64(), open.as_u64());
940
941 let spec = BarSpecification::new(1, BarAggregation::Month, PriceType::Last);
942 assert_eq!(bar_close_from_open(open, &spec).as_u64(), open.as_u64());
943 }
944
945 #[rstest]
946 fn test_ib_timestamp_to_unix_nanos() {
947 let dt = datetime!(2024-01-01 10:00:00 UTC);
948 let result = ib_timestamp_to_unix_nanos(&dt);
949 assert!(result.as_i64() > 0);
950 }
951
952 #[rstest]
953 fn test_jiff_to_ib_datetime() {
954 let utc_dt = "2024-01-01T10:00:00Z".parse::<Timestamp>().unwrap();
955 let result = jiff_to_ib_datetime(&utc_dt);
956 assert_eq!(result.year(), 2024);
957 assert_eq!(result.month(), time::Month::January);
958 assert_eq!(result.day(), 1);
959 }
960
961 #[rstest]
962 fn test_calculate_duration_segments_with_start_and_end() {
963 let start = "2024-01-01T10:00:00Z".parse::<Timestamp>().unwrap();
964 let end = "2024-01-02T10:00:00Z".parse::<Timestamp>().unwrap();
965 let result = calculate_duration_segments(Some(start), Some(end), None);
966
967 assert_eq!(result, vec![(end, IBDuration::days(1))]);
968 }
969
970 #[rstest]
971 fn test_calculate_duration_segments_without_start_defaults_to_one_day() {
972 let end = "2024-01-02T10:00:00Z".parse::<Timestamp>().unwrap();
973 let result = calculate_duration_segments(None, Some(end), None);
974
975 assert_eq!(result, vec![(end, IBDuration::days(1))]);
976 }
977
978 #[rstest]
979 fn test_calculate_duration_segments_with_explicit_duration() {
980 let end = "2024-01-02T10:00:00Z".parse::<Timestamp>().unwrap();
981 let result = calculate_duration_segments(None, Some(end), Some(IBDuration::weeks(2)));
982
983 assert_eq!(result, vec![(end, IBDuration::weeks(2))]);
984 }
985
986 #[rstest]
987 fn test_calculate_duration_segments_splits_years_days_and_seconds() {
988 let end = "2025-07-03T10:00:01Z".parse::<Timestamp>().unwrap();
989 let start = "2024-01-01T10:00:00Z".parse::<Timestamp>().unwrap();
990 let segments = calculate_duration_segments(Some(start), Some(end), None);
991
992 assert_eq!(
993 segments,
994 vec![
995 (end, IBDuration::years(1)),
996 (
997 "2024-07-03T10:00:01Z".parse::<Timestamp>().unwrap(),
998 IBDuration::days(184),
999 ),
1000 (
1001 "2024-01-01T10:00:01Z".parse::<Timestamp>().unwrap(),
1002 IBDuration::seconds(1),
1003 ),
1004 ]
1005 );
1006 }
1007
1008 #[rstest]
1009 fn test_calculate_duration_segments_rounds_subsecond_up() {
1010 let start = Timestamp::new(1, 0).unwrap();
1011 let end = Timestamp::new(1, 1).unwrap();
1012 let segments = calculate_duration_segments(Some(start), Some(end), None);
1013
1014 assert_eq!(segments, vec![(end, IBDuration::seconds(1))]);
1015 }
1016
1017 #[rstest]
1018 #[case(None, Some(Timestamp::from_second(2).unwrap()), 0, Some(10), true)]
1019 #[case(Some(Timestamp::from_second(1).unwrap()), Some(Timestamp::from_second(2).unwrap()), 0, Some(10), true)]
1020 #[case(Some(Timestamp::from_second(2).unwrap()), Some(Timestamp::from_second(1).unwrap()), 0, Some(10), false)]
1021 #[case(Some(Timestamp::from_second(1).unwrap()), Some(Timestamp::from_second(2).unwrap()), 10, Some(10), false)]
1022 #[case(Some(Timestamp::from_second(1).unwrap()), Some(Timestamp::from_second(2).unwrap()), 10, None, true)]
1023 fn test_should_continue_historical_tick_pagination(
1024 #[case] start: Option<Timestamp>,
1025 #[case] end: Option<Timestamp>,
1026 #[case] current_len: usize,
1027 #[case] limit: Option<usize>,
1028 #[case] expected: bool,
1029 ) {
1030 assert_eq!(
1031 should_continue_historical_tick_pagination(start, end, current_len, limit),
1032 expected
1033 );
1034 }
1035
1036 #[rstest]
1037 fn test_extend_historical_tick_batch_retreats_one_nanosecond() {
1038 let mut ticks = vec![UnixNanos::from(30)];
1039 let mut end = Some(Timestamp::from_nanosecond(100).unwrap());
1040
1041 let should_continue = extend_historical_tick_batch(
1042 &mut ticks,
1043 vec![UnixNanos::from(20), UnixNanos::from(10)],
1044 Some(Timestamp::from_nanosecond(1).unwrap()),
1045 &mut end,
1046 Some(UnixNanos::from(1)),
1047 Some(UnixNanos::from(100)),
1048 Some(10),
1049 |ts| *ts,
1050 );
1051
1052 assert!(should_continue);
1053 assert_eq!(
1054 ticks,
1055 vec![
1056 UnixNanos::from(30),
1057 UnixNanos::from(20),
1058 UnixNanos::from(10)
1059 ]
1060 );
1061 assert_eq!(end, Some(Timestamp::from_nanosecond(9).unwrap()));
1062 }
1063
1064 #[rstest]
1065 fn test_extend_historical_tick_batch_stops_and_trims_at_start() {
1066 let mut ticks = Vec::new();
1067 let mut end = Some(Timestamp::from_nanosecond(100).unwrap());
1068
1069 let should_continue = extend_historical_tick_batch(
1070 &mut ticks,
1071 vec![UnixNanos::from(20), UnixNanos::from(10)],
1072 Some(Timestamp::from_nanosecond(10).unwrap()),
1073 &mut end,
1074 Some(UnixNanos::from(10)),
1075 Some(UnixNanos::from(15)),
1076 Some(10),
1077 |ts| *ts,
1078 );
1079
1080 assert!(!should_continue);
1081 assert_eq!(ticks, vec![UnixNanos::from(10)]);
1082 assert_eq!(end, Some(Timestamp::from_nanosecond(9).unwrap()));
1083 }
1084
1085 #[rstest]
1086 fn test_extend_historical_tick_batch_drops_boundary_duplicates() {
1087 let mut ticks = vec![UnixNanos::from(30)];
1088 let mut end = Some(Timestamp::from_nanosecond(29).unwrap());
1089
1090 let should_continue = extend_historical_tick_batch(
1091 &mut ticks,
1092 vec![
1093 UnixNanos::from(35),
1094 UnixNanos::from(30),
1095 UnixNanos::from(20),
1096 ],
1097 None,
1098 &mut end,
1099 None,
1100 None,
1101 Some(10),
1102 |ts| *ts,
1103 );
1104
1105 assert!(should_continue);
1106 assert_eq!(ticks, vec![UnixNanos::from(30), UnixNanos::from(20)]);
1107 assert_eq!(end, Some(Timestamp::from_nanosecond(19).unwrap()));
1108 }
1109
1110 #[rstest]
1111 fn test_extend_historical_tick_batch_stops_when_end_cannot_retreat() {
1112 let mut ticks = vec![UnixNanos::from(30)];
1113 let mut end = Some(Timestamp::from_nanosecond(29).unwrap());
1114
1115 let should_continue = extend_historical_tick_batch(
1116 &mut ticks,
1117 vec![UnixNanos::from(35), UnixNanos::from(30)],
1118 None,
1119 &mut end,
1120 None,
1121 None,
1122 Some(10),
1123 |ts| *ts,
1124 );
1125
1126 assert!(!should_continue);
1127 assert_eq!(ticks, vec![UnixNanos::from(30)]);
1128 assert_eq!(end, Some(Timestamp::from_nanosecond(29).unwrap()));
1129 }
1130
1131 #[rstest]
1132 fn test_bar_request_segments_attaches_end_dates_when_not_continuous() {
1133 let end = "2025-01-01T00:00:00Z".parse::<Timestamp>().unwrap();
1134 let earlier = "2024-06-01T00:00:00Z".parse::<Timestamp>().unwrap();
1135 let segments = vec![(end, IBDuration::years(1)), (earlier, IBDuration::days(30))];
1136
1137 let result = bar_request_segments(segments, false);
1138
1139 assert_eq!(result.len(), 2);
1140 assert_eq!(result[0].0, Some(end));
1141 assert_eq!(result[1].0, Some(earlier));
1142 }
1143
1144 #[rstest]
1145 fn test_bar_request_segments_drops_end_date_and_keeps_only_first_for_continuous() {
1146 let end = "2025-01-01T00:00:00Z".parse::<Timestamp>().unwrap();
1147 let earlier = "2024-06-01T00:00:00Z".parse::<Timestamp>().unwrap();
1148 let segments = vec![(end, IBDuration::years(1)), (earlier, IBDuration::days(30))];
1149
1150 let result = bar_request_segments(segments, true);
1151
1152 assert_eq!(result.len(), 1);
1153 assert_eq!(result[0].0, None);
1154 assert_eq!(result[0].1, IBDuration::years(1));
1155 }
1156
1157 #[rstest]
1158 fn test_bar_request_segments_empty_input_yields_nothing_for_continuous() {
1159 let result = bar_request_segments(vec![], true);
1160 assert!(result.is_empty());
1161 }
1162
1163 fn create_equity() -> InstrumentAny {
1164 let equity = Equity::builder()
1165 .instrument_id(create_test_instrument_id())
1166 .raw_symbol(Symbol::from("AAPL"))
1167 .currency(Currency::from("USD"))
1168 .price_precision(2)
1169 .price_increment(Price::from("0.05"))
1170 .lot_size(Quantity::from("1"))
1171 .ts_event(UnixNanos::default())
1172 .ts_init(UnixNanos::default())
1173 .build()
1174 .unwrap();
1175 InstrumentAny::from(equity)
1176 }
1177
1178 #[rstest]
1179 #[case(1.12097, "1.12095")]
1180 #[case(1.12098, "1.12100")]
1181 #[case(1.12100, "1.12100")]
1182 #[case(1.12095, "1.12095")]
1183 #[case(-1.12097, "-1.12095")]
1184 fn test_price_converter_rounds_fx_pair_to_increment(
1185 #[case] ib_price: f64,
1186 #[case] expected: &str,
1187 ) {
1188 let instrument = currency_pair("EUR", "USD", 5, "0.00005");
1189 let prices = IbPriceConverter::for_instrument(&instrument, 1);
1190
1191 let price = prices.price(ib_price).unwrap();
1192
1193 assert_eq!(price, Price::from(expected));
1194 assert_eq!(price.precision, 5);
1195 }
1196
1197 #[rstest]
1198 #[case(1e25)]
1199 #[case(-1e25)]
1200 #[case(7.9e28)]
1201 #[case(1e300)]
1202 fn test_price_converter_returns_error_for_out_of_range_price(#[case] ib_price: f64) {
1203 let fx = IbPriceConverter::for_instrument(¤cy_pair("EUR", "USD", 5, "0.00005"), 1);
1204 let magnified = IbPriceConverter::new(4).with_price_magnifier(100);
1205
1206 assert!(fx.price(ib_price).is_err());
1207 assert!(fx.bid_price(ib_price).is_err());
1208 assert!(fx.ask_price(ib_price).is_err());
1209 assert!(magnified.price(ib_price).is_err());
1210 }
1211
1212 #[rstest]
1213 fn test_price_converter_rounds_jpy_pair_to_increment() {
1214 let instrument = currency_pair("USD", "JPY", 3, "0.005");
1215 let prices = IbPriceConverter::for_instrument(&instrument, 1);
1216
1217 assert_eq!(prices.price(151.234).unwrap(), Price::from("151.235"));
1218 assert_eq!(prices.price(151.232).unwrap(), Price::from("151.230"));
1219 }
1220
1221 #[rstest]
1222 #[case(1.000025, "1.00000")]
1223 #[case(1.000075, "1.00010")]
1224 #[case(1.000125, "1.00010")]
1225 #[case(1.000026, "1.00005")]
1226 #[case(1.120025, "1.12000")]
1227 #[case(1.120175, "1.12020")]
1228 fn test_price_converter_ties_go_to_even_multiple(
1229 #[case] ib_price: f64,
1230 #[case] expected: &str,
1231 ) {
1232 let instrument = currency_pair("EUR", "USD", 5, "0.00005");
1233 let prices = IbPriceConverter::for_instrument(&instrument, 1);
1234
1235 let price = prices.price(ib_price).unwrap();
1236
1237 assert_eq!(price, Price::from(expected));
1238 assert_eq!(price.precision, 5);
1239 }
1240
1241 #[rstest]
1242 #[case(151.0025, "151.000")]
1243 #[case(151.0075, "151.010")]
1244 #[case(151.0125, "151.010")]
1245 fn test_price_converter_ties_go_to_even_multiple_for_jpy_pair(
1246 #[case] ib_price: f64,
1247 #[case] expected: &str,
1248 ) {
1249 let instrument = currency_pair("USD", "JPY", 3, "0.005");
1250 let prices = IbPriceConverter::for_instrument(&instrument, 1);
1251
1252 assert_eq!(prices.price(ib_price).unwrap(), Price::from(expected));
1253 }
1254
1255 #[rstest]
1256 fn test_price_converter_rounds_every_fx_midpoint_like_exact_ties_to_even() {
1257 let instrument = currency_pair("EUR", "USD", 5, "0.00005");
1258 let prices = IbPriceConverter::for_instrument(&instrument, 1);
1259
1260 for n in 223_850_i64..=224_150 {
1262 let micros = n * 5;
1263 let ib_price: f64 = format!("{}.{:06}", micros / 1_000_000, micros % 1_000_000)
1264 .parse()
1265 .unwrap();
1266
1267 let (quotient, remainder) = (n / 10, n % 10);
1269 let steps = match remainder.cmp(&5) {
1270 Ordering::Less => quotient,
1271 Ordering::Greater => quotient + 1,
1272 Ordering::Equal => quotient + quotient % 2,
1273 };
1274 let hundred_thousandths = steps * 5;
1275 let expected = Price::from(
1276 format!(
1277 "{}.{:05}",
1278 hundred_thousandths / 100_000,
1279 hundred_thousandths % 100_000
1280 )
1281 .as_str(),
1282 );
1283
1284 assert_eq!(
1285 prices.price(ib_price).unwrap(),
1286 expected,
1287 "ib_price={ib_price}"
1288 );
1289 }
1290 }
1291
1292 #[rstest]
1293 fn test_price_converter_does_not_round_crypto_pair() {
1294 let instrument = currency_pair("BTC", "USD", 2, "0.05");
1295 let prices = IbPriceConverter::for_instrument(&instrument, 1);
1296
1297 assert_eq!(prices.price(100.03).unwrap(), Price::from("100.03"));
1298 }
1299
1300 #[rstest]
1301 fn test_price_converter_does_not_round_equity() {
1302 let instrument = create_equity();
1303 let prices = IbPriceConverter::for_instrument(&instrument, 1);
1304
1305 assert_eq!(prices.precision(), 2);
1306 assert_eq!(prices.price(100.03).unwrap(), Price::from("100.03"));
1307 }
1308
1309 #[rstest]
1310 fn test_price_converter_new_applies_precision_only() {
1311 let prices = IbPriceConverter::new(5);
1312
1313 assert_eq!(prices.precision(), 5);
1314 assert_eq!(prices.price(1.12097).unwrap(), Price::from("1.12097"));
1315 }
1316
1317 #[rstest]
1318 fn test_price_converter_rejects_non_finite_price() {
1319 let prices = IbPriceConverter::new(2);
1320
1321 assert!(prices.price(f64::NAN).is_err());
1322 }
1323
1324 #[rstest]
1325 fn test_ib_bar_to_nautilus_bar_rounds_fx_prices_to_increment() {
1326 let ib_bar = ibapi::market_data::historical::Bar {
1327 date: datetime!(2024-01-01 10:00:00 UTC).into(),
1328 open: 1.12097,
1329 high: 1.12103,
1330 low: 1.12091,
1331 close: 1.12098,
1332 volume: 0.0,
1333 wap: 0.0,
1334 count: 0,
1335 };
1336 let instrument = currency_pair("EUR", "USD", 5, "0.00005");
1337 let bar_type = BarType::new(
1338 instrument.id(),
1339 BarSpecification::new(1, BarAggregation::Minute, PriceType::Mid),
1340 AggregationSource::External,
1341 );
1342
1343 let bar = ib_bar_to_nautilus_bar(
1344 &ib_bar,
1345 bar_type,
1346 IbPriceConverter::for_instrument(&instrument, 1),
1347 0,
1348 )
1349 .unwrap();
1350
1351 assert_eq!(bar.open, Price::from("1.12095"));
1352 assert_eq!(bar.high, Price::from("1.12105"));
1353 assert_eq!(bar.low, Price::from("1.12090"));
1354 assert_eq!(bar.close, Price::from("1.12100"));
1355 }
1356
1357 #[rstest]
1358 #[case(1.12098, "1.12095", "1.12100")]
1359 #[case(1.12099, "1.12095", "1.12100")]
1360 #[case(1.12097, "1.12095", "1.12100")]
1361 #[case(1.12095, "1.12095", "1.12095")]
1362 #[case(-1.12097, "-1.12100", "-1.12095")]
1363 fn test_price_converter_rounds_bid_down_and_ask_up(
1364 #[case] ib_price: f64,
1365 #[case] bid: &str,
1366 #[case] ask: &str,
1367 ) {
1368 let instrument = currency_pair("EUR", "USD", 5, "0.00005");
1369 let prices = IbPriceConverter::for_instrument(&instrument, 1);
1370
1371 assert_eq!(prices.bid_price(ib_price).unwrap(), Price::from(bid));
1372 assert_eq!(prices.ask_price(ib_price).unwrap(), Price::from(ask));
1373 }
1374
1375 #[rstest]
1376 fn test_price_converter_rounds_bid_down_and_ask_up_for_jpy_pair() {
1377 let instrument = currency_pair("USD", "JPY", 3, "0.005");
1378 let prices = IbPriceConverter::for_instrument(&instrument, 1);
1379
1380 assert_eq!(prices.bid_price(151.0049).unwrap(), Price::from("151.000"));
1381 assert_eq!(prices.ask_price(151.0001).unwrap(), Price::from("151.005"));
1382 }
1383
1384 #[rstest]
1385 #[case(PriceType::Bid, "1.12095")]
1386 #[case(PriceType::Ask, "1.12100")]
1387 #[case(PriceType::Mid, "1.12095")]
1388 #[case(PriceType::Last, "1.12095")]
1389 fn test_price_converter_bar_price_follows_price_type(
1390 #[case] price_type: PriceType,
1391 #[case] expected: &str,
1392 ) {
1393 let instrument = currency_pair("EUR", "USD", 5, "0.00005");
1394 let prices = IbPriceConverter::for_instrument(&instrument, 1);
1395
1396 assert_eq!(
1397 prices.bar_price(price_type, 1.12097).unwrap(),
1398 Price::from(expected)
1399 );
1400 }
1401
1402 #[rstest]
1403 fn test_price_converter_bid_and_ask_do_not_round_equity() {
1404 let instrument = create_equity();
1405 let prices = IbPriceConverter::for_instrument(&instrument, 1);
1406
1407 assert_eq!(prices.bid_price(100.03).unwrap(), Price::from("100.03"));
1408 assert_eq!(prices.ask_price(100.03).unwrap(), Price::from("100.03"));
1409 }
1410
1411 #[rstest]
1412 #[case(PriceType::Bid, "1.12095", "1.12105", "1.12090", "1.12095")]
1413 #[case(PriceType::Ask, "1.12100", "1.12110", "1.12095", "1.12100")]
1414 fn test_ib_bar_to_nautilus_bar_rounds_fx_bid_and_ask_bars(
1415 #[case] price_type: PriceType,
1416 #[case] open: &str,
1417 #[case] high: &str,
1418 #[case] low: &str,
1419 #[case] close: &str,
1420 ) {
1421 let ib_bar = ibapi::market_data::historical::Bar {
1422 date: datetime!(2024-01-01 10:00:00 UTC).into(),
1423 open: 1.12097,
1424 high: 1.12109,
1425 low: 1.12091,
1426 close: 1.12096,
1427 volume: 0.0,
1428 wap: 0.0,
1429 count: 0,
1430 };
1431 let instrument = currency_pair("EUR", "USD", 5, "0.00005");
1432 let bar_type = BarType::new(
1433 instrument.id(),
1434 BarSpecification::new(1, BarAggregation::Minute, price_type),
1435 AggregationSource::External,
1436 );
1437
1438 let bar = ib_bar_to_nautilus_bar(
1439 &ib_bar,
1440 bar_type,
1441 IbPriceConverter::for_instrument(&instrument, 1),
1442 0,
1443 )
1444 .unwrap();
1445
1446 assert_eq!(bar.open, Price::from(open));
1447 assert_eq!(bar.high, Price::from(high));
1448 assert_eq!(bar.low, Price::from(low));
1449 assert_eq!(bar.close, Price::from(close));
1450 assert!(bar.high >= bar.low);
1451 }
1452
1453 #[rstest]
1454 #[case(425.25, "4.2525")]
1455 #[case(486.25, "4.8625")]
1456 #[case(-100.0, "-1.0000")]
1457 fn test_price_converter_divides_by_price_magnifier(
1458 #[case] ib_price: f64,
1459 #[case] expected: &str,
1460 ) {
1461 let prices = IbPriceConverter::new(4).with_price_magnifier(100);
1462
1463 assert_eq!(prices.price(ib_price).unwrap(), Price::from(expected));
1464 }
1465
1466 #[rstest]
1467 fn test_price_converter_converts_magnified_quarter_cent_steps_exactly() {
1468 let prices = IbPriceConverter::new(4).with_price_magnifier(100);
1469
1470 for n in 0_i64..=2000 {
1472 let ib_price = n as f64 * 0.25;
1473 let ten_thousandths = n * 25;
1474 let expected = Price::from(
1475 format!(
1476 "{}.{:04}",
1477 ten_thousandths / 10_000,
1478 ten_thousandths % 10_000
1479 )
1480 .as_str(),
1481 );
1482
1483 assert_eq!(
1484 prices.price(ib_price).unwrap(),
1485 expected,
1486 "ib_price={ib_price}"
1487 );
1488 }
1489 }
1490
1491 #[rstest]
1492 #[case(0)]
1493 #[case(-5)]
1494 #[case(1)]
1495 fn test_price_converter_treats_magnifier_below_one_as_one(#[case] magnifier: i32) {
1496 let prices = IbPriceConverter::new(2).with_price_magnifier(magnifier);
1497
1498 assert_eq!(prices.price(150.25).unwrap(), Price::from("150.25"));
1499 }
1500
1501 #[rstest]
1502 #[case::magnified_sentinel(100, -100.0, None, true)]
1503 #[case::magnified_sentinel_zero_size(100, -100.0, Some(0.0), true)]
1504 #[case::magnified_price_with_size(100, -100.0, Some(5.0), false)]
1505 #[case::unmagnified_sentinel(100, -1.0, None, true)]
1506 #[case::magnified_price(100, 5.5, None, false)]
1507 #[case::other_negative_price(100, -1.25, None, false)]
1508 #[case::plain_sentinel(1, -1.0, None, true)]
1509 #[case::plain_negative_price(1, -100.0, None, false)]
1510 fn test_price_converter_detects_magnified_sentinel(
1511 #[case] magnifier: i32,
1512 #[case] ib_price: f64,
1513 #[case] size: Option<f64>,
1514 #[case] expected: bool,
1515 ) {
1516 let prices = IbPriceConverter::new(5).with_price_magnifier(magnifier);
1517
1518 assert_eq!(prices.is_sentinel(ib_price, size), expected);
1519 }
1520
1521 #[rstest]
1522 fn test_price_converter_divides_before_rounding_fx_to_increment() {
1523 let instrument = currency_pair("EUR", "USD", 5, "0.00005");
1524 let prices = IbPriceConverter::for_instrument(&instrument, 100);
1525
1526 assert_eq!(prices.price(112.097).unwrap(), Price::from("1.12095"));
1527 }
1528
1529 #[rstest]
1530 fn test_ib_bar_to_nautilus_bar_divides_magnified_prices() {
1531 let ib_bar = ibapi::market_data::historical::Bar {
1532 date: datetime!(2024-01-01 10:00:00 UTC).into(),
1533 open: 425.25,
1534 high: 430.0,
1535 low: 424.5,
1536 close: 428.5,
1537 volume: 10.0,
1538 wap: 0.0,
1539 count: 1,
1540 };
1541 let bar_type = BarType::new(
1542 create_test_instrument_id(),
1543 BarSpecification::new(1, BarAggregation::Minute, PriceType::Last),
1544 AggregationSource::External,
1545 );
1546
1547 let bar = ib_bar_to_nautilus_bar(
1548 &ib_bar,
1549 bar_type,
1550 IbPriceConverter::new(4).with_price_magnifier(100),
1551 0,
1552 )
1553 .unwrap();
1554
1555 assert_eq!(bar.open, Price::from("4.2525"));
1556 assert_eq!(bar.high, Price::from("4.3000"));
1557 assert_eq!(bar.low, Price::from("4.2450"));
1558 assert_eq!(bar.close, Price::from("4.2850"));
1559 }
1560}