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 BarType::Standard { .. } => true,
478 BarType::Composite { .. } => false,
479 }
480 }
481
482 pub fn is_composite(&self) -> bool {
484 match &self {
485 BarType::Standard { .. } => false,
486 BarType::Composite { .. } => true,
487 }
488 }
489
490 pub fn standard(&self) -> Self {
492 match &self {
493 &&b @ BarType::Standard { .. } => b,
494 BarType::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 @ BarType::Standard { .. } => b, BarType::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 BarType::Standard { instrument_id, .. } | BarType::Composite { instrument_id, .. } => {
527 *instrument_id
528 }
529 }
530 }
531
532 pub fn spec(&self) -> BarSpecification {
534 match &self {
535 BarType::Standard { spec, .. } | BarType::Composite { spec, .. } => *spec,
536 }
537 }
538
539 pub fn aggregation_source(&self) -> AggregationSource {
541 match &self {
542 BarType::Standard {
543 aggregation_source, ..
544 }
545 | BarType::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 BarType::Standard {
670 instrument_id,
671 spec,
672 aggregation_source,
673 } => {
674 write!(f, "{instrument_id}-{spec}-{aggregation_source}")
675 }
676 BarType::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 chrono::TimeZone;
870 use rstest::rstest;
871
872 use super::*;
873 use crate::identifiers::{Symbol, Venue};
874
875 #[rstest]
876 fn test_bar_specification_new_invalid() {
877 let result = BarSpecification::new_checked(0, BarAggregation::Tick, PriceType::Last);
878 assert!(result.is_err());
879 }
880
881 #[rstest]
882 #[should_panic(expected = "Invalid step: 0 (must be non-zero)")]
883 fn test_bar_specification_new_checked_with_invalid_step_panics() {
884 let aggregation = BarAggregation::Tick;
885 let price_type = PriceType::Last;
886
887 let _ = BarSpecification::new(0, aggregation, price_type);
888 }
889
890 #[rstest]
891 #[case(BarAggregation::Millisecond, 1, TimeDelta::milliseconds(1))]
892 #[case(BarAggregation::Millisecond, 10, TimeDelta::milliseconds(10))]
893 #[case(BarAggregation::Second, 1, TimeDelta::seconds(1))]
894 #[case(BarAggregation::Second, 15, TimeDelta::seconds(15))]
895 #[case(BarAggregation::Minute, 1, TimeDelta::minutes(1))]
896 #[case(BarAggregation::Minute, 60, TimeDelta::minutes(60))]
897 #[case(BarAggregation::Hour, 1, TimeDelta::hours(1))]
898 #[case(BarAggregation::Hour, 4, TimeDelta::hours(4))]
899 #[case(BarAggregation::Day, 1, TimeDelta::days(1))]
900 #[case(BarAggregation::Day, 2, TimeDelta::days(2))]
901 #[should_panic(expected = "Aggregation not time based")]
902 #[case(BarAggregation::Tick, 1, TimeDelta::zero())]
903 fn test_get_bar_interval(
904 #[case] aggregation: BarAggregation,
905 #[case] step: usize,
906 #[case] expected: TimeDelta,
907 ) {
908 let bar_type = BarType::Standard {
909 instrument_id: InstrumentId::from("BTCUSDT-PERP.BINANCE"),
910 spec: BarSpecification::new(step, aggregation, PriceType::Last),
911 aggregation_source: AggregationSource::Internal,
912 };
913
914 let interval = get_bar_interval(&bar_type);
915 assert_eq!(interval, expected);
916 }
917
918 #[rstest]
919 #[case(BarAggregation::Millisecond, 1, UnixNanos::from(1_000_000))]
920 #[case(BarAggregation::Millisecond, 10, UnixNanos::from(10_000_000))]
921 #[case(BarAggregation::Second, 1, UnixNanos::from(1_000_000_000))]
922 #[case(BarAggregation::Second, 10, UnixNanos::from(10_000_000_000))]
923 #[case(BarAggregation::Minute, 1, UnixNanos::from(60_000_000_000))]
924 #[case(BarAggregation::Minute, 60, UnixNanos::from(3_600_000_000_000))]
925 #[case(BarAggregation::Hour, 1, UnixNanos::from(3_600_000_000_000))]
926 #[case(BarAggregation::Hour, 4, UnixNanos::from(14_400_000_000_000))]
927 #[case(BarAggregation::Day, 1, UnixNanos::from(86_400_000_000_000))]
928 #[case(BarAggregation::Day, 2, UnixNanos::from(172_800_000_000_000))]
929 #[should_panic(expected = "Aggregation not time based")]
930 #[case(BarAggregation::Tick, 1, UnixNanos::from(0))]
931 fn test_get_bar_interval_ns(
932 #[case] aggregation: BarAggregation,
933 #[case] step: usize,
934 #[case] expected: UnixNanos,
935 ) {
936 let bar_type = BarType::Standard {
937 instrument_id: InstrumentId::from("BTCUSDT-PERP.BINANCE"),
938 spec: BarSpecification::new(step, aggregation, PriceType::Last),
939 aggregation_source: AggregationSource::Internal,
940 };
941
942 let interval_ns = get_bar_interval_ns(&bar_type);
943 assert_eq!(interval_ns, expected);
944 }
945
946 #[rstest]
947 #[case::millisecond(
948 Utc.timestamp_opt(1658349296, 123_000_000).unwrap(), BarAggregation::Millisecond,
950 1,
951 Utc.timestamp_opt(1658349296, 123_000_000).unwrap(), )]
953 #[rstest]
954 #[case::millisecond(
955 Utc.timestamp_opt(1658349296, 123_000_000).unwrap(), BarAggregation::Millisecond,
957 10,
958 Utc.timestamp_opt(1658349296, 120_000_000).unwrap(), )]
960 #[case::second(
961 Utc.with_ymd_and_hms(2024, 7, 21, 12, 34, 56).unwrap(),
962 BarAggregation::Millisecond,
963 1000,
964 Utc.with_ymd_and_hms(2024, 7, 21, 12, 34, 56).unwrap()
965 )]
966 #[case::second(
967 Utc.with_ymd_and_hms(2024, 7, 21, 12, 34, 56).unwrap(),
968 BarAggregation::Second,
969 1,
970 Utc.with_ymd_and_hms(2024, 7, 21, 12, 34, 56).unwrap()
971 )]
972 #[case::second(
973 Utc.with_ymd_and_hms(2024, 7, 21, 12, 34, 56).unwrap(),
974 BarAggregation::Second,
975 5,
976 Utc.with_ymd_and_hms(2024, 7, 21, 12, 34, 55).unwrap()
977 )]
978 #[case::second(
979 Utc.with_ymd_and_hms(2024, 7, 21, 12, 34, 56).unwrap(),
980 BarAggregation::Second,
981 60,
982 Utc.with_ymd_and_hms(2024, 7, 21, 12, 34, 0).unwrap()
983 )]
984 #[case::minute(
985 Utc.with_ymd_and_hms(2024, 7, 21, 12, 34, 56).unwrap(),
986 BarAggregation::Minute,
987 1,
988 Utc.with_ymd_and_hms(2024, 7, 21, 12, 34, 0).unwrap()
989 )]
990 #[case::minute(
991 Utc.with_ymd_and_hms(2024, 7, 21, 12, 34, 56).unwrap(),
992 BarAggregation::Minute,
993 5,
994 Utc.with_ymd_and_hms(2024, 7, 21, 12, 30, 0).unwrap()
995 )]
996 #[case::minute(
997 Utc.with_ymd_and_hms(2024, 7, 21, 12, 34, 56).unwrap(),
998 BarAggregation::Minute,
999 60,
1000 Utc.with_ymd_and_hms(2024, 7, 21, 12, 0, 0).unwrap()
1001 )]
1002 #[case::hour(
1003 Utc.with_ymd_and_hms(2024, 7, 21, 12, 34, 56).unwrap(),
1004 BarAggregation::Hour,
1005 1,
1006 Utc.with_ymd_and_hms(2024, 7, 21, 12, 0, 0).unwrap()
1007 )]
1008 #[case::hour(
1009 Utc.with_ymd_and_hms(2024, 7, 21, 12, 34, 56).unwrap(),
1010 BarAggregation::Hour,
1011 2,
1012 Utc.with_ymd_and_hms(2024, 7, 21, 12, 0, 0).unwrap()
1013 )]
1014 #[case::day(
1015 Utc.with_ymd_and_hms(2024, 7, 21, 12, 34, 56).unwrap(),
1016 BarAggregation::Day,
1017 1,
1018 Utc.with_ymd_and_hms(2024, 7, 21, 0, 0, 0).unwrap()
1019 )]
1020 fn test_get_time_bar_start(
1021 #[case] now: DateTime<Utc>,
1022 #[case] aggregation: BarAggregation,
1023 #[case] step: usize,
1024 #[case] expected: DateTime<Utc>,
1025 ) {
1026 let bar_type = BarType::Standard {
1027 instrument_id: InstrumentId::from("BTCUSDT-PERP.BINANCE"),
1028 spec: BarSpecification::new(step, aggregation, PriceType::Last),
1029 aggregation_source: AggregationSource::Internal,
1030 };
1031
1032 let start_time = get_time_bar_start(now, &bar_type, None);
1033 assert_eq!(start_time, expected);
1034 }
1035
1036 #[rstest]
1037 fn test_bar_spec_string_reprs() {
1038 let bar_spec = BarSpecification::new(1, BarAggregation::Minute, PriceType::Bid);
1039 assert_eq!(bar_spec.to_string(), "1-MINUTE-BID");
1040 assert_eq!(format!("{bar_spec}"), "1-MINUTE-BID");
1041 }
1042
1043 #[rstest]
1044 fn test_bar_type_parse_valid() {
1045 let input = "BTCUSDT-PERP.BINANCE-1-MINUTE-LAST-EXTERNAL";
1046 let bar_type = BarType::from(input);
1047
1048 assert_eq!(
1049 bar_type.instrument_id(),
1050 InstrumentId::from("BTCUSDT-PERP.BINANCE")
1051 );
1052 assert_eq!(
1053 bar_type.spec(),
1054 BarSpecification::new(1, BarAggregation::Minute, PriceType::Last)
1055 );
1056 assert_eq!(bar_type.aggregation_source(), AggregationSource::External);
1057 assert_eq!(bar_type, BarType::from(input));
1058 }
1059
1060 #[rstest]
1061 fn test_bar_type_composite_parse_valid() {
1062 let input = "BTCUSDT-PERP.BINANCE-2-MINUTE-LAST-INTERNAL@1-MINUTE-EXTERNAL";
1063 let bar_type = BarType::from(input);
1064 let standard = bar_type.standard();
1065
1066 assert_eq!(
1067 bar_type.instrument_id(),
1068 InstrumentId::from("BTCUSDT-PERP.BINANCE")
1069 );
1070 assert_eq!(
1071 bar_type.spec(),
1072 BarSpecification::new(2, BarAggregation::Minute, PriceType::Last,)
1073 );
1074 assert_eq!(bar_type.aggregation_source(), AggregationSource::Internal);
1075 assert_eq!(bar_type, BarType::from(input));
1076 assert!(bar_type.is_composite());
1077
1078 assert_eq!(
1079 standard.instrument_id(),
1080 InstrumentId::from("BTCUSDT-PERP.BINANCE")
1081 );
1082 assert_eq!(
1083 standard.spec(),
1084 BarSpecification::new(2, BarAggregation::Minute, PriceType::Last,)
1085 );
1086 assert_eq!(standard.aggregation_source(), AggregationSource::Internal);
1087 assert!(standard.is_standard());
1088
1089 let composite = bar_type.composite();
1090 let composite_input = "BTCUSDT-PERP.BINANCE-1-MINUTE-LAST-EXTERNAL";
1091
1092 assert_eq!(
1093 composite.instrument_id(),
1094 InstrumentId::from("BTCUSDT-PERP.BINANCE")
1095 );
1096 assert_eq!(
1097 composite.spec(),
1098 BarSpecification::new(1, BarAggregation::Minute, PriceType::Last,)
1099 );
1100 assert_eq!(composite.aggregation_source(), AggregationSource::External);
1101 assert_eq!(composite, BarType::from(composite_input));
1102 assert!(composite.is_standard());
1103 }
1104
1105 #[rstest]
1106 fn test_bar_type_parse_invalid_token_pos_0() {
1107 let input = "BTCUSDT-PERP-1-MINUTE-LAST-INTERNAL";
1108 let result = BarType::from_str(input);
1109
1110 assert_eq!(
1111 result.unwrap_err().to_string(),
1112 format!(
1113 "Error parsing `BarType` from '{input}', invalid token: 'BTCUSDT-PERP' at position 0"
1114 )
1115 );
1116 }
1117
1118 #[rstest]
1119 fn test_bar_type_parse_invalid_token_pos_1() {
1120 let input = "BTCUSDT-PERP.BINANCE-INVALID-MINUTE-LAST-INTERNAL";
1121 let result = BarType::from_str(input);
1122
1123 assert_eq!(
1124 result.unwrap_err().to_string(),
1125 format!(
1126 "Error parsing `BarType` from '{input}', invalid token: 'INVALID' at position 1"
1127 )
1128 );
1129 }
1130
1131 #[rstest]
1132 fn test_bar_type_parse_invalid_token_pos_2() {
1133 let input = "BTCUSDT-PERP.BINANCE-1-INVALID-LAST-INTERNAL";
1134 let result = BarType::from_str(input);
1135
1136 assert_eq!(
1137 result.unwrap_err().to_string(),
1138 format!(
1139 "Error parsing `BarType` from '{input}', invalid token: 'INVALID' at position 2"
1140 )
1141 );
1142 }
1143
1144 #[rstest]
1145 fn test_bar_type_parse_invalid_token_pos_3() {
1146 let input = "BTCUSDT-PERP.BINANCE-1-MINUTE-INVALID-INTERNAL";
1147 let result = BarType::from_str(input);
1148
1149 assert_eq!(
1150 result.unwrap_err().to_string(),
1151 format!(
1152 "Error parsing `BarType` from '{input}', invalid token: 'INVALID' at position 3"
1153 )
1154 );
1155 }
1156
1157 #[rstest]
1158 fn test_bar_type_parse_invalid_token_pos_4() {
1159 let input = "BTCUSDT-PERP.BINANCE-1-MINUTE-BID-INVALID";
1160 let result = BarType::from_str(input);
1161
1162 assert!(result.is_err());
1163 assert_eq!(
1164 result.unwrap_err().to_string(),
1165 format!(
1166 "Error parsing `BarType` from '{input}', invalid token: 'INVALID' at position 4"
1167 )
1168 );
1169 }
1170
1171 #[rstest]
1172 fn test_bar_type_parse_invalid_token_pos_5() {
1173 let input = "BTCUSDT-PERP.BINANCE-2-MINUTE-LAST-INTERNAL@INVALID-MINUTE-EXTERNAL";
1174 let result = BarType::from_str(input);
1175
1176 assert!(result.is_err());
1177 assert_eq!(
1178 result.unwrap_err().to_string(),
1179 format!(
1180 "Error parsing `BarType` from '{input}', invalid token: 'INVALID' at position 5"
1181 )
1182 );
1183 }
1184
1185 #[rstest]
1186 fn test_bar_type_parse_invalid_token_pos_6() {
1187 let input = "BTCUSDT-PERP.BINANCE-2-MINUTE-LAST-INTERNAL@1-INVALID-EXTERNAL";
1188 let result = BarType::from_str(input);
1189
1190 assert!(result.is_err());
1191 assert_eq!(
1192 result.unwrap_err().to_string(),
1193 format!(
1194 "Error parsing `BarType` from '{input}', invalid token: 'INVALID' at position 6"
1195 )
1196 );
1197 }
1198
1199 #[rstest]
1200 fn test_bar_type_parse_invalid_token_pos_7() {
1201 let input = "BTCUSDT-PERP.BINANCE-2-MINUTE-LAST-INTERNAL@1-MINUTE-INVALID";
1202 let result = BarType::from_str(input);
1203
1204 assert!(result.is_err());
1205 assert_eq!(
1206 result.unwrap_err().to_string(),
1207 format!(
1208 "Error parsing `BarType` from '{input}', invalid token: 'INVALID' at position 7"
1209 )
1210 );
1211 }
1212
1213 #[rstest]
1214 fn test_bar_type_equality() {
1215 let instrument_id1 = InstrumentId {
1216 symbol: Symbol::new("AUD/USD"),
1217 venue: Venue::new("SIM"),
1218 };
1219 let instrument_id2 = InstrumentId {
1220 symbol: Symbol::new("GBP/USD"),
1221 venue: Venue::new("SIM"),
1222 };
1223 let bar_spec = BarSpecification::new(1, BarAggregation::Minute, PriceType::Bid);
1224 let bar_type1 = BarType::Standard {
1225 instrument_id: instrument_id1,
1226 spec: bar_spec,
1227 aggregation_source: AggregationSource::External,
1228 };
1229 let bar_type2 = BarType::Standard {
1230 instrument_id: instrument_id1,
1231 spec: bar_spec,
1232 aggregation_source: AggregationSource::External,
1233 };
1234 let bar_type3 = BarType::Standard {
1235 instrument_id: instrument_id2,
1236 spec: bar_spec,
1237 aggregation_source: AggregationSource::External,
1238 };
1239 assert_eq!(bar_type1, bar_type1);
1240 assert_eq!(bar_type1, bar_type2);
1241 assert_ne!(bar_type1, bar_type3);
1242 }
1243
1244 #[rstest]
1245 fn test_bar_type_comparison() {
1246 let instrument_id1 = InstrumentId {
1247 symbol: Symbol::new("AUD/USD"),
1248 venue: Venue::new("SIM"),
1249 };
1250
1251 let instrument_id2 = InstrumentId {
1252 symbol: Symbol::new("GBP/USD"),
1253 venue: Venue::new("SIM"),
1254 };
1255 let bar_spec = BarSpecification::new(1, BarAggregation::Minute, PriceType::Bid);
1256 let bar_spec2 = BarSpecification::new(2, BarAggregation::Minute, PriceType::Bid);
1257 let bar_type1 = BarType::Standard {
1258 instrument_id: instrument_id1,
1259 spec: bar_spec,
1260 aggregation_source: AggregationSource::External,
1261 };
1262 let bar_type2 = BarType::Standard {
1263 instrument_id: instrument_id1,
1264 spec: bar_spec,
1265 aggregation_source: AggregationSource::External,
1266 };
1267 let bar_type3 = BarType::Standard {
1268 instrument_id: instrument_id2,
1269 spec: bar_spec,
1270 aggregation_source: AggregationSource::External,
1271 };
1272 let bar_type4 = BarType::Composite {
1273 instrument_id: instrument_id2,
1274 spec: bar_spec2,
1275 aggregation_source: AggregationSource::Internal,
1276
1277 composite_step: 1,
1278 composite_aggregation: BarAggregation::Minute,
1279 composite_aggregation_source: AggregationSource::External,
1280 };
1281
1282 assert!(bar_type1 <= bar_type2);
1283 assert!(bar_type1 < bar_type3);
1284 assert!(bar_type3 > bar_type1);
1285 assert!(bar_type3 >= bar_type1);
1286 assert!(bar_type4 >= bar_type1);
1287 }
1288
1289 #[rstest]
1290 fn test_bar_new() {
1291 let bar_type = BarType::from("AAPL.XNAS-1-MINUTE-LAST-INTERNAL");
1292 let open = Price::from("100.0");
1293 let high = Price::from("105.0");
1294 let low = Price::from("95.0");
1295 let close = Price::from("102.0");
1296 let volume = Quantity::from("1000");
1297 let ts_event = UnixNanos::from(1_000_000);
1298 let ts_init = UnixNanos::from(2_000_000);
1299
1300 let bar = Bar::new(bar_type, open, high, low, close, volume, ts_event, ts_init);
1301
1302 assert_eq!(bar.bar_type, bar_type);
1303 assert_eq!(bar.open, open);
1304 assert_eq!(bar.high, high);
1305 assert_eq!(bar.low, low);
1306 assert_eq!(bar.close, close);
1307 assert_eq!(bar.volume, volume);
1308 assert_eq!(bar.ts_event, ts_event);
1309 assert_eq!(bar.ts_init, ts_init);
1310 }
1311
1312 #[rstest]
1313 #[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(
1320 #[case] open: &str,
1321 #[case] high: &str,
1322 #[case] low: &str,
1323 #[case] close: &str,
1324 ) {
1325 let bar_type = BarType::from("AAPL.XNAS-1-MINUTE-LAST-INTERNAL");
1326 let open = Price::from(open);
1327 let high = Price::from(high);
1328 let low = Price::from(low);
1329 let close = Price::from(close);
1330 let volume = Quantity::from("1000");
1331 let ts_event = UnixNanos::from(1_000_000);
1332 let ts_init = UnixNanos::from(2_000_000);
1333
1334 let result = Bar::new_checked(bar_type, open, high, low, close, volume, ts_event, ts_init);
1335
1336 assert!(result.is_err());
1337 }
1338
1339 #[rstest]
1340 fn test_bar_equality() {
1341 let instrument_id = InstrumentId {
1342 symbol: Symbol::new("AUDUSD"),
1343 venue: Venue::new("SIM"),
1344 };
1345 let bar_spec = BarSpecification::new(1, BarAggregation::Minute, PriceType::Bid);
1346 let bar_type = BarType::Standard {
1347 instrument_id,
1348 spec: bar_spec,
1349 aggregation_source: AggregationSource::External,
1350 };
1351 let bar1 = Bar {
1352 bar_type,
1353 open: Price::from("1.00001"),
1354 high: Price::from("1.00004"),
1355 low: Price::from("1.00002"),
1356 close: Price::from("1.00003"),
1357 volume: Quantity::from("100000"),
1358 ts_event: UnixNanos::default(),
1359 ts_init: UnixNanos::from(1),
1360 };
1361
1362 let bar2 = Bar {
1363 bar_type,
1364 open: Price::from("1.00000"),
1365 high: Price::from("1.00004"),
1366 low: Price::from("1.00002"),
1367 close: Price::from("1.00003"),
1368 volume: Quantity::from("100000"),
1369 ts_event: UnixNanos::default(),
1370 ts_init: UnixNanos::from(1),
1371 };
1372 assert_eq!(bar1, bar1);
1373 assert_ne!(bar1, bar2);
1374 }
1375
1376 #[rstest]
1377 fn test_json_serialization() {
1378 let bar = Bar::default();
1379 let serialized = bar.to_json_bytes().unwrap();
1380 let deserialized = Bar::from_json_bytes(serialized.as_ref()).unwrap();
1381 assert_eq!(deserialized, bar);
1382 }
1383
1384 #[rstest]
1385 fn test_msgpack_serialization() {
1386 let bar = Bar::default();
1387 let serialized = bar.to_msgpack_bytes().unwrap();
1388 let deserialized = Bar::from_msgpack_bytes(serialized.as_ref()).unwrap();
1389 assert_eq!(deserialized, bar);
1390 }
1391}