1use std::{
19 collections::HashMap,
20 fmt::{Debug, Display},
21 hash::Hash,
22 num::{NonZero, NonZeroUsize},
23 str::FromStr,
24};
25
26use chrono::{DateTime, Datelike, Duration, SubsecRound, TimeDelta, Timelike, Utc};
27use derive_builder::Builder;
28use indexmap::IndexMap;
29use nautilus_core::{
30 UnixNanos,
31 correctness::{FAILED, check_predicate_true},
32 datetime::{add_n_months, subtract_n_months},
33 serialization::Serializable,
34};
35use serde::{Deserialize, Deserializer, Serialize, Serializer};
36
37use super::HasTsInit;
38use crate::{
39 enums::{AggregationSource, BarAggregation, PriceType},
40 identifiers::InstrumentId,
41 types::{Price, Quantity, fixed::FIXED_SIZE_BINARY},
42};
43
44pub const BAR_SPEC_1_SECOND_LAST: BarSpecification = BarSpecification {
45 step: NonZero::new(1).unwrap(),
46 aggregation: BarAggregation::Second,
47 price_type: PriceType::Last,
48};
49pub const BAR_SPEC_1_MINUTE_LAST: BarSpecification = BarSpecification {
50 step: NonZero::new(1).unwrap(),
51 aggregation: BarAggregation::Minute,
52 price_type: PriceType::Last,
53};
54pub const BAR_SPEC_3_MINUTE_LAST: BarSpecification = BarSpecification {
55 step: NonZero::new(3).unwrap(),
56 aggregation: BarAggregation::Minute,
57 price_type: PriceType::Last,
58};
59pub const BAR_SPEC_5_MINUTE_LAST: BarSpecification = BarSpecification {
60 step: NonZero::new(5).unwrap(),
61 aggregation: BarAggregation::Minute,
62 price_type: PriceType::Last,
63};
64pub const BAR_SPEC_15_MINUTE_LAST: BarSpecification = BarSpecification {
65 step: NonZero::new(15).unwrap(),
66 aggregation: BarAggregation::Minute,
67 price_type: PriceType::Last,
68};
69pub const BAR_SPEC_30_MINUTE_LAST: BarSpecification = BarSpecification {
70 step: NonZero::new(30).unwrap(),
71 aggregation: BarAggregation::Minute,
72 price_type: PriceType::Last,
73};
74pub const BAR_SPEC_1_HOUR_LAST: BarSpecification = BarSpecification {
75 step: NonZero::new(1).unwrap(),
76 aggregation: BarAggregation::Hour,
77 price_type: PriceType::Last,
78};
79pub const BAR_SPEC_2_HOUR_LAST: BarSpecification = BarSpecification {
80 step: NonZero::new(2).unwrap(),
81 aggregation: BarAggregation::Hour,
82 price_type: PriceType::Last,
83};
84pub const BAR_SPEC_4_HOUR_LAST: BarSpecification = BarSpecification {
85 step: NonZero::new(4).unwrap(),
86 aggregation: BarAggregation::Hour,
87 price_type: PriceType::Last,
88};
89pub const BAR_SPEC_6_HOUR_LAST: BarSpecification = BarSpecification {
90 step: NonZero::new(6).unwrap(),
91 aggregation: BarAggregation::Hour,
92 price_type: PriceType::Last,
93};
94pub const BAR_SPEC_12_HOUR_LAST: BarSpecification = BarSpecification {
95 step: NonZero::new(12).unwrap(),
96 aggregation: BarAggregation::Hour,
97 price_type: PriceType::Last,
98};
99pub const BAR_SPEC_1_DAY_LAST: BarSpecification = BarSpecification {
100 step: NonZero::new(1).unwrap(),
101 aggregation: BarAggregation::Day,
102 price_type: PriceType::Last,
103};
104pub const BAR_SPEC_2_DAY_LAST: BarSpecification = BarSpecification {
105 step: NonZero::new(2).unwrap(),
106 aggregation: BarAggregation::Day,
107 price_type: PriceType::Last,
108};
109pub const BAR_SPEC_3_DAY_LAST: BarSpecification = BarSpecification {
110 step: NonZero::new(3).unwrap(),
111 aggregation: BarAggregation::Day,
112 price_type: PriceType::Last,
113};
114pub const BAR_SPEC_5_DAY_LAST: BarSpecification = BarSpecification {
115 step: NonZero::new(5).unwrap(),
116 aggregation: BarAggregation::Day,
117 price_type: PriceType::Last,
118};
119pub const BAR_SPEC_1_WEEK_LAST: BarSpecification = BarSpecification {
120 step: NonZero::new(1).unwrap(),
121 aggregation: BarAggregation::Week,
122 price_type: PriceType::Last,
123};
124pub const BAR_SPEC_1_MONTH_LAST: BarSpecification = BarSpecification {
125 step: NonZero::new(1).unwrap(),
126 aggregation: BarAggregation::Month,
127 price_type: PriceType::Last,
128};
129pub const BAR_SPEC_3_MONTH_LAST: BarSpecification = BarSpecification {
130 step: NonZero::new(3).unwrap(),
131 aggregation: BarAggregation::Month,
132 price_type: PriceType::Last,
133};
134pub const BAR_SPEC_6_MONTH_LAST: BarSpecification = BarSpecification {
135 step: NonZero::new(6).unwrap(),
136 aggregation: BarAggregation::Month,
137 price_type: PriceType::Last,
138};
139pub const BAR_SPEC_12_MONTH_LAST: BarSpecification = BarSpecification {
140 step: NonZero::new(12).unwrap(),
141 aggregation: BarAggregation::Month,
142 price_type: PriceType::Last,
143};
144
145pub fn get_bar_interval(bar_type: &BarType) -> TimeDelta {
151 let spec = bar_type.spec();
152
153 match spec.aggregation {
154 BarAggregation::Millisecond => TimeDelta::milliseconds(spec.step.get() as i64),
155 BarAggregation::Second => TimeDelta::seconds(spec.step.get() as i64),
156 BarAggregation::Minute => TimeDelta::minutes(spec.step.get() as i64),
157 BarAggregation::Hour => TimeDelta::hours(spec.step.get() as i64),
158 BarAggregation::Day => TimeDelta::days(spec.step.get() as i64),
159 BarAggregation::Week => TimeDelta::days(7 * spec.step.get() as i64),
160 BarAggregation::Month => TimeDelta::days(0),
161 _ => panic!("Aggregation not time based"),
162 }
163}
164
165pub fn get_bar_interval_ns(bar_type: &BarType) -> UnixNanos {
171 let interval_ns = get_bar_interval(bar_type)
172 .num_nanoseconds()
173 .expect("Invalid bar interval") as u64;
174 UnixNanos::from(interval_ns)
175}
176
177pub fn get_time_bar_start(
184 now: DateTime<Utc>,
185 bar_type: &BarType,
186 time_bars_origin: Option<TimeDelta>,
187) -> DateTime<Utc> {
188 let spec = bar_type.spec();
189 let step = spec.step.get() as i64;
190 let origin_offset: TimeDelta = time_bars_origin.unwrap_or_else(TimeDelta::zero);
191
192 match spec.aggregation {
193 BarAggregation::Millisecond => {
194 find_closest_smaller_time(now, origin_offset, Duration::milliseconds(step))
195 }
196 BarAggregation::Second => {
197 find_closest_smaller_time(now, origin_offset, Duration::seconds(step))
198 }
199 BarAggregation::Minute => {
200 find_closest_smaller_time(now, origin_offset, Duration::minutes(step))
201 }
202 BarAggregation::Hour => {
203 find_closest_smaller_time(now, origin_offset, Duration::hours(step))
204 }
205 BarAggregation::Day => find_closest_smaller_time(now, origin_offset, Duration::days(step)),
206 BarAggregation::Week => {
207 let mut start_time = now.trunc_subsecs(0)
208 - Duration::seconds(now.second() as i64)
209 - Duration::minutes(now.minute() as i64)
210 - Duration::hours(now.hour() as i64)
211 - TimeDelta::days(now.weekday().num_days_from_monday() as i64);
212 start_time += origin_offset;
213
214 if now < start_time {
215 start_time -= Duration::weeks(1);
216 }
217
218 start_time
219 }
220 BarAggregation::Month => {
221 let mut start_time = DateTime::from_naive_utc_and_offset(
223 chrono::NaiveDate::from_ymd_opt(now.year(), 1, 1)
224 .expect("valid date")
225 .and_hms_opt(0, 0, 0)
226 .expect("valid time"),
227 Utc,
228 );
229 start_time += origin_offset;
230
231 if now < start_time {
232 start_time =
233 subtract_n_months(start_time, 12).expect("Failed to subtract 12 months");
234 }
235
236 let months_step = step as u32;
237 while start_time <= now {
238 start_time =
239 add_n_months(start_time, months_step).expect("Failed to add months in loop");
240 }
241
242 start_time =
243 subtract_n_months(start_time, months_step).expect("Failed to subtract months_step");
244 start_time
245 }
246 _ => panic!(
247 "Aggregation type {} not supported for time bars",
248 spec.aggregation
249 ),
250 }
251}
252
253fn find_closest_smaller_time(
258 now: DateTime<Utc>,
259 daily_time_origin: TimeDelta,
260 period: TimeDelta,
261) -> DateTime<Utc> {
262 let day_start = now.trunc_subsecs(0)
264 - Duration::seconds(now.second() as i64)
265 - Duration::minutes(now.minute() as i64)
266 - Duration::hours(now.hour() as i64);
267 let base_time = day_start + daily_time_origin;
268
269 let time_difference = now - base_time;
270 let num_periods = (time_difference.num_nanoseconds().unwrap_or(0)
271 / period.num_nanoseconds().unwrap_or(1)) as i32;
272
273 base_time + period * num_periods
274}
275
276#[repr(C)]
279#[derive(
280 Clone, Copy, Hash, PartialEq, Eq, PartialOrd, Ord, Debug, Serialize, Deserialize, Builder,
281)]
282#[cfg_attr(
283 feature = "python",
284 pyo3::pyclass(module = "nautilus_trader.core.nautilus_pyo3.model")
285)]
286pub struct BarSpecification {
287 pub step: NonZeroUsize,
289 pub aggregation: BarAggregation,
291 pub price_type: PriceType,
293}
294
295impl BarSpecification {
296 pub fn new_checked(
306 step: usize,
307 aggregation: BarAggregation,
308 price_type: PriceType,
309 ) -> anyhow::Result<Self> {
310 let step = NonZeroUsize::new(step)
311 .ok_or(anyhow::anyhow!("Invalid step: {step} (must be non-zero)"))?;
312 Ok(Self {
313 step,
314 aggregation,
315 price_type,
316 })
317 }
318
319 #[must_use]
325 pub fn new(step: usize, aggregation: BarAggregation, price_type: PriceType) -> Self {
326 Self::new_checked(step, aggregation, price_type).expect(FAILED)
327 }
328
329 pub fn timedelta(&self) -> TimeDelta {
335 match self.aggregation {
336 BarAggregation::Millisecond => Duration::milliseconds(self.step.get() as i64),
337 BarAggregation::Second => Duration::seconds(self.step.get() as i64),
338 BarAggregation::Minute => Duration::minutes(self.step.get() as i64),
339 BarAggregation::Hour => Duration::hours(self.step.get() as i64),
340 BarAggregation::Day => Duration::days(self.step.get() as i64),
341 _ => panic!(
342 "Timedelta not supported for aggregation type: {:?}",
343 self.aggregation
344 ),
345 }
346 }
347
348 pub fn is_time_aggregated(&self) -> bool {
356 matches!(
357 self.aggregation,
358 BarAggregation::Millisecond
359 | BarAggregation::Second
360 | BarAggregation::Minute
361 | BarAggregation::Hour
362 | BarAggregation::Day
363 | BarAggregation::Month
364 )
365 }
366
367 pub fn is_threshold_aggregated(&self) -> bool {
375 matches!(
376 self.aggregation,
377 BarAggregation::Tick
378 | BarAggregation::TickImbalance
379 | BarAggregation::Volume
380 | BarAggregation::VolumeImbalance
381 | BarAggregation::Value
382 | BarAggregation::ValueImbalance
383 )
384 }
385
386 pub fn is_information_aggregated(&self) -> bool {
391 matches!(
392 self.aggregation,
393 BarAggregation::TickRuns | BarAggregation::VolumeRuns | BarAggregation::ValueRuns
394 )
395 }
396}
397
398impl Display for BarSpecification {
399 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
400 write!(f, "{}-{}-{}", self.step, self.aggregation, self.price_type)
401 }
402}
403
404#[repr(C)]
407#[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord, Hash)]
408#[cfg_attr(
409 feature = "python",
410 pyo3::pyclass(module = "nautilus_trader.core.nautilus_pyo3.model")
411)]
412pub enum BarType {
413 Standard {
414 instrument_id: InstrumentId,
416 spec: BarSpecification,
418 aggregation_source: AggregationSource,
420 },
421 Composite {
422 instrument_id: InstrumentId,
424 spec: BarSpecification,
426 aggregation_source: AggregationSource,
428
429 composite_step: usize,
431 composite_aggregation: BarAggregation,
433 composite_aggregation_source: AggregationSource,
435 },
436}
437
438impl BarType {
439 #[must_use]
441 pub fn new(
442 instrument_id: InstrumentId,
443 spec: BarSpecification,
444 aggregation_source: AggregationSource,
445 ) -> Self {
446 Self::Standard {
447 instrument_id,
448 spec,
449 aggregation_source,
450 }
451 }
452
453 pub fn new_composite(
455 instrument_id: InstrumentId,
456 spec: BarSpecification,
457 aggregation_source: AggregationSource,
458
459 composite_step: usize,
460 composite_aggregation: BarAggregation,
461 composite_aggregation_source: AggregationSource,
462 ) -> Self {
463 Self::Composite {
464 instrument_id,
465 spec,
466 aggregation_source,
467
468 composite_step,
469 composite_aggregation,
470 composite_aggregation_source,
471 }
472 }
473
474 pub fn is_standard(&self) -> bool {
476 match &self {
477 Self::Standard { .. } => true,
478 Self::Composite { .. } => false,
479 }
480 }
481
482 pub fn is_composite(&self) -> bool {
484 match &self {
485 Self::Standard { .. } => false,
486 Self::Composite { .. } => true,
487 }
488 }
489
490 pub fn standard(&self) -> Self {
492 match &self {
493 &&b @ Self::Standard { .. } => b,
494 Self::Composite {
495 instrument_id,
496 spec,
497 aggregation_source,
498 ..
499 } => Self::new(*instrument_id, *spec, *aggregation_source),
500 }
501 }
502
503 pub fn composite(&self) -> Self {
505 match &self {
506 &&b @ Self::Standard { .. } => b, Self::Composite {
508 instrument_id,
509 spec,
510 aggregation_source: _,
511
512 composite_step,
513 composite_aggregation,
514 composite_aggregation_source,
515 } => Self::new(
516 *instrument_id,
517 BarSpecification::new(*composite_step, *composite_aggregation, spec.price_type),
518 *composite_aggregation_source,
519 ),
520 }
521 }
522
523 pub fn instrument_id(&self) -> InstrumentId {
525 match &self {
526 Self::Standard { instrument_id, .. } | Self::Composite { instrument_id, .. } => {
527 *instrument_id
528 }
529 }
530 }
531
532 pub fn spec(&self) -> BarSpecification {
534 match &self {
535 Self::Standard { spec, .. } | Self::Composite { spec, .. } => *spec,
536 }
537 }
538
539 pub fn aggregation_source(&self) -> AggregationSource {
541 match &self {
542 Self::Standard {
543 aggregation_source, ..
544 }
545 | Self::Composite {
546 aggregation_source, ..
547 } => *aggregation_source,
548 }
549 }
550}
551
552#[derive(thiserror::Error, Debug)]
553#[error("Error parsing `BarType` from '{input}', invalid token: '{token}' at position {position}")]
554pub struct BarTypeParseError {
555 input: String,
556 token: String,
557 position: usize,
558}
559
560impl FromStr for BarType {
561 type Err = BarTypeParseError;
562
563 fn from_str(s: &str) -> Result<Self, Self::Err> {
564 let parts: Vec<&str> = s.split('@').collect();
565 let standard = parts[0];
566 let composite_str = parts.get(1);
567
568 let pieces: Vec<&str> = standard.rsplitn(5, '-').collect();
569 let rev_pieces: Vec<&str> = pieces.into_iter().rev().collect();
570 if rev_pieces.len() != 5 {
571 return Err(BarTypeParseError {
572 input: s.to_string(),
573 token: String::new(),
574 position: 0,
575 });
576 }
577
578 let instrument_id =
579 InstrumentId::from_str(rev_pieces[0]).map_err(|_| BarTypeParseError {
580 input: s.to_string(),
581 token: rev_pieces[0].to_string(),
582 position: 0,
583 })?;
584
585 let step = rev_pieces[1].parse().map_err(|_| BarTypeParseError {
586 input: s.to_string(),
587 token: rev_pieces[1].to_string(),
588 position: 1,
589 })?;
590 let aggregation =
591 BarAggregation::from_str(rev_pieces[2]).map_err(|_| BarTypeParseError {
592 input: s.to_string(),
593 token: rev_pieces[2].to_string(),
594 position: 2,
595 })?;
596 let price_type = PriceType::from_str(rev_pieces[3]).map_err(|_| BarTypeParseError {
597 input: s.to_string(),
598 token: rev_pieces[3].to_string(),
599 position: 3,
600 })?;
601 let aggregation_source =
602 AggregationSource::from_str(rev_pieces[4]).map_err(|_| BarTypeParseError {
603 input: s.to_string(),
604 token: rev_pieces[4].to_string(),
605 position: 4,
606 })?;
607
608 if let Some(composite_str) = composite_str {
609 let composite_pieces: Vec<&str> = composite_str.rsplitn(3, '-').collect();
610 let rev_composite_pieces: Vec<&str> = composite_pieces.into_iter().rev().collect();
611 if rev_composite_pieces.len() != 3 {
612 return Err(BarTypeParseError {
613 input: s.to_string(),
614 token: String::new(),
615 position: 5,
616 });
617 }
618
619 let composite_step =
620 rev_composite_pieces[0]
621 .parse()
622 .map_err(|_| BarTypeParseError {
623 input: s.to_string(),
624 token: rev_composite_pieces[0].to_string(),
625 position: 5,
626 })?;
627 let composite_aggregation =
628 BarAggregation::from_str(rev_composite_pieces[1]).map_err(|_| {
629 BarTypeParseError {
630 input: s.to_string(),
631 token: rev_composite_pieces[1].to_string(),
632 position: 6,
633 }
634 })?;
635 let composite_aggregation_source = AggregationSource::from_str(rev_composite_pieces[2])
636 .map_err(|_| BarTypeParseError {
637 input: s.to_string(),
638 token: rev_composite_pieces[2].to_string(),
639 position: 7,
640 })?;
641
642 Ok(Self::new_composite(
643 instrument_id,
644 BarSpecification::new(step, aggregation, price_type),
645 aggregation_source,
646 composite_step,
647 composite_aggregation,
648 composite_aggregation_source,
649 ))
650 } else {
651 Ok(Self::Standard {
652 instrument_id,
653 spec: BarSpecification::new(step, aggregation, price_type),
654 aggregation_source,
655 })
656 }
657 }
658}
659
660impl From<&str> for BarType {
661 fn from(value: &str) -> Self {
662 Self::from_str(value).expect(FAILED)
663 }
664}
665
666impl Display for BarType {
667 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
668 match &self {
669 Self::Standard {
670 instrument_id,
671 spec,
672 aggregation_source,
673 } => {
674 write!(f, "{instrument_id}-{spec}-{aggregation_source}")
675 }
676 Self::Composite {
677 instrument_id,
678 spec,
679 aggregation_source,
680
681 composite_step,
682 composite_aggregation,
683 composite_aggregation_source,
684 } => {
685 write!(
686 f,
687 "{}-{}-{}@{}-{}-{}",
688 instrument_id,
689 spec,
690 aggregation_source,
691 *composite_step,
692 *composite_aggregation,
693 *composite_aggregation_source
694 )
695 }
696 }
697 }
698}
699
700impl Serialize for BarType {
701 fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
702 where
703 S: Serializer,
704 {
705 serializer.serialize_str(&self.to_string())
706 }
707}
708
709impl<'de> Deserialize<'de> for BarType {
710 fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
711 where
712 D: Deserializer<'de>,
713 {
714 let s: String = Deserialize::deserialize(deserializer)?;
715 Self::from_str(&s).map_err(serde::de::Error::custom)
716 }
717}
718
719#[repr(C)]
721#[derive(Clone, Copy, Hash, PartialEq, Eq, Debug, Serialize, Deserialize)]
722#[serde(tag = "type")]
723#[cfg_attr(
724 feature = "python",
725 pyo3::pyclass(module = "nautilus_trader.core.nautilus_pyo3.model")
726)]
727pub struct Bar {
728 pub bar_type: BarType,
730 pub open: Price,
732 pub high: Price,
734 pub low: Price,
736 pub close: Price,
738 pub volume: Quantity,
740 pub ts_event: UnixNanos,
742 pub ts_init: UnixNanos,
744}
745
746impl Bar {
747 #[allow(clippy::too_many_arguments)]
760 pub fn new_checked(
761 bar_type: BarType,
762 open: Price,
763 high: Price,
764 low: Price,
765 close: Price,
766 volume: Quantity,
767 ts_event: UnixNanos,
768 ts_init: UnixNanos,
769 ) -> anyhow::Result<Self> {
770 check_predicate_true(high >= open, "high >= open")?;
771 check_predicate_true(high >= low, "high >= low")?;
772 check_predicate_true(high >= close, "high >= close")?;
773 check_predicate_true(low <= close, "low <= close")?;
774 check_predicate_true(low <= open, "low <= open")?;
775
776 Ok(Self {
777 bar_type,
778 open,
779 high,
780 low,
781 close,
782 volume,
783 ts_event,
784 ts_init,
785 })
786 }
787
788 #[allow(clippy::too_many_arguments)]
797 pub fn new(
798 bar_type: BarType,
799 open: Price,
800 high: Price,
801 low: Price,
802 close: Price,
803 volume: Quantity,
804 ts_event: UnixNanos,
805 ts_init: UnixNanos,
806 ) -> Self {
807 Self::new_checked(bar_type, open, high, low, close, volume, ts_event, ts_init)
808 .expect(FAILED)
809 }
810
811 pub fn instrument_id(&self) -> InstrumentId {
812 self.bar_type.instrument_id()
813 }
814
815 #[must_use]
817 pub fn get_metadata(
818 bar_type: &BarType,
819 price_precision: u8,
820 size_precision: u8,
821 ) -> HashMap<String, String> {
822 let mut metadata = HashMap::new();
823 let instrument_id = bar_type.instrument_id();
824 metadata.insert("bar_type".to_string(), bar_type.to_string());
825 metadata.insert("instrument_id".to_string(), instrument_id.to_string());
826 metadata.insert("price_precision".to_string(), price_precision.to_string());
827 metadata.insert("size_precision".to_string(), size_precision.to_string());
828 metadata
829 }
830
831 #[must_use]
833 pub fn get_fields() -> IndexMap<String, String> {
834 let mut metadata = IndexMap::new();
835 metadata.insert("open".to_string(), FIXED_SIZE_BINARY.to_string());
836 metadata.insert("high".to_string(), FIXED_SIZE_BINARY.to_string());
837 metadata.insert("low".to_string(), FIXED_SIZE_BINARY.to_string());
838 metadata.insert("close".to_string(), FIXED_SIZE_BINARY.to_string());
839 metadata.insert("volume".to_string(), FIXED_SIZE_BINARY.to_string());
840 metadata.insert("ts_event".to_string(), "UInt64".to_string());
841 metadata.insert("ts_init".to_string(), "UInt64".to_string());
842 metadata
843 }
844}
845
846impl Display for Bar {
847 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
848 write!(
849 f,
850 "{},{},{},{},{},{},{}",
851 self.bar_type, self.open, self.high, self.low, self.close, self.volume, self.ts_event
852 )
853 }
854}
855
856impl Serializable for Bar {}
857
858impl HasTsInit for Bar {
859 fn ts_init(&self) -> UnixNanos {
860 self.ts_init
861 }
862}
863
864#[cfg(test)]
868mod tests {
869 use std::str::FromStr;
870
871 use chrono::TimeZone;
872 use rstest::rstest;
873
874 use super::*;
875 use crate::identifiers::{Symbol, Venue};
876
877 #[rstest]
878 fn test_bar_specification_new_invalid() {
879 let result = BarSpecification::new_checked(0, BarAggregation::Tick, PriceType::Last);
880 assert!(result.is_err());
881 }
882
883 #[rstest]
884 #[should_panic(expected = "Invalid step: 0 (must be non-zero)")]
885 fn test_bar_specification_new_checked_with_invalid_step_panics() {
886 let aggregation = BarAggregation::Tick;
887 let price_type = PriceType::Last;
888
889 let _ = BarSpecification::new(0, aggregation, price_type);
890 }
891
892 #[rstest]
893 #[case(BarAggregation::Millisecond, 1, TimeDelta::milliseconds(1))]
894 #[case(BarAggregation::Millisecond, 10, TimeDelta::milliseconds(10))]
895 #[case(BarAggregation::Second, 1, TimeDelta::seconds(1))]
896 #[case(BarAggregation::Second, 15, TimeDelta::seconds(15))]
897 #[case(BarAggregation::Minute, 1, TimeDelta::minutes(1))]
898 #[case(BarAggregation::Minute, 60, TimeDelta::minutes(60))]
899 #[case(BarAggregation::Hour, 1, TimeDelta::hours(1))]
900 #[case(BarAggregation::Hour, 4, TimeDelta::hours(4))]
901 #[case(BarAggregation::Day, 1, TimeDelta::days(1))]
902 #[case(BarAggregation::Day, 2, TimeDelta::days(2))]
903 #[should_panic(expected = "Aggregation not time based")]
904 #[case(BarAggregation::Tick, 1, TimeDelta::zero())]
905 fn test_get_bar_interval(
906 #[case] aggregation: BarAggregation,
907 #[case] step: usize,
908 #[case] expected: TimeDelta,
909 ) {
910 let bar_type = BarType::Standard {
911 instrument_id: InstrumentId::from("BTCUSDT-PERP.BINANCE"),
912 spec: BarSpecification::new(step, aggregation, PriceType::Last),
913 aggregation_source: AggregationSource::Internal,
914 };
915
916 let interval = get_bar_interval(&bar_type);
917 assert_eq!(interval, expected);
918 }
919
920 #[rstest]
921 #[case(BarAggregation::Millisecond, 1, UnixNanos::from(1_000_000))]
922 #[case(BarAggregation::Millisecond, 10, UnixNanos::from(10_000_000))]
923 #[case(BarAggregation::Second, 1, UnixNanos::from(1_000_000_000))]
924 #[case(BarAggregation::Second, 10, UnixNanos::from(10_000_000_000))]
925 #[case(BarAggregation::Minute, 1, UnixNanos::from(60_000_000_000))]
926 #[case(BarAggregation::Minute, 60, UnixNanos::from(3_600_000_000_000))]
927 #[case(BarAggregation::Hour, 1, UnixNanos::from(3_600_000_000_000))]
928 #[case(BarAggregation::Hour, 4, UnixNanos::from(14_400_000_000_000))]
929 #[case(BarAggregation::Day, 1, UnixNanos::from(86_400_000_000_000))]
930 #[case(BarAggregation::Day, 2, UnixNanos::from(172_800_000_000_000))]
931 #[should_panic(expected = "Aggregation not time based")]
932 #[case(BarAggregation::Tick, 1, UnixNanos::from(0))]
933 fn test_get_bar_interval_ns(
934 #[case] aggregation: BarAggregation,
935 #[case] step: usize,
936 #[case] expected: UnixNanos,
937 ) {
938 let bar_type = BarType::Standard {
939 instrument_id: InstrumentId::from("BTCUSDT-PERP.BINANCE"),
940 spec: BarSpecification::new(step, aggregation, PriceType::Last),
941 aggregation_source: AggregationSource::Internal,
942 };
943
944 let interval_ns = get_bar_interval_ns(&bar_type);
945 assert_eq!(interval_ns, expected);
946 }
947
948 #[rstest]
949 #[case::millisecond(
950 Utc.timestamp_opt(1658349296, 123_000_000).unwrap(), BarAggregation::Millisecond,
952 1,
953 Utc.timestamp_opt(1658349296, 123_000_000).unwrap(), )]
955 #[rstest]
956 #[case::millisecond(
957 Utc.timestamp_opt(1658349296, 123_000_000).unwrap(), BarAggregation::Millisecond,
959 10,
960 Utc.timestamp_opt(1658349296, 120_000_000).unwrap(), )]
962 #[case::second(
963 Utc.with_ymd_and_hms(2024, 7, 21, 12, 34, 56).unwrap(),
964 BarAggregation::Millisecond,
965 1000,
966 Utc.with_ymd_and_hms(2024, 7, 21, 12, 34, 56).unwrap()
967 )]
968 #[case::second(
969 Utc.with_ymd_and_hms(2024, 7, 21, 12, 34, 56).unwrap(),
970 BarAggregation::Second,
971 1,
972 Utc.with_ymd_and_hms(2024, 7, 21, 12, 34, 56).unwrap()
973 )]
974 #[case::second(
975 Utc.with_ymd_and_hms(2024, 7, 21, 12, 34, 56).unwrap(),
976 BarAggregation::Second,
977 5,
978 Utc.with_ymd_and_hms(2024, 7, 21, 12, 34, 55).unwrap()
979 )]
980 #[case::second(
981 Utc.with_ymd_and_hms(2024, 7, 21, 12, 34, 56).unwrap(),
982 BarAggregation::Second,
983 60,
984 Utc.with_ymd_and_hms(2024, 7, 21, 12, 34, 0).unwrap()
985 )]
986 #[case::minute(
987 Utc.with_ymd_and_hms(2024, 7, 21, 12, 34, 56).unwrap(),
988 BarAggregation::Minute,
989 1,
990 Utc.with_ymd_and_hms(2024, 7, 21, 12, 34, 0).unwrap()
991 )]
992 #[case::minute(
993 Utc.with_ymd_and_hms(2024, 7, 21, 12, 34, 56).unwrap(),
994 BarAggregation::Minute,
995 5,
996 Utc.with_ymd_and_hms(2024, 7, 21, 12, 30, 0).unwrap()
997 )]
998 #[case::minute(
999 Utc.with_ymd_and_hms(2024, 7, 21, 12, 34, 56).unwrap(),
1000 BarAggregation::Minute,
1001 60,
1002 Utc.with_ymd_and_hms(2024, 7, 21, 12, 0, 0).unwrap()
1003 )]
1004 #[case::hour(
1005 Utc.with_ymd_and_hms(2024, 7, 21, 12, 34, 56).unwrap(),
1006 BarAggregation::Hour,
1007 1,
1008 Utc.with_ymd_and_hms(2024, 7, 21, 12, 0, 0).unwrap()
1009 )]
1010 #[case::hour(
1011 Utc.with_ymd_and_hms(2024, 7, 21, 12, 34, 56).unwrap(),
1012 BarAggregation::Hour,
1013 2,
1014 Utc.with_ymd_and_hms(2024, 7, 21, 12, 0, 0).unwrap()
1015 )]
1016 #[case::day(
1017 Utc.with_ymd_and_hms(2024, 7, 21, 12, 34, 56).unwrap(),
1018 BarAggregation::Day,
1019 1,
1020 Utc.with_ymd_and_hms(2024, 7, 21, 0, 0, 0).unwrap()
1021 )]
1022 fn test_get_time_bar_start(
1023 #[case] now: DateTime<Utc>,
1024 #[case] aggregation: BarAggregation,
1025 #[case] step: usize,
1026 #[case] expected: DateTime<Utc>,
1027 ) {
1028 let bar_type = BarType::Standard {
1029 instrument_id: InstrumentId::from("BTCUSDT-PERP.BINANCE"),
1030 spec: BarSpecification::new(step, aggregation, PriceType::Last),
1031 aggregation_source: AggregationSource::Internal,
1032 };
1033
1034 let start_time = get_time_bar_start(now, &bar_type, None);
1035 assert_eq!(start_time, expected);
1036 }
1037
1038 #[rstest]
1039 fn test_bar_spec_string_reprs() {
1040 let bar_spec = BarSpecification::new(1, BarAggregation::Minute, PriceType::Bid);
1041 assert_eq!(bar_spec.to_string(), "1-MINUTE-BID");
1042 assert_eq!(format!("{bar_spec}"), "1-MINUTE-BID");
1043 }
1044
1045 #[rstest]
1046 fn test_bar_type_parse_valid() {
1047 let input = "BTCUSDT-PERP.BINANCE-1-MINUTE-LAST-EXTERNAL";
1048 let bar_type = BarType::from(input);
1049
1050 assert_eq!(
1051 bar_type.instrument_id(),
1052 InstrumentId::from("BTCUSDT-PERP.BINANCE")
1053 );
1054 assert_eq!(
1055 bar_type.spec(),
1056 BarSpecification::new(1, BarAggregation::Minute, PriceType::Last)
1057 );
1058 assert_eq!(bar_type.aggregation_source(), AggregationSource::External);
1059 assert_eq!(bar_type, BarType::from(input));
1060 }
1061
1062 #[rstest]
1063 fn test_bar_type_from_str_with_utf8_symbol() {
1064 let non_ascii_instrument = "TËST-PÉRP.BINANCE";
1065 let non_ascii_bar_type = "TËST-PÉRP.BINANCE-1-MINUTE-LAST-EXTERNAL";
1066
1067 let bar_type = BarType::from_str(non_ascii_bar_type).unwrap();
1068
1069 assert_eq!(
1070 bar_type.instrument_id(),
1071 InstrumentId::from_str(non_ascii_instrument).unwrap()
1072 );
1073 assert_eq!(
1074 bar_type.spec(),
1075 BarSpecification::new(1, BarAggregation::Minute, PriceType::Last)
1076 );
1077 assert_eq!(bar_type.aggregation_source(), AggregationSource::External);
1078 assert_eq!(bar_type.to_string(), non_ascii_bar_type);
1079 }
1080
1081 #[rstest]
1082 fn test_bar_type_composite_parse_valid() {
1083 let input = "BTCUSDT-PERP.BINANCE-2-MINUTE-LAST-INTERNAL@1-MINUTE-EXTERNAL";
1084 let bar_type = BarType::from(input);
1085 let standard = bar_type.standard();
1086
1087 assert_eq!(
1088 bar_type.instrument_id(),
1089 InstrumentId::from("BTCUSDT-PERP.BINANCE")
1090 );
1091 assert_eq!(
1092 bar_type.spec(),
1093 BarSpecification::new(2, BarAggregation::Minute, PriceType::Last,)
1094 );
1095 assert_eq!(bar_type.aggregation_source(), AggregationSource::Internal);
1096 assert_eq!(bar_type, BarType::from(input));
1097 assert!(bar_type.is_composite());
1098
1099 assert_eq!(
1100 standard.instrument_id(),
1101 InstrumentId::from("BTCUSDT-PERP.BINANCE")
1102 );
1103 assert_eq!(
1104 standard.spec(),
1105 BarSpecification::new(2, BarAggregation::Minute, PriceType::Last,)
1106 );
1107 assert_eq!(standard.aggregation_source(), AggregationSource::Internal);
1108 assert!(standard.is_standard());
1109
1110 let composite = bar_type.composite();
1111 let composite_input = "BTCUSDT-PERP.BINANCE-1-MINUTE-LAST-EXTERNAL";
1112
1113 assert_eq!(
1114 composite.instrument_id(),
1115 InstrumentId::from("BTCUSDT-PERP.BINANCE")
1116 );
1117 assert_eq!(
1118 composite.spec(),
1119 BarSpecification::new(1, BarAggregation::Minute, PriceType::Last,)
1120 );
1121 assert_eq!(composite.aggregation_source(), AggregationSource::External);
1122 assert_eq!(composite, BarType::from(composite_input));
1123 assert!(composite.is_standard());
1124 }
1125
1126 #[rstest]
1127 fn test_bar_type_parse_invalid_token_pos_0() {
1128 let input = "BTCUSDT-PERP-1-MINUTE-LAST-INTERNAL";
1129 let result = BarType::from_str(input);
1130
1131 assert_eq!(
1132 result.unwrap_err().to_string(),
1133 format!(
1134 "Error parsing `BarType` from '{input}', invalid token: 'BTCUSDT-PERP' at position 0"
1135 )
1136 );
1137 }
1138
1139 #[rstest]
1140 fn test_bar_type_parse_invalid_token_pos_1() {
1141 let input = "BTCUSDT-PERP.BINANCE-INVALID-MINUTE-LAST-INTERNAL";
1142 let result = BarType::from_str(input);
1143
1144 assert_eq!(
1145 result.unwrap_err().to_string(),
1146 format!(
1147 "Error parsing `BarType` from '{input}', invalid token: 'INVALID' at position 1"
1148 )
1149 );
1150 }
1151
1152 #[rstest]
1153 fn test_bar_type_parse_invalid_token_pos_2() {
1154 let input = "BTCUSDT-PERP.BINANCE-1-INVALID-LAST-INTERNAL";
1155 let result = BarType::from_str(input);
1156
1157 assert_eq!(
1158 result.unwrap_err().to_string(),
1159 format!(
1160 "Error parsing `BarType` from '{input}', invalid token: 'INVALID' at position 2"
1161 )
1162 );
1163 }
1164
1165 #[rstest]
1166 fn test_bar_type_parse_invalid_token_pos_3() {
1167 let input = "BTCUSDT-PERP.BINANCE-1-MINUTE-INVALID-INTERNAL";
1168 let result = BarType::from_str(input);
1169
1170 assert_eq!(
1171 result.unwrap_err().to_string(),
1172 format!(
1173 "Error parsing `BarType` from '{input}', invalid token: 'INVALID' at position 3"
1174 )
1175 );
1176 }
1177
1178 #[rstest]
1179 fn test_bar_type_parse_invalid_token_pos_4() {
1180 let input = "BTCUSDT-PERP.BINANCE-1-MINUTE-BID-INVALID";
1181 let result = BarType::from_str(input);
1182
1183 assert!(result.is_err());
1184 assert_eq!(
1185 result.unwrap_err().to_string(),
1186 format!(
1187 "Error parsing `BarType` from '{input}', invalid token: 'INVALID' at position 4"
1188 )
1189 );
1190 }
1191
1192 #[rstest]
1193 fn test_bar_type_parse_invalid_token_pos_5() {
1194 let input = "BTCUSDT-PERP.BINANCE-2-MINUTE-LAST-INTERNAL@INVALID-MINUTE-EXTERNAL";
1195 let result = BarType::from_str(input);
1196
1197 assert!(result.is_err());
1198 assert_eq!(
1199 result.unwrap_err().to_string(),
1200 format!(
1201 "Error parsing `BarType` from '{input}', invalid token: 'INVALID' at position 5"
1202 )
1203 );
1204 }
1205
1206 #[rstest]
1207 fn test_bar_type_parse_invalid_token_pos_6() {
1208 let input = "BTCUSDT-PERP.BINANCE-2-MINUTE-LAST-INTERNAL@1-INVALID-EXTERNAL";
1209 let result = BarType::from_str(input);
1210
1211 assert!(result.is_err());
1212 assert_eq!(
1213 result.unwrap_err().to_string(),
1214 format!(
1215 "Error parsing `BarType` from '{input}', invalid token: 'INVALID' at position 6"
1216 )
1217 );
1218 }
1219
1220 #[rstest]
1221 fn test_bar_type_parse_invalid_token_pos_7() {
1222 let input = "BTCUSDT-PERP.BINANCE-2-MINUTE-LAST-INTERNAL@1-MINUTE-INVALID";
1223 let result = BarType::from_str(input);
1224
1225 assert!(result.is_err());
1226 assert_eq!(
1227 result.unwrap_err().to_string(),
1228 format!(
1229 "Error parsing `BarType` from '{input}', invalid token: 'INVALID' at position 7"
1230 )
1231 );
1232 }
1233
1234 #[rstest]
1235 fn test_bar_type_equality() {
1236 let instrument_id1 = InstrumentId {
1237 symbol: Symbol::new("AUD/USD"),
1238 venue: Venue::new("SIM"),
1239 };
1240 let instrument_id2 = InstrumentId {
1241 symbol: Symbol::new("GBP/USD"),
1242 venue: Venue::new("SIM"),
1243 };
1244 let bar_spec = BarSpecification::new(1, BarAggregation::Minute, PriceType::Bid);
1245 let bar_type1 = BarType::Standard {
1246 instrument_id: instrument_id1,
1247 spec: bar_spec,
1248 aggregation_source: AggregationSource::External,
1249 };
1250 let bar_type2 = BarType::Standard {
1251 instrument_id: instrument_id1,
1252 spec: bar_spec,
1253 aggregation_source: AggregationSource::External,
1254 };
1255 let bar_type3 = BarType::Standard {
1256 instrument_id: instrument_id2,
1257 spec: bar_spec,
1258 aggregation_source: AggregationSource::External,
1259 };
1260 assert_eq!(bar_type1, bar_type1);
1261 assert_eq!(bar_type1, bar_type2);
1262 assert_ne!(bar_type1, bar_type3);
1263 }
1264
1265 #[rstest]
1266 fn test_bar_type_comparison() {
1267 let instrument_id1 = InstrumentId {
1268 symbol: Symbol::new("AUD/USD"),
1269 venue: Venue::new("SIM"),
1270 };
1271
1272 let instrument_id2 = InstrumentId {
1273 symbol: Symbol::new("GBP/USD"),
1274 venue: Venue::new("SIM"),
1275 };
1276 let bar_spec = BarSpecification::new(1, BarAggregation::Minute, PriceType::Bid);
1277 let bar_spec2 = BarSpecification::new(2, BarAggregation::Minute, PriceType::Bid);
1278 let bar_type1 = BarType::Standard {
1279 instrument_id: instrument_id1,
1280 spec: bar_spec,
1281 aggregation_source: AggregationSource::External,
1282 };
1283 let bar_type2 = BarType::Standard {
1284 instrument_id: instrument_id1,
1285 spec: bar_spec,
1286 aggregation_source: AggregationSource::External,
1287 };
1288 let bar_type3 = BarType::Standard {
1289 instrument_id: instrument_id2,
1290 spec: bar_spec,
1291 aggregation_source: AggregationSource::External,
1292 };
1293 let bar_type4 = BarType::Composite {
1294 instrument_id: instrument_id2,
1295 spec: bar_spec2,
1296 aggregation_source: AggregationSource::Internal,
1297
1298 composite_step: 1,
1299 composite_aggregation: BarAggregation::Minute,
1300 composite_aggregation_source: AggregationSource::External,
1301 };
1302
1303 assert!(bar_type1 <= bar_type2);
1304 assert!(bar_type1 < bar_type3);
1305 assert!(bar_type3 > bar_type1);
1306 assert!(bar_type3 >= bar_type1);
1307 assert!(bar_type4 >= bar_type1);
1308 }
1309
1310 #[rstest]
1311 fn test_bar_new() {
1312 let bar_type = BarType::from("AAPL.XNAS-1-MINUTE-LAST-INTERNAL");
1313 let open = Price::from("100.0");
1314 let high = Price::from("105.0");
1315 let low = Price::from("95.0");
1316 let close = Price::from("102.0");
1317 let volume = Quantity::from("1000");
1318 let ts_event = UnixNanos::from(1_000_000);
1319 let ts_init = UnixNanos::from(2_000_000);
1320
1321 let bar = Bar::new(bar_type, open, high, low, close, volume, ts_event, ts_init);
1322
1323 assert_eq!(bar.bar_type, bar_type);
1324 assert_eq!(bar.open, open);
1325 assert_eq!(bar.high, high);
1326 assert_eq!(bar.low, low);
1327 assert_eq!(bar.close, close);
1328 assert_eq!(bar.volume, volume);
1329 assert_eq!(bar.ts_event, ts_event);
1330 assert_eq!(bar.ts_init, ts_init);
1331 }
1332
1333 #[rstest]
1334 #[case("100.0", "90.0", "95.0", "92.0")] #[case("100.0", "105.0", "110.0", "102.0")] #[case("100.0", "105.0", "95.0", "110.0")] #[case("100.0", "105.0", "95.0", "90.0")] #[case("100.0", "110.0", "105.0", "108.0")] #[case("100.0", "90.0", "110.0", "120.0")] fn test_bar_new_checked_conditions(
1341 #[case] open: &str,
1342 #[case] high: &str,
1343 #[case] low: &str,
1344 #[case] close: &str,
1345 ) {
1346 let bar_type = BarType::from("AAPL.XNAS-1-MINUTE-LAST-INTERNAL");
1347 let open = Price::from(open);
1348 let high = Price::from(high);
1349 let low = Price::from(low);
1350 let close = Price::from(close);
1351 let volume = Quantity::from("1000");
1352 let ts_event = UnixNanos::from(1_000_000);
1353 let ts_init = UnixNanos::from(2_000_000);
1354
1355 let result = Bar::new_checked(bar_type, open, high, low, close, volume, ts_event, ts_init);
1356
1357 assert!(result.is_err());
1358 }
1359
1360 #[rstest]
1361 fn test_bar_equality() {
1362 let instrument_id = InstrumentId {
1363 symbol: Symbol::new("AUDUSD"),
1364 venue: Venue::new("SIM"),
1365 };
1366 let bar_spec = BarSpecification::new(1, BarAggregation::Minute, PriceType::Bid);
1367 let bar_type = BarType::Standard {
1368 instrument_id,
1369 spec: bar_spec,
1370 aggregation_source: AggregationSource::External,
1371 };
1372 let bar1 = Bar {
1373 bar_type,
1374 open: Price::from("1.00001"),
1375 high: Price::from("1.00004"),
1376 low: Price::from("1.00002"),
1377 close: Price::from("1.00003"),
1378 volume: Quantity::from("100000"),
1379 ts_event: UnixNanos::default(),
1380 ts_init: UnixNanos::from(1),
1381 };
1382
1383 let bar2 = Bar {
1384 bar_type,
1385 open: Price::from("1.00000"),
1386 high: Price::from("1.00004"),
1387 low: Price::from("1.00002"),
1388 close: Price::from("1.00003"),
1389 volume: Quantity::from("100000"),
1390 ts_event: UnixNanos::default(),
1391 ts_init: UnixNanos::from(1),
1392 };
1393 assert_eq!(bar1, bar1);
1394 assert_ne!(bar1, bar2);
1395 }
1396
1397 #[rstest]
1398 fn test_json_serialization() {
1399 let bar = Bar::default();
1400 let serialized = bar.to_json_bytes().unwrap();
1401 let deserialized = Bar::from_json_bytes(serialized.as_ref()).unwrap();
1402 assert_eq!(deserialized, bar);
1403 }
1404
1405 #[rstest]
1406 fn test_msgpack_serialization() {
1407 let bar = Bar::default();
1408 let serialized = bar.to_msgpack_bytes().unwrap();
1409 let deserialized = Bar::from_msgpack_bytes(serialized.as_ref()).unwrap();
1410 assert_eq!(deserialized, bar);
1411 }
1412}