1use std::{
19 collections::HashMap,
20 fmt::{Debug, Display},
21 hash::Hash,
22 num::{NonZero, NonZeroUsize},
23 str::FromStr,
24};
25
26use derive_builder::Builder;
27use indexmap::IndexMap;
28use jiff::{SignedDuration, Timestamp, civil::Date, tz::Offset};
29use nautilus_core::{
30 DurationNanos, 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::{ARROW_TIMESTAMP_NANOSECOND, HasTsInit};
38use crate::{
39 enums::{AggregationSource, BarAggregation, PriceType},
40 identifiers::InstrumentId,
41 types::{Price, Quantity, fixed::FIXED_DECIMAL},
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};
49
50pub const BAR_SPEC_1_MINUTE_LAST: BarSpecification = BarSpecification {
51 step: NonZero::new(1).unwrap(),
52 aggregation: BarAggregation::Minute,
53 price_type: PriceType::Last,
54};
55
56pub const BAR_SPEC_3_MINUTE_LAST: BarSpecification = BarSpecification {
57 step: NonZero::new(3).unwrap(),
58 aggregation: BarAggregation::Minute,
59 price_type: PriceType::Last,
60};
61
62pub const BAR_SPEC_5_MINUTE_LAST: BarSpecification = BarSpecification {
63 step: NonZero::new(5).unwrap(),
64 aggregation: BarAggregation::Minute,
65 price_type: PriceType::Last,
66};
67
68pub const BAR_SPEC_15_MINUTE_LAST: BarSpecification = BarSpecification {
69 step: NonZero::new(15).unwrap(),
70 aggregation: BarAggregation::Minute,
71 price_type: PriceType::Last,
72};
73
74pub const BAR_SPEC_30_MINUTE_LAST: BarSpecification = BarSpecification {
75 step: NonZero::new(30).unwrap(),
76 aggregation: BarAggregation::Minute,
77 price_type: PriceType::Last,
78};
79
80pub const BAR_SPEC_1_HOUR_LAST: BarSpecification = BarSpecification {
81 step: NonZero::new(1).unwrap(),
82 aggregation: BarAggregation::Hour,
83 price_type: PriceType::Last,
84};
85
86pub const BAR_SPEC_2_HOUR_LAST: BarSpecification = BarSpecification {
87 step: NonZero::new(2).unwrap(),
88 aggregation: BarAggregation::Hour,
89 price_type: PriceType::Last,
90};
91
92pub const BAR_SPEC_4_HOUR_LAST: BarSpecification = BarSpecification {
93 step: NonZero::new(4).unwrap(),
94 aggregation: BarAggregation::Hour,
95 price_type: PriceType::Last,
96};
97
98pub const BAR_SPEC_6_HOUR_LAST: BarSpecification = BarSpecification {
99 step: NonZero::new(6).unwrap(),
100 aggregation: BarAggregation::Hour,
101 price_type: PriceType::Last,
102};
103
104pub const BAR_SPEC_12_HOUR_LAST: BarSpecification = BarSpecification {
105 step: NonZero::new(12).unwrap(),
106 aggregation: BarAggregation::Hour,
107 price_type: PriceType::Last,
108};
109
110pub const BAR_SPEC_1_DAY_LAST: BarSpecification = BarSpecification {
111 step: NonZero::new(1).unwrap(),
112 aggregation: BarAggregation::Day,
113 price_type: PriceType::Last,
114};
115
116pub const BAR_SPEC_2_DAY_LAST: BarSpecification = BarSpecification {
117 step: NonZero::new(2).unwrap(),
118 aggregation: BarAggregation::Day,
119 price_type: PriceType::Last,
120};
121
122pub const BAR_SPEC_3_DAY_LAST: BarSpecification = BarSpecification {
123 step: NonZero::new(3).unwrap(),
124 aggregation: BarAggregation::Day,
125 price_type: PriceType::Last,
126};
127
128pub const BAR_SPEC_5_DAY_LAST: BarSpecification = BarSpecification {
129 step: NonZero::new(5).unwrap(),
130 aggregation: BarAggregation::Day,
131 price_type: PriceType::Last,
132};
133
134pub const BAR_SPEC_1_WEEK_LAST: BarSpecification = BarSpecification {
135 step: NonZero::new(1).unwrap(),
136 aggregation: BarAggregation::Week,
137 price_type: PriceType::Last,
138};
139
140pub const BAR_SPEC_1_MONTH_LAST: BarSpecification = BarSpecification {
141 step: NonZero::new(1).unwrap(),
142 aggregation: BarAggregation::Month,
143 price_type: PriceType::Last,
144};
145
146pub const BAR_SPEC_3_MONTH_LAST: BarSpecification = BarSpecification {
147 step: NonZero::new(3).unwrap(),
148 aggregation: BarAggregation::Month,
149 price_type: PriceType::Last,
150};
151
152pub const BAR_SPEC_6_MONTH_LAST: BarSpecification = BarSpecification {
153 step: NonZero::new(6).unwrap(),
154 aggregation: BarAggregation::Month,
155 price_type: PriceType::Last,
156};
157
158pub const BAR_SPEC_12_MONTH_LAST: BarSpecification = BarSpecification {
159 step: NonZero::new(12).unwrap(),
160 aggregation: BarAggregation::Month,
161 price_type: PriceType::Last,
162};
163
164#[must_use]
171pub fn get_bar_interval(bar_type: &BarType) -> SignedDuration {
172 let spec = bar_type.spec();
173 let step = step_to_i64(spec.step);
174
175 match spec.aggregation {
176 BarAggregation::Millisecond => SignedDuration::from_millis(step),
177 BarAggregation::Second => SignedDuration::from_secs(step),
178 BarAggregation::Minute => SignedDuration::from_mins(step),
179 BarAggregation::Hour => SignedDuration::from_hours(step),
180 BarAggregation::Day => duration_days(step),
181 BarAggregation::Week => {
182 duration_days(step.checked_mul(7).expect("`step` overflows i64 days"))
183 }
184 BarAggregation::Month => {
185 duration_days(step.checked_mul(30).expect("`step` overflows i64 days"))
187 }
188 BarAggregation::Year => {
189 duration_days(step.checked_mul(365).expect("`step` overflows i64 days"))
191 }
192 _ => panic!("Aggregation not time based"),
193 }
194}
195
196#[must_use]
202pub fn get_bar_interval_ns(bar_type: &BarType) -> DurationNanos {
203 DurationNanos::try_from(get_bar_interval(bar_type)).expect("Invalid bar interval")
204}
205
206#[must_use]
214pub fn get_time_bar_start(
215 now: Timestamp,
216 bar_type: &BarType,
217 time_bars_origin: Option<SignedDuration>,
218) -> Timestamp {
219 let spec = bar_type.spec();
220 let step = step_to_i64(spec.step);
221 let origin_offset = time_bars_origin.unwrap_or(SignedDuration::ZERO);
222
223 match spec.aggregation {
224 BarAggregation::Millisecond => {
225 find_closest_smaller_time(now, origin_offset, SignedDuration::from_millis(step))
226 }
227 BarAggregation::Second => {
228 find_closest_smaller_time(now, origin_offset, SignedDuration::from_secs(step))
229 }
230 BarAggregation::Minute => {
231 find_closest_smaller_time(now, origin_offset, SignedDuration::from_mins(step))
232 }
233 BarAggregation::Hour => {
234 find_closest_smaller_time(now, origin_offset, SignedDuration::from_hours(step))
235 }
236 BarAggregation::Day => find_closest_smaller_time(now, origin_offset, duration_days(step)),
237 BarAggregation::Week => {
238 let now_civil = Offset::UTC.to_datetime(now);
239 let days_from_monday = i64::from(now_civil.weekday().to_monday_zero_offset());
240 let week_start_date = now_civil
241 .date()
242 .checked_sub(jiff::Span::new().days(days_from_monday))
243 .expect("valid week start");
244 let mut start_time = Offset::UTC
245 .to_timestamp(week_start_date.at(0, 0, 0, 0))
246 .expect("valid UTC week start");
247 start_time += origin_offset;
248
249 if now < start_time {
250 start_time -=
251 duration_days(step.checked_mul(7).expect("`step` overflows i64 days"));
252 }
253
254 start_time
255 }
256 BarAggregation::Month => {
257 let now_civil = Offset::UTC.to_datetime(now);
259 let mut start_time = Offset::UTC
260 .to_timestamp(
261 Date::new(now_civil.year(), 1, 1)
262 .expect("valid year start date")
263 .at(0, 0, 0, 0),
264 )
265 .expect("valid UTC year start");
266 start_time += origin_offset;
267
268 if now < start_time {
269 start_time =
270 subtract_n_months(start_time, 12).expect("Failed to subtract 12 months");
271 }
272
273 let months_step =
274 u32::try_from(step).expect("`step` exceeds u32 range for month arithmetic");
275
276 while start_time <= now {
277 start_time =
278 add_n_months(start_time, months_step).expect("Failed to add months in loop");
279 }
280
281 start_time =
282 subtract_n_months(start_time, months_step).expect("Failed to subtract months_step");
283 start_time
284 }
285 BarAggregation::Year => {
286 let step_i32 =
287 i32::try_from(step).expect("`step` exceeds i32 range for year arithmetic");
288
289 let year_start = |year: i32| {
291 let year = i16::try_from(year).expect("year exceeds Jiff supported range");
292 Offset::UTC
293 .to_timestamp(
294 Date::new(year, 1, 1)
295 .expect("valid year start date")
296 .at(0, 0, 0, 0),
297 )
298 .expect("valid UTC year start")
299 + origin_offset
300 };
301
302 let mut year = i32::from(Offset::UTC.to_datetime(now).year());
303 if year_start(year) > now {
304 year = year
305 .checked_sub(step_i32)
306 .expect("year arithmetic underflow");
307 }
308
309 loop {
310 let next_year = year
311 .checked_add(step_i32)
312 .expect("year arithmetic overflow");
313
314 if year_start(next_year) > now {
315 break;
316 }
317 year = next_year;
318 }
319
320 year_start(year)
321 }
322 _ => panic!(
323 "Aggregation type {} not supported for time bars",
324 spec.aggregation
325 ),
326 }
327}
328
329fn find_closest_smaller_time(
334 now: Timestamp,
335 daily_time_origin: SignedDuration,
336 period: SignedDuration,
337) -> Timestamp {
338 let day_start = Offset::UTC
340 .to_timestamp(Offset::UTC.to_datetime(now).date().at(0, 0, 0, 0))
341 .expect("valid UTC day start");
342 let base_time = day_start + daily_time_origin;
343
344 let time_difference = base_time.duration_until(now);
345 let period_ns = period.as_nanos();
346 debug_assert_ne!(period_ns, 0, "bar period must be non-zero");
347
348 let num_periods = time_difference.as_nanos().div_euclid(period_ns);
351
352 base_time + SignedDuration::from_nanos_i128(num_periods * period_ns)
353}
354
355fn duration_days(days: i64) -> SignedDuration {
356 try_duration_days(days).unwrap_or_else(|e| panic!("{e}"))
357}
358
359fn try_duration_days(days: i64) -> anyhow::Result<SignedDuration> {
360 let hours = days
361 .checked_mul(24)
362 .ok_or_else(|| anyhow::anyhow!("days overflow i64 hours"))?;
363 SignedDuration::try_from_hours(hours)
364 .ok_or_else(|| anyhow::anyhow!("days exceed signed duration range"))
365}
366
367fn try_time_interval(step: usize, aggregation: BarAggregation) -> anyhow::Result<SignedDuration> {
368 let step_i64 = i64::try_from(step)
369 .map_err(|_| invalid_interval_step(step, aggregation, "step exceeds i64 range"))?;
370
371 let duration = match aggregation {
372 BarAggregation::Millisecond => SignedDuration::from_millis(step_i64),
373 BarAggregation::Second => SignedDuration::from_secs(step_i64),
374 BarAggregation::Minute => SignedDuration::try_from_mins(step_i64).ok_or_else(|| {
375 invalid_interval_step(step, aggregation, "step exceeds signed duration range")
376 })?,
377 BarAggregation::Hour => SignedDuration::try_from_hours(step_i64).ok_or_else(|| {
378 invalid_interval_step(step, aggregation, "step exceeds signed duration range")
379 })?,
380 BarAggregation::Day => try_scaled_days(step, aggregation, step_i64, 1)?,
381 BarAggregation::Week => try_scaled_days(step, aggregation, step_i64, 7)?,
382 BarAggregation::Month => try_scaled_days(step, aggregation, step_i64, 30)?,
383 BarAggregation::Year => try_scaled_days(step, aggregation, step_i64, 365)?,
384 _ => anyhow::bail!("Timedelta not supported for aggregation type: {aggregation:?}"),
385 };
386
387 u64::try_from(duration.as_nanos())
388 .map_err(|_| invalid_interval_step(step, aggregation, "interval overflows nanoseconds"))?;
389
390 Ok(duration)
391}
392
393fn try_scaled_days(
394 step: usize,
395 aggregation: BarAggregation,
396 step_i64: i64,
397 multiplier: i64,
398) -> anyhow::Result<SignedDuration> {
399 let days = step_i64
400 .checked_mul(multiplier)
401 .ok_or_else(|| invalid_interval_step(step, aggregation, "step overflows i64 days"))?;
402 try_duration_days(days).map_err(|e| invalid_interval_step(step, aggregation, &e.to_string()))
403}
404
405fn invalid_interval_step(step: usize, aggregation: BarAggregation, reason: &str) -> anyhow::Error {
406 anyhow::anyhow!(
407 "Invalid step in bar_type.spec.step: {step} for aggregation={aggregation}. {reason}"
408 )
409}
410
411fn step_to_i64(step: NonZeroUsize) -> i64 {
417 i64::try_from(step.get()).expect("`step` exceeds i64 range")
418}
419
420#[repr(C)]
423#[derive(
424 Clone, Copy, Hash, PartialEq, Eq, PartialOrd, Ord, Debug, Serialize, Deserialize, Builder,
425)]
426#[builder(build_fn(validate = "Self::validate"))]
427#[serde(try_from = "BarSpecificationFields")]
428#[cfg_attr(
429 feature = "python",
430 pyo3::pyclass(module = "nautilus_trader.model", from_py_object)
431)]
432#[cfg_attr(
433 feature = "python",
434 pyo3_stub_gen::derive::gen_stub_pyclass(module = "nautilus_trader.model")
435)]
436pub struct BarSpecification {
437 pub step: NonZeroUsize,
439 pub aggregation: BarAggregation,
441 pub price_type: PriceType,
443}
444
445impl BarSpecificationBuilder {
446 fn validate(&self) -> Result<(), String> {
447 if let (Some(step), Some(aggregation)) = (self.step, self.aggregation) {
448 BarSpecification::validate_step(step.get(), aggregation).map_err(|e| e.to_string())?;
449 }
450
451 Ok(())
452 }
453}
454
455#[derive(Deserialize)]
458struct BarSpecificationFields {
459 step: NonZeroUsize,
460 aggregation: BarAggregation,
461 price_type: PriceType,
462}
463
464impl TryFrom<BarSpecificationFields> for BarSpecification {
465 type Error = anyhow::Error;
466
467 fn try_from(fields: BarSpecificationFields) -> Result<Self, Self::Error> {
468 Self::new_checked(fields.step.get(), fields.aggregation, fields.price_type)
469 }
470}
471
472impl BarSpecification {
473 pub fn new_checked(
485 step: usize,
486 aggregation: BarAggregation,
487 price_type: PriceType,
488 ) -> anyhow::Result<Self> {
489 let step = NonZeroUsize::new(step)
490 .ok_or(anyhow::anyhow!("Invalid step: {step} (must be non-zero)"))?;
491 Self::validate_step(step.get(), aggregation)?;
492
493 Ok(Self {
494 step,
495 aggregation,
496 price_type,
497 })
498 }
499
500 fn validate_step(step: usize, aggregation: BarAggregation) -> anyhow::Result<()> {
501 match aggregation {
502 BarAggregation::Millisecond => {
503 Self::validate_periodic_step(step, aggregation, 1000, false)?;
504 }
505 BarAggregation::Second | BarAggregation::Minute => {
506 Self::validate_periodic_step(step, aggregation, 60, false)?;
507 }
508 BarAggregation::Hour => Self::validate_periodic_step(step, aggregation, 24, false)?,
509 BarAggregation::Month => Self::validate_periodic_step(step, aggregation, 12, true)?,
512 BarAggregation::Day | BarAggregation::Week | BarAggregation::Year => {}
513 _ => return Ok(()),
514 }
515
516 try_time_interval(step, aggregation).map(|_| ())
517 }
518
519 fn validate_periodic_step(
520 step: usize,
521 aggregation: BarAggregation,
522 subunits: usize,
523 allow_equal: bool,
524 ) -> anyhow::Result<()> {
525 if !subunits.is_multiple_of(step) {
526 anyhow::bail!(
527 "Invalid step in bar_type.spec.step: {step} for aggregation={aggregation}. \
528 step must evenly divide {subunits} (so it is periodic).",
529 );
530 }
531
532 if !allow_equal && subunits == step {
533 anyhow::bail!(
534 "Invalid step in bar_type.spec.step: {step} for aggregation={aggregation}. \
535 step must not be {subunits}. Use higher aggregation unit instead.",
536 );
537 }
538
539 Ok(())
540 }
541
542 #[must_use]
550 pub fn new(step: usize, aggregation: BarAggregation, price_type: PriceType) -> Self {
551 Self::new_checked(step, aggregation, price_type).expect(FAILED)
552 }
553
554 #[must_use]
567 pub fn timedelta(&self) -> SignedDuration {
568 let step = step_to_i64(self.step);
569
570 match self.aggregation {
571 BarAggregation::Millisecond => SignedDuration::from_millis(step),
572 BarAggregation::Second => SignedDuration::from_secs(step),
573 BarAggregation::Minute => SignedDuration::from_mins(step),
574 BarAggregation::Hour => SignedDuration::from_hours(step),
575 BarAggregation::Day => duration_days(step),
576 BarAggregation::Week => {
577 duration_days(step.checked_mul(7).expect("`step` overflows i64 days"))
578 }
579 BarAggregation::Month => {
580 duration_days(step.checked_mul(30).expect("`step` overflows i64 days"))
582 }
583 BarAggregation::Year => {
584 duration_days(step.checked_mul(365).expect("`step` overflows i64 days"))
586 }
587 _ => panic!(
588 "Timedelta not supported for aggregation type: {:?}",
589 self.aggregation
590 ),
591 }
592 }
593
594 #[must_use]
604 pub fn is_time_aggregated(&self) -> bool {
605 matches!(
606 self.aggregation,
607 BarAggregation::Millisecond
608 | BarAggregation::Second
609 | BarAggregation::Minute
610 | BarAggregation::Hour
611 | BarAggregation::Day
612 | BarAggregation::Week
613 | BarAggregation::Month
614 | BarAggregation::Year
615 )
616 }
617
618 #[must_use]
626 pub fn is_threshold_aggregated(&self) -> bool {
627 matches!(
628 self.aggregation,
629 BarAggregation::Tick
630 | BarAggregation::TickImbalance
631 | BarAggregation::Volume
632 | BarAggregation::VolumeImbalance
633 | BarAggregation::Value
634 | BarAggregation::ValueImbalance
635 )
636 }
637
638 #[must_use]
643 pub fn is_information_aggregated(&self) -> bool {
644 matches!(
645 self.aggregation,
646 BarAggregation::TickRuns | BarAggregation::VolumeRuns | BarAggregation::ValueRuns
647 )
648 }
649}
650
651impl Display for BarSpecification {
652 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
653 write!(f, "{}-{}-{}", self.step, self.aggregation, self.price_type)
654 }
655}
656
657#[repr(C)]
660#[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord, Hash)]
661#[cfg_attr(
662 feature = "python",
663 pyo3::pyclass(module = "nautilus_trader.model", from_py_object)
664)]
665#[cfg_attr(
666 feature = "python",
667 pyo3_stub_gen::derive::gen_stub_pyclass_enum(module = "nautilus_trader.model")
668)]
669pub enum BarType {
670 Standard {
671 instrument_id: InstrumentId,
673 spec: BarSpecification,
675 aggregation_source: AggregationSource,
677 },
678 Composite {
679 instrument_id: InstrumentId,
681 spec: BarSpecification,
683 aggregation_source: AggregationSource,
685
686 composite_step: usize,
688 composite_aggregation: BarAggregation,
690 composite_aggregation_source: AggregationSource,
692 },
693}
694
695impl BarType {
696 #[must_use]
698 pub fn new(
699 instrument_id: InstrumentId,
700 spec: BarSpecification,
701 aggregation_source: AggregationSource,
702 ) -> Self {
703 Self::Standard {
704 instrument_id,
705 spec,
706 aggregation_source,
707 }
708 }
709
710 pub fn new_composite_checked(
717 instrument_id: InstrumentId,
718 spec: BarSpecification,
719 aggregation_source: AggregationSource,
720
721 composite_step: usize,
722 composite_aggregation: BarAggregation,
723 composite_aggregation_source: AggregationSource,
724 ) -> anyhow::Result<Self> {
725 BarSpecification::new_checked(composite_step, composite_aggregation, spec.price_type)?;
727
728 Ok(Self::Composite {
729 instrument_id,
730 spec,
731 aggregation_source,
732
733 composite_step,
734 composite_aggregation,
735 composite_aggregation_source,
736 })
737 }
738
739 #[must_use]
746 pub fn new_composite(
747 instrument_id: InstrumentId,
748 spec: BarSpecification,
749 aggregation_source: AggregationSource,
750
751 composite_step: usize,
752 composite_aggregation: BarAggregation,
753 composite_aggregation_source: AggregationSource,
754 ) -> Self {
755 Self::new_composite_checked(
756 instrument_id,
757 spec,
758 aggregation_source,
759 composite_step,
760 composite_aggregation,
761 composite_aggregation_source,
762 )
763 .expect(FAILED)
764 }
765
766 #[must_use]
768 pub fn is_standard(&self) -> bool {
769 matches!(self, Self::Standard { .. })
770 }
771
772 #[must_use]
774 pub fn is_composite(&self) -> bool {
775 matches!(self, Self::Composite { .. })
776 }
777
778 #[must_use]
780 pub fn is_externally_aggregated(&self) -> bool {
781 self.aggregation_source() == AggregationSource::External
782 }
783
784 #[must_use]
786 pub fn is_internally_aggregated(&self) -> bool {
787 self.aggregation_source() == AggregationSource::Internal
788 }
789
790 #[must_use]
792 pub fn standard(&self) -> Self {
793 match self {
794 &b @ Self::Standard { .. } => b,
795 Self::Composite {
796 instrument_id,
797 spec,
798 aggregation_source,
799 ..
800 } => Self::new(*instrument_id, *spec, *aggregation_source),
801 }
802 }
803
804 #[must_use]
806 pub fn composite(&self) -> Self {
807 match self {
808 &b @ Self::Standard { .. } => b, Self::Composite {
810 instrument_id,
811 spec,
812 aggregation_source: _,
813
814 composite_step,
815 composite_aggregation,
816 composite_aggregation_source,
817 } => Self::new(
818 *instrument_id,
819 BarSpecification::new(*composite_step, *composite_aggregation, spec.price_type),
820 *composite_aggregation_source,
821 ),
822 }
823 }
824
825 #[must_use]
827 pub fn instrument_id(&self) -> InstrumentId {
828 match &self {
829 Self::Standard { instrument_id, .. } | Self::Composite { instrument_id, .. } => {
830 *instrument_id
831 }
832 }
833 }
834
835 #[must_use]
837 pub fn spec(&self) -> BarSpecification {
838 match &self {
839 Self::Standard { spec, .. } | Self::Composite { spec, .. } => *spec,
840 }
841 }
842
843 #[must_use]
845 pub fn aggregation_source(&self) -> AggregationSource {
846 match &self {
847 Self::Standard {
848 aggregation_source, ..
849 }
850 | Self::Composite {
851 aggregation_source, ..
852 } => *aggregation_source,
853 }
854 }
855
856 #[must_use]
862 pub fn id_spec_key(&self) -> (InstrumentId, BarSpecification) {
863 (self.instrument_id(), self.spec())
864 }
865}
866
867#[derive(thiserror::Error, Debug)]
868#[error("Error parsing `BarType` from '{input}', invalid token: '{token}' at position {position}")]
869pub struct BarTypeParseError {
870 input: String,
871 token: String,
872 position: usize,
873}
874
875impl FromStr for BarType {
876 type Err = BarTypeParseError;
877
878 #[expect(clippy::needless_collect)] fn from_str(s: &str) -> Result<Self, Self::Err> {
880 let parts: Vec<&str> = s.split('@').collect();
881 if parts.len() > 2 {
882 return Err(BarTypeParseError {
883 input: s.to_string(),
884 token: parts[2].to_string(),
885 position: 5,
886 });
887 }
888 let standard = parts[0];
889 let composite_str = parts.get(1);
890
891 let pieces: Vec<&str> = standard.rsplitn(5, '-').collect();
892 let rev_pieces: Vec<&str> = pieces.into_iter().rev().collect();
893 if rev_pieces.len() != 5 {
894 return Err(BarTypeParseError {
895 input: s.to_string(),
896 token: String::new(),
897 position: 0,
898 });
899 }
900
901 let instrument_id =
902 InstrumentId::from_str(rev_pieces[0]).map_err(|_| BarTypeParseError {
903 input: s.to_string(),
904 token: rev_pieces[0].to_string(),
905 position: 0,
906 })?;
907
908 let step = rev_pieces[1].parse().map_err(|_| BarTypeParseError {
909 input: s.to_string(),
910 token: rev_pieces[1].to_string(),
911 position: 1,
912 })?;
913 let aggregation =
914 BarAggregation::from_str(rev_pieces[2]).map_err(|_| BarTypeParseError {
915 input: s.to_string(),
916 token: rev_pieces[2].to_string(),
917 position: 2,
918 })?;
919 let price_type = PriceType::from_str(rev_pieces[3]).map_err(|_| BarTypeParseError {
920 input: s.to_string(),
921 token: rev_pieces[3].to_string(),
922 position: 3,
923 })?;
924 let aggregation_source =
925 AggregationSource::from_str(rev_pieces[4]).map_err(|_| BarTypeParseError {
926 input: s.to_string(),
927 token: rev_pieces[4].to_string(),
928 position: 4,
929 })?;
930 let spec = BarSpecification::new_checked(step, aggregation, price_type).map_err(|_| {
931 BarTypeParseError {
932 input: s.to_string(),
933 token: rev_pieces[1].to_string(),
934 position: 1,
935 }
936 })?;
937
938 if let Some(composite_str) = composite_str {
939 let composite_pieces: Vec<&str> = composite_str.rsplitn(3, '-').collect();
940 let rev_composite_pieces: Vec<&str> = composite_pieces.into_iter().rev().collect();
941 if rev_composite_pieces.len() != 3 {
942 return Err(BarTypeParseError {
943 input: s.to_string(),
944 token: String::new(),
945 position: 5,
946 });
947 }
948
949 let composite_step =
950 rev_composite_pieces[0]
951 .parse()
952 .map_err(|_| BarTypeParseError {
953 input: s.to_string(),
954 token: rev_composite_pieces[0].to_string(),
955 position: 5,
956 })?;
957 let composite_aggregation =
958 BarAggregation::from_str(rev_composite_pieces[1]).map_err(|_| {
959 BarTypeParseError {
960 input: s.to_string(),
961 token: rev_composite_pieces[1].to_string(),
962 position: 6,
963 }
964 })?;
965 let composite_aggregation_source = AggregationSource::from_str(rev_composite_pieces[2])
966 .map_err(|_| BarTypeParseError {
967 input: s.to_string(),
968 token: rev_composite_pieces[2].to_string(),
969 position: 7,
970 })?;
971 BarSpecification::new_checked(composite_step, composite_aggregation, price_type)
972 .map_err(|_| BarTypeParseError {
973 input: s.to_string(),
974 token: rev_composite_pieces[0].to_string(),
975 position: 5,
976 })?;
977
978 Ok(Self::new_composite(
979 instrument_id,
980 spec,
981 aggregation_source,
982 composite_step,
983 composite_aggregation,
984 composite_aggregation_source,
985 ))
986 } else {
987 Ok(Self::Standard {
988 instrument_id,
989 spec,
990 aggregation_source,
991 })
992 }
993 }
994}
995
996impl<T: AsRef<str>> From<T> for BarType {
997 fn from(value: T) -> Self {
998 Self::from_str(value.as_ref()).expect(FAILED)
999 }
1000}
1001
1002impl Display for BarType {
1003 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
1004 match &self {
1005 Self::Standard {
1006 instrument_id,
1007 spec,
1008 aggregation_source,
1009 } => {
1010 write!(f, "{instrument_id}-{spec}-{aggregation_source}")
1011 }
1012 Self::Composite {
1013 instrument_id,
1014 spec,
1015 aggregation_source,
1016
1017 composite_step,
1018 composite_aggregation,
1019 composite_aggregation_source,
1020 } => {
1021 write!(
1022 f,
1023 "{}-{}-{}@{}-{}-{}",
1024 instrument_id,
1025 spec,
1026 aggregation_source,
1027 *composite_step,
1028 *composite_aggregation,
1029 *composite_aggregation_source
1030 )
1031 }
1032 }
1033 }
1034}
1035
1036impl Serialize for BarType {
1037 fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
1038 where
1039 S: Serializer,
1040 {
1041 serializer.serialize_str(&self.to_string())
1042 }
1043}
1044
1045impl<'de> Deserialize<'de> for BarType {
1046 fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
1047 where
1048 D: Deserializer<'de>,
1049 {
1050 let s: std::borrow::Cow<'de, str> = Deserialize::deserialize(deserializer)?;
1051 Self::from_str(s.as_ref()).map_err(serde::de::Error::custom)
1052 }
1053}
1054
1055#[repr(C)]
1057#[derive(Clone, Copy, Hash, PartialEq, Eq, Debug, Serialize, Deserialize)]
1058#[serde(tag = "type", try_from = "BarFields")]
1059#[cfg_attr(
1060 feature = "python",
1061 pyo3::pyclass(module = "nautilus_trader.model", from_py_object)
1062)]
1063#[cfg_attr(
1064 feature = "python",
1065 pyo3_stub_gen::derive::gen_stub_pyclass(module = "nautilus_trader.model")
1066)]
1067pub struct Bar {
1068 pub bar_type: BarType,
1070 pub open: Price,
1072 pub high: Price,
1074 pub low: Price,
1076 pub close: Price,
1078 pub volume: Quantity,
1080 pub ts_event: UnixNanos,
1082 pub ts_init: UnixNanos,
1084}
1085
1086#[derive(Deserialize)]
1089struct BarFields {
1090 bar_type: BarType,
1091 open: Price,
1092 high: Price,
1093 low: Price,
1094 close: Price,
1095 volume: Quantity,
1096 ts_event: UnixNanos,
1097 ts_init: UnixNanos,
1098}
1099
1100impl TryFrom<BarFields> for Bar {
1101 type Error = anyhow::Error;
1102
1103 fn try_from(fields: BarFields) -> Result<Self, Self::Error> {
1104 Self::new_checked(
1105 fields.bar_type,
1106 fields.open,
1107 fields.high,
1108 fields.low,
1109 fields.close,
1110 fields.volume,
1111 fields.ts_event,
1112 fields.ts_init,
1113 )
1114 }
1115}
1116
1117impl Bar {
1118 #[expect(clippy::too_many_arguments)]
1133 pub fn new_checked(
1134 bar_type: BarType,
1135 open: Price,
1136 high: Price,
1137 low: Price,
1138 close: Price,
1139 volume: Quantity,
1140 ts_event: UnixNanos,
1141 ts_init: UnixNanos,
1142 ) -> anyhow::Result<Self> {
1143 check_predicate_true(high >= open, "high >= open")?;
1144 check_predicate_true(high >= low, "high >= low")?;
1145 check_predicate_true(high >= close, "high >= close")?;
1146 check_predicate_true(low <= close, "low <= close")?;
1147 check_predicate_true(low <= open, "low <= open")?;
1148
1149 debug_assert!(
1150 open.precision == high.precision
1151 && open.precision == low.precision
1152 && open.precision == close.precision,
1153 "Bar prices must share a uniform precision (Arrow encoding assumes it)"
1154 );
1155
1156 Ok(Self {
1157 bar_type,
1158 open,
1159 high,
1160 low,
1161 close,
1162 volume,
1163 ts_event,
1164 ts_init,
1165 })
1166 }
1167
1168 #[expect(clippy::too_many_arguments)]
1179 #[must_use]
1180 pub fn new(
1181 bar_type: BarType,
1182 open: Price,
1183 high: Price,
1184 low: Price,
1185 close: Price,
1186 volume: Quantity,
1187 ts_event: UnixNanos,
1188 ts_init: UnixNanos,
1189 ) -> Self {
1190 Self::new_checked(bar_type, open, high, low, close, volume, ts_event, ts_init)
1191 .expect(FAILED)
1192 }
1193
1194 #[must_use]
1195 pub fn instrument_id(&self) -> InstrumentId {
1196 self.bar_type.instrument_id()
1197 }
1198
1199 #[must_use]
1201 pub fn get_metadata(
1202 bar_type: &BarType,
1203 price_precision: u8,
1204 size_precision: u8,
1205 ) -> HashMap<String, String> {
1206 let mut metadata = HashMap::new();
1207 let instrument_id = bar_type.instrument_id();
1208 metadata.insert("bar_type".to_string(), bar_type.to_string());
1209 metadata.insert("instrument_id".to_string(), instrument_id.to_string());
1210 metadata.insert("price_precision".to_string(), price_precision.to_string());
1211 metadata.insert("size_precision".to_string(), size_precision.to_string());
1212 metadata
1213 }
1214
1215 #[must_use]
1217 pub fn get_fields() -> IndexMap<String, String> {
1218 let mut metadata = IndexMap::new();
1219 metadata.insert("open".to_string(), FIXED_DECIMAL.to_string());
1220 metadata.insert("high".to_string(), FIXED_DECIMAL.to_string());
1221 metadata.insert("low".to_string(), FIXED_DECIMAL.to_string());
1222 metadata.insert("close".to_string(), FIXED_DECIMAL.to_string());
1223 metadata.insert("volume".to_string(), FIXED_DECIMAL.to_string());
1224 metadata.insert(
1225 "ts_event".to_string(),
1226 ARROW_TIMESTAMP_NANOSECOND.to_string(),
1227 );
1228 metadata.insert(
1229 "ts_init".to_string(),
1230 ARROW_TIMESTAMP_NANOSECOND.to_string(),
1231 );
1232 metadata
1233 }
1234}
1235
1236impl Display for Bar {
1237 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
1238 write!(
1239 f,
1240 "{},{},{},{},{},{},{}",
1241 self.bar_type, self.open, self.high, self.low, self.close, self.volume, self.ts_event
1242 )
1243 }
1244}
1245
1246impl Serializable for Bar {}
1247
1248impl HasTsInit for Bar {
1249 fn ts_init(&self) -> UnixNanos {
1250 self.ts_init
1251 }
1252}
1253
1254#[cfg(test)]
1255mod tests {
1256 use std::str::FromStr;
1257
1258 use nautilus_core::serialization::msgpack::{FromMsgPack, ToMsgPack};
1259 use rstest::rstest;
1260
1261 use super::*;
1262 use crate::identifiers::{Symbol, Venue};
1263
1264 fn timestamp(value: &str) -> Timestamp {
1265 value.parse().unwrap()
1266 }
1267
1268 #[rstest]
1269 fn test_bar_specification_new_invalid() {
1270 let result = BarSpecification::new_checked(0, BarAggregation::Tick, PriceType::Last);
1271 assert!(
1272 result
1273 .unwrap_err()
1274 .to_string()
1275 .contains("Invalid step: 0 (must be non-zero)")
1276 );
1277 }
1278
1279 #[rstest]
1280 #[should_panic(expected = "Invalid step: 0 (must be non-zero)")]
1281 fn test_bar_specification_new_checked_with_invalid_step_panics() {
1282 let aggregation = BarAggregation::Tick;
1283 let price_type = PriceType::Last;
1284
1285 let _ = BarSpecification::new(0, aggregation, price_type);
1286 }
1287
1288 #[rstest]
1289 #[should_panic(expected = "Invalid step in bar_type.spec.step: 7")]
1290 fn test_bar_specification_new_with_invalid_periodic_step_panics() {
1291 let _ = BarSpecification::new(7, BarAggregation::Minute, PriceType::Last);
1292 }
1293
1294 #[rstest]
1295 #[case(
1296 BarAggregation::Millisecond,
1297 12,
1298 "Invalid step in bar_type.spec.step: 12 for aggregation=MILLISECOND. step must evenly divide 1000"
1299 )]
1300 #[case(
1301 BarAggregation::Millisecond,
1302 1000,
1303 "Invalid step in bar_type.spec.step: 1000 for aggregation=MILLISECOND. step must not be 1000"
1304 )]
1305 #[case(
1306 BarAggregation::Second,
1307 50,
1308 "Invalid step in bar_type.spec.step: 50 for aggregation=SECOND. step must evenly divide 60"
1309 )]
1310 #[case(
1311 BarAggregation::Second,
1312 60,
1313 "Invalid step in bar_type.spec.step: 60 for aggregation=SECOND. step must not be 60"
1314 )]
1315 #[case(
1316 BarAggregation::Minute,
1317 40,
1318 "Invalid step in bar_type.spec.step: 40 for aggregation=MINUTE. step must evenly divide 60"
1319 )]
1320 #[case(
1321 BarAggregation::Minute,
1322 60,
1323 "Invalid step in bar_type.spec.step: 60 for aggregation=MINUTE. step must not be 60"
1324 )]
1325 #[case(
1326 BarAggregation::Hour,
1327 5,
1328 "Invalid step in bar_type.spec.step: 5 for aggregation=HOUR. step must evenly divide 24"
1329 )]
1330 #[case(
1331 BarAggregation::Hour,
1332 13,
1333 "Invalid step in bar_type.spec.step: 13 for aggregation=HOUR. step must evenly divide 24"
1334 )]
1335 #[case(
1336 BarAggregation::Hour,
1337 24,
1338 "Invalid step in bar_type.spec.step: 24 for aggregation=HOUR. step must not be 24"
1339 )]
1340 #[case(
1341 BarAggregation::Month,
1342 5,
1343 "Invalid step in bar_type.spec.step: 5 for aggregation=MONTH. step must evenly divide 12"
1344 )]
1345 fn test_bar_specification_new_checked_invalid_periodic_step(
1346 #[case] aggregation: BarAggregation,
1347 #[case] step: usize,
1348 #[case] expected: &str,
1349 ) {
1350 let result = BarSpecification::new_checked(step, aggregation, PriceType::Last);
1351
1352 assert!(result.unwrap_err().to_string().starts_with(expected));
1353 }
1354
1355 #[rstest]
1356 #[case(BarAggregation::Day)]
1357 #[case(BarAggregation::Week)]
1358 #[case(BarAggregation::Year)]
1359 #[case(BarAggregation::Tick)]
1360 #[case(BarAggregation::TickImbalance)]
1361 #[case(BarAggregation::TickRuns)]
1362 #[case(BarAggregation::Volume)]
1363 #[case(BarAggregation::VolumeImbalance)]
1364 #[case(BarAggregation::VolumeRuns)]
1365 #[case(BarAggregation::Value)]
1366 #[case(BarAggregation::ValueImbalance)]
1367 #[case(BarAggregation::ValueRuns)]
1368 #[case(BarAggregation::Renko)]
1369 fn test_bar_specification_new_checked_allows_non_periodic_steps(
1370 #[case] aggregation: BarAggregation,
1371 ) {
1372 let result = BarSpecification::new_checked(7, aggregation, PriceType::Last);
1373
1374 assert!(result.is_ok());
1375 }
1376
1377 #[rstest]
1378 #[case(BarAggregation::Day, 213_503)]
1379 #[case(BarAggregation::Week, 30_500)]
1380 #[case(BarAggregation::Year, 584)]
1381 fn test_bar_specification_new_checked_accepts_max_interval_step(
1382 #[case] aggregation: BarAggregation,
1383 #[case] step: usize,
1384 ) {
1385 let spec = BarSpecification::new_checked(step, aggregation, PriceType::Last).unwrap();
1386 let interval = spec.timedelta();
1387 let interval_ns = u64::try_from(interval.as_nanos()).unwrap();
1388
1389 assert_eq!(spec.step.get(), step);
1390 assert_eq!(spec.aggregation, aggregation);
1391 assert_eq!(
1392 get_bar_interval_ns(&BarType::new(
1393 InstrumentId::from("BTCUSDT-PERP.BINANCE"),
1394 spec,
1395 AggregationSource::Internal,
1396 ))
1397 .as_u64(),
1398 interval_ns
1399 );
1400 }
1401
1402 #[rstest]
1403 #[case(BarAggregation::Day, 213_504)]
1404 #[case(BarAggregation::Week, 30_501)]
1405 #[case(BarAggregation::Year, 585)]
1406 fn test_bar_specification_new_checked_rejects_unrepresentable_interval(
1407 #[case] aggregation: BarAggregation,
1408 #[case] step: usize,
1409 ) {
1410 let result = BarSpecification::new_checked(step, aggregation, PriceType::Last);
1411
1412 assert!(
1413 result
1414 .unwrap_err()
1415 .to_string()
1416 .contains("interval overflows nanoseconds")
1417 );
1418 }
1419
1420 #[rstest]
1421 #[should_panic(expected = "interval overflows nanoseconds")]
1422 fn test_bar_specification_new_unrepresentable_interval_panics() {
1423 let _ = BarSpecification::new(213_504, BarAggregation::Day, PriceType::Last);
1424 }
1425
1426 #[rstest]
1427 fn test_bar_specification_new_checked_accepts_12_month_interval() {
1428 let spec =
1429 BarSpecification::new_checked(12, BarAggregation::Month, PriceType::Last).unwrap();
1430
1431 assert_eq!(spec, BAR_SPEC_12_MONTH_LAST);
1432 assert_eq!(spec.timedelta(), duration_days(360));
1433 assert_eq!(
1434 u64::try_from(spec.timedelta().as_nanos()).unwrap(),
1435 31_104_000_000_000_000
1436 );
1437 }
1438
1439 #[rstest]
1440 fn test_try_time_interval_covers_derived_multipliers() {
1441 let i64_max = usize::try_from(i64::MAX).unwrap();
1442
1443 assert!(
1444 BarSpecification::new_checked(i64_max, BarAggregation::Week, PriceType::Last)
1445 .unwrap_err()
1446 .to_string()
1447 .contains("step overflows i64 days")
1448 );
1449 assert!(
1450 try_time_interval(usize::MAX, BarAggregation::Day)
1451 .unwrap_err()
1452 .to_string()
1453 .contains("step exceeds i64 range")
1454 );
1455 assert!(
1456 try_time_interval(i64_max, BarAggregation::Week)
1457 .unwrap_err()
1458 .to_string()
1459 .contains("step overflows i64 days")
1460 );
1461 assert!(
1462 try_time_interval(i64_max, BarAggregation::Month)
1463 .unwrap_err()
1464 .to_string()
1465 .contains("step overflows i64 days")
1466 );
1467 assert!(
1468 try_time_interval(i64_max, BarAggregation::Year)
1469 .unwrap_err()
1470 .to_string()
1471 .contains("step overflows i64 days")
1472 );
1473 assert!(
1474 try_duration_days(i64::MAX)
1475 .unwrap_err()
1476 .to_string()
1477 .contains("days overflow i64 hours")
1478 );
1479 assert!(
1480 try_duration_days(i64::MAX / 24)
1481 .unwrap_err()
1482 .to_string()
1483 .contains("days exceed signed duration range")
1484 );
1485 }
1486
1487 #[rstest]
1488 fn test_bar_specification_parse_and_builder_reject_unrepresentable_interval() {
1489 let step = 30_501;
1490 let json = format!(r#"{{"step":{step},"aggregation":"WEEK","price_type":"LAST"}}"#);
1491
1492 assert!(serde_json::from_str::<BarSpecification>(&json).is_err());
1493 assert!(
1494 BarSpecificationBuilder::default()
1495 .step(NonZeroUsize::new(step).unwrap())
1496 .aggregation(BarAggregation::Week)
1497 .price_type(PriceType::Last)
1498 .build()
1499 .is_err()
1500 );
1501 assert!(
1502 BarType::from_str(&format!("BTCUSDT-PERP.BINANCE-{step}-WEEK-LAST-INTERNAL")).is_err()
1503 );
1504 assert_eq!(
1505 BarType::from_str("BTCUSDT-PERP.BINANCE-30500-WEEK-LAST-INTERNAL")
1506 .unwrap()
1507 .spec()
1508 .timedelta(),
1509 duration_days(213_500)
1510 );
1511 }
1512
1513 #[rstest]
1514 #[case(BarAggregation::Millisecond, 1, SignedDuration::from_millis(1))]
1515 #[case(BarAggregation::Millisecond, 10, SignedDuration::from_millis(10))]
1516 #[case(BarAggregation::Second, 1, SignedDuration::from_secs(1))]
1517 #[case(BarAggregation::Second, 15, SignedDuration::from_secs(15))]
1518 #[case(BarAggregation::Minute, 1, SignedDuration::from_mins(1))]
1519 #[case(BarAggregation::Minute, 30, SignedDuration::from_mins(30))]
1520 #[case(BarAggregation::Hour, 1, SignedDuration::from_hours(1))]
1521 #[case(BarAggregation::Hour, 4, SignedDuration::from_hours(4))]
1522 #[case(BarAggregation::Day, 1, duration_days(1))]
1523 #[case(BarAggregation::Day, 2, duration_days(2))]
1524 #[case(BarAggregation::Week, 1, duration_days(7))]
1525 #[case(BarAggregation::Week, 2, duration_days(14))]
1526 #[case(BarAggregation::Month, 1, duration_days(30))]
1527 #[case(BarAggregation::Month, 3, duration_days(90))]
1528 #[case(BarAggregation::Year, 1, duration_days(365))]
1529 #[case(BarAggregation::Year, 2, duration_days(730))]
1530 #[should_panic(expected = "Aggregation not time based")]
1531 #[case(BarAggregation::Tick, 1, SignedDuration::ZERO)]
1532 fn test_get_bar_interval(
1533 #[case] aggregation: BarAggregation,
1534 #[case] step: usize,
1535 #[case] expected: SignedDuration,
1536 ) {
1537 let bar_type = BarType::Standard {
1538 instrument_id: InstrumentId::from("BTCUSDT-PERP.BINANCE"),
1539 spec: BarSpecification::new(step, aggregation, PriceType::Last),
1540 aggregation_source: AggregationSource::Internal,
1541 };
1542
1543 let interval = get_bar_interval(&bar_type);
1544 assert_eq!(interval, expected);
1545 }
1546
1547 #[rstest]
1548 #[case(BarAggregation::Millisecond, 1, DurationNanos::new(1_000_000))]
1549 #[case(BarAggregation::Millisecond, 10, DurationNanos::new(10_000_000))]
1550 #[case(BarAggregation::Second, 1, DurationNanos::new(1_000_000_000))]
1551 #[case(BarAggregation::Second, 10, DurationNanos::new(10_000_000_000))]
1552 #[case(BarAggregation::Minute, 1, DurationNanos::new(60_000_000_000))]
1553 #[case(BarAggregation::Minute, 30, DurationNanos::new(1_800_000_000_000))]
1554 #[case(BarAggregation::Hour, 1, DurationNanos::new(3_600_000_000_000))]
1555 #[case(BarAggregation::Hour, 4, DurationNanos::new(14_400_000_000_000))]
1556 #[case(BarAggregation::Day, 1, DurationNanos::new(86_400_000_000_000))]
1557 #[case(BarAggregation::Day, 2, DurationNanos::new(172_800_000_000_000))]
1558 #[case(BarAggregation::Week, 1, DurationNanos::new(604_800_000_000_000))]
1559 #[case(BarAggregation::Week, 2, DurationNanos::new(1_209_600_000_000_000))]
1560 #[case(BarAggregation::Month, 1, DurationNanos::new(2_592_000_000_000_000))]
1561 #[case(BarAggregation::Month, 3, DurationNanos::new(7_776_000_000_000_000))]
1562 #[case(BarAggregation::Year, 1, DurationNanos::new(31_536_000_000_000_000))]
1563 #[case(BarAggregation::Year, 2, DurationNanos::new(63_072_000_000_000_000))]
1564 #[should_panic(expected = "Aggregation not time based")]
1565 #[case(BarAggregation::Tick, 1, DurationNanos::new(0))]
1566 fn test_get_bar_interval_ns(
1567 #[case] aggregation: BarAggregation,
1568 #[case] step: usize,
1569 #[case] expected: DurationNanos,
1570 ) {
1571 let bar_type = BarType::Standard {
1572 instrument_id: InstrumentId::from("BTCUSDT-PERP.BINANCE"),
1573 spec: BarSpecification::new(step, aggregation, PriceType::Last),
1574 aggregation_source: AggregationSource::Internal,
1575 };
1576
1577 let interval_ns = get_bar_interval_ns(&bar_type);
1578 assert_eq!(interval_ns, expected);
1579 }
1580
1581 fn bar_type_with_raw_step(step: usize, aggregation: BarAggregation) -> BarType {
1582 let spec = BarSpecification {
1584 step: NonZeroUsize::new(step).unwrap(),
1585 aggregation,
1586 price_type: PriceType::Last,
1587 };
1588 BarType::new(
1589 InstrumentId::from("BTCUSDT-PERP.BINANCE"),
1590 spec,
1591 AggregationSource::Internal,
1592 )
1593 }
1594
1595 #[rstest]
1596 #[should_panic(expected = "`step` exceeds i64 range")]
1597 fn test_get_bar_interval_step_exceeds_i64_panics() {
1598 let bar_type = bar_type_with_raw_step(usize::MAX, BarAggregation::Second);
1599 let _ = get_bar_interval(&bar_type);
1600 }
1601
1602 #[rstest]
1603 #[should_panic(expected = "`step` overflows i64 days")]
1604 fn test_get_bar_interval_week_step_overflow_panics() {
1605 let step = usize::try_from(i64::MAX).unwrap();
1606 let bar_type = bar_type_with_raw_step(step, BarAggregation::Week);
1607 let _ = get_bar_interval(&bar_type);
1608 }
1609
1610 #[rstest]
1611 #[should_panic(expected = "`step` overflows i64 days")]
1612 fn test_timedelta_year_step_overflow_panics() {
1613 let step = usize::try_from(i64::MAX).unwrap();
1614 let bar_type = bar_type_with_raw_step(step, BarAggregation::Year);
1615 let _ = bar_type.spec().timedelta();
1616 }
1617
1618 #[rstest]
1619 #[should_panic(expected = "`step` exceeds u32 range for month arithmetic")]
1620 fn test_get_time_bar_start_month_step_exceeds_u32_panics() {
1621 let bar_type = bar_type_with_raw_step(1_usize << 40, BarAggregation::Month);
1622 let now = timestamp("2024-07-21T12:00:00Z");
1623 let _ = get_time_bar_start(now, &bar_type, None);
1624 }
1625
1626 #[rstest]
1627 #[should_panic(expected = "`step` exceeds i32 range for year arithmetic")]
1628 fn test_get_time_bar_start_year_step_exceeds_i32_panics() {
1629 let bar_type = bar_type_with_raw_step(1_usize << 40, BarAggregation::Year);
1630 let now = timestamp("2024-07-21T12:00:00Z");
1631 let _ = get_time_bar_start(now, &bar_type, None);
1632 }
1633
1634 #[rstest]
1635 #[should_panic(expected = "year exceeds Jiff supported range")]
1636 fn test_get_time_bar_start_year_step_exceeds_jiff_range_panics() {
1637 let bar_type = bar_type_with_raw_step(32_000, BarAggregation::Year);
1638 let now = timestamp("2024-07-21T12:00:00Z");
1639 let _ = get_time_bar_start(now, &bar_type, None);
1640 }
1641
1642 #[rstest]
1643 #[case::millisecond(
1644 Timestamp::new(1_658_349_296, 123_000_000).unwrap(), BarAggregation::Millisecond,
1646 1,
1647 Timestamp::new(1_658_349_296, 123_000_000).unwrap(), )]
1649 #[rstest]
1650 #[case::millisecond(
1651 Timestamp::new(1_658_349_296, 123_000_000).unwrap(), BarAggregation::Millisecond,
1653 10,
1654 Timestamp::new(1_658_349_296, 120_000_000).unwrap(), )]
1656 #[case::second(
1657 timestamp("2024-07-21T12:34:56Z"),
1658 BarAggregation::Second,
1659 1,
1660 timestamp("2024-07-21T12:34:56Z")
1661 )]
1662 #[case::second(
1663 timestamp("2024-07-21T12:34:56Z"),
1664 BarAggregation::Second,
1665 5,
1666 timestamp("2024-07-21T12:34:55Z")
1667 )]
1668 #[case::minute(
1669 timestamp("2024-07-21T12:34:56Z"),
1670 BarAggregation::Minute,
1671 1,
1672 timestamp("2024-07-21T12:34:00Z")
1673 )]
1674 #[case::minute(
1675 timestamp("2024-07-21T12:34:56Z"),
1676 BarAggregation::Minute,
1677 5,
1678 timestamp("2024-07-21T12:30:00Z")
1679 )]
1680 #[case::hour(
1681 timestamp("2024-07-21T12:34:56Z"),
1682 BarAggregation::Hour,
1683 1,
1684 timestamp("2024-07-21T12:00:00Z")
1685 )]
1686 #[case::hour(
1687 timestamp("2024-07-21T12:34:56Z"),
1688 BarAggregation::Hour,
1689 2,
1690 timestamp("2024-07-21T12:00:00Z")
1691 )]
1692 #[case::day(
1693 timestamp("2024-07-21T12:34:56Z"),
1694 BarAggregation::Day,
1695 1,
1696 timestamp("2024-07-21T00:00:00Z")
1697 )]
1698 fn test_get_time_bar_start(
1699 #[case] now: Timestamp,
1700 #[case] aggregation: BarAggregation,
1701 #[case] step: usize,
1702 #[case] expected: Timestamp,
1703 ) {
1704 let bar_type = BarType::Standard {
1705 instrument_id: InstrumentId::from("BTCUSDT-PERP.BINANCE"),
1706 spec: BarSpecification::new(step, aggregation, PriceType::Last),
1707 aggregation_source: AggregationSource::Internal,
1708 };
1709
1710 let start_time = get_time_bar_start(now, &bar_type, None);
1711 assert_eq!(start_time, expected);
1712 }
1713
1714 #[rstest]
1715 fn test_bar_spec_string_reprs() {
1716 let bar_spec = BarSpecification::new(1, BarAggregation::Minute, PriceType::Bid);
1717 assert_eq!(bar_spec.to_string(), "1-MINUTE-BID");
1718 assert_eq!(format!("{bar_spec}"), "1-MINUTE-BID");
1719 }
1720
1721 #[rstest]
1722 fn test_bar_type_parse_valid() {
1723 let input = "BTCUSDT-PERP.BINANCE-1-MINUTE-LAST-EXTERNAL";
1724 let bar_type = BarType::from(input);
1725
1726 assert_eq!(
1727 bar_type.instrument_id(),
1728 InstrumentId::from("BTCUSDT-PERP.BINANCE")
1729 );
1730 assert_eq!(
1731 bar_type.spec(),
1732 BarSpecification::new(1, BarAggregation::Minute, PriceType::Last)
1733 );
1734 assert_eq!(bar_type.aggregation_source(), AggregationSource::External);
1735 assert_eq!(bar_type, BarType::from(input));
1736 }
1737
1738 #[rstest]
1739 #[case("BTCUSDT-PERP.BINANCE-1-MINUTE-LAST-EXTERNAL", true, false)]
1740 #[case("BTCUSDT-PERP.BINANCE-1-MINUTE-LAST-INTERNAL", false, true)]
1741 #[case(
1742 "BTCUSDT-PERP.BINANCE-2-MINUTE-LAST-INTERNAL@1-MINUTE-EXTERNAL",
1743 false,
1744 true
1745 )]
1746 #[case(
1747 "BTCUSDT-PERP.BINANCE-2-MINUTE-LAST-EXTERNAL@1-MINUTE-INTERNAL",
1748 true,
1749 false
1750 )]
1751 fn test_bar_type_aggregation_source_predicates(
1752 #[case] input: &str,
1753 #[case] expected_external: bool,
1754 #[case] expected_internal: bool,
1755 ) {
1756 let bar_type = BarType::from(input);
1757 assert_eq!(bar_type.is_externally_aggregated(), expected_external);
1758 assert_eq!(bar_type.is_internally_aggregated(), expected_internal);
1759 }
1760
1761 #[rstest]
1762 fn test_bar_type_composite_aggregation_source_predicates_track_inner() {
1763 let bar_type =
1764 BarType::from("BTCUSDT-PERP.BINANCE-2-MINUTE-LAST-INTERNAL@1-MINUTE-EXTERNAL");
1765
1766 assert!(bar_type.is_internally_aggregated());
1767 assert!(!bar_type.is_externally_aggregated());
1768
1769 let composite = bar_type.composite();
1770 assert!(composite.is_externally_aggregated());
1771 assert!(!composite.is_internally_aggregated());
1772 }
1773
1774 #[rstest]
1775 fn test_bar_type_from_str_with_utf8_symbol() {
1776 let non_ascii_instrument = "TËST-PÉRP.BINANCE";
1777 let non_ascii_bar_type = "TËST-PÉRP.BINANCE-1-MINUTE-LAST-EXTERNAL";
1778
1779 let bar_type = BarType::from_str(non_ascii_bar_type).unwrap();
1780
1781 assert_eq!(
1782 bar_type.instrument_id(),
1783 InstrumentId::from_str(non_ascii_instrument).unwrap()
1784 );
1785 assert_eq!(
1786 bar_type.spec(),
1787 BarSpecification::new(1, BarAggregation::Minute, PriceType::Last)
1788 );
1789 assert_eq!(bar_type.aggregation_source(), AggregationSource::External);
1790 assert_eq!(bar_type.to_string(), non_ascii_bar_type);
1791 }
1792
1793 #[rstest]
1794 fn test_bar_type_composite_parse_valid() {
1795 let input = "BTCUSDT-PERP.BINANCE-2-MINUTE-LAST-INTERNAL@1-MINUTE-EXTERNAL";
1796 let bar_type = BarType::from(input);
1797 let standard = bar_type.standard();
1798
1799 assert_eq!(
1800 bar_type.instrument_id(),
1801 InstrumentId::from("BTCUSDT-PERP.BINANCE")
1802 );
1803 assert_eq!(
1804 bar_type.spec(),
1805 BarSpecification::new(2, BarAggregation::Minute, PriceType::Last,)
1806 );
1807 assert_eq!(bar_type.aggregation_source(), AggregationSource::Internal);
1808 assert_eq!(bar_type, BarType::from(input));
1809 assert!(bar_type.is_composite());
1810
1811 assert_eq!(
1812 standard.instrument_id(),
1813 InstrumentId::from("BTCUSDT-PERP.BINANCE")
1814 );
1815 assert_eq!(
1816 standard.spec(),
1817 BarSpecification::new(2, BarAggregation::Minute, PriceType::Last,)
1818 );
1819 assert_eq!(standard.aggregation_source(), AggregationSource::Internal);
1820 assert!(standard.is_standard());
1821
1822 let composite = bar_type.composite();
1823 let composite_input = "BTCUSDT-PERP.BINANCE-1-MINUTE-LAST-EXTERNAL";
1824
1825 assert_eq!(
1826 composite.instrument_id(),
1827 InstrumentId::from("BTCUSDT-PERP.BINANCE")
1828 );
1829 assert_eq!(
1830 composite.spec(),
1831 BarSpecification::new(1, BarAggregation::Minute, PriceType::Last,)
1832 );
1833 assert_eq!(composite.aggregation_source(), AggregationSource::External);
1834 assert_eq!(composite, BarType::from(composite_input));
1835 assert!(composite.is_standard());
1836 }
1837
1838 #[rstest]
1839 fn test_bar_type_parse_invalid_token_pos_0() {
1840 let input = "BTCUSDT-PERP-1-MINUTE-LAST-INTERNAL";
1841 let result = BarType::from_str(input);
1842
1843 assert_eq!(
1844 result.unwrap_err().to_string(),
1845 format!(
1846 "Error parsing `BarType` from '{input}', invalid token: 'BTCUSDT-PERP' at position 0"
1847 )
1848 );
1849 }
1850
1851 #[rstest]
1852 fn test_bar_type_parse_invalid_token_pos_1() {
1853 let input = "BTCUSDT-PERP.BINANCE-INVALID-MINUTE-LAST-INTERNAL";
1854 let result = BarType::from_str(input);
1855
1856 assert_eq!(
1857 result.unwrap_err().to_string(),
1858 format!(
1859 "Error parsing `BarType` from '{input}', invalid token: 'INVALID' at position 1"
1860 )
1861 );
1862 }
1863
1864 #[rstest]
1865 fn test_bar_type_parse_invalid_spec_step() {
1866 let input = "BTCUSDT-PERP.BINANCE-60-MINUTE-LAST-INTERNAL";
1867 let result = BarType::from_str(input);
1868
1869 assert_eq!(
1870 result.unwrap_err().to_string(),
1871 format!("Error parsing `BarType` from '{input}', invalid token: '60' at position 1")
1872 );
1873 }
1874
1875 #[rstest]
1876 fn test_bar_type_parse_invalid_token_pos_2() {
1877 let input = "BTCUSDT-PERP.BINANCE-1-INVALID-LAST-INTERNAL";
1878 let result = BarType::from_str(input);
1879
1880 assert_eq!(
1881 result.unwrap_err().to_string(),
1882 format!(
1883 "Error parsing `BarType` from '{input}', invalid token: 'INVALID' at position 2"
1884 )
1885 );
1886 }
1887
1888 #[rstest]
1889 fn test_bar_type_parse_invalid_token_pos_3() {
1890 let input = "BTCUSDT-PERP.BINANCE-1-MINUTE-INVALID-INTERNAL";
1891 let result = BarType::from_str(input);
1892
1893 assert_eq!(
1894 result.unwrap_err().to_string(),
1895 format!(
1896 "Error parsing `BarType` from '{input}', invalid token: 'INVALID' at position 3"
1897 )
1898 );
1899 }
1900
1901 #[rstest]
1902 fn test_bar_type_parse_invalid_token_pos_4() {
1903 let input = "BTCUSDT-PERP.BINANCE-1-MINUTE-BID-INVALID";
1904 let result = BarType::from_str(input);
1905
1906 assert!(result.is_err());
1907 assert_eq!(
1908 result.unwrap_err().to_string(),
1909 format!(
1910 "Error parsing `BarType` from '{input}', invalid token: 'INVALID' at position 4"
1911 )
1912 );
1913 }
1914
1915 #[rstest]
1916 fn test_bar_type_parse_invalid_token_pos_5() {
1917 let input = "BTCUSDT-PERP.BINANCE-2-MINUTE-LAST-INTERNAL@INVALID-MINUTE-EXTERNAL";
1918 let result = BarType::from_str(input);
1919
1920 assert!(result.is_err());
1921 assert_eq!(
1922 result.unwrap_err().to_string(),
1923 format!(
1924 "Error parsing `BarType` from '{input}', invalid token: 'INVALID' at position 5"
1925 )
1926 );
1927 }
1928
1929 #[rstest]
1930 fn test_bar_type_parse_invalid_composite_spec_step() {
1931 let input = "BTCUSDT-PERP.BINANCE-2-MINUTE-LAST-INTERNAL@60-MINUTE-EXTERNAL";
1932 let result = BarType::from_str(input);
1933
1934 assert!(result.is_err());
1935 assert_eq!(
1936 result.unwrap_err().to_string(),
1937 format!("Error parsing `BarType` from '{input}', invalid token: '60' at position 5")
1938 );
1939 }
1940
1941 #[rstest]
1942 fn test_bar_type_parse_invalid_token_pos_6() {
1943 let input = "BTCUSDT-PERP.BINANCE-2-MINUTE-LAST-INTERNAL@1-INVALID-EXTERNAL";
1944 let result = BarType::from_str(input);
1945
1946 assert!(result.is_err());
1947 assert_eq!(
1948 result.unwrap_err().to_string(),
1949 format!(
1950 "Error parsing `BarType` from '{input}', invalid token: 'INVALID' at position 6"
1951 )
1952 );
1953 }
1954
1955 #[rstest]
1956 fn test_bar_type_parse_invalid_token_pos_7() {
1957 let input = "BTCUSDT-PERP.BINANCE-2-MINUTE-LAST-INTERNAL@1-MINUTE-INVALID";
1958 let result = BarType::from_str(input);
1959
1960 assert!(result.is_err());
1961 assert_eq!(
1962 result.unwrap_err().to_string(),
1963 format!(
1964 "Error parsing `BarType` from '{input}', invalid token: 'INVALID' at position 7"
1965 )
1966 );
1967 }
1968
1969 #[rstest]
1970 fn test_bar_type_parse_rejects_extra_composite_segment() {
1971 let input = "BTCUSDT-PERP.BINANCE-2-MINUTE-LAST-INTERNAL@1-MINUTE-EXTERNAL@1-HOUR-EXTERNAL";
1972 let result = BarType::from_str(input);
1973
1974 assert_eq!(
1975 result.unwrap_err().to_string(),
1976 format!(
1977 "Error parsing `BarType` from '{input}', invalid token: '1-HOUR-EXTERNAL' at position 5"
1978 )
1979 );
1980 }
1981
1982 #[rstest]
1983 fn test_bar_type_equality() {
1984 let instrument_id1 = InstrumentId {
1985 symbol: Symbol::new("AUD/USD"),
1986 venue: Venue::new("SIM"),
1987 };
1988 let instrument_id2 = InstrumentId {
1989 symbol: Symbol::new("GBP/USD"),
1990 venue: Venue::new("SIM"),
1991 };
1992 let bar_spec = BarSpecification::new(1, BarAggregation::Minute, PriceType::Bid);
1993 let bar_type1 = BarType::Standard {
1994 instrument_id: instrument_id1,
1995 spec: bar_spec,
1996 aggregation_source: AggregationSource::External,
1997 };
1998 let bar_type2 = BarType::Standard {
1999 instrument_id: instrument_id1,
2000 spec: bar_spec,
2001 aggregation_source: AggregationSource::External,
2002 };
2003 let bar_type3 = BarType::Standard {
2004 instrument_id: instrument_id2,
2005 spec: bar_spec,
2006 aggregation_source: AggregationSource::External,
2007 };
2008 assert_eq!(bar_type1, bar_type1);
2009 assert_eq!(bar_type1, bar_type2);
2010 assert_ne!(bar_type1, bar_type3);
2011 }
2012
2013 #[rstest]
2014 fn test_bar_type_id_spec_key_ignores_aggregation_source() {
2015 let bar_type_external = BarType::from_str("ESM4.XCME-1-MINUTE-LAST-EXTERNAL").unwrap();
2016 let bar_type_internal = BarType::from_str("ESM4.XCME-1-MINUTE-LAST-INTERNAL").unwrap();
2017
2018 assert_ne!(bar_type_external, bar_type_internal);
2020
2021 assert_eq!(
2023 bar_type_external.id_spec_key(),
2024 bar_type_internal.id_spec_key()
2025 );
2026
2027 let (instrument_id, spec) = bar_type_external.id_spec_key();
2029 assert_eq!(instrument_id, bar_type_external.instrument_id());
2030 assert_eq!(spec, bar_type_external.spec());
2031 }
2032
2033 #[rstest]
2034 fn test_bar_type_comparison() {
2035 let instrument_id1 = InstrumentId {
2036 symbol: Symbol::new("AUD/USD"),
2037 venue: Venue::new("SIM"),
2038 };
2039
2040 let instrument_id2 = InstrumentId {
2041 symbol: Symbol::new("GBP/USD"),
2042 venue: Venue::new("SIM"),
2043 };
2044 let bar_spec = BarSpecification::new(1, BarAggregation::Minute, PriceType::Bid);
2045 let bar_spec2 = BarSpecification::new(2, BarAggregation::Minute, PriceType::Bid);
2046 let bar_type1 = BarType::Standard {
2047 instrument_id: instrument_id1,
2048 spec: bar_spec,
2049 aggregation_source: AggregationSource::External,
2050 };
2051 let bar_type2 = BarType::Standard {
2052 instrument_id: instrument_id1,
2053 spec: bar_spec,
2054 aggregation_source: AggregationSource::External,
2055 };
2056 let bar_type3 = BarType::Standard {
2057 instrument_id: instrument_id2,
2058 spec: bar_spec,
2059 aggregation_source: AggregationSource::External,
2060 };
2061 let bar_type4 = BarType::Composite {
2062 instrument_id: instrument_id2,
2063 spec: bar_spec2,
2064 aggregation_source: AggregationSource::Internal,
2065
2066 composite_step: 1,
2067 composite_aggregation: BarAggregation::Minute,
2068 composite_aggregation_source: AggregationSource::External,
2069 };
2070
2071 assert!(bar_type1 <= bar_type2);
2072 assert!(bar_type1 < bar_type3);
2073 assert!(bar_type3 > bar_type1);
2074 assert!(bar_type3 >= bar_type1);
2075 assert!(bar_type4 >= bar_type1);
2076 }
2077
2078 #[rstest]
2079 fn test_bar_new() {
2080 let bar_type = BarType::from("AAPL.XNAS-1-MINUTE-LAST-INTERNAL");
2081 let open = Price::from("100.0");
2082 let high = Price::from("105.0");
2083 let low = Price::from("95.0");
2084 let close = Price::from("102.0");
2085 let volume = Quantity::from("1000");
2086 let ts_event = UnixNanos::from(1_000_000);
2087 let ts_init = UnixNanos::from(2_000_000);
2088
2089 let bar = Bar::new(bar_type, open, high, low, close, volume, ts_event, ts_init);
2090
2091 assert_eq!(bar.bar_type, bar_type);
2092 assert_eq!(bar.open, open);
2093 assert_eq!(bar.high, high);
2094 assert_eq!(bar.low, low);
2095 assert_eq!(bar.close, close);
2096 assert_eq!(bar.volume, volume);
2097 assert_eq!(bar.ts_event, ts_event);
2098 assert_eq!(bar.ts_init, ts_init);
2099 }
2100
2101 #[rstest]
2102 #[case("100.0", "90.0", "95.0", "92.0", "high >= open")]
2103 #[case("100.0", "105.0", "110.0", "102.0", "high >= low")]
2104 #[case("100.0", "105.0", "95.0", "110.0", "high >= close")]
2105 #[case("100.0", "105.0", "95.0", "90.0", "low <= close")]
2106 #[case("100.0", "110.0", "105.0", "108.0", "low <= open")]
2107 #[case("100.0", "90.0", "110.0", "120.0", "high >= open")] fn test_bar_new_checked_conditions(
2109 #[case] open: &str,
2110 #[case] high: &str,
2111 #[case] low: &str,
2112 #[case] close: &str,
2113 #[case] expected: &str,
2114 ) {
2115 let bar_type = BarType::from("AAPL.XNAS-1-MINUTE-LAST-INTERNAL");
2116 let open = Price::from(open);
2117 let high = Price::from(high);
2118 let low = Price::from(low);
2119 let close = Price::from(close);
2120 let volume = Quantity::from("1000");
2121 let ts_event = UnixNanos::from(1_000_000);
2122 let ts_init = UnixNanos::from(2_000_000);
2123
2124 let result = Bar::new_checked(bar_type, open, high, low, close, volume, ts_event, ts_init);
2125
2126 let error = result.unwrap_err();
2127 assert!(
2128 error.to_string().contains(expected),
2129 "unexpected message: {error}"
2130 );
2131 }
2132
2133 #[rstest]
2134 fn test_bar_equality() {
2135 let instrument_id = InstrumentId {
2136 symbol: Symbol::new("AUDUSD"),
2137 venue: Venue::new("SIM"),
2138 };
2139 let bar_spec = BarSpecification::new(1, BarAggregation::Minute, PriceType::Bid);
2140 let bar_type = BarType::Standard {
2141 instrument_id,
2142 spec: bar_spec,
2143 aggregation_source: AggregationSource::External,
2144 };
2145 let bar1 = Bar {
2146 bar_type,
2147 open: Price::from("1.00001"),
2148 high: Price::from("1.00004"),
2149 low: Price::from("1.00002"),
2150 close: Price::from("1.00003"),
2151 volume: Quantity::from("100000"),
2152 ts_event: UnixNanos::default(),
2153 ts_init: UnixNanos::from(1),
2154 };
2155
2156 let bar2 = Bar {
2157 bar_type,
2158 open: Price::from("1.00000"),
2159 high: Price::from("1.00004"),
2160 low: Price::from("1.00002"),
2161 close: Price::from("1.00003"),
2162 volume: Quantity::from("100000"),
2163 ts_event: UnixNanos::default(),
2164 ts_init: UnixNanos::from(1),
2165 };
2166 assert_eq!(bar1, bar1);
2167 assert_ne!(bar1, bar2);
2168 }
2169
2170 #[rstest]
2171 fn test_json_serialization() {
2172 let bar = Bar::default();
2173 let serialized = bar.to_json_bytes().unwrap();
2174 let deserialized = Bar::from_json_bytes(serialized.as_ref()).unwrap();
2175 assert_eq!(deserialized, bar);
2176 }
2177
2178 #[rstest]
2179 fn test_msgpack_serialization() {
2180 let bar = Bar::default();
2181 let serialized = bar.to_msgpack_bytes().unwrap();
2182 let deserialized = Bar::from_msgpack_bytes(serialized.as_ref()).unwrap();
2183 assert_eq!(deserialized, bar);
2184 }
2185
2186 #[rstest]
2187 fn test_bar_deserialization_rejects_invalid_ohlc() {
2188 let json = r#"{
2189 "type": "Bar",
2190 "bar_type": "AUD/USD.SIM-1-MINUTE-BID-EXTERNAL",
2191 "open": "1.00010",
2192 "high": "1.00000",
2193 "low": "1.00020",
2194 "close": "1.00010",
2195 "volume": "100000",
2196 "ts_event": 0,
2197 "ts_init": 0
2198 }"#;
2199
2200 let result = Bar::from_json_bytes(json.as_bytes());
2201 assert!(
2202 result.is_err(),
2203 "high < low must fail deserialization, was {result:?}"
2204 );
2205 }
2206
2207 #[rstest]
2208 fn test_bar_specification_deserialization_rejects_invalid_step() {
2209 let json = r#"{"step":7,"aggregation":"MINUTE","price_type":"LAST"}"#;
2210
2211 let result = serde_json::from_str::<BarSpecification>(json);
2212 assert!(
2213 result.is_err(),
2214 "non-periodic step must fail deserialization, was {result:?}"
2215 );
2216 }
2217
2218 #[rstest]
2219 fn test_bar_specification_builder_rejects_invalid_step() {
2220 let result = BarSpecificationBuilder::default()
2221 .step(NonZeroUsize::new(7).unwrap())
2222 .aggregation(BarAggregation::Minute)
2223 .price_type(PriceType::Last)
2224 .build();
2225
2226 assert!(
2227 result.is_err(),
2228 "non-periodic step must fail builder validation, was {result:?}"
2229 );
2230 }
2231
2232 #[rstest]
2233 fn test_bar_spec_12_month_round_trips() {
2234 let bar_type = BarType::new(
2237 InstrumentId::from("BTC-USDT.OKX"),
2238 BAR_SPEC_12_MONTH_LAST,
2239 AggregationSource::External,
2240 );
2241
2242 let parsed = BarType::from_str(&bar_type.to_string()).unwrap();
2243 assert_eq!(parsed, bar_type);
2244 assert_eq!(
2245 BarSpecification::new_checked(12, BarAggregation::Month, PriceType::Last).unwrap(),
2246 BAR_SPEC_12_MONTH_LAST,
2247 );
2248 }
2249
2250 #[rstest]
2251 fn test_bar_type_new_composite_checked_invalid_step() {
2252 let instrument_id = InstrumentId::from("AUD/USD.SIM");
2253 let spec = BarSpecification::new(5, BarAggregation::Minute, PriceType::Bid);
2254
2255 let result = BarType::new_composite_checked(
2256 instrument_id,
2257 spec,
2258 AggregationSource::Internal,
2259 0,
2260 BarAggregation::Minute,
2261 AggregationSource::External,
2262 );
2263
2264 assert!(
2265 result.is_err(),
2266 "zero composite step must fail, was {result:?}"
2267 );
2268 }
2269}
2270
2271#[cfg(test)]
2272mod property_tests {
2273 use std::str::FromStr;
2274
2275 use proptest::prelude::*;
2276 use rstest::rstest;
2277
2278 use super::*;
2279 use crate::identifiers::{Symbol, Venue};
2280
2281 fn symbol_strategy() -> impl Strategy<Value = &'static str> {
2282 prop::sample::select(vec![
2283 "AAPL",
2284 "BTC-PERP",
2285 "EUR/USD",
2286 "ES-MINI-4",
2287 "MSFT.OQ",
2288 "6E",
2289 ])
2290 }
2291
2292 fn venue_strategy() -> impl Strategy<Value = &'static str> {
2293 prop::sample::select(vec!["SIM", "XNAS", "GLBX", "BINANCE"])
2294 }
2295
2296 fn time_spec_strategy() -> impl Strategy<Value = (BarAggregation, usize)> {
2297 prop_oneof![
2298 (
2299 Just(BarAggregation::Millisecond),
2300 prop::sample::select(vec![1usize, 2, 5, 10, 25, 50, 100, 250, 500]),
2301 ),
2302 (
2303 Just(BarAggregation::Second),
2304 prop::sample::select(vec![1usize, 2, 3, 5, 10, 15, 30]),
2305 ),
2306 (
2307 Just(BarAggregation::Minute),
2308 prop::sample::select(vec![1usize, 2, 5, 15, 30]),
2309 ),
2310 (
2311 Just(BarAggregation::Hour),
2312 prop::sample::select(vec![1usize, 2, 4, 12]),
2313 ),
2314 (
2315 Just(BarAggregation::Day),
2316 prop::sample::select(vec![1usize, 2, 3]),
2317 ),
2318 (Just(BarAggregation::Week), Just(1usize)),
2319 ]
2320 }
2321
2322 fn spec_strategy() -> impl Strategy<Value = (BarAggregation, usize)> {
2323 prop_oneof![
2324 time_spec_strategy(),
2325 (
2328 Just(BarAggregation::Month),
2329 prop::sample::select(vec![1usize, 2, 3, 4, 6, 12]),
2330 ),
2331 (Just(BarAggregation::Tick), 1usize..=10_000),
2332 (Just(BarAggregation::Volume), 1usize..=10_000),
2333 (Just(BarAggregation::Value), 1usize..=10_000),
2334 ]
2335 }
2336
2337 fn price_type_strategy() -> impl Strategy<Value = PriceType> {
2338 prop::sample::select(vec![
2339 PriceType::Bid,
2340 PriceType::Ask,
2341 PriceType::Mid,
2342 PriceType::Last,
2343 ])
2344 }
2345
2346 fn source_strategy() -> impl Strategy<Value = AggregationSource> {
2347 prop_oneof![
2348 Just(AggregationSource::Internal),
2349 Just(AggregationSource::External),
2350 ]
2351 }
2352
2353 proptest! {
2354 #[rstest]
2355 fn prop_bar_type_string_round_trip(
2356 symbol in symbol_strategy(),
2357 venue in venue_strategy(),
2358 (aggregation, step) in spec_strategy(),
2359 price_type in price_type_strategy(),
2360 source in source_strategy(),
2361 composite in prop::option::of((time_spec_strategy(), source_strategy())),
2362 ) {
2363 let instrument_id = InstrumentId::new(Symbol::from(symbol), Venue::from(venue));
2364 let spec = BarSpecification::new(step, aggregation, price_type);
2365
2366 let bar_type = match composite {
2367 None => BarType::new(instrument_id, spec, source),
2368 Some(((composite_aggregation, composite_step), composite_source)) => {
2369 BarType::new_composite(
2370 instrument_id,
2371 spec,
2372 source,
2373 composite_step,
2374 composite_aggregation,
2375 composite_source,
2376 )
2377 }
2378 };
2379
2380 let parsed = BarType::from_str(&bar_type.to_string());
2381 prop_assert!(parsed.is_ok(), "failed to parse '{bar_type}': {parsed:?}");
2382 prop_assert_eq!(parsed.unwrap(), bar_type);
2383 }
2384
2385 #[rstest]
2386 fn prop_get_time_bar_start_alignment(
2387 (aggregation, step) in time_spec_strategy(),
2388 epoch_secs in 946_684_800i64..2_524_608_000i64,
2389 subsec_nanos in 0u32..1_000_000_000u32,
2390 ) {
2391 let instrument_id = InstrumentId::from("AAPL.XNAS");
2392 let spec = BarSpecification::new(step, aggregation, PriceType::Last);
2393 let bar_type = BarType::new(instrument_id, spec, AggregationSource::Internal);
2394
2395 let now = Timestamp::new(epoch_secs, subsec_nanos.cast_signed()).unwrap();
2396 let start = get_time_bar_start(now, &bar_type, None);
2397 let interval = get_bar_interval(&bar_type);
2398
2399 prop_assert!(start <= now, "start {start} must not be after now {now}");
2400 prop_assert!(
2401 now.duration_since(start) < interval,
2402 "now {now} must fall within one interval of start {start}"
2403 );
2404 }
2405 }
2406}