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