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::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};
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_SIZE_BINARY.to_string());
1220 metadata.insert("high".to_string(), FIXED_SIZE_BINARY.to_string());
1221 metadata.insert("low".to_string(), FIXED_SIZE_BINARY.to_string());
1222 metadata.insert("close".to_string(), FIXED_SIZE_BINARY.to_string());
1223 metadata.insert("volume".to_string(), FIXED_SIZE_BINARY.to_string());
1224 metadata.insert("ts_event".to_string(), "UInt64".to_string());
1225 metadata.insert("ts_init".to_string(), "UInt64".to_string());
1226 metadata
1227 }
1228}
1229
1230impl Display for Bar {
1231 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
1232 write!(
1233 f,
1234 "{},{},{},{},{},{},{}",
1235 self.bar_type, self.open, self.high, self.low, self.close, self.volume, self.ts_event
1236 )
1237 }
1238}
1239
1240impl Serializable for Bar {}
1241
1242impl HasTsInit for Bar {
1243 fn ts_init(&self) -> UnixNanos {
1244 self.ts_init
1245 }
1246}
1247
1248#[cfg(test)]
1249mod tests {
1250 use std::str::FromStr;
1251
1252 use nautilus_core::serialization::msgpack::{FromMsgPack, ToMsgPack};
1253 use rstest::rstest;
1254
1255 use super::*;
1256 use crate::identifiers::{Symbol, Venue};
1257
1258 fn timestamp(value: &str) -> Timestamp {
1259 value.parse().unwrap()
1260 }
1261
1262 #[rstest]
1263 fn test_bar_specification_new_invalid() {
1264 let result = BarSpecification::new_checked(0, BarAggregation::Tick, PriceType::Last);
1265 assert!(
1266 result
1267 .unwrap_err()
1268 .to_string()
1269 .contains("Invalid step: 0 (must be non-zero)")
1270 );
1271 }
1272
1273 #[rstest]
1274 #[should_panic(expected = "Invalid step: 0 (must be non-zero)")]
1275 fn test_bar_specification_new_checked_with_invalid_step_panics() {
1276 let aggregation = BarAggregation::Tick;
1277 let price_type = PriceType::Last;
1278
1279 let _ = BarSpecification::new(0, aggregation, price_type);
1280 }
1281
1282 #[rstest]
1283 #[should_panic(expected = "Invalid step in bar_type.spec.step: 7")]
1284 fn test_bar_specification_new_with_invalid_periodic_step_panics() {
1285 let _ = BarSpecification::new(7, BarAggregation::Minute, PriceType::Last);
1286 }
1287
1288 #[rstest]
1289 #[case(
1290 BarAggregation::Millisecond,
1291 12,
1292 "Invalid step in bar_type.spec.step: 12 for aggregation=MILLISECOND. step must evenly divide 1000"
1293 )]
1294 #[case(
1295 BarAggregation::Millisecond,
1296 1000,
1297 "Invalid step in bar_type.spec.step: 1000 for aggregation=MILLISECOND. step must not be 1000"
1298 )]
1299 #[case(
1300 BarAggregation::Second,
1301 50,
1302 "Invalid step in bar_type.spec.step: 50 for aggregation=SECOND. step must evenly divide 60"
1303 )]
1304 #[case(
1305 BarAggregation::Second,
1306 60,
1307 "Invalid step in bar_type.spec.step: 60 for aggregation=SECOND. step must not be 60"
1308 )]
1309 #[case(
1310 BarAggregation::Minute,
1311 40,
1312 "Invalid step in bar_type.spec.step: 40 for aggregation=MINUTE. step must evenly divide 60"
1313 )]
1314 #[case(
1315 BarAggregation::Minute,
1316 60,
1317 "Invalid step in bar_type.spec.step: 60 for aggregation=MINUTE. step must not be 60"
1318 )]
1319 #[case(
1320 BarAggregation::Hour,
1321 5,
1322 "Invalid step in bar_type.spec.step: 5 for aggregation=HOUR. step must evenly divide 24"
1323 )]
1324 #[case(
1325 BarAggregation::Hour,
1326 13,
1327 "Invalid step in bar_type.spec.step: 13 for aggregation=HOUR. step must evenly divide 24"
1328 )]
1329 #[case(
1330 BarAggregation::Hour,
1331 24,
1332 "Invalid step in bar_type.spec.step: 24 for aggregation=HOUR. step must not be 24"
1333 )]
1334 #[case(
1335 BarAggregation::Month,
1336 5,
1337 "Invalid step in bar_type.spec.step: 5 for aggregation=MONTH. step must evenly divide 12"
1338 )]
1339 fn test_bar_specification_new_checked_invalid_periodic_step(
1340 #[case] aggregation: BarAggregation,
1341 #[case] step: usize,
1342 #[case] expected: &str,
1343 ) {
1344 let result = BarSpecification::new_checked(step, aggregation, PriceType::Last);
1345
1346 assert!(result.unwrap_err().to_string().starts_with(expected));
1347 }
1348
1349 #[rstest]
1350 #[case(BarAggregation::Day)]
1351 #[case(BarAggregation::Week)]
1352 #[case(BarAggregation::Year)]
1353 #[case(BarAggregation::Tick)]
1354 #[case(BarAggregation::TickImbalance)]
1355 #[case(BarAggregation::TickRuns)]
1356 #[case(BarAggregation::Volume)]
1357 #[case(BarAggregation::VolumeImbalance)]
1358 #[case(BarAggregation::VolumeRuns)]
1359 #[case(BarAggregation::Value)]
1360 #[case(BarAggregation::ValueImbalance)]
1361 #[case(BarAggregation::ValueRuns)]
1362 #[case(BarAggregation::Renko)]
1363 fn test_bar_specification_new_checked_allows_non_periodic_steps(
1364 #[case] aggregation: BarAggregation,
1365 ) {
1366 let result = BarSpecification::new_checked(7, aggregation, PriceType::Last);
1367
1368 assert!(result.is_ok());
1369 }
1370
1371 #[rstest]
1372 #[case(BarAggregation::Day, 213_503)]
1373 #[case(BarAggregation::Week, 30_500)]
1374 #[case(BarAggregation::Year, 584)]
1375 fn test_bar_specification_new_checked_accepts_max_interval_step(
1376 #[case] aggregation: BarAggregation,
1377 #[case] step: usize,
1378 ) {
1379 let spec = BarSpecification::new_checked(step, aggregation, PriceType::Last).unwrap();
1380 let interval = spec.timedelta();
1381 let interval_ns = u64::try_from(interval.as_nanos()).unwrap();
1382
1383 assert_eq!(spec.step.get(), step);
1384 assert_eq!(spec.aggregation, aggregation);
1385 assert_eq!(
1386 get_bar_interval_ns(&BarType::new(
1387 InstrumentId::from("BTCUSDT-PERP.BINANCE"),
1388 spec,
1389 AggregationSource::Internal,
1390 ))
1391 .as_u64(),
1392 interval_ns
1393 );
1394 }
1395
1396 #[rstest]
1397 #[case(BarAggregation::Day, 213_504)]
1398 #[case(BarAggregation::Week, 30_501)]
1399 #[case(BarAggregation::Year, 585)]
1400 fn test_bar_specification_new_checked_rejects_unrepresentable_interval(
1401 #[case] aggregation: BarAggregation,
1402 #[case] step: usize,
1403 ) {
1404 let result = BarSpecification::new_checked(step, aggregation, PriceType::Last);
1405
1406 assert!(
1407 result
1408 .unwrap_err()
1409 .to_string()
1410 .contains("interval overflows nanoseconds")
1411 );
1412 }
1413
1414 #[rstest]
1415 #[should_panic(expected = "interval overflows nanoseconds")]
1416 fn test_bar_specification_new_unrepresentable_interval_panics() {
1417 let _ = BarSpecification::new(213_504, BarAggregation::Day, PriceType::Last);
1418 }
1419
1420 #[rstest]
1421 fn test_bar_specification_new_checked_accepts_12_month_interval() {
1422 let spec =
1423 BarSpecification::new_checked(12, BarAggregation::Month, PriceType::Last).unwrap();
1424
1425 assert_eq!(spec, BAR_SPEC_12_MONTH_LAST);
1426 assert_eq!(spec.timedelta(), duration_days(360));
1427 assert_eq!(
1428 u64::try_from(spec.timedelta().as_nanos()).unwrap(),
1429 31_104_000_000_000_000
1430 );
1431 }
1432
1433 #[rstest]
1434 fn test_try_time_interval_covers_derived_multipliers() {
1435 let i64_max = usize::try_from(i64::MAX).unwrap();
1436
1437 assert!(
1438 BarSpecification::new_checked(i64_max, BarAggregation::Week, PriceType::Last)
1439 .unwrap_err()
1440 .to_string()
1441 .contains("step overflows i64 days")
1442 );
1443 assert!(
1444 try_time_interval(usize::MAX, BarAggregation::Day)
1445 .unwrap_err()
1446 .to_string()
1447 .contains("step exceeds i64 range")
1448 );
1449 assert!(
1450 try_time_interval(i64_max, BarAggregation::Week)
1451 .unwrap_err()
1452 .to_string()
1453 .contains("step overflows i64 days")
1454 );
1455 assert!(
1456 try_time_interval(i64_max, BarAggregation::Month)
1457 .unwrap_err()
1458 .to_string()
1459 .contains("step overflows i64 days")
1460 );
1461 assert!(
1462 try_time_interval(i64_max, BarAggregation::Year)
1463 .unwrap_err()
1464 .to_string()
1465 .contains("step overflows i64 days")
1466 );
1467 assert!(
1468 try_duration_days(i64::MAX)
1469 .unwrap_err()
1470 .to_string()
1471 .contains("days overflow i64 hours")
1472 );
1473 assert!(
1474 try_duration_days(i64::MAX / 24)
1475 .unwrap_err()
1476 .to_string()
1477 .contains("days exceed signed duration range")
1478 );
1479 }
1480
1481 #[rstest]
1482 fn test_bar_specification_parse_and_builder_reject_unrepresentable_interval() {
1483 let step = 30_501;
1484 let json = format!(r#"{{"step":{step},"aggregation":"WEEK","price_type":"LAST"}}"#);
1485
1486 assert!(serde_json::from_str::<BarSpecification>(&json).is_err());
1487 assert!(
1488 BarSpecificationBuilder::default()
1489 .step(NonZeroUsize::new(step).unwrap())
1490 .aggregation(BarAggregation::Week)
1491 .price_type(PriceType::Last)
1492 .build()
1493 .is_err()
1494 );
1495 assert!(
1496 BarType::from_str(&format!("BTCUSDT-PERP.BINANCE-{step}-WEEK-LAST-INTERNAL")).is_err()
1497 );
1498 assert_eq!(
1499 BarType::from_str("BTCUSDT-PERP.BINANCE-30500-WEEK-LAST-INTERNAL")
1500 .unwrap()
1501 .spec()
1502 .timedelta(),
1503 duration_days(213_500)
1504 );
1505 }
1506
1507 #[rstest]
1508 #[case(BarAggregation::Millisecond, 1, SignedDuration::from_millis(1))]
1509 #[case(BarAggregation::Millisecond, 10, SignedDuration::from_millis(10))]
1510 #[case(BarAggregation::Second, 1, SignedDuration::from_secs(1))]
1511 #[case(BarAggregation::Second, 15, SignedDuration::from_secs(15))]
1512 #[case(BarAggregation::Minute, 1, SignedDuration::from_mins(1))]
1513 #[case(BarAggregation::Minute, 30, SignedDuration::from_mins(30))]
1514 #[case(BarAggregation::Hour, 1, SignedDuration::from_hours(1))]
1515 #[case(BarAggregation::Hour, 4, SignedDuration::from_hours(4))]
1516 #[case(BarAggregation::Day, 1, duration_days(1))]
1517 #[case(BarAggregation::Day, 2, duration_days(2))]
1518 #[case(BarAggregation::Week, 1, duration_days(7))]
1519 #[case(BarAggregation::Week, 2, duration_days(14))]
1520 #[case(BarAggregation::Month, 1, duration_days(30))]
1521 #[case(BarAggregation::Month, 3, duration_days(90))]
1522 #[case(BarAggregation::Year, 1, duration_days(365))]
1523 #[case(BarAggregation::Year, 2, duration_days(730))]
1524 #[should_panic(expected = "Aggregation not time based")]
1525 #[case(BarAggregation::Tick, 1, SignedDuration::ZERO)]
1526 fn test_get_bar_interval(
1527 #[case] aggregation: BarAggregation,
1528 #[case] step: usize,
1529 #[case] expected: SignedDuration,
1530 ) {
1531 let bar_type = BarType::Standard {
1532 instrument_id: InstrumentId::from("BTCUSDT-PERP.BINANCE"),
1533 spec: BarSpecification::new(step, aggregation, PriceType::Last),
1534 aggregation_source: AggregationSource::Internal,
1535 };
1536
1537 let interval = get_bar_interval(&bar_type);
1538 assert_eq!(interval, expected);
1539 }
1540
1541 #[rstest]
1542 #[case(BarAggregation::Millisecond, 1, DurationNanos::from_millis(1))]
1543 #[case(BarAggregation::Millisecond, 10, DurationNanos::from_millis(10))]
1544 #[case(BarAggregation::Second, 1, DurationNanos::from_secs(1))]
1545 #[case(BarAggregation::Second, 10, DurationNanos::from_secs(10))]
1546 #[case(BarAggregation::Minute, 1, DurationNanos::from_mins(1))]
1547 #[case(BarAggregation::Minute, 30, DurationNanos::from_mins(30))]
1548 #[case(BarAggregation::Hour, 1, DurationNanos::from_hours(1))]
1549 #[case(BarAggregation::Hour, 4, DurationNanos::from_hours(4))]
1550 #[case(BarAggregation::Day, 1, DurationNanos::from_days(1))]
1551 #[case(BarAggregation::Day, 2, DurationNanos::from_hours(48))]
1552 #[case(BarAggregation::Week, 1, DurationNanos::from_hours(168))]
1553 #[case(BarAggregation::Week, 2, DurationNanos::from_hours(336))]
1554 #[case(BarAggregation::Month, 1, DurationNanos::from_hours(720))]
1555 #[case(BarAggregation::Month, 3, DurationNanos::from_hours(2_160))]
1556 #[case(BarAggregation::Year, 1, DurationNanos::from_hours(8_760))]
1557 #[case(BarAggregation::Year, 2, DurationNanos::from_hours(17_520))]
1558 #[should_panic(expected = "Aggregation not time based")]
1559 #[case(BarAggregation::Tick, 1, DurationNanos::ZERO)]
1560 fn test_get_bar_interval_ns(
1561 #[case] aggregation: BarAggregation,
1562 #[case] step: usize,
1563 #[case] expected: DurationNanos,
1564 ) {
1565 let bar_type = BarType::Standard {
1566 instrument_id: InstrumentId::from("BTCUSDT-PERP.BINANCE"),
1567 spec: BarSpecification::new(step, aggregation, PriceType::Last),
1568 aggregation_source: AggregationSource::Internal,
1569 };
1570
1571 let interval_ns = get_bar_interval_ns(&bar_type);
1572 assert_eq!(interval_ns, expected);
1573 }
1574
1575 fn bar_type_with_raw_step(step: usize, aggregation: BarAggregation) -> BarType {
1576 let spec = BarSpecification {
1578 step: NonZeroUsize::new(step).unwrap(),
1579 aggregation,
1580 price_type: PriceType::Last,
1581 };
1582 BarType::new(
1583 InstrumentId::from("BTCUSDT-PERP.BINANCE"),
1584 spec,
1585 AggregationSource::Internal,
1586 )
1587 }
1588
1589 #[rstest]
1590 #[should_panic(expected = "`step` exceeds i64 range")]
1591 fn test_get_bar_interval_step_exceeds_i64_panics() {
1592 let bar_type = bar_type_with_raw_step(usize::MAX, BarAggregation::Second);
1593 let _ = get_bar_interval(&bar_type);
1594 }
1595
1596 #[rstest]
1597 #[should_panic(expected = "`step` overflows i64 days")]
1598 fn test_get_bar_interval_week_step_overflow_panics() {
1599 let step = usize::try_from(i64::MAX).unwrap();
1600 let bar_type = bar_type_with_raw_step(step, BarAggregation::Week);
1601 let _ = get_bar_interval(&bar_type);
1602 }
1603
1604 #[rstest]
1605 #[should_panic(expected = "`step` overflows i64 days")]
1606 fn test_timedelta_year_step_overflow_panics() {
1607 let step = usize::try_from(i64::MAX).unwrap();
1608 let bar_type = bar_type_with_raw_step(step, BarAggregation::Year);
1609 let _ = bar_type.spec().timedelta();
1610 }
1611
1612 #[rstest]
1613 #[should_panic(expected = "`step` exceeds u32 range for month arithmetic")]
1614 fn test_get_time_bar_start_month_step_exceeds_u32_panics() {
1615 let bar_type = bar_type_with_raw_step(1_usize << 40, BarAggregation::Month);
1616 let now = timestamp("2024-07-21T12:00:00Z");
1617 let _ = get_time_bar_start(now, &bar_type, None);
1618 }
1619
1620 #[rstest]
1621 #[should_panic(expected = "`step` exceeds i32 range for year arithmetic")]
1622 fn test_get_time_bar_start_year_step_exceeds_i32_panics() {
1623 let bar_type = bar_type_with_raw_step(1_usize << 40, BarAggregation::Year);
1624 let now = timestamp("2024-07-21T12:00:00Z");
1625 let _ = get_time_bar_start(now, &bar_type, None);
1626 }
1627
1628 #[rstest]
1629 #[should_panic(expected = "year exceeds Jiff supported range")]
1630 fn test_get_time_bar_start_year_step_exceeds_jiff_range_panics() {
1631 let bar_type = bar_type_with_raw_step(32_000, BarAggregation::Year);
1632 let now = timestamp("2024-07-21T12:00:00Z");
1633 let _ = get_time_bar_start(now, &bar_type, None);
1634 }
1635
1636 #[rstest]
1637 #[case::millisecond(
1638 Timestamp::new(1_658_349_296, 123_000_000).unwrap(), BarAggregation::Millisecond,
1640 1,
1641 Timestamp::new(1_658_349_296, 123_000_000).unwrap(), )]
1643 #[rstest]
1644 #[case::millisecond(
1645 Timestamp::new(1_658_349_296, 123_000_000).unwrap(), BarAggregation::Millisecond,
1647 10,
1648 Timestamp::new(1_658_349_296, 120_000_000).unwrap(), )]
1650 #[case::second(
1651 timestamp("2024-07-21T12:34:56Z"),
1652 BarAggregation::Second,
1653 1,
1654 timestamp("2024-07-21T12:34:56Z")
1655 )]
1656 #[case::second(
1657 timestamp("2024-07-21T12:34:56Z"),
1658 BarAggregation::Second,
1659 5,
1660 timestamp("2024-07-21T12:34:55Z")
1661 )]
1662 #[case::minute(
1663 timestamp("2024-07-21T12:34:56Z"),
1664 BarAggregation::Minute,
1665 1,
1666 timestamp("2024-07-21T12:34:00Z")
1667 )]
1668 #[case::minute(
1669 timestamp("2024-07-21T12:34:56Z"),
1670 BarAggregation::Minute,
1671 5,
1672 timestamp("2024-07-21T12:30:00Z")
1673 )]
1674 #[case::hour(
1675 timestamp("2024-07-21T12:34:56Z"),
1676 BarAggregation::Hour,
1677 1,
1678 timestamp("2024-07-21T12:00:00Z")
1679 )]
1680 #[case::hour(
1681 timestamp("2024-07-21T12:34:56Z"),
1682 BarAggregation::Hour,
1683 2,
1684 timestamp("2024-07-21T12:00:00Z")
1685 )]
1686 #[case::day(
1687 timestamp("2024-07-21T12:34:56Z"),
1688 BarAggregation::Day,
1689 1,
1690 timestamp("2024-07-21T00:00:00Z")
1691 )]
1692 fn test_get_time_bar_start(
1693 #[case] now: Timestamp,
1694 #[case] aggregation: BarAggregation,
1695 #[case] step: usize,
1696 #[case] expected: Timestamp,
1697 ) {
1698 let bar_type = BarType::Standard {
1699 instrument_id: InstrumentId::from("BTCUSDT-PERP.BINANCE"),
1700 spec: BarSpecification::new(step, aggregation, PriceType::Last),
1701 aggregation_source: AggregationSource::Internal,
1702 };
1703
1704 let start_time = get_time_bar_start(now, &bar_type, None);
1705 assert_eq!(start_time, expected);
1706 }
1707
1708 #[rstest]
1709 fn test_bar_spec_string_reprs() {
1710 let bar_spec = BarSpecification::new(1, BarAggregation::Minute, PriceType::Bid);
1711 assert_eq!(bar_spec.to_string(), "1-MINUTE-BID");
1712 assert_eq!(format!("{bar_spec}"), "1-MINUTE-BID");
1713 }
1714
1715 #[rstest]
1716 fn test_bar_type_parse_valid() {
1717 let input = "BTCUSDT-PERP.BINANCE-1-MINUTE-LAST-EXTERNAL";
1718 let bar_type = BarType::from(input);
1719
1720 assert_eq!(
1721 bar_type.instrument_id(),
1722 InstrumentId::from("BTCUSDT-PERP.BINANCE")
1723 );
1724 assert_eq!(
1725 bar_type.spec(),
1726 BarSpecification::new(1, BarAggregation::Minute, PriceType::Last)
1727 );
1728 assert_eq!(bar_type.aggregation_source(), AggregationSource::External);
1729 assert_eq!(bar_type, BarType::from(input));
1730 }
1731
1732 #[rstest]
1733 #[case("BTCUSDT-PERP.BINANCE-1-MINUTE-LAST-EXTERNAL", true, false)]
1734 #[case("BTCUSDT-PERP.BINANCE-1-MINUTE-LAST-INTERNAL", false, true)]
1735 #[case(
1736 "BTCUSDT-PERP.BINANCE-2-MINUTE-LAST-INTERNAL@1-MINUTE-EXTERNAL",
1737 false,
1738 true
1739 )]
1740 #[case(
1741 "BTCUSDT-PERP.BINANCE-2-MINUTE-LAST-EXTERNAL@1-MINUTE-INTERNAL",
1742 true,
1743 false
1744 )]
1745 fn test_bar_type_aggregation_source_predicates(
1746 #[case] input: &str,
1747 #[case] expected_external: bool,
1748 #[case] expected_internal: bool,
1749 ) {
1750 let bar_type = BarType::from(input);
1751 assert_eq!(bar_type.is_externally_aggregated(), expected_external);
1752 assert_eq!(bar_type.is_internally_aggregated(), expected_internal);
1753 }
1754
1755 #[rstest]
1756 fn test_bar_type_composite_aggregation_source_predicates_track_inner() {
1757 let bar_type =
1758 BarType::from("BTCUSDT-PERP.BINANCE-2-MINUTE-LAST-INTERNAL@1-MINUTE-EXTERNAL");
1759
1760 assert!(bar_type.is_internally_aggregated());
1761 assert!(!bar_type.is_externally_aggregated());
1762
1763 let composite = bar_type.composite();
1764 assert!(composite.is_externally_aggregated());
1765 assert!(!composite.is_internally_aggregated());
1766 }
1767
1768 #[rstest]
1769 fn test_bar_type_from_str_with_utf8_symbol() {
1770 let non_ascii_instrument = "TËST-PÉRP.BINANCE";
1771 let non_ascii_bar_type = "TËST-PÉRP.BINANCE-1-MINUTE-LAST-EXTERNAL";
1772
1773 let bar_type = BarType::from_str(non_ascii_bar_type).unwrap();
1774
1775 assert_eq!(
1776 bar_type.instrument_id(),
1777 InstrumentId::from_str(non_ascii_instrument).unwrap()
1778 );
1779 assert_eq!(
1780 bar_type.spec(),
1781 BarSpecification::new(1, BarAggregation::Minute, PriceType::Last)
1782 );
1783 assert_eq!(bar_type.aggregation_source(), AggregationSource::External);
1784 assert_eq!(bar_type.to_string(), non_ascii_bar_type);
1785 }
1786
1787 #[rstest]
1788 fn test_bar_type_composite_parse_valid() {
1789 let input = "BTCUSDT-PERP.BINANCE-2-MINUTE-LAST-INTERNAL@1-MINUTE-EXTERNAL";
1790 let bar_type = BarType::from(input);
1791 let standard = bar_type.standard();
1792
1793 assert_eq!(
1794 bar_type.instrument_id(),
1795 InstrumentId::from("BTCUSDT-PERP.BINANCE")
1796 );
1797 assert_eq!(
1798 bar_type.spec(),
1799 BarSpecification::new(2, BarAggregation::Minute, PriceType::Last,)
1800 );
1801 assert_eq!(bar_type.aggregation_source(), AggregationSource::Internal);
1802 assert_eq!(bar_type, BarType::from(input));
1803 assert!(bar_type.is_composite());
1804
1805 assert_eq!(
1806 standard.instrument_id(),
1807 InstrumentId::from("BTCUSDT-PERP.BINANCE")
1808 );
1809 assert_eq!(
1810 standard.spec(),
1811 BarSpecification::new(2, BarAggregation::Minute, PriceType::Last,)
1812 );
1813 assert_eq!(standard.aggregation_source(), AggregationSource::Internal);
1814 assert!(standard.is_standard());
1815
1816 let composite = bar_type.composite();
1817 let composite_input = "BTCUSDT-PERP.BINANCE-1-MINUTE-LAST-EXTERNAL";
1818
1819 assert_eq!(
1820 composite.instrument_id(),
1821 InstrumentId::from("BTCUSDT-PERP.BINANCE")
1822 );
1823 assert_eq!(
1824 composite.spec(),
1825 BarSpecification::new(1, BarAggregation::Minute, PriceType::Last,)
1826 );
1827 assert_eq!(composite.aggregation_source(), AggregationSource::External);
1828 assert_eq!(composite, BarType::from(composite_input));
1829 assert!(composite.is_standard());
1830 }
1831
1832 #[rstest]
1833 fn test_bar_type_parse_invalid_token_pos_0() {
1834 let input = "BTCUSDT-PERP-1-MINUTE-LAST-INTERNAL";
1835 let result = BarType::from_str(input);
1836
1837 assert_eq!(
1838 result.unwrap_err().to_string(),
1839 format!(
1840 "Error parsing `BarType` from '{input}', invalid token: 'BTCUSDT-PERP' at position 0"
1841 )
1842 );
1843 }
1844
1845 #[rstest]
1846 fn test_bar_type_parse_invalid_token_pos_1() {
1847 let input = "BTCUSDT-PERP.BINANCE-INVALID-MINUTE-LAST-INTERNAL";
1848 let result = BarType::from_str(input);
1849
1850 assert_eq!(
1851 result.unwrap_err().to_string(),
1852 format!(
1853 "Error parsing `BarType` from '{input}', invalid token: 'INVALID' at position 1"
1854 )
1855 );
1856 }
1857
1858 #[rstest]
1859 fn test_bar_type_parse_invalid_spec_step() {
1860 let input = "BTCUSDT-PERP.BINANCE-60-MINUTE-LAST-INTERNAL";
1861 let result = BarType::from_str(input);
1862
1863 assert_eq!(
1864 result.unwrap_err().to_string(),
1865 format!("Error parsing `BarType` from '{input}', invalid token: '60' at position 1")
1866 );
1867 }
1868
1869 #[rstest]
1870 fn test_bar_type_parse_invalid_token_pos_2() {
1871 let input = "BTCUSDT-PERP.BINANCE-1-INVALID-LAST-INTERNAL";
1872 let result = BarType::from_str(input);
1873
1874 assert_eq!(
1875 result.unwrap_err().to_string(),
1876 format!(
1877 "Error parsing `BarType` from '{input}', invalid token: 'INVALID' at position 2"
1878 )
1879 );
1880 }
1881
1882 #[rstest]
1883 fn test_bar_type_parse_invalid_token_pos_3() {
1884 let input = "BTCUSDT-PERP.BINANCE-1-MINUTE-INVALID-INTERNAL";
1885 let result = BarType::from_str(input);
1886
1887 assert_eq!(
1888 result.unwrap_err().to_string(),
1889 format!(
1890 "Error parsing `BarType` from '{input}', invalid token: 'INVALID' at position 3"
1891 )
1892 );
1893 }
1894
1895 #[rstest]
1896 fn test_bar_type_parse_invalid_token_pos_4() {
1897 let input = "BTCUSDT-PERP.BINANCE-1-MINUTE-BID-INVALID";
1898 let result = BarType::from_str(input);
1899
1900 assert!(result.is_err());
1901 assert_eq!(
1902 result.unwrap_err().to_string(),
1903 format!(
1904 "Error parsing `BarType` from '{input}', invalid token: 'INVALID' at position 4"
1905 )
1906 );
1907 }
1908
1909 #[rstest]
1910 fn test_bar_type_parse_invalid_token_pos_5() {
1911 let input = "BTCUSDT-PERP.BINANCE-2-MINUTE-LAST-INTERNAL@INVALID-MINUTE-EXTERNAL";
1912 let result = BarType::from_str(input);
1913
1914 assert!(result.is_err());
1915 assert_eq!(
1916 result.unwrap_err().to_string(),
1917 format!(
1918 "Error parsing `BarType` from '{input}', invalid token: 'INVALID' at position 5"
1919 )
1920 );
1921 }
1922
1923 #[rstest]
1924 fn test_bar_type_parse_invalid_composite_spec_step() {
1925 let input = "BTCUSDT-PERP.BINANCE-2-MINUTE-LAST-INTERNAL@60-MINUTE-EXTERNAL";
1926 let result = BarType::from_str(input);
1927
1928 assert!(result.is_err());
1929 assert_eq!(
1930 result.unwrap_err().to_string(),
1931 format!("Error parsing `BarType` from '{input}', invalid token: '60' at position 5")
1932 );
1933 }
1934
1935 #[rstest]
1936 fn test_bar_type_parse_invalid_token_pos_6() {
1937 let input = "BTCUSDT-PERP.BINANCE-2-MINUTE-LAST-INTERNAL@1-INVALID-EXTERNAL";
1938 let result = BarType::from_str(input);
1939
1940 assert!(result.is_err());
1941 assert_eq!(
1942 result.unwrap_err().to_string(),
1943 format!(
1944 "Error parsing `BarType` from '{input}', invalid token: 'INVALID' at position 6"
1945 )
1946 );
1947 }
1948
1949 #[rstest]
1950 fn test_bar_type_parse_invalid_token_pos_7() {
1951 let input = "BTCUSDT-PERP.BINANCE-2-MINUTE-LAST-INTERNAL@1-MINUTE-INVALID";
1952 let result = BarType::from_str(input);
1953
1954 assert!(result.is_err());
1955 assert_eq!(
1956 result.unwrap_err().to_string(),
1957 format!(
1958 "Error parsing `BarType` from '{input}', invalid token: 'INVALID' at position 7"
1959 )
1960 );
1961 }
1962
1963 #[rstest]
1964 fn test_bar_type_parse_rejects_extra_composite_segment() {
1965 let input = "BTCUSDT-PERP.BINANCE-2-MINUTE-LAST-INTERNAL@1-MINUTE-EXTERNAL@1-HOUR-EXTERNAL";
1966 let result = BarType::from_str(input);
1967
1968 assert_eq!(
1969 result.unwrap_err().to_string(),
1970 format!(
1971 "Error parsing `BarType` from '{input}', invalid token: '1-HOUR-EXTERNAL' at position 5"
1972 )
1973 );
1974 }
1975
1976 #[rstest]
1977 fn test_bar_type_equality() {
1978 let instrument_id1 = InstrumentId {
1979 symbol: Symbol::new("AUD/USD"),
1980 venue: Venue::new("SIM"),
1981 };
1982 let instrument_id2 = InstrumentId {
1983 symbol: Symbol::new("GBP/USD"),
1984 venue: Venue::new("SIM"),
1985 };
1986 let bar_spec = BarSpecification::new(1, BarAggregation::Minute, PriceType::Bid);
1987 let bar_type1 = BarType::Standard {
1988 instrument_id: instrument_id1,
1989 spec: bar_spec,
1990 aggregation_source: AggregationSource::External,
1991 };
1992 let bar_type2 = BarType::Standard {
1993 instrument_id: instrument_id1,
1994 spec: bar_spec,
1995 aggregation_source: AggregationSource::External,
1996 };
1997 let bar_type3 = BarType::Standard {
1998 instrument_id: instrument_id2,
1999 spec: bar_spec,
2000 aggregation_source: AggregationSource::External,
2001 };
2002 assert_eq!(bar_type1, bar_type1);
2003 assert_eq!(bar_type1, bar_type2);
2004 assert_ne!(bar_type1, bar_type3);
2005 }
2006
2007 #[rstest]
2008 fn test_bar_type_id_spec_key_ignores_aggregation_source() {
2009 let bar_type_external = BarType::from_str("ESM4.XCME-1-MINUTE-LAST-EXTERNAL").unwrap();
2010 let bar_type_internal = BarType::from_str("ESM4.XCME-1-MINUTE-LAST-INTERNAL").unwrap();
2011
2012 assert_ne!(bar_type_external, bar_type_internal);
2014
2015 assert_eq!(
2017 bar_type_external.id_spec_key(),
2018 bar_type_internal.id_spec_key()
2019 );
2020
2021 let (instrument_id, spec) = bar_type_external.id_spec_key();
2023 assert_eq!(instrument_id, bar_type_external.instrument_id());
2024 assert_eq!(spec, bar_type_external.spec());
2025 }
2026
2027 #[rstest]
2028 fn test_bar_type_comparison() {
2029 let instrument_id1 = InstrumentId {
2030 symbol: Symbol::new("AUD/USD"),
2031 venue: Venue::new("SIM"),
2032 };
2033
2034 let instrument_id2 = InstrumentId {
2035 symbol: Symbol::new("GBP/USD"),
2036 venue: Venue::new("SIM"),
2037 };
2038 let bar_spec = BarSpecification::new(1, BarAggregation::Minute, PriceType::Bid);
2039 let bar_spec2 = BarSpecification::new(2, BarAggregation::Minute, PriceType::Bid);
2040 let bar_type1 = BarType::Standard {
2041 instrument_id: instrument_id1,
2042 spec: bar_spec,
2043 aggregation_source: AggregationSource::External,
2044 };
2045 let bar_type2 = BarType::Standard {
2046 instrument_id: instrument_id1,
2047 spec: bar_spec,
2048 aggregation_source: AggregationSource::External,
2049 };
2050 let bar_type3 = BarType::Standard {
2051 instrument_id: instrument_id2,
2052 spec: bar_spec,
2053 aggregation_source: AggregationSource::External,
2054 };
2055 let bar_type4 = BarType::Composite {
2056 instrument_id: instrument_id2,
2057 spec: bar_spec2,
2058 aggregation_source: AggregationSource::Internal,
2059
2060 composite_step: 1,
2061 composite_aggregation: BarAggregation::Minute,
2062 composite_aggregation_source: AggregationSource::External,
2063 };
2064
2065 assert!(bar_type1 <= bar_type2);
2066 assert!(bar_type1 < bar_type3);
2067 assert!(bar_type3 > bar_type1);
2068 assert!(bar_type3 >= bar_type1);
2069 assert!(bar_type4 >= bar_type1);
2070 }
2071
2072 #[rstest]
2073 fn test_bar_new() {
2074 let bar_type = BarType::from("AAPL.XNAS-1-MINUTE-LAST-INTERNAL");
2075 let open = Price::from("100.0");
2076 let high = Price::from("105.0");
2077 let low = Price::from("95.0");
2078 let close = Price::from("102.0");
2079 let volume = Quantity::from("1000");
2080 let ts_event = UnixNanos::from(1_000_000);
2081 let ts_init = UnixNanos::from(2_000_000);
2082
2083 let bar = Bar::new(bar_type, open, high, low, close, volume, ts_event, ts_init);
2084
2085 assert_eq!(bar.bar_type, bar_type);
2086 assert_eq!(bar.open, open);
2087 assert_eq!(bar.high, high);
2088 assert_eq!(bar.low, low);
2089 assert_eq!(bar.close, close);
2090 assert_eq!(bar.volume, volume);
2091 assert_eq!(bar.ts_event, ts_event);
2092 assert_eq!(bar.ts_init, ts_init);
2093 }
2094
2095 #[rstest]
2096 #[case("100.0", "90.0", "95.0", "92.0", "high >= open")]
2097 #[case("100.0", "105.0", "110.0", "102.0", "high >= low")]
2098 #[case("100.0", "105.0", "95.0", "110.0", "high >= close")]
2099 #[case("100.0", "105.0", "95.0", "90.0", "low <= close")]
2100 #[case("100.0", "110.0", "105.0", "108.0", "low <= open")]
2101 #[case("100.0", "90.0", "110.0", "120.0", "high >= open")] fn test_bar_new_checked_conditions(
2103 #[case] open: &str,
2104 #[case] high: &str,
2105 #[case] low: &str,
2106 #[case] close: &str,
2107 #[case] expected: &str,
2108 ) {
2109 let bar_type = BarType::from("AAPL.XNAS-1-MINUTE-LAST-INTERNAL");
2110 let open = Price::from(open);
2111 let high = Price::from(high);
2112 let low = Price::from(low);
2113 let close = Price::from(close);
2114 let volume = Quantity::from("1000");
2115 let ts_event = UnixNanos::from(1_000_000);
2116 let ts_init = UnixNanos::from(2_000_000);
2117
2118 let result = Bar::new_checked(bar_type, open, high, low, close, volume, ts_event, ts_init);
2119
2120 let error = result.unwrap_err();
2121 assert!(
2122 error.to_string().contains(expected),
2123 "unexpected message: {error}"
2124 );
2125 }
2126
2127 #[rstest]
2128 fn test_bar_equality() {
2129 let instrument_id = InstrumentId {
2130 symbol: Symbol::new("AUDUSD"),
2131 venue: Venue::new("SIM"),
2132 };
2133 let bar_spec = BarSpecification::new(1, BarAggregation::Minute, PriceType::Bid);
2134 let bar_type = BarType::Standard {
2135 instrument_id,
2136 spec: bar_spec,
2137 aggregation_source: AggregationSource::External,
2138 };
2139 let bar1 = Bar {
2140 bar_type,
2141 open: Price::from("1.00001"),
2142 high: Price::from("1.00004"),
2143 low: Price::from("1.00002"),
2144 close: Price::from("1.00003"),
2145 volume: Quantity::from("100000"),
2146 ts_event: UnixNanos::default(),
2147 ts_init: UnixNanos::from(1),
2148 };
2149
2150 let bar2 = Bar {
2151 bar_type,
2152 open: Price::from("1.00000"),
2153 high: Price::from("1.00004"),
2154 low: Price::from("1.00002"),
2155 close: Price::from("1.00003"),
2156 volume: Quantity::from("100000"),
2157 ts_event: UnixNanos::default(),
2158 ts_init: UnixNanos::from(1),
2159 };
2160 assert_eq!(bar1, bar1);
2161 assert_ne!(bar1, bar2);
2162 }
2163
2164 #[rstest]
2165 fn test_json_serialization() {
2166 let bar = Bar::default();
2167 let serialized = bar.to_json_bytes().unwrap();
2168 let deserialized = Bar::from_json_bytes(serialized.as_ref()).unwrap();
2169 assert_eq!(deserialized, bar);
2170 }
2171
2172 #[rstest]
2173 fn test_msgpack_serialization() {
2174 let bar = Bar::default();
2175 let serialized = bar.to_msgpack_bytes().unwrap();
2176 let deserialized = Bar::from_msgpack_bytes(serialized.as_ref()).unwrap();
2177 assert_eq!(deserialized, bar);
2178 }
2179
2180 #[rstest]
2181 fn test_bar_deserialization_rejects_invalid_ohlc() {
2182 let json = r#"{
2183 "type": "Bar",
2184 "bar_type": "AUD/USD.SIM-1-MINUTE-BID-EXTERNAL",
2185 "open": "1.00010",
2186 "high": "1.00000",
2187 "low": "1.00020",
2188 "close": "1.00010",
2189 "volume": "100000",
2190 "ts_event": 0,
2191 "ts_init": 0
2192 }"#;
2193
2194 let result = Bar::from_json_bytes(json.as_bytes());
2195 assert!(
2196 result.is_err(),
2197 "high < low must fail deserialization, was {result:?}"
2198 );
2199 }
2200
2201 #[rstest]
2202 fn test_bar_specification_deserialization_rejects_invalid_step() {
2203 let json = r#"{"step":7,"aggregation":"MINUTE","price_type":"LAST"}"#;
2204
2205 let result = serde_json::from_str::<BarSpecification>(json);
2206 assert!(
2207 result.is_err(),
2208 "non-periodic step must fail deserialization, was {result:?}"
2209 );
2210 }
2211
2212 #[rstest]
2213 fn test_bar_specification_builder_rejects_invalid_step() {
2214 let result = BarSpecificationBuilder::default()
2215 .step(NonZeroUsize::new(7).unwrap())
2216 .aggregation(BarAggregation::Minute)
2217 .price_type(PriceType::Last)
2218 .build();
2219
2220 assert!(
2221 result.is_err(),
2222 "non-periodic step must fail builder validation, was {result:?}"
2223 );
2224 }
2225
2226 #[rstest]
2227 fn test_bar_spec_12_month_round_trips() {
2228 let bar_type = BarType::new(
2231 InstrumentId::from("BTC-USDT.OKX"),
2232 BAR_SPEC_12_MONTH_LAST,
2233 AggregationSource::External,
2234 );
2235
2236 let parsed = BarType::from_str(&bar_type.to_string()).unwrap();
2237 assert_eq!(parsed, bar_type);
2238 assert_eq!(
2239 BarSpecification::new_checked(12, BarAggregation::Month, PriceType::Last).unwrap(),
2240 BAR_SPEC_12_MONTH_LAST,
2241 );
2242 }
2243
2244 #[rstest]
2245 fn test_bar_type_new_composite_checked_invalid_step() {
2246 let instrument_id = InstrumentId::from("AUD/USD.SIM");
2247 let spec = BarSpecification::new(5, BarAggregation::Minute, PriceType::Bid);
2248
2249 let result = BarType::new_composite_checked(
2250 instrument_id,
2251 spec,
2252 AggregationSource::Internal,
2253 0,
2254 BarAggregation::Minute,
2255 AggregationSource::External,
2256 );
2257
2258 assert!(
2259 result.is_err(),
2260 "zero composite step must fail, was {result:?}"
2261 );
2262 }
2263}
2264
2265#[cfg(test)]
2266mod property_tests {
2267 use std::str::FromStr;
2268
2269 use proptest::prelude::*;
2270 use rstest::rstest;
2271
2272 use super::*;
2273 use crate::identifiers::{Symbol, Venue};
2274
2275 fn symbol_strategy() -> impl Strategy<Value = &'static str> {
2276 prop::sample::select(vec![
2277 "AAPL",
2278 "BTC-PERP",
2279 "EUR/USD",
2280 "ES-MINI-4",
2281 "MSFT.OQ",
2282 "6E",
2283 ])
2284 }
2285
2286 fn venue_strategy() -> impl Strategy<Value = &'static str> {
2287 prop::sample::select(vec!["SIM", "XNAS", "GLBX", "BINANCE"])
2288 }
2289
2290 fn time_spec_strategy() -> impl Strategy<Value = (BarAggregation, usize)> {
2291 prop_oneof![
2292 (
2293 Just(BarAggregation::Millisecond),
2294 prop::sample::select(vec![1usize, 2, 5, 10, 25, 50, 100, 250, 500]),
2295 ),
2296 (
2297 Just(BarAggregation::Second),
2298 prop::sample::select(vec![1usize, 2, 3, 5, 10, 15, 30]),
2299 ),
2300 (
2301 Just(BarAggregation::Minute),
2302 prop::sample::select(vec![1usize, 2, 5, 15, 30]),
2303 ),
2304 (
2305 Just(BarAggregation::Hour),
2306 prop::sample::select(vec![1usize, 2, 4, 12]),
2307 ),
2308 (
2309 Just(BarAggregation::Day),
2310 prop::sample::select(vec![1usize, 2, 3]),
2311 ),
2312 (Just(BarAggregation::Week), Just(1usize)),
2313 ]
2314 }
2315
2316 fn spec_strategy() -> impl Strategy<Value = (BarAggregation, usize)> {
2317 prop_oneof![
2318 time_spec_strategy(),
2319 (
2322 Just(BarAggregation::Month),
2323 prop::sample::select(vec![1usize, 2, 3, 4, 6, 12]),
2324 ),
2325 (Just(BarAggregation::Tick), 1usize..=10_000),
2326 (Just(BarAggregation::Volume), 1usize..=10_000),
2327 (Just(BarAggregation::Value), 1usize..=10_000),
2328 ]
2329 }
2330
2331 fn price_type_strategy() -> impl Strategy<Value = PriceType> {
2332 prop::sample::select(vec![
2333 PriceType::Bid,
2334 PriceType::Ask,
2335 PriceType::Mid,
2336 PriceType::Last,
2337 ])
2338 }
2339
2340 fn source_strategy() -> impl Strategy<Value = AggregationSource> {
2341 prop_oneof![
2342 Just(AggregationSource::Internal),
2343 Just(AggregationSource::External),
2344 ]
2345 }
2346
2347 proptest! {
2348 #[rstest]
2349 fn prop_bar_type_string_round_trip(
2350 symbol in symbol_strategy(),
2351 venue in venue_strategy(),
2352 (aggregation, step) in spec_strategy(),
2353 price_type in price_type_strategy(),
2354 source in source_strategy(),
2355 composite in prop::option::of((time_spec_strategy(), source_strategy())),
2356 ) {
2357 let instrument_id = InstrumentId::new(Symbol::from(symbol), Venue::from(venue));
2358 let spec = BarSpecification::new(step, aggregation, price_type);
2359
2360 let bar_type = match composite {
2361 None => BarType::new(instrument_id, spec, source),
2362 Some(((composite_aggregation, composite_step), composite_source)) => {
2363 BarType::new_composite(
2364 instrument_id,
2365 spec,
2366 source,
2367 composite_step,
2368 composite_aggregation,
2369 composite_source,
2370 )
2371 }
2372 };
2373
2374 let parsed = BarType::from_str(&bar_type.to_string());
2375 prop_assert!(parsed.is_ok(), "failed to parse '{bar_type}': {parsed:?}");
2376 prop_assert_eq!(parsed.unwrap(), bar_type);
2377 }
2378
2379 #[rstest]
2380 fn prop_get_time_bar_start_alignment(
2381 (aggregation, step) in time_spec_strategy(),
2382 epoch_secs in 946_684_800i64..2_524_608_000i64,
2383 subsec_nanos in 0u32..1_000_000_000u32,
2384 ) {
2385 let instrument_id = InstrumentId::from("AAPL.XNAS");
2386 let spec = BarSpecification::new(step, aggregation, PriceType::Last);
2387 let bar_type = BarType::new(instrument_id, spec, AggregationSource::Internal);
2388
2389 let now = Timestamp::new(epoch_secs, subsec_nanos.cast_signed()).unwrap();
2390 let start = get_time_bar_start(now, &bar_type, None);
2391 let interval = get_bar_interval(&bar_type);
2392
2393 prop_assert!(start <= now, "start {start} must not be after now {now}");
2394 prop_assert!(
2395 now.duration_since(start) < interval,
2396 "now {now} must fall within one interval of start {start}"
2397 );
2398 }
2399 }
2400}