1use std::{
19 collections::HashMap,
20 fmt::{Debug, Display},
21 hash::Hash,
22 num::{NonZero, NonZeroUsize},
23 str::FromStr,
24};
25
26use chrono::{DateTime, Datelike, Duration, SubsecRound, TimeDelta, Timelike, Utc};
27use derive_builder::Builder;
28use indexmap::IndexMap;
29use nautilus_core::{
30 UnixNanos,
31 correctness::{FAILED, check_predicate_true},
32 datetime::{add_n_months, subtract_n_months},
33 serialization::Serializable,
34};
35use serde::{Deserialize, Deserializer, Serialize, Serializer};
36
37use super::HasTsInit;
38use crate::{
39 enums::{AggregationSource, BarAggregation, PriceType},
40 identifiers::InstrumentId,
41 types::{Price, Quantity, fixed::FIXED_SIZE_BINARY},
42};
43
44pub const BAR_SPEC_1_SECOND_LAST: BarSpecification = BarSpecification {
45 step: NonZero::new(1).unwrap(),
46 aggregation: BarAggregation::Second,
47 price_type: PriceType::Last,
48};
49pub const BAR_SPEC_1_MINUTE_LAST: BarSpecification = BarSpecification {
50 step: NonZero::new(1).unwrap(),
51 aggregation: BarAggregation::Minute,
52 price_type: PriceType::Last,
53};
54pub const BAR_SPEC_3_MINUTE_LAST: BarSpecification = BarSpecification {
55 step: NonZero::new(3).unwrap(),
56 aggregation: BarAggregation::Minute,
57 price_type: PriceType::Last,
58};
59pub const BAR_SPEC_5_MINUTE_LAST: BarSpecification = BarSpecification {
60 step: NonZero::new(5).unwrap(),
61 aggregation: BarAggregation::Minute,
62 price_type: PriceType::Last,
63};
64pub const BAR_SPEC_15_MINUTE_LAST: BarSpecification = BarSpecification {
65 step: NonZero::new(15).unwrap(),
66 aggregation: BarAggregation::Minute,
67 price_type: PriceType::Last,
68};
69pub const BAR_SPEC_30_MINUTE_LAST: BarSpecification = BarSpecification {
70 step: NonZero::new(30).unwrap(),
71 aggregation: BarAggregation::Minute,
72 price_type: PriceType::Last,
73};
74pub const BAR_SPEC_1_HOUR_LAST: BarSpecification = BarSpecification {
75 step: NonZero::new(1).unwrap(),
76 aggregation: BarAggregation::Hour,
77 price_type: PriceType::Last,
78};
79pub const BAR_SPEC_2_HOUR_LAST: BarSpecification = BarSpecification {
80 step: NonZero::new(2).unwrap(),
81 aggregation: BarAggregation::Hour,
82 price_type: PriceType::Last,
83};
84pub const BAR_SPEC_4_HOUR_LAST: BarSpecification = BarSpecification {
85 step: NonZero::new(4).unwrap(),
86 aggregation: BarAggregation::Hour,
87 price_type: PriceType::Last,
88};
89pub const BAR_SPEC_6_HOUR_LAST: BarSpecification = BarSpecification {
90 step: NonZero::new(6).unwrap(),
91 aggregation: BarAggregation::Hour,
92 price_type: PriceType::Last,
93};
94pub const BAR_SPEC_12_HOUR_LAST: BarSpecification = BarSpecification {
95 step: NonZero::new(12).unwrap(),
96 aggregation: BarAggregation::Hour,
97 price_type: PriceType::Last,
98};
99pub const BAR_SPEC_1_DAY_LAST: BarSpecification = BarSpecification {
100 step: NonZero::new(1).unwrap(),
101 aggregation: BarAggregation::Day,
102 price_type: PriceType::Last,
103};
104pub const BAR_SPEC_2_DAY_LAST: BarSpecification = BarSpecification {
105 step: NonZero::new(2).unwrap(),
106 aggregation: BarAggregation::Day,
107 price_type: PriceType::Last,
108};
109pub const BAR_SPEC_3_DAY_LAST: BarSpecification = BarSpecification {
110 step: NonZero::new(3).unwrap(),
111 aggregation: BarAggregation::Day,
112 price_type: PriceType::Last,
113};
114pub const BAR_SPEC_5_DAY_LAST: BarSpecification = BarSpecification {
115 step: NonZero::new(5).unwrap(),
116 aggregation: BarAggregation::Day,
117 price_type: PriceType::Last,
118};
119pub const BAR_SPEC_1_WEEK_LAST: BarSpecification = BarSpecification {
120 step: NonZero::new(1).unwrap(),
121 aggregation: BarAggregation::Week,
122 price_type: PriceType::Last,
123};
124pub const BAR_SPEC_1_MONTH_LAST: BarSpecification = BarSpecification {
125 step: NonZero::new(1).unwrap(),
126 aggregation: BarAggregation::Month,
127 price_type: PriceType::Last,
128};
129pub const BAR_SPEC_3_MONTH_LAST: BarSpecification = BarSpecification {
130 step: NonZero::new(3).unwrap(),
131 aggregation: BarAggregation::Month,
132 price_type: PriceType::Last,
133};
134pub const BAR_SPEC_6_MONTH_LAST: BarSpecification = BarSpecification {
135 step: NonZero::new(6).unwrap(),
136 aggregation: BarAggregation::Month,
137 price_type: PriceType::Last,
138};
139pub const BAR_SPEC_12_MONTH_LAST: BarSpecification = BarSpecification {
140 step: NonZero::new(12).unwrap(),
141 aggregation: BarAggregation::Month,
142 price_type: PriceType::Last,
143};
144
145#[must_use]
152pub fn get_bar_interval(bar_type: &BarType) -> TimeDelta {
153 let spec = bar_type.spec();
154 let step = step_to_i64(spec.step);
155
156 match spec.aggregation {
157 BarAggregation::Millisecond => TimeDelta::milliseconds(step),
158 BarAggregation::Second => TimeDelta::seconds(step),
159 BarAggregation::Minute => TimeDelta::minutes(step),
160 BarAggregation::Hour => TimeDelta::hours(step),
161 BarAggregation::Day => TimeDelta::days(step),
162 BarAggregation::Week => {
163 TimeDelta::days(step.checked_mul(7).expect("`step` overflows i64 days"))
164 }
165 BarAggregation::Month => {
166 TimeDelta::days(step.checked_mul(30).expect("`step` overflows i64 days"))
168 }
169 BarAggregation::Year => {
170 TimeDelta::days(step.checked_mul(365).expect("`step` overflows i64 days"))
172 }
173 _ => panic!("Aggregation not time based"),
174 }
175}
176
177#[must_use]
183pub fn get_bar_interval_ns(bar_type: &BarType) -> UnixNanos {
184 let interval_ns = get_bar_interval(bar_type)
185 .num_nanoseconds()
186 .expect("Invalid bar interval")
187 .cast_unsigned();
188 UnixNanos::from(interval_ns)
189}
190
191pub fn get_time_bar_start(
199 now: DateTime<Utc>,
200 bar_type: &BarType,
201 time_bars_origin: Option<TimeDelta>,
202) -> DateTime<Utc> {
203 let spec = bar_type.spec();
204 let step = step_to_i64(spec.step);
205 let origin_offset: TimeDelta = time_bars_origin.unwrap_or_else(TimeDelta::zero);
206
207 match spec.aggregation {
208 BarAggregation::Millisecond => {
209 find_closest_smaller_time(now, origin_offset, Duration::milliseconds(step))
210 }
211 BarAggregation::Second => {
212 find_closest_smaller_time(now, origin_offset, Duration::seconds(step))
213 }
214 BarAggregation::Minute => {
215 find_closest_smaller_time(now, origin_offset, Duration::minutes(step))
216 }
217 BarAggregation::Hour => {
218 find_closest_smaller_time(now, origin_offset, Duration::hours(step))
219 }
220 BarAggregation::Day => find_closest_smaller_time(now, origin_offset, Duration::days(step)),
221 BarAggregation::Week => {
222 let mut start_time = now.trunc_subsecs(0)
223 - Duration::seconds(i64::from(now.second()))
224 - Duration::minutes(i64::from(now.minute()))
225 - Duration::hours(i64::from(now.hour()))
226 - TimeDelta::days(i64::from(now.weekday().num_days_from_monday()));
227 start_time += origin_offset;
228
229 if now < start_time {
230 start_time -= Duration::weeks(step);
231 }
232
233 start_time
234 }
235 BarAggregation::Month => {
236 let mut start_time = DateTime::from_naive_utc_and_offset(
238 chrono::NaiveDate::from_ymd_opt(now.year(), 1, 1)
239 .expect("valid date")
240 .and_hms_opt(0, 0, 0)
241 .expect("valid time"),
242 Utc,
243 );
244 start_time += origin_offset;
245
246 if now < start_time {
247 start_time =
248 subtract_n_months(start_time, 12).expect("Failed to subtract 12 months");
249 }
250
251 let months_step =
252 u32::try_from(step).expect("`step` exceeds u32 range for month arithmetic");
253
254 while start_time <= now {
255 start_time =
256 add_n_months(start_time, months_step).expect("Failed to add months in loop");
257 }
258
259 start_time =
260 subtract_n_months(start_time, months_step).expect("Failed to subtract months_step");
261 start_time
262 }
263 BarAggregation::Year => {
264 let step_i32 =
265 i32::try_from(step).expect("`step` exceeds i32 range for year arithmetic");
266
267 let year_start = |y: i32| {
269 DateTime::from_naive_utc_and_offset(
270 chrono::NaiveDate::from_ymd_opt(y, 1, 1)
271 .expect("valid date")
272 .and_hms_opt(0, 0, 0)
273 .expect("valid time"),
274 Utc,
275 ) + origin_offset
276 };
277
278 let mut year = now.year();
279 if year_start(year) > now {
280 year -= step_i32;
281 }
282
283 while year_start(year + step_i32) <= now {
284 year += step_i32;
285 }
286
287 year_start(year)
288 }
289 _ => panic!(
290 "Aggregation type {} not supported for time bars",
291 spec.aggregation
292 ),
293 }
294}
295
296fn find_closest_smaller_time(
301 now: DateTime<Utc>,
302 daily_time_origin: TimeDelta,
303 period: TimeDelta,
304) -> DateTime<Utc> {
305 let day_start = now.trunc_subsecs(0)
307 - Duration::seconds(i64::from(now.second()))
308 - Duration::minutes(i64::from(now.minute()))
309 - Duration::hours(i64::from(now.hour()));
310 let base_time = day_start + daily_time_origin;
311
312 let time_difference = now - base_time;
313 let period_ns = period.num_nanoseconds().unwrap_or(1);
314
315 let num_periods = time_difference
318 .num_nanoseconds()
319 .unwrap_or(0)
320 .div_euclid(period_ns);
321
322 base_time + TimeDelta::nanoseconds(num_periods * period_ns)
323}
324
325fn step_to_i64(step: NonZeroUsize) -> i64 {
331 i64::try_from(step.get()).expect("`step` exceeds i64 range")
332}
333
334#[repr(C)]
337#[derive(
338 Clone, Copy, Hash, PartialEq, Eq, PartialOrd, Ord, Debug, Serialize, Deserialize, Builder,
339)]
340#[builder(build_fn(validate = "Self::validate"))]
341#[serde(try_from = "BarSpecificationFields")]
342#[cfg_attr(
343 feature = "python",
344 pyo3::pyclass(module = "nautilus_trader.core.nautilus_pyo3.model", from_py_object)
345)]
346#[cfg_attr(
347 feature = "python",
348 pyo3_stub_gen::derive::gen_stub_pyclass(module = "nautilus_trader.model")
349)]
350pub struct BarSpecification {
351 pub step: NonZeroUsize,
353 pub aggregation: BarAggregation,
355 pub price_type: PriceType,
357}
358
359impl BarSpecificationBuilder {
360 fn validate(&self) -> Result<(), String> {
361 if let (Some(step), Some(aggregation)) = (self.step, self.aggregation) {
362 BarSpecification::validate_step(step.get(), aggregation).map_err(|e| e.to_string())?;
363 }
364
365 Ok(())
366 }
367}
368
369#[derive(Deserialize)]
372struct BarSpecificationFields {
373 step: NonZeroUsize,
374 aggregation: BarAggregation,
375 price_type: PriceType,
376}
377
378impl TryFrom<BarSpecificationFields> for BarSpecification {
379 type Error = anyhow::Error;
380
381 fn try_from(fields: BarSpecificationFields) -> Result<Self, Self::Error> {
382 Self::new_checked(fields.step.get(), fields.aggregation, fields.price_type)
383 }
384}
385
386impl BarSpecification {
387 pub fn new_checked(
398 step: usize,
399 aggregation: BarAggregation,
400 price_type: PriceType,
401 ) -> anyhow::Result<Self> {
402 let step = NonZeroUsize::new(step)
403 .ok_or(anyhow::anyhow!("Invalid step: {step} (must be non-zero)"))?;
404 Self::validate_step(step.get(), aggregation)?;
405
406 Ok(Self {
407 step,
408 aggregation,
409 price_type,
410 })
411 }
412
413 fn validate_step(step: usize, aggregation: BarAggregation) -> anyhow::Result<()> {
414 match aggregation {
415 BarAggregation::Millisecond => {
416 Self::validate_periodic_step(step, aggregation, 1000, false)
417 }
418 BarAggregation::Second | BarAggregation::Minute => {
419 Self::validate_periodic_step(step, aggregation, 60, false)
420 }
421 BarAggregation::Hour => Self::validate_periodic_step(step, aggregation, 24, false),
422 BarAggregation::Month => Self::validate_periodic_step(step, aggregation, 12, true),
425 _ => Ok(()),
426 }
427 }
428
429 fn validate_periodic_step(
430 step: usize,
431 aggregation: BarAggregation,
432 subunits: usize,
433 allow_equal: bool,
434 ) -> anyhow::Result<()> {
435 if !subunits.is_multiple_of(step) {
436 anyhow::bail!(
437 "Invalid step in bar_type.spec.step: {step} for aggregation={aggregation}. \
438 step must evenly divide {subunits} (so it is periodic).",
439 );
440 }
441
442 if !allow_equal && subunits == step {
443 anyhow::bail!(
444 "Invalid step in bar_type.spec.step: {step} for aggregation={aggregation}. \
445 step must not be {subunits}. Use higher aggregation unit instead.",
446 );
447 }
448
449 Ok(())
450 }
451
452 #[must_use]
459 pub fn new(step: usize, aggregation: BarAggregation, price_type: PriceType) -> Self {
460 Self::new_checked(step, aggregation, price_type).expect(FAILED)
461 }
462
463 #[must_use]
476 pub fn timedelta(&self) -> TimeDelta {
477 let step = step_to_i64(self.step);
478
479 match self.aggregation {
480 BarAggregation::Millisecond => Duration::milliseconds(step),
481 BarAggregation::Second => Duration::seconds(step),
482 BarAggregation::Minute => Duration::minutes(step),
483 BarAggregation::Hour => Duration::hours(step),
484 BarAggregation::Day => Duration::days(step),
485 BarAggregation::Week => {
486 Duration::days(step.checked_mul(7).expect("`step` overflows i64 days"))
487 }
488 BarAggregation::Month => {
489 Duration::days(step.checked_mul(30).expect("`step` overflows i64 days"))
491 }
492 BarAggregation::Year => {
493 Duration::days(step.checked_mul(365).expect("`step` overflows i64 days"))
495 }
496 _ => panic!(
497 "Timedelta not supported for aggregation type: {:?}",
498 self.aggregation
499 ),
500 }
501 }
502
503 #[must_use]
513 pub fn is_time_aggregated(&self) -> bool {
514 matches!(
515 self.aggregation,
516 BarAggregation::Millisecond
517 | BarAggregation::Second
518 | BarAggregation::Minute
519 | BarAggregation::Hour
520 | BarAggregation::Day
521 | BarAggregation::Week
522 | BarAggregation::Month
523 | BarAggregation::Year
524 )
525 }
526
527 #[must_use]
535 pub fn is_threshold_aggregated(&self) -> bool {
536 matches!(
537 self.aggregation,
538 BarAggregation::Tick
539 | BarAggregation::TickImbalance
540 | BarAggregation::Volume
541 | BarAggregation::VolumeImbalance
542 | BarAggregation::Value
543 | BarAggregation::ValueImbalance
544 )
545 }
546
547 #[must_use]
552 pub fn is_information_aggregated(&self) -> bool {
553 matches!(
554 self.aggregation,
555 BarAggregation::TickRuns | BarAggregation::VolumeRuns | BarAggregation::ValueRuns
556 )
557 }
558}
559
560impl Display for BarSpecification {
561 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
562 write!(f, "{}-{}-{}", self.step, self.aggregation, self.price_type)
563 }
564}
565
566#[repr(C)]
569#[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord, Hash)]
570#[cfg_attr(
571 feature = "python",
572 pyo3::pyclass(module = "nautilus_trader.core.nautilus_pyo3.model", from_py_object)
573)]
574#[cfg_attr(
575 feature = "python",
576 pyo3_stub_gen::derive::gen_stub_pyclass_enum(module = "nautilus_trader.model")
577)]
578pub enum BarType {
579 Standard {
580 instrument_id: InstrumentId,
582 spec: BarSpecification,
584 aggregation_source: AggregationSource,
586 },
587 Composite {
588 instrument_id: InstrumentId,
590 spec: BarSpecification,
592 aggregation_source: AggregationSource,
594
595 composite_step: usize,
597 composite_aggregation: BarAggregation,
599 composite_aggregation_source: AggregationSource,
601 },
602}
603
604impl BarType {
605 #[must_use]
607 pub fn new(
608 instrument_id: InstrumentId,
609 spec: BarSpecification,
610 aggregation_source: AggregationSource,
611 ) -> Self {
612 Self::Standard {
613 instrument_id,
614 spec,
615 aggregation_source,
616 }
617 }
618
619 pub fn new_composite_checked(
626 instrument_id: InstrumentId,
627 spec: BarSpecification,
628 aggregation_source: AggregationSource,
629
630 composite_step: usize,
631 composite_aggregation: BarAggregation,
632 composite_aggregation_source: AggregationSource,
633 ) -> anyhow::Result<Self> {
634 BarSpecification::new_checked(composite_step, composite_aggregation, spec.price_type)?;
636
637 Ok(Self::Composite {
638 instrument_id,
639 spec,
640 aggregation_source,
641
642 composite_step,
643 composite_aggregation,
644 composite_aggregation_source,
645 })
646 }
647
648 #[must_use]
655 pub fn new_composite(
656 instrument_id: InstrumentId,
657 spec: BarSpecification,
658 aggregation_source: AggregationSource,
659
660 composite_step: usize,
661 composite_aggregation: BarAggregation,
662 composite_aggregation_source: AggregationSource,
663 ) -> Self {
664 Self::new_composite_checked(
665 instrument_id,
666 spec,
667 aggregation_source,
668 composite_step,
669 composite_aggregation,
670 composite_aggregation_source,
671 )
672 .expect(FAILED)
673 }
674
675 #[must_use]
677 pub fn is_standard(&self) -> bool {
678 match &self {
679 Self::Standard { .. } => true,
680 Self::Composite { .. } => false,
681 }
682 }
683
684 #[must_use]
686 pub fn is_composite(&self) -> bool {
687 match &self {
688 Self::Standard { .. } => false,
689 Self::Composite { .. } => true,
690 }
691 }
692
693 #[must_use]
695 pub fn is_externally_aggregated(&self) -> bool {
696 self.aggregation_source() == AggregationSource::External
697 }
698
699 #[must_use]
701 pub fn is_internally_aggregated(&self) -> bool {
702 self.aggregation_source() == AggregationSource::Internal
703 }
704
705 #[must_use]
707 pub fn standard(&self) -> Self {
708 match self {
709 &b @ Self::Standard { .. } => b,
710 Self::Composite {
711 instrument_id,
712 spec,
713 aggregation_source,
714 ..
715 } => Self::new(*instrument_id, *spec, *aggregation_source),
716 }
717 }
718
719 #[must_use]
721 pub fn composite(&self) -> Self {
722 match self {
723 &b @ Self::Standard { .. } => b, Self::Composite {
725 instrument_id,
726 spec,
727 aggregation_source: _,
728
729 composite_step,
730 composite_aggregation,
731 composite_aggregation_source,
732 } => Self::new(
733 *instrument_id,
734 BarSpecification::new(*composite_step, *composite_aggregation, spec.price_type),
735 *composite_aggregation_source,
736 ),
737 }
738 }
739
740 #[must_use]
742 pub fn instrument_id(&self) -> InstrumentId {
743 match &self {
744 Self::Standard { instrument_id, .. } | Self::Composite { instrument_id, .. } => {
745 *instrument_id
746 }
747 }
748 }
749
750 #[must_use]
752 pub fn spec(&self) -> BarSpecification {
753 match &self {
754 Self::Standard { spec, .. } | Self::Composite { spec, .. } => *spec,
755 }
756 }
757
758 #[must_use]
760 pub fn aggregation_source(&self) -> AggregationSource {
761 match &self {
762 Self::Standard {
763 aggregation_source, ..
764 }
765 | Self::Composite {
766 aggregation_source, ..
767 } => *aggregation_source,
768 }
769 }
770
771 #[must_use]
777 pub fn id_spec_key(&self) -> (InstrumentId, BarSpecification) {
778 (self.instrument_id(), self.spec())
779 }
780}
781
782#[derive(thiserror::Error, Debug)]
783#[error("Error parsing `BarType` from '{input}', invalid token: '{token}' at position {position}")]
784pub struct BarTypeParseError {
785 input: String,
786 token: String,
787 position: usize,
788}
789
790impl FromStr for BarType {
791 type Err = BarTypeParseError;
792
793 #[expect(clippy::needless_collect)] fn from_str(s: &str) -> Result<Self, Self::Err> {
795 let parts: Vec<&str> = s.split('@').collect();
796 if parts.len() > 2 {
797 return Err(BarTypeParseError {
798 input: s.to_string(),
799 token: parts[2].to_string(),
800 position: 5,
801 });
802 }
803 let standard = parts[0];
804 let composite_str = parts.get(1);
805
806 let pieces: Vec<&str> = standard.rsplitn(5, '-').collect();
807 let rev_pieces: Vec<&str> = pieces.into_iter().rev().collect();
808 if rev_pieces.len() != 5 {
809 return Err(BarTypeParseError {
810 input: s.to_string(),
811 token: String::new(),
812 position: 0,
813 });
814 }
815
816 let instrument_id =
817 InstrumentId::from_str(rev_pieces[0]).map_err(|_| BarTypeParseError {
818 input: s.to_string(),
819 token: rev_pieces[0].to_string(),
820 position: 0,
821 })?;
822
823 let step = rev_pieces[1].parse().map_err(|_| BarTypeParseError {
824 input: s.to_string(),
825 token: rev_pieces[1].to_string(),
826 position: 1,
827 })?;
828 let aggregation =
829 BarAggregation::from_str(rev_pieces[2]).map_err(|_| BarTypeParseError {
830 input: s.to_string(),
831 token: rev_pieces[2].to_string(),
832 position: 2,
833 })?;
834 let price_type = PriceType::from_str(rev_pieces[3]).map_err(|_| BarTypeParseError {
835 input: s.to_string(),
836 token: rev_pieces[3].to_string(),
837 position: 3,
838 })?;
839 let aggregation_source =
840 AggregationSource::from_str(rev_pieces[4]).map_err(|_| BarTypeParseError {
841 input: s.to_string(),
842 token: rev_pieces[4].to_string(),
843 position: 4,
844 })?;
845 let spec = BarSpecification::new_checked(step, aggregation, price_type).map_err(|_| {
846 BarTypeParseError {
847 input: s.to_string(),
848 token: rev_pieces[1].to_string(),
849 position: 1,
850 }
851 })?;
852
853 if let Some(composite_str) = composite_str {
854 let composite_pieces: Vec<&str> = composite_str.rsplitn(3, '-').collect();
855 let rev_composite_pieces: Vec<&str> = composite_pieces.into_iter().rev().collect();
856 if rev_composite_pieces.len() != 3 {
857 return Err(BarTypeParseError {
858 input: s.to_string(),
859 token: String::new(),
860 position: 5,
861 });
862 }
863
864 let composite_step =
865 rev_composite_pieces[0]
866 .parse()
867 .map_err(|_| BarTypeParseError {
868 input: s.to_string(),
869 token: rev_composite_pieces[0].to_string(),
870 position: 5,
871 })?;
872 let composite_aggregation =
873 BarAggregation::from_str(rev_composite_pieces[1]).map_err(|_| {
874 BarTypeParseError {
875 input: s.to_string(),
876 token: rev_composite_pieces[1].to_string(),
877 position: 6,
878 }
879 })?;
880 let composite_aggregation_source = AggregationSource::from_str(rev_composite_pieces[2])
881 .map_err(|_| BarTypeParseError {
882 input: s.to_string(),
883 token: rev_composite_pieces[2].to_string(),
884 position: 7,
885 })?;
886 BarSpecification::new_checked(composite_step, composite_aggregation, price_type)
887 .map_err(|_| BarTypeParseError {
888 input: s.to_string(),
889 token: rev_composite_pieces[0].to_string(),
890 position: 5,
891 })?;
892
893 Ok(Self::new_composite(
894 instrument_id,
895 spec,
896 aggregation_source,
897 composite_step,
898 composite_aggregation,
899 composite_aggregation_source,
900 ))
901 } else {
902 Ok(Self::Standard {
903 instrument_id,
904 spec,
905 aggregation_source,
906 })
907 }
908 }
909}
910
911impl<T: AsRef<str>> From<T> for BarType {
912 fn from(value: T) -> Self {
913 Self::from_str(value.as_ref()).expect(FAILED)
914 }
915}
916
917impl Display for BarType {
918 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
919 match &self {
920 Self::Standard {
921 instrument_id,
922 spec,
923 aggregation_source,
924 } => {
925 write!(f, "{instrument_id}-{spec}-{aggregation_source}")
926 }
927 Self::Composite {
928 instrument_id,
929 spec,
930 aggregation_source,
931
932 composite_step,
933 composite_aggregation,
934 composite_aggregation_source,
935 } => {
936 write!(
937 f,
938 "{}-{}-{}@{}-{}-{}",
939 instrument_id,
940 spec,
941 aggregation_source,
942 *composite_step,
943 *composite_aggregation,
944 *composite_aggregation_source
945 )
946 }
947 }
948 }
949}
950
951impl Serialize for BarType {
952 fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
953 where
954 S: Serializer,
955 {
956 serializer.serialize_str(&self.to_string())
957 }
958}
959
960impl<'de> Deserialize<'de> for BarType {
961 fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
962 where
963 D: Deserializer<'de>,
964 {
965 let s: std::borrow::Cow<'de, str> = Deserialize::deserialize(deserializer)?;
966 Self::from_str(s.as_ref()).map_err(serde::de::Error::custom)
967 }
968}
969
970#[repr(C)]
972#[derive(Clone, Copy, Hash, PartialEq, Eq, Debug, Serialize, Deserialize)]
973#[serde(tag = "type", try_from = "BarFields")]
974#[cfg_attr(
975 feature = "python",
976 pyo3::pyclass(module = "nautilus_trader.core.nautilus_pyo3.model", from_py_object)
977)]
978#[cfg_attr(
979 feature = "python",
980 pyo3_stub_gen::derive::gen_stub_pyclass(module = "nautilus_trader.model")
981)]
982pub struct Bar {
983 pub bar_type: BarType,
985 pub open: Price,
987 pub high: Price,
989 pub low: Price,
991 pub close: Price,
993 pub volume: Quantity,
995 pub ts_event: UnixNanos,
997 pub ts_init: UnixNanos,
999}
1000
1001#[derive(Deserialize)]
1004struct BarFields {
1005 bar_type: BarType,
1006 open: Price,
1007 high: Price,
1008 low: Price,
1009 close: Price,
1010 volume: Quantity,
1011 ts_event: UnixNanos,
1012 ts_init: UnixNanos,
1013}
1014
1015impl TryFrom<BarFields> for Bar {
1016 type Error = anyhow::Error;
1017
1018 fn try_from(fields: BarFields) -> Result<Self, Self::Error> {
1019 Self::new_checked(
1020 fields.bar_type,
1021 fields.open,
1022 fields.high,
1023 fields.low,
1024 fields.close,
1025 fields.volume,
1026 fields.ts_event,
1027 fields.ts_init,
1028 )
1029 }
1030}
1031
1032impl Bar {
1033 #[expect(clippy::too_many_arguments)]
1048 pub fn new_checked(
1049 bar_type: BarType,
1050 open: Price,
1051 high: Price,
1052 low: Price,
1053 close: Price,
1054 volume: Quantity,
1055 ts_event: UnixNanos,
1056 ts_init: UnixNanos,
1057 ) -> anyhow::Result<Self> {
1058 check_predicate_true(high >= open, "high >= open")?;
1059 check_predicate_true(high >= low, "high >= low")?;
1060 check_predicate_true(high >= close, "high >= close")?;
1061 check_predicate_true(low <= close, "low <= close")?;
1062 check_predicate_true(low <= open, "low <= open")?;
1063
1064 debug_assert!(
1065 open.precision == high.precision
1066 && open.precision == low.precision
1067 && open.precision == close.precision,
1068 "Bar prices must share a uniform precision (Arrow encoding assumes it)"
1069 );
1070
1071 Ok(Self {
1072 bar_type,
1073 open,
1074 high,
1075 low,
1076 close,
1077 volume,
1078 ts_event,
1079 ts_init,
1080 })
1081 }
1082
1083 #[expect(clippy::too_many_arguments)]
1094 #[must_use]
1095 pub fn new(
1096 bar_type: BarType,
1097 open: Price,
1098 high: Price,
1099 low: Price,
1100 close: Price,
1101 volume: Quantity,
1102 ts_event: UnixNanos,
1103 ts_init: UnixNanos,
1104 ) -> Self {
1105 Self::new_checked(bar_type, open, high, low, close, volume, ts_event, ts_init)
1106 .expect(FAILED)
1107 }
1108
1109 #[must_use]
1110 pub fn instrument_id(&self) -> InstrumentId {
1111 self.bar_type.instrument_id()
1112 }
1113
1114 #[must_use]
1116 pub fn get_metadata(
1117 bar_type: &BarType,
1118 price_precision: u8,
1119 size_precision: u8,
1120 ) -> HashMap<String, String> {
1121 let mut metadata = HashMap::new();
1122 let instrument_id = bar_type.instrument_id();
1123 metadata.insert("bar_type".to_string(), bar_type.to_string());
1124 metadata.insert("instrument_id".to_string(), instrument_id.to_string());
1125 metadata.insert("price_precision".to_string(), price_precision.to_string());
1126 metadata.insert("size_precision".to_string(), size_precision.to_string());
1127 metadata
1128 }
1129
1130 #[must_use]
1132 pub fn get_fields() -> IndexMap<String, String> {
1133 let mut metadata = IndexMap::new();
1134 metadata.insert("open".to_string(), FIXED_SIZE_BINARY.to_string());
1135 metadata.insert("high".to_string(), FIXED_SIZE_BINARY.to_string());
1136 metadata.insert("low".to_string(), FIXED_SIZE_BINARY.to_string());
1137 metadata.insert("close".to_string(), FIXED_SIZE_BINARY.to_string());
1138 metadata.insert("volume".to_string(), FIXED_SIZE_BINARY.to_string());
1139 metadata.insert("ts_event".to_string(), "UInt64".to_string());
1140 metadata.insert("ts_init".to_string(), "UInt64".to_string());
1141 metadata
1142 }
1143}
1144
1145impl Display for Bar {
1146 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
1147 write!(
1148 f,
1149 "{},{},{},{},{},{},{}",
1150 self.bar_type, self.open, self.high, self.low, self.close, self.volume, self.ts_event
1151 )
1152 }
1153}
1154
1155impl Serializable for Bar {}
1156
1157impl HasTsInit for Bar {
1158 fn ts_init(&self) -> UnixNanos {
1159 self.ts_init
1160 }
1161}
1162
1163#[cfg(test)]
1164mod tests {
1165 use std::str::FromStr;
1166
1167 use chrono::TimeZone;
1168 use nautilus_core::serialization::msgpack::{FromMsgPack, ToMsgPack};
1169 use rstest::rstest;
1170
1171 use super::*;
1172 use crate::identifiers::{Symbol, Venue};
1173
1174 #[rstest]
1175 fn test_bar_specification_new_invalid() {
1176 let result = BarSpecification::new_checked(0, BarAggregation::Tick, PriceType::Last);
1177 assert!(
1178 result
1179 .unwrap_err()
1180 .to_string()
1181 .contains("Invalid step: 0 (must be non-zero)")
1182 );
1183 }
1184
1185 #[rstest]
1186 #[should_panic(expected = "Invalid step: 0 (must be non-zero)")]
1187 fn test_bar_specification_new_checked_with_invalid_step_panics() {
1188 let aggregation = BarAggregation::Tick;
1189 let price_type = PriceType::Last;
1190
1191 let _ = BarSpecification::new(0, aggregation, price_type);
1192 }
1193
1194 #[rstest]
1195 #[should_panic(expected = "Invalid step in bar_type.spec.step: 7")]
1196 fn test_bar_specification_new_with_invalid_periodic_step_panics() {
1197 let _ = BarSpecification::new(7, BarAggregation::Minute, PriceType::Last);
1198 }
1199
1200 #[rstest]
1201 #[case(
1202 BarAggregation::Millisecond,
1203 12,
1204 "Invalid step in bar_type.spec.step: 12 for aggregation=MILLISECOND. step must evenly divide 1000"
1205 )]
1206 #[case(
1207 BarAggregation::Millisecond,
1208 1000,
1209 "Invalid step in bar_type.spec.step: 1000 for aggregation=MILLISECOND. step must not be 1000"
1210 )]
1211 #[case(
1212 BarAggregation::Second,
1213 50,
1214 "Invalid step in bar_type.spec.step: 50 for aggregation=SECOND. step must evenly divide 60"
1215 )]
1216 #[case(
1217 BarAggregation::Second,
1218 60,
1219 "Invalid step in bar_type.spec.step: 60 for aggregation=SECOND. step must not be 60"
1220 )]
1221 #[case(
1222 BarAggregation::Minute,
1223 40,
1224 "Invalid step in bar_type.spec.step: 40 for aggregation=MINUTE. step must evenly divide 60"
1225 )]
1226 #[case(
1227 BarAggregation::Minute,
1228 60,
1229 "Invalid step in bar_type.spec.step: 60 for aggregation=MINUTE. step must not be 60"
1230 )]
1231 #[case(
1232 BarAggregation::Hour,
1233 5,
1234 "Invalid step in bar_type.spec.step: 5 for aggregation=HOUR. step must evenly divide 24"
1235 )]
1236 #[case(
1237 BarAggregation::Hour,
1238 13,
1239 "Invalid step in bar_type.spec.step: 13 for aggregation=HOUR. step must evenly divide 24"
1240 )]
1241 #[case(
1242 BarAggregation::Hour,
1243 24,
1244 "Invalid step in bar_type.spec.step: 24 for aggregation=HOUR. step must not be 24"
1245 )]
1246 #[case(
1247 BarAggregation::Month,
1248 5,
1249 "Invalid step in bar_type.spec.step: 5 for aggregation=MONTH. step must evenly divide 12"
1250 )]
1251 fn test_bar_specification_new_checked_invalid_periodic_step(
1252 #[case] aggregation: BarAggregation,
1253 #[case] step: usize,
1254 #[case] expected: &str,
1255 ) {
1256 let result = BarSpecification::new_checked(step, aggregation, PriceType::Last);
1257
1258 assert!(result.unwrap_err().to_string().starts_with(expected));
1259 }
1260
1261 #[rstest]
1262 #[case(BarAggregation::Day)]
1263 #[case(BarAggregation::Week)]
1264 #[case(BarAggregation::Year)]
1265 #[case(BarAggregation::Tick)]
1266 #[case(BarAggregation::TickImbalance)]
1267 #[case(BarAggregation::TickRuns)]
1268 #[case(BarAggregation::Volume)]
1269 #[case(BarAggregation::VolumeImbalance)]
1270 #[case(BarAggregation::VolumeRuns)]
1271 #[case(BarAggregation::Value)]
1272 #[case(BarAggregation::ValueImbalance)]
1273 #[case(BarAggregation::ValueRuns)]
1274 #[case(BarAggregation::Renko)]
1275 fn test_bar_specification_new_checked_allows_non_periodic_steps(
1276 #[case] aggregation: BarAggregation,
1277 ) {
1278 let result = BarSpecification::new_checked(7, aggregation, PriceType::Last);
1279
1280 assert!(result.is_ok());
1281 }
1282
1283 #[rstest]
1284 #[case(BarAggregation::Millisecond, 1, TimeDelta::milliseconds(1))]
1285 #[case(BarAggregation::Millisecond, 10, TimeDelta::milliseconds(10))]
1286 #[case(BarAggregation::Second, 1, TimeDelta::seconds(1))]
1287 #[case(BarAggregation::Second, 15, TimeDelta::seconds(15))]
1288 #[case(BarAggregation::Minute, 1, TimeDelta::minutes(1))]
1289 #[case(BarAggregation::Minute, 30, TimeDelta::minutes(30))]
1290 #[case(BarAggregation::Hour, 1, TimeDelta::hours(1))]
1291 #[case(BarAggregation::Hour, 4, TimeDelta::hours(4))]
1292 #[case(BarAggregation::Day, 1, TimeDelta::days(1))]
1293 #[case(BarAggregation::Day, 2, TimeDelta::days(2))]
1294 #[case(BarAggregation::Week, 1, TimeDelta::days(7))]
1295 #[case(BarAggregation::Week, 2, TimeDelta::days(14))]
1296 #[case(BarAggregation::Month, 1, TimeDelta::days(30))]
1297 #[case(BarAggregation::Month, 3, TimeDelta::days(90))]
1298 #[case(BarAggregation::Year, 1, TimeDelta::days(365))]
1299 #[case(BarAggregation::Year, 2, TimeDelta::days(730))]
1300 #[should_panic(expected = "Aggregation not time based")]
1301 #[case(BarAggregation::Tick, 1, TimeDelta::zero())]
1302 fn test_get_bar_interval(
1303 #[case] aggregation: BarAggregation,
1304 #[case] step: usize,
1305 #[case] expected: TimeDelta,
1306 ) {
1307 let bar_type = BarType::Standard {
1308 instrument_id: InstrumentId::from("BTCUSDT-PERP.BINANCE"),
1309 spec: BarSpecification::new(step, aggregation, PriceType::Last),
1310 aggregation_source: AggregationSource::Internal,
1311 };
1312
1313 let interval = get_bar_interval(&bar_type);
1314 assert_eq!(interval, expected);
1315 }
1316
1317 #[rstest]
1318 #[case(BarAggregation::Millisecond, 1, UnixNanos::from(1_000_000))]
1319 #[case(BarAggregation::Millisecond, 10, UnixNanos::from(10_000_000))]
1320 #[case(BarAggregation::Second, 1, UnixNanos::from(1_000_000_000))]
1321 #[case(BarAggregation::Second, 10, UnixNanos::from(10_000_000_000))]
1322 #[case(BarAggregation::Minute, 1, UnixNanos::from(60_000_000_000))]
1323 #[case(BarAggregation::Minute, 30, UnixNanos::from(1_800_000_000_000))]
1324 #[case(BarAggregation::Hour, 1, UnixNanos::from(3_600_000_000_000))]
1325 #[case(BarAggregation::Hour, 4, UnixNanos::from(14_400_000_000_000))]
1326 #[case(BarAggregation::Day, 1, UnixNanos::from(86_400_000_000_000))]
1327 #[case(BarAggregation::Day, 2, UnixNanos::from(172_800_000_000_000))]
1328 #[case(BarAggregation::Week, 1, UnixNanos::from(604_800_000_000_000))]
1329 #[case(BarAggregation::Week, 2, UnixNanos::from(1_209_600_000_000_000))]
1330 #[case(BarAggregation::Month, 1, UnixNanos::from(2_592_000_000_000_000))]
1331 #[case(BarAggregation::Month, 3, UnixNanos::from(7_776_000_000_000_000))]
1332 #[case(BarAggregation::Year, 1, UnixNanos::from(31_536_000_000_000_000))]
1333 #[case(BarAggregation::Year, 2, UnixNanos::from(63_072_000_000_000_000))]
1334 #[should_panic(expected = "Aggregation not time based")]
1335 #[case(BarAggregation::Tick, 1, UnixNanos::from(0))]
1336 fn test_get_bar_interval_ns(
1337 #[case] aggregation: BarAggregation,
1338 #[case] step: usize,
1339 #[case] expected: UnixNanos,
1340 ) {
1341 let bar_type = BarType::Standard {
1342 instrument_id: InstrumentId::from("BTCUSDT-PERP.BINANCE"),
1343 spec: BarSpecification::new(step, aggregation, PriceType::Last),
1344 aggregation_source: AggregationSource::Internal,
1345 };
1346
1347 let interval_ns = get_bar_interval_ns(&bar_type);
1348 assert_eq!(interval_ns, expected);
1349 }
1350
1351 fn bar_type_with_raw_step(step: usize, aggregation: BarAggregation) -> BarType {
1352 let spec = BarSpecification {
1354 step: NonZeroUsize::new(step).unwrap(),
1355 aggregation,
1356 price_type: PriceType::Last,
1357 };
1358 BarType::new(
1359 InstrumentId::from("BTCUSDT-PERP.BINANCE"),
1360 spec,
1361 AggregationSource::Internal,
1362 )
1363 }
1364
1365 #[rstest]
1366 #[should_panic(expected = "`step` exceeds i64 range")]
1367 fn test_get_bar_interval_step_exceeds_i64_panics() {
1368 let bar_type = bar_type_with_raw_step(usize::MAX, BarAggregation::Second);
1369 let _ = get_bar_interval(&bar_type);
1370 }
1371
1372 #[rstest]
1373 #[should_panic(expected = "`step` overflows i64 days")]
1374 fn test_get_bar_interval_week_step_overflow_panics() {
1375 let step = usize::try_from(i64::MAX).unwrap();
1376 let bar_type = bar_type_with_raw_step(step, BarAggregation::Week);
1377 let _ = get_bar_interval(&bar_type);
1378 }
1379
1380 #[rstest]
1381 #[should_panic(expected = "`step` overflows i64 days")]
1382 fn test_timedelta_year_step_overflow_panics() {
1383 let step = usize::try_from(i64::MAX).unwrap();
1384 let bar_type = bar_type_with_raw_step(step, BarAggregation::Year);
1385 let _ = bar_type.spec().timedelta();
1386 }
1387
1388 #[rstest]
1389 #[should_panic(expected = "`step` exceeds u32 range for month arithmetic")]
1390 fn test_get_time_bar_start_month_step_exceeds_u32_panics() {
1391 let bar_type = bar_type_with_raw_step(1_usize << 40, BarAggregation::Month);
1392 let now = Utc.with_ymd_and_hms(2024, 7, 21, 12, 0, 0).unwrap();
1393 let _ = get_time_bar_start(now, &bar_type, None);
1394 }
1395
1396 #[rstest]
1397 #[should_panic(expected = "`step` exceeds i32 range for year arithmetic")]
1398 fn test_get_time_bar_start_year_step_exceeds_i32_panics() {
1399 let bar_type = bar_type_with_raw_step(1_usize << 40, BarAggregation::Year);
1400 let now = Utc.with_ymd_and_hms(2024, 7, 21, 12, 0, 0).unwrap();
1401 let _ = get_time_bar_start(now, &bar_type, None);
1402 }
1403
1404 #[rstest]
1405 #[case::millisecond(
1406 Utc.timestamp_opt(1_658_349_296, 123_000_000).unwrap(), BarAggregation::Millisecond,
1408 1,
1409 Utc.timestamp_opt(1_658_349_296, 123_000_000).unwrap(), )]
1411 #[rstest]
1412 #[case::millisecond(
1413 Utc.timestamp_opt(1_658_349_296, 123_000_000).unwrap(), BarAggregation::Millisecond,
1415 10,
1416 Utc.timestamp_opt(1_658_349_296, 120_000_000).unwrap(), )]
1418 #[case::second(
1419 Utc.with_ymd_and_hms(2024, 7, 21, 12, 34, 56).unwrap(),
1420 BarAggregation::Second,
1421 1,
1422 Utc.with_ymd_and_hms(2024, 7, 21, 12, 34, 56).unwrap()
1423 )]
1424 #[case::second(
1425 Utc.with_ymd_and_hms(2024, 7, 21, 12, 34, 56).unwrap(),
1426 BarAggregation::Second,
1427 5,
1428 Utc.with_ymd_and_hms(2024, 7, 21, 12, 34, 55).unwrap()
1429 )]
1430 #[case::minute(
1431 Utc.with_ymd_and_hms(2024, 7, 21, 12, 34, 56).unwrap(),
1432 BarAggregation::Minute,
1433 1,
1434 Utc.with_ymd_and_hms(2024, 7, 21, 12, 34, 0).unwrap()
1435 )]
1436 #[case::minute(
1437 Utc.with_ymd_and_hms(2024, 7, 21, 12, 34, 56).unwrap(),
1438 BarAggregation::Minute,
1439 5,
1440 Utc.with_ymd_and_hms(2024, 7, 21, 12, 30, 0).unwrap()
1441 )]
1442 #[case::hour(
1443 Utc.with_ymd_and_hms(2024, 7, 21, 12, 34, 56).unwrap(),
1444 BarAggregation::Hour,
1445 1,
1446 Utc.with_ymd_and_hms(2024, 7, 21, 12, 0, 0).unwrap()
1447 )]
1448 #[case::hour(
1449 Utc.with_ymd_and_hms(2024, 7, 21, 12, 34, 56).unwrap(),
1450 BarAggregation::Hour,
1451 2,
1452 Utc.with_ymd_and_hms(2024, 7, 21, 12, 0, 0).unwrap()
1453 )]
1454 #[case::day(
1455 Utc.with_ymd_and_hms(2024, 7, 21, 12, 34, 56).unwrap(),
1456 BarAggregation::Day,
1457 1,
1458 Utc.with_ymd_and_hms(2024, 7, 21, 0, 0, 0).unwrap()
1459 )]
1460 fn test_get_time_bar_start(
1461 #[case] now: DateTime<Utc>,
1462 #[case] aggregation: BarAggregation,
1463 #[case] step: usize,
1464 #[case] expected: DateTime<Utc>,
1465 ) {
1466 let bar_type = BarType::Standard {
1467 instrument_id: InstrumentId::from("BTCUSDT-PERP.BINANCE"),
1468 spec: BarSpecification::new(step, aggregation, PriceType::Last),
1469 aggregation_source: AggregationSource::Internal,
1470 };
1471
1472 let start_time = get_time_bar_start(now, &bar_type, None);
1473 assert_eq!(start_time, expected);
1474 }
1475
1476 #[rstest]
1477 fn test_bar_spec_string_reprs() {
1478 let bar_spec = BarSpecification::new(1, BarAggregation::Minute, PriceType::Bid);
1479 assert_eq!(bar_spec.to_string(), "1-MINUTE-BID");
1480 assert_eq!(format!("{bar_spec}"), "1-MINUTE-BID");
1481 }
1482
1483 #[rstest]
1484 fn test_bar_type_parse_valid() {
1485 let input = "BTCUSDT-PERP.BINANCE-1-MINUTE-LAST-EXTERNAL";
1486 let bar_type = BarType::from(input);
1487
1488 assert_eq!(
1489 bar_type.instrument_id(),
1490 InstrumentId::from("BTCUSDT-PERP.BINANCE")
1491 );
1492 assert_eq!(
1493 bar_type.spec(),
1494 BarSpecification::new(1, BarAggregation::Minute, PriceType::Last)
1495 );
1496 assert_eq!(bar_type.aggregation_source(), AggregationSource::External);
1497 assert_eq!(bar_type, BarType::from(input));
1498 }
1499
1500 #[rstest]
1501 #[case("BTCUSDT-PERP.BINANCE-1-MINUTE-LAST-EXTERNAL", true, false)]
1502 #[case("BTCUSDT-PERP.BINANCE-1-MINUTE-LAST-INTERNAL", false, true)]
1503 #[case(
1504 "BTCUSDT-PERP.BINANCE-2-MINUTE-LAST-INTERNAL@1-MINUTE-EXTERNAL",
1505 false,
1506 true
1507 )]
1508 #[case(
1509 "BTCUSDT-PERP.BINANCE-2-MINUTE-LAST-EXTERNAL@1-MINUTE-INTERNAL",
1510 true,
1511 false
1512 )]
1513 fn test_bar_type_aggregation_source_predicates(
1514 #[case] input: &str,
1515 #[case] expected_external: bool,
1516 #[case] expected_internal: bool,
1517 ) {
1518 let bar_type = BarType::from(input);
1519 assert_eq!(bar_type.is_externally_aggregated(), expected_external);
1520 assert_eq!(bar_type.is_internally_aggregated(), expected_internal);
1521 }
1522
1523 #[rstest]
1524 fn test_bar_type_composite_aggregation_source_predicates_track_inner() {
1525 let bar_type =
1526 BarType::from("BTCUSDT-PERP.BINANCE-2-MINUTE-LAST-INTERNAL@1-MINUTE-EXTERNAL");
1527
1528 assert!(bar_type.is_internally_aggregated());
1529 assert!(!bar_type.is_externally_aggregated());
1530
1531 let composite = bar_type.composite();
1532 assert!(composite.is_externally_aggregated());
1533 assert!(!composite.is_internally_aggregated());
1534 }
1535
1536 #[rstest]
1537 fn test_bar_type_from_str_with_utf8_symbol() {
1538 let non_ascii_instrument = "TËST-PÉRP.BINANCE";
1539 let non_ascii_bar_type = "TËST-PÉRP.BINANCE-1-MINUTE-LAST-EXTERNAL";
1540
1541 let bar_type = BarType::from_str(non_ascii_bar_type).unwrap();
1542
1543 assert_eq!(
1544 bar_type.instrument_id(),
1545 InstrumentId::from_str(non_ascii_instrument).unwrap()
1546 );
1547 assert_eq!(
1548 bar_type.spec(),
1549 BarSpecification::new(1, BarAggregation::Minute, PriceType::Last)
1550 );
1551 assert_eq!(bar_type.aggregation_source(), AggregationSource::External);
1552 assert_eq!(bar_type.to_string(), non_ascii_bar_type);
1553 }
1554
1555 #[rstest]
1556 fn test_bar_type_composite_parse_valid() {
1557 let input = "BTCUSDT-PERP.BINANCE-2-MINUTE-LAST-INTERNAL@1-MINUTE-EXTERNAL";
1558 let bar_type = BarType::from(input);
1559 let standard = bar_type.standard();
1560
1561 assert_eq!(
1562 bar_type.instrument_id(),
1563 InstrumentId::from("BTCUSDT-PERP.BINANCE")
1564 );
1565 assert_eq!(
1566 bar_type.spec(),
1567 BarSpecification::new(2, BarAggregation::Minute, PriceType::Last,)
1568 );
1569 assert_eq!(bar_type.aggregation_source(), AggregationSource::Internal);
1570 assert_eq!(bar_type, BarType::from(input));
1571 assert!(bar_type.is_composite());
1572
1573 assert_eq!(
1574 standard.instrument_id(),
1575 InstrumentId::from("BTCUSDT-PERP.BINANCE")
1576 );
1577 assert_eq!(
1578 standard.spec(),
1579 BarSpecification::new(2, BarAggregation::Minute, PriceType::Last,)
1580 );
1581 assert_eq!(standard.aggregation_source(), AggregationSource::Internal);
1582 assert!(standard.is_standard());
1583
1584 let composite = bar_type.composite();
1585 let composite_input = "BTCUSDT-PERP.BINANCE-1-MINUTE-LAST-EXTERNAL";
1586
1587 assert_eq!(
1588 composite.instrument_id(),
1589 InstrumentId::from("BTCUSDT-PERP.BINANCE")
1590 );
1591 assert_eq!(
1592 composite.spec(),
1593 BarSpecification::new(1, BarAggregation::Minute, PriceType::Last,)
1594 );
1595 assert_eq!(composite.aggregation_source(), AggregationSource::External);
1596 assert_eq!(composite, BarType::from(composite_input));
1597 assert!(composite.is_standard());
1598 }
1599
1600 #[rstest]
1601 fn test_bar_type_parse_invalid_token_pos_0() {
1602 let input = "BTCUSDT-PERP-1-MINUTE-LAST-INTERNAL";
1603 let result = BarType::from_str(input);
1604
1605 assert_eq!(
1606 result.unwrap_err().to_string(),
1607 format!(
1608 "Error parsing `BarType` from '{input}', invalid token: 'BTCUSDT-PERP' at position 0"
1609 )
1610 );
1611 }
1612
1613 #[rstest]
1614 fn test_bar_type_parse_invalid_token_pos_1() {
1615 let input = "BTCUSDT-PERP.BINANCE-INVALID-MINUTE-LAST-INTERNAL";
1616 let result = BarType::from_str(input);
1617
1618 assert_eq!(
1619 result.unwrap_err().to_string(),
1620 format!(
1621 "Error parsing `BarType` from '{input}', invalid token: 'INVALID' at position 1"
1622 )
1623 );
1624 }
1625
1626 #[rstest]
1627 fn test_bar_type_parse_invalid_spec_step() {
1628 let input = "BTCUSDT-PERP.BINANCE-60-MINUTE-LAST-INTERNAL";
1629 let result = BarType::from_str(input);
1630
1631 assert_eq!(
1632 result.unwrap_err().to_string(),
1633 format!("Error parsing `BarType` from '{input}', invalid token: '60' at position 1")
1634 );
1635 }
1636
1637 #[rstest]
1638 fn test_bar_type_parse_invalid_token_pos_2() {
1639 let input = "BTCUSDT-PERP.BINANCE-1-INVALID-LAST-INTERNAL";
1640 let result = BarType::from_str(input);
1641
1642 assert_eq!(
1643 result.unwrap_err().to_string(),
1644 format!(
1645 "Error parsing `BarType` from '{input}', invalid token: 'INVALID' at position 2"
1646 )
1647 );
1648 }
1649
1650 #[rstest]
1651 fn test_bar_type_parse_invalid_token_pos_3() {
1652 let input = "BTCUSDT-PERP.BINANCE-1-MINUTE-INVALID-INTERNAL";
1653 let result = BarType::from_str(input);
1654
1655 assert_eq!(
1656 result.unwrap_err().to_string(),
1657 format!(
1658 "Error parsing `BarType` from '{input}', invalid token: 'INVALID' at position 3"
1659 )
1660 );
1661 }
1662
1663 #[rstest]
1664 fn test_bar_type_parse_invalid_token_pos_4() {
1665 let input = "BTCUSDT-PERP.BINANCE-1-MINUTE-BID-INVALID";
1666 let result = BarType::from_str(input);
1667
1668 assert!(result.is_err());
1669 assert_eq!(
1670 result.unwrap_err().to_string(),
1671 format!(
1672 "Error parsing `BarType` from '{input}', invalid token: 'INVALID' at position 4"
1673 )
1674 );
1675 }
1676
1677 #[rstest]
1678 fn test_bar_type_parse_invalid_token_pos_5() {
1679 let input = "BTCUSDT-PERP.BINANCE-2-MINUTE-LAST-INTERNAL@INVALID-MINUTE-EXTERNAL";
1680 let result = BarType::from_str(input);
1681
1682 assert!(result.is_err());
1683 assert_eq!(
1684 result.unwrap_err().to_string(),
1685 format!(
1686 "Error parsing `BarType` from '{input}', invalid token: 'INVALID' at position 5"
1687 )
1688 );
1689 }
1690
1691 #[rstest]
1692 fn test_bar_type_parse_invalid_composite_spec_step() {
1693 let input = "BTCUSDT-PERP.BINANCE-2-MINUTE-LAST-INTERNAL@60-MINUTE-EXTERNAL";
1694 let result = BarType::from_str(input);
1695
1696 assert!(result.is_err());
1697 assert_eq!(
1698 result.unwrap_err().to_string(),
1699 format!("Error parsing `BarType` from '{input}', invalid token: '60' at position 5")
1700 );
1701 }
1702
1703 #[rstest]
1704 fn test_bar_type_parse_invalid_token_pos_6() {
1705 let input = "BTCUSDT-PERP.BINANCE-2-MINUTE-LAST-INTERNAL@1-INVALID-EXTERNAL";
1706 let result = BarType::from_str(input);
1707
1708 assert!(result.is_err());
1709 assert_eq!(
1710 result.unwrap_err().to_string(),
1711 format!(
1712 "Error parsing `BarType` from '{input}', invalid token: 'INVALID' at position 6"
1713 )
1714 );
1715 }
1716
1717 #[rstest]
1718 fn test_bar_type_parse_invalid_token_pos_7() {
1719 let input = "BTCUSDT-PERP.BINANCE-2-MINUTE-LAST-INTERNAL@1-MINUTE-INVALID";
1720 let result = BarType::from_str(input);
1721
1722 assert!(result.is_err());
1723 assert_eq!(
1724 result.unwrap_err().to_string(),
1725 format!(
1726 "Error parsing `BarType` from '{input}', invalid token: 'INVALID' at position 7"
1727 )
1728 );
1729 }
1730
1731 #[rstest]
1732 fn test_bar_type_parse_rejects_extra_composite_segment() {
1733 let input = "BTCUSDT-PERP.BINANCE-2-MINUTE-LAST-INTERNAL@1-MINUTE-EXTERNAL@1-HOUR-EXTERNAL";
1734 let result = BarType::from_str(input);
1735
1736 assert_eq!(
1737 result.unwrap_err().to_string(),
1738 format!(
1739 "Error parsing `BarType` from '{input}', invalid token: '1-HOUR-EXTERNAL' at position 5"
1740 )
1741 );
1742 }
1743
1744 #[rstest]
1745 fn test_bar_type_equality() {
1746 let instrument_id1 = InstrumentId {
1747 symbol: Symbol::new("AUD/USD"),
1748 venue: Venue::new("SIM"),
1749 };
1750 let instrument_id2 = InstrumentId {
1751 symbol: Symbol::new("GBP/USD"),
1752 venue: Venue::new("SIM"),
1753 };
1754 let bar_spec = BarSpecification::new(1, BarAggregation::Minute, PriceType::Bid);
1755 let bar_type1 = BarType::Standard {
1756 instrument_id: instrument_id1,
1757 spec: bar_spec,
1758 aggregation_source: AggregationSource::External,
1759 };
1760 let bar_type2 = BarType::Standard {
1761 instrument_id: instrument_id1,
1762 spec: bar_spec,
1763 aggregation_source: AggregationSource::External,
1764 };
1765 let bar_type3 = BarType::Standard {
1766 instrument_id: instrument_id2,
1767 spec: bar_spec,
1768 aggregation_source: AggregationSource::External,
1769 };
1770 assert_eq!(bar_type1, bar_type1);
1771 assert_eq!(bar_type1, bar_type2);
1772 assert_ne!(bar_type1, bar_type3);
1773 }
1774
1775 #[rstest]
1776 fn test_bar_type_id_spec_key_ignores_aggregation_source() {
1777 let bar_type_external = BarType::from_str("ESM4.XCME-1-MINUTE-LAST-EXTERNAL").unwrap();
1778 let bar_type_internal = BarType::from_str("ESM4.XCME-1-MINUTE-LAST-INTERNAL").unwrap();
1779
1780 assert_ne!(bar_type_external, bar_type_internal);
1782
1783 assert_eq!(
1785 bar_type_external.id_spec_key(),
1786 bar_type_internal.id_spec_key()
1787 );
1788
1789 let (instrument_id, spec) = bar_type_external.id_spec_key();
1791 assert_eq!(instrument_id, bar_type_external.instrument_id());
1792 assert_eq!(spec, bar_type_external.spec());
1793 }
1794
1795 #[rstest]
1796 fn test_bar_type_comparison() {
1797 let instrument_id1 = InstrumentId {
1798 symbol: Symbol::new("AUD/USD"),
1799 venue: Venue::new("SIM"),
1800 };
1801
1802 let instrument_id2 = InstrumentId {
1803 symbol: Symbol::new("GBP/USD"),
1804 venue: Venue::new("SIM"),
1805 };
1806 let bar_spec = BarSpecification::new(1, BarAggregation::Minute, PriceType::Bid);
1807 let bar_spec2 = BarSpecification::new(2, BarAggregation::Minute, PriceType::Bid);
1808 let bar_type1 = BarType::Standard {
1809 instrument_id: instrument_id1,
1810 spec: bar_spec,
1811 aggregation_source: AggregationSource::External,
1812 };
1813 let bar_type2 = BarType::Standard {
1814 instrument_id: instrument_id1,
1815 spec: bar_spec,
1816 aggregation_source: AggregationSource::External,
1817 };
1818 let bar_type3 = BarType::Standard {
1819 instrument_id: instrument_id2,
1820 spec: bar_spec,
1821 aggregation_source: AggregationSource::External,
1822 };
1823 let bar_type4 = BarType::Composite {
1824 instrument_id: instrument_id2,
1825 spec: bar_spec2,
1826 aggregation_source: AggregationSource::Internal,
1827
1828 composite_step: 1,
1829 composite_aggregation: BarAggregation::Minute,
1830 composite_aggregation_source: AggregationSource::External,
1831 };
1832
1833 assert!(bar_type1 <= bar_type2);
1834 assert!(bar_type1 < bar_type3);
1835 assert!(bar_type3 > bar_type1);
1836 assert!(bar_type3 >= bar_type1);
1837 assert!(bar_type4 >= bar_type1);
1838 }
1839
1840 #[rstest]
1841 fn test_bar_new() {
1842 let bar_type = BarType::from("AAPL.XNAS-1-MINUTE-LAST-INTERNAL");
1843 let open = Price::from("100.0");
1844 let high = Price::from("105.0");
1845 let low = Price::from("95.0");
1846 let close = Price::from("102.0");
1847 let volume = Quantity::from("1000");
1848 let ts_event = UnixNanos::from(1_000_000);
1849 let ts_init = UnixNanos::from(2_000_000);
1850
1851 let bar = Bar::new(bar_type, open, high, low, close, volume, ts_event, ts_init);
1852
1853 assert_eq!(bar.bar_type, bar_type);
1854 assert_eq!(bar.open, open);
1855 assert_eq!(bar.high, high);
1856 assert_eq!(bar.low, low);
1857 assert_eq!(bar.close, close);
1858 assert_eq!(bar.volume, volume);
1859 assert_eq!(bar.ts_event, ts_event);
1860 assert_eq!(bar.ts_init, ts_init);
1861 }
1862
1863 #[rstest]
1864 #[case("100.0", "90.0", "95.0", "92.0", "high >= open")]
1865 #[case("100.0", "105.0", "110.0", "102.0", "high >= low")]
1866 #[case("100.0", "105.0", "95.0", "110.0", "high >= close")]
1867 #[case("100.0", "105.0", "95.0", "90.0", "low <= close")]
1868 #[case("100.0", "110.0", "105.0", "108.0", "low <= open")]
1869 #[case("100.0", "90.0", "110.0", "120.0", "high >= open")] fn test_bar_new_checked_conditions(
1871 #[case] open: &str,
1872 #[case] high: &str,
1873 #[case] low: &str,
1874 #[case] close: &str,
1875 #[case] expected: &str,
1876 ) {
1877 let bar_type = BarType::from("AAPL.XNAS-1-MINUTE-LAST-INTERNAL");
1878 let open = Price::from(open);
1879 let high = Price::from(high);
1880 let low = Price::from(low);
1881 let close = Price::from(close);
1882 let volume = Quantity::from("1000");
1883 let ts_event = UnixNanos::from(1_000_000);
1884 let ts_init = UnixNanos::from(2_000_000);
1885
1886 let result = Bar::new_checked(bar_type, open, high, low, close, volume, ts_event, ts_init);
1887
1888 let error = result.unwrap_err();
1889 assert!(
1890 error.to_string().contains(expected),
1891 "unexpected message: {error}"
1892 );
1893 }
1894
1895 #[rstest]
1896 fn test_bar_equality() {
1897 let instrument_id = InstrumentId {
1898 symbol: Symbol::new("AUDUSD"),
1899 venue: Venue::new("SIM"),
1900 };
1901 let bar_spec = BarSpecification::new(1, BarAggregation::Minute, PriceType::Bid);
1902 let bar_type = BarType::Standard {
1903 instrument_id,
1904 spec: bar_spec,
1905 aggregation_source: AggregationSource::External,
1906 };
1907 let bar1 = Bar {
1908 bar_type,
1909 open: Price::from("1.00001"),
1910 high: Price::from("1.00004"),
1911 low: Price::from("1.00002"),
1912 close: Price::from("1.00003"),
1913 volume: Quantity::from("100000"),
1914 ts_event: UnixNanos::default(),
1915 ts_init: UnixNanos::from(1),
1916 };
1917
1918 let bar2 = Bar {
1919 bar_type,
1920 open: Price::from("1.00000"),
1921 high: Price::from("1.00004"),
1922 low: Price::from("1.00002"),
1923 close: Price::from("1.00003"),
1924 volume: Quantity::from("100000"),
1925 ts_event: UnixNanos::default(),
1926 ts_init: UnixNanos::from(1),
1927 };
1928 assert_eq!(bar1, bar1);
1929 assert_ne!(bar1, bar2);
1930 }
1931
1932 #[rstest]
1933 fn test_json_serialization() {
1934 let bar = Bar::default();
1935 let serialized = bar.to_json_bytes().unwrap();
1936 let deserialized = Bar::from_json_bytes(serialized.as_ref()).unwrap();
1937 assert_eq!(deserialized, bar);
1938 }
1939
1940 #[rstest]
1941 fn test_msgpack_serialization() {
1942 let bar = Bar::default();
1943 let serialized = bar.to_msgpack_bytes().unwrap();
1944 let deserialized = Bar::from_msgpack_bytes(serialized.as_ref()).unwrap();
1945 assert_eq!(deserialized, bar);
1946 }
1947
1948 #[rstest]
1949 fn test_bar_deserialization_rejects_invalid_ohlc() {
1950 let json = r#"{
1951 "type": "Bar",
1952 "bar_type": "AUD/USD.SIM-1-MINUTE-BID-EXTERNAL",
1953 "open": "1.00010",
1954 "high": "1.00000",
1955 "low": "1.00020",
1956 "close": "1.00010",
1957 "volume": "100000",
1958 "ts_event": 0,
1959 "ts_init": 0
1960 }"#;
1961
1962 let result = Bar::from_json_bytes(json.as_bytes());
1963 assert!(
1964 result.is_err(),
1965 "high < low must fail deserialization, was {result:?}"
1966 );
1967 }
1968
1969 #[rstest]
1970 fn test_bar_specification_deserialization_rejects_invalid_step() {
1971 let json = r#"{"step":7,"aggregation":"MINUTE","price_type":"LAST"}"#;
1972
1973 let result = serde_json::from_str::<BarSpecification>(json);
1974 assert!(
1975 result.is_err(),
1976 "non-periodic step must fail deserialization, was {result:?}"
1977 );
1978 }
1979
1980 #[rstest]
1981 fn test_bar_specification_builder_rejects_invalid_step() {
1982 let result = BarSpecificationBuilder::default()
1983 .step(NonZeroUsize::new(7).unwrap())
1984 .aggregation(BarAggregation::Minute)
1985 .price_type(PriceType::Last)
1986 .build();
1987
1988 assert!(
1989 result.is_err(),
1990 "non-periodic step must fail builder validation, was {result:?}"
1991 );
1992 }
1993
1994 #[rstest]
1995 fn test_bar_spec_12_month_round_trips() {
1996 let bar_type = BarType::new(
1999 InstrumentId::from("BTC-USDT.OKX"),
2000 BAR_SPEC_12_MONTH_LAST,
2001 AggregationSource::External,
2002 );
2003
2004 let parsed = BarType::from_str(&bar_type.to_string()).unwrap();
2005 assert_eq!(parsed, bar_type);
2006 assert_eq!(
2007 BarSpecification::new_checked(12, BarAggregation::Month, PriceType::Last).unwrap(),
2008 BAR_SPEC_12_MONTH_LAST,
2009 );
2010 }
2011
2012 #[rstest]
2013 fn test_bar_type_new_composite_checked_invalid_step() {
2014 let instrument_id = InstrumentId::from("AUD/USD.SIM");
2015 let spec = BarSpecification::new(5, BarAggregation::Minute, PriceType::Bid);
2016
2017 let result = BarType::new_composite_checked(
2018 instrument_id,
2019 spec,
2020 AggregationSource::Internal,
2021 0,
2022 BarAggregation::Minute,
2023 AggregationSource::External,
2024 );
2025
2026 assert!(
2027 result.is_err(),
2028 "zero composite step must fail, was {result:?}"
2029 );
2030 }
2031}
2032
2033#[cfg(test)]
2034mod property_tests {
2035 use std::str::FromStr;
2036
2037 use chrono::{TimeZone, Utc};
2038 use proptest::prelude::*;
2039 use rstest::rstest;
2040
2041 use super::*;
2042 use crate::identifiers::{Symbol, Venue};
2043
2044 fn symbol_strategy() -> impl Strategy<Value = &'static str> {
2045 prop::sample::select(vec![
2046 "AAPL",
2047 "BTC-PERP",
2048 "EUR/USD",
2049 "ES-MINI-4",
2050 "MSFT.OQ",
2051 "6E",
2052 ])
2053 }
2054
2055 fn venue_strategy() -> impl Strategy<Value = &'static str> {
2056 prop::sample::select(vec!["SIM", "XNAS", "GLBX", "BINANCE"])
2057 }
2058
2059 fn time_spec_strategy() -> impl Strategy<Value = (BarAggregation, usize)> {
2060 prop_oneof![
2061 (
2062 Just(BarAggregation::Millisecond),
2063 prop::sample::select(vec![1usize, 2, 5, 10, 25, 50, 100, 250, 500]),
2064 ),
2065 (
2066 Just(BarAggregation::Second),
2067 prop::sample::select(vec![1usize, 2, 3, 5, 10, 15, 30]),
2068 ),
2069 (
2070 Just(BarAggregation::Minute),
2071 prop::sample::select(vec![1usize, 2, 5, 15, 30]),
2072 ),
2073 (
2074 Just(BarAggregation::Hour),
2075 prop::sample::select(vec![1usize, 2, 4, 12]),
2076 ),
2077 (
2078 Just(BarAggregation::Day),
2079 prop::sample::select(vec![1usize, 2, 3]),
2080 ),
2081 (Just(BarAggregation::Week), Just(1usize)),
2082 ]
2083 }
2084
2085 fn spec_strategy() -> impl Strategy<Value = (BarAggregation, usize)> {
2086 prop_oneof![
2087 time_spec_strategy(),
2088 (
2091 Just(BarAggregation::Month),
2092 prop::sample::select(vec![1usize, 2, 3, 4, 6, 12]),
2093 ),
2094 (Just(BarAggregation::Tick), 1usize..=10_000),
2095 (Just(BarAggregation::Volume), 1usize..=10_000),
2096 (Just(BarAggregation::Value), 1usize..=10_000),
2097 ]
2098 }
2099
2100 fn price_type_strategy() -> impl Strategy<Value = PriceType> {
2101 prop::sample::select(vec![
2102 PriceType::Bid,
2103 PriceType::Ask,
2104 PriceType::Mid,
2105 PriceType::Last,
2106 ])
2107 }
2108
2109 fn source_strategy() -> impl Strategy<Value = AggregationSource> {
2110 prop_oneof![
2111 Just(AggregationSource::Internal),
2112 Just(AggregationSource::External),
2113 ]
2114 }
2115
2116 proptest! {
2117 #[rstest]
2118 fn prop_bar_type_string_round_trip(
2119 symbol in symbol_strategy(),
2120 venue in venue_strategy(),
2121 (aggregation, step) in spec_strategy(),
2122 price_type in price_type_strategy(),
2123 source in source_strategy(),
2124 composite in prop::option::of((time_spec_strategy(), source_strategy())),
2125 ) {
2126 let instrument_id = InstrumentId::new(Symbol::from(symbol), Venue::from(venue));
2127 let spec = BarSpecification::new(step, aggregation, price_type);
2128
2129 let bar_type = match composite {
2130 None => BarType::new(instrument_id, spec, source),
2131 Some(((composite_aggregation, composite_step), composite_source)) => {
2132 BarType::new_composite(
2133 instrument_id,
2134 spec,
2135 source,
2136 composite_step,
2137 composite_aggregation,
2138 composite_source,
2139 )
2140 }
2141 };
2142
2143 let parsed = BarType::from_str(&bar_type.to_string());
2144 prop_assert!(parsed.is_ok(), "failed to parse '{bar_type}': {parsed:?}");
2145 prop_assert_eq!(parsed.unwrap(), bar_type);
2146 }
2147
2148 #[rstest]
2149 fn prop_get_time_bar_start_alignment(
2150 (aggregation, step) in time_spec_strategy(),
2151 epoch_secs in 946_684_800i64..2_524_608_000i64,
2152 subsec_nanos in 0u32..1_000_000_000u32,
2153 ) {
2154 let instrument_id = InstrumentId::from("AAPL.XNAS");
2155 let spec = BarSpecification::new(step, aggregation, PriceType::Last);
2156 let bar_type = BarType::new(instrument_id, spec, AggregationSource::Internal);
2157
2158 let now = Utc.timestamp_opt(epoch_secs, subsec_nanos).unwrap();
2159 let start = get_time_bar_start(now, &bar_type, None);
2160 let interval = get_bar_interval(&bar_type);
2161
2162 prop_assert!(start <= now, "start {start} must not be after now {now}");
2163 prop_assert!(
2164 now - start < interval,
2165 "now {now} must fall within one interval of start {start}"
2166 );
2167 }
2168 }
2169}