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