Skip to main content

nautilus_model/data/
bar.rs

1// -------------------------------------------------------------------------------------------------
2//  Copyright (C) 2015-2026 Nautech Systems Pty Ltd. All rights reserved.
3//  https://nautechsystems.io
4//
5//  Licensed under the GNU Lesser General Public License Version 3.0 (the "License");
6//  You may not use this file except in compliance with the License.
7//  You may obtain a copy of the License at https://www.gnu.org/licenses/lgpl-3.0.en.html
8//
9//  Unless required by applicable law or agreed to in writing, software
10//  distributed under the License is distributed on an "AS IS" BASIS,
11//  WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12//  See the License for the specific language governing permissions and
13//  limitations under the License.
14// -------------------------------------------------------------------------------------------------
15
16//! Bar aggregate structures, data types, and functionality.
17
18use 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    deserialization::StringInput,
43    enums::{AggregationSource, BarAggregation, PriceType},
44    identifiers::InstrumentId,
45    types::{Price, Quantity, fixed::FIXED_DECIMAL},
46};
47
48pub const BAR_SPEC_1_SECOND_LAST: BarSpecification = BarSpecification {
49    step: NonZero::new(1).unwrap(),
50    aggregation: BarAggregation::Second,
51    price_type: PriceType::Last,
52};
53
54pub const BAR_SPEC_1_MINUTE_LAST: BarSpecification = BarSpecification {
55    step: NonZero::new(1).unwrap(),
56    aggregation: BarAggregation::Minute,
57    price_type: PriceType::Last,
58};
59
60pub const BAR_SPEC_3_MINUTE_LAST: BarSpecification = BarSpecification {
61    step: NonZero::new(3).unwrap(),
62    aggregation: BarAggregation::Minute,
63    price_type: PriceType::Last,
64};
65
66pub const BAR_SPEC_5_MINUTE_LAST: BarSpecification = BarSpecification {
67    step: NonZero::new(5).unwrap(),
68    aggregation: BarAggregation::Minute,
69    price_type: PriceType::Last,
70};
71
72pub const BAR_SPEC_15_MINUTE_LAST: BarSpecification = BarSpecification {
73    step: NonZero::new(15).unwrap(),
74    aggregation: BarAggregation::Minute,
75    price_type: PriceType::Last,
76};
77
78pub const BAR_SPEC_30_MINUTE_LAST: BarSpecification = BarSpecification {
79    step: NonZero::new(30).unwrap(),
80    aggregation: BarAggregation::Minute,
81    price_type: PriceType::Last,
82};
83
84pub const BAR_SPEC_1_HOUR_LAST: BarSpecification = BarSpecification {
85    step: NonZero::new(1).unwrap(),
86    aggregation: BarAggregation::Hour,
87    price_type: PriceType::Last,
88};
89
90pub const BAR_SPEC_2_HOUR_LAST: BarSpecification = BarSpecification {
91    step: NonZero::new(2).unwrap(),
92    aggregation: BarAggregation::Hour,
93    price_type: PriceType::Last,
94};
95
96pub const BAR_SPEC_4_HOUR_LAST: BarSpecification = BarSpecification {
97    step: NonZero::new(4).unwrap(),
98    aggregation: BarAggregation::Hour,
99    price_type: PriceType::Last,
100};
101
102pub const BAR_SPEC_6_HOUR_LAST: BarSpecification = BarSpecification {
103    step: NonZero::new(6).unwrap(),
104    aggregation: BarAggregation::Hour,
105    price_type: PriceType::Last,
106};
107
108pub const BAR_SPEC_12_HOUR_LAST: BarSpecification = BarSpecification {
109    step: NonZero::new(12).unwrap(),
110    aggregation: BarAggregation::Hour,
111    price_type: PriceType::Last,
112};
113
114pub const BAR_SPEC_1_DAY_LAST: BarSpecification = BarSpecification {
115    step: NonZero::new(1).unwrap(),
116    aggregation: BarAggregation::Day,
117    price_type: PriceType::Last,
118};
119
120pub const BAR_SPEC_2_DAY_LAST: BarSpecification = BarSpecification {
121    step: NonZero::new(2).unwrap(),
122    aggregation: BarAggregation::Day,
123    price_type: PriceType::Last,
124};
125
126pub const BAR_SPEC_3_DAY_LAST: BarSpecification = BarSpecification {
127    step: NonZero::new(3).unwrap(),
128    aggregation: BarAggregation::Day,
129    price_type: PriceType::Last,
130};
131
132pub const BAR_SPEC_5_DAY_LAST: BarSpecification = BarSpecification {
133    step: NonZero::new(5).unwrap(),
134    aggregation: BarAggregation::Day,
135    price_type: PriceType::Last,
136};
137
138pub const BAR_SPEC_1_WEEK_LAST: BarSpecification = BarSpecification {
139    step: NonZero::new(1).unwrap(),
140    aggregation: BarAggregation::Week,
141    price_type: PriceType::Last,
142};
143
144pub const BAR_SPEC_1_MONTH_LAST: BarSpecification = BarSpecification {
145    step: NonZero::new(1).unwrap(),
146    aggregation: BarAggregation::Month,
147    price_type: PriceType::Last,
148};
149
150pub const BAR_SPEC_3_MONTH_LAST: BarSpecification = BarSpecification {
151    step: NonZero::new(3).unwrap(),
152    aggregation: BarAggregation::Month,
153    price_type: PriceType::Last,
154};
155
156pub const BAR_SPEC_6_MONTH_LAST: BarSpecification = BarSpecification {
157    step: NonZero::new(6).unwrap(),
158    aggregation: BarAggregation::Month,
159    price_type: PriceType::Last,
160};
161
162pub const BAR_SPEC_12_MONTH_LAST: BarSpecification = BarSpecification {
163    step: NonZero::new(12).unwrap(),
164    aggregation: BarAggregation::Month,
165    price_type: PriceType::Last,
166};
167
168/// Returns the bar interval as a [`SignedDuration`].
169///
170/// # Panics
171///
172/// Panics if the aggregation method of the given `bar_type` is not time based,
173/// or if `step` is too large for the interval arithmetic.
174#[must_use]
175pub fn get_bar_interval(bar_type: &BarType) -> SignedDuration {
176    let spec = bar_type.spec();
177    let step = step_to_i64(spec.step);
178
179    match spec.aggregation {
180        BarAggregation::Millisecond => SignedDuration::from_millis(step),
181        BarAggregation::Second => SignedDuration::from_secs(step),
182        BarAggregation::Minute => SignedDuration::from_mins(step),
183        BarAggregation::Hour => SignedDuration::from_hours(step),
184        BarAggregation::Day => duration_days(step),
185        BarAggregation::Week => {
186            duration_days(step.checked_mul(7).expect("`step` overflows i64 days"))
187        }
188        BarAggregation::Month => {
189            // Proxy for comparing bar lengths
190            duration_days(step.checked_mul(30).expect("`step` overflows i64 days"))
191        }
192        BarAggregation::Year => {
193            // Proxy for comparing bar lengths
194            duration_days(step.checked_mul(365).expect("`step` overflows i64 days"))
195        }
196        _ => panic!("Aggregation not time based"),
197    }
198}
199
200/// Returns the bar interval as `UnixNanos`.
201///
202/// # Panics
203///
204/// Panics if the aggregation method of the given `bar_type` is not time based.
205#[must_use]
206pub fn get_bar_interval_ns(bar_type: &BarType) -> DurationNanos {
207    DurationNanos::try_from(get_bar_interval(bar_type)).expect("Invalid bar interval")
208}
209
210/// Returns the time bar start as a timezone-aware `Timestamp`.
211///
212/// # Panics
213///
214/// Panics if computing the base civil date or datetime from `now` fails,
215/// if `step` cannot be represented for the calendar arithmetic,
216/// or if the aggregation type is unsupported.
217#[must_use]
218pub fn get_time_bar_start(
219    now: Timestamp,
220    bar_type: &BarType,
221    time_bars_origin: Option<SignedDuration>,
222) -> Timestamp {
223    get_time_bar_bounds(now, bar_type, time_bars_origin, &TimeZone::UTC, None).1
224}
225
226/// Returns the previous, current, and next time bar boundaries in UTC.
227///
228/// Days and longer periods follow local calendar boundaries in `time_zone`. The origin offset
229/// shifts the nominal civil boundary before resolving DST gaps and folds. Shorter periods retain
230/// UTC alignment. Gaps shift forward; folds use the first occurrence.
231/// `calendar_origin` keeps the recurrence grid anchored to a timer's starting local date;
232/// `None` uses the current local date.
233///
234/// # Panics
235///
236/// Panics if the aggregation is unsupported or calendar arithmetic exceeds the supported range.
237#[must_use]
238pub fn get_time_bar_bounds(
239    now: Timestamp,
240    bar_type: &BarType,
241    time_bars_origin: Option<SignedDuration>,
242    time_zone: &TimeZone,
243    calendar_origin: Option<Date>,
244) -> (Timestamp, Timestamp, Timestamp) {
245    let spec = bar_type.spec();
246    let step = step_to_i64(spec.step);
247    let origin = time_bars_origin.unwrap_or(SignedDuration::ZERO);
248    let local_date = time_zone.to_datetime(now).date();
249    let reference_date = calendar_origin.unwrap_or(local_date);
250
251    let start = |date: Date| {
252        let civil = date
253            .at(0, 0, 0, 0)
254            .checked_add(origin)
255            .expect("valid bar origin");
256        time_zone
257            .to_timestamp(civil)
258            .expect("valid time bar boundary")
259    };
260
261    match spec.aggregation {
262        BarAggregation::Day | BarAggregation::Week => {
263            let (span, days_per_period) = if spec.aggregation == BarAggregation::Day {
264                (Span::new().try_days(step).expect("valid day step"), step)
265            } else {
266                (
267                    Span::new().try_weeks(step).expect("valid week step"),
268                    step * 7,
269                )
270            };
271
272            let date = if spec.aggregation == BarAggregation::Week {
273                reference_date
274                    .checked_sub(
275                        Span::new()
276                            .days(i64::from(reference_date.weekday().to_monday_zero_offset())),
277                    )
278                    .expect("valid week start")
279            } else {
280                reference_date
281            };
282
283            let days = date
284                .until(local_date)
285                .expect("valid date difference")
286                .get_days();
287            let index = i64::from(days).div_euclid(days_per_period);
288            find_calendar_bounds(now, index, 1, |index| {
289                start(
290                    date.checked_add(span.checked_mul(index).expect("valid day offset"))
291                        .expect("valid bar date"),
292                )
293            })
294        }
295        BarAggregation::Month => {
296            let step = i64::from(
297                u32::try_from(step).expect("`step` exceeds u32 range for month arithmetic"),
298            );
299            let origin_year = reference_date.year();
300            let base = Date::new(origin_year, 1, 1)
301                .expect("valid year start")
302                .at(0, 0, 0, 0)
303                .checked_add(origin)
304                .expect("valid month origin");
305
306            let boundary = |months: i64| {
307                let civil = base
308                    .checked_add(Span::new().try_months(months).expect("valid month step"))
309                    .expect("valid month boundary");
310                time_zone.to_timestamp(civil).expect("valid month boundary")
311            };
312
313            let index = (i64::from(local_date.year()) - i64::from(origin_year)) * 12;
314            find_calendar_bounds(now, index, step, boundary)
315        }
316        BarAggregation::Year => {
317            let step = i32::try_from(step).expect("`step` exceeds i32 range for year arithmetic");
318
319            let boundary = |year: i64| {
320                start(
321                    Date::new(
322                        i16::try_from(year).expect("year exceeds Jiff supported range"),
323                        1,
324                        1,
325                    )
326                    .expect("valid year start date"),
327                )
328            };
329
330            let step = i64::from(step);
331            let origin_year = i64::from(reference_date.year());
332            let years = i64::from(local_date.year()) - origin_year;
333            let index = origin_year + years.div_euclid(step) * step;
334            find_calendar_bounds(now, index, step, boundary)
335        }
336        _ => {
337            let origin_offset = origin;
338
339            let start = match spec.aggregation {
340                BarAggregation::Millisecond => {
341                    find_closest_smaller_time(now, origin_offset, SignedDuration::from_millis(step))
342                }
343                BarAggregation::Second => {
344                    find_closest_smaller_time(now, origin_offset, SignedDuration::from_secs(step))
345                }
346                BarAggregation::Minute => {
347                    find_closest_smaller_time(now, origin_offset, SignedDuration::from_mins(step))
348                }
349                BarAggregation::Hour => {
350                    find_closest_smaller_time(now, origin_offset, SignedDuration::from_hours(step))
351                }
352                _ => panic!(
353                    "Aggregation type {} not supported for time bars",
354                    spec.aggregation
355                ),
356            };
357
358            let interval = get_bar_interval(bar_type);
359            (start - interval, start, start + interval)
360        }
361    }
362}
363
364fn find_calendar_bounds(
365    now: Timestamp,
366    mut index: i64,
367    step: i64,
368    boundary: impl Fn(i64) -> Timestamp,
369) -> (Timestamp, Timestamp, Timestamp) {
370    while boundary(index) > now {
371        index = index.checked_sub(step).expect("calendar index underflow");
372    }
373
374    loop {
375        let next = index.checked_add(step).expect("calendar index overflow");
376        let close = boundary(next);
377        if close > now {
378            let previous = index.checked_sub(step).expect("calendar index underflow");
379            return (boundary(previous), boundary(index), close);
380        }
381
382        index = next;
383    }
384}
385
386/// Finds the closest smaller time based on a daily time origin and period.
387///
388/// This function calculates the most recent time that is aligned with the given period
389/// and is less than or equal to the current time.
390fn find_closest_smaller_time(
391    now: Timestamp,
392    daily_time_origin: SignedDuration,
393    period: SignedDuration,
394) -> Timestamp {
395    // Floor to start of day
396    let day_start = Offset::UTC
397        .to_timestamp(Offset::UTC.to_datetime(now).date().at(0, 0, 0, 0))
398        .expect("valid UTC day start");
399    let base_time = day_start + daily_time_origin;
400
401    let time_difference = base_time.duration_until(now);
402    let period_ns = period.as_nanos();
403    debug_assert_ne!(period_ns, 0, "bar period must be non-zero");
404
405    // Use div_euclid for floor division (rounds toward -inf, not zero)
406    // so negative deltas (now before origin) yield the previous period boundary
407    let num_periods = time_difference.as_nanos().div_euclid(period_ns);
408
409    base_time + SignedDuration::from_nanos_i128(num_periods * period_ns)
410}
411
412fn duration_days(days: i64) -> SignedDuration {
413    try_duration_days(days).unwrap_or_else(|e| panic!("{e}"))
414}
415
416fn try_duration_days(days: i64) -> anyhow::Result<SignedDuration> {
417    let hours = days
418        .checked_mul(24)
419        .ok_or_else(|| anyhow::anyhow!("days overflow i64 hours"))?;
420    SignedDuration::try_from_hours(hours)
421        .ok_or_else(|| anyhow::anyhow!("days exceed signed duration range"))
422}
423
424fn try_time_interval(step: usize, aggregation: BarAggregation) -> anyhow::Result<SignedDuration> {
425    let step_i64 = i64::try_from(step)
426        .map_err(|_| invalid_interval_step(step, aggregation, "step exceeds i64 range"))?;
427
428    let duration = match aggregation {
429        BarAggregation::Millisecond => SignedDuration::from_millis(step_i64),
430        BarAggregation::Second => SignedDuration::from_secs(step_i64),
431        BarAggregation::Minute => SignedDuration::try_from_mins(step_i64).ok_or_else(|| {
432            invalid_interval_step(step, aggregation, "step exceeds signed duration range")
433        })?,
434        BarAggregation::Hour => SignedDuration::try_from_hours(step_i64).ok_or_else(|| {
435            invalid_interval_step(step, aggregation, "step exceeds signed duration range")
436        })?,
437        BarAggregation::Day => try_scaled_days(step, aggregation, step_i64, 1)?,
438        BarAggregation::Week => try_scaled_days(step, aggregation, step_i64, 7)?,
439        BarAggregation::Month => try_scaled_days(step, aggregation, step_i64, 30)?,
440        BarAggregation::Year => try_scaled_days(step, aggregation, step_i64, 365)?,
441        _ => anyhow::bail!("Timedelta not supported for aggregation type: {aggregation:?}"),
442    };
443
444    u64::try_from(duration.as_nanos())
445        .map_err(|_| invalid_interval_step(step, aggregation, "interval overflows nanoseconds"))?;
446
447    Ok(duration)
448}
449
450fn try_scaled_days(
451    step: usize,
452    aggregation: BarAggregation,
453    step_i64: i64,
454    multiplier: i64,
455) -> anyhow::Result<SignedDuration> {
456    let days = step_i64
457        .checked_mul(multiplier)
458        .ok_or_else(|| invalid_interval_step(step, aggregation, "step overflows i64 days"))?;
459    try_duration_days(days).map_err(|e| invalid_interval_step(step, aggregation, &e.to_string()))
460}
461
462fn invalid_interval_step(step: usize, aggregation: BarAggregation, reason: &str) -> anyhow::Error {
463    anyhow::anyhow!(
464        "Invalid step in bar_type.spec.step: {step} for aggregation={aggregation}. {reason}"
465    )
466}
467
468/// Converts a bar specification step to `i64` for time arithmetic.
469///
470/// # Panics
471///
472/// Panics if `step` exceeds the `i64` range.
473fn step_to_i64(step: NonZeroUsize) -> i64 {
474    i64::try_from(step.get()).expect("`step` exceeds i64 range")
475}
476
477/// Represents a bar aggregation specification including a step, aggregation
478/// method/rule and price type.
479#[repr(C)]
480#[derive(
481    Clone, Copy, Hash, PartialEq, Eq, PartialOrd, Ord, Debug, Serialize, Deserialize, Builder,
482)]
483#[builder(build_fn(validate = "Self::validate"))]
484#[serde(try_from = "BarSpecificationFields")]
485#[cfg_attr(
486    feature = "python",
487    pyo3::pyclass(module = "nautilus_trader.model", from_py_object)
488)]
489#[cfg_attr(
490    feature = "python",
491    pyo3_stub_gen::derive::gen_stub_pyclass(module = "nautilus_trader.model")
492)]
493pub struct BarSpecification {
494    /// The step for binning samples for bar aggregation.
495    pub step: NonZeroUsize,
496    /// The type of bar aggregation.
497    pub aggregation: BarAggregation,
498    /// The price type to use for aggregation.
499    pub price_type: PriceType,
500}
501
502impl BarSpecificationBuilder {
503    fn validate(&self) -> Result<(), String> {
504        if let (Some(step), Some(aggregation)) = (self.step, self.aggregation) {
505            BarSpecification::validate_step(step.get(), aggregation).map_err(|e| e.to_string())?;
506        }
507
508        Ok(())
509    }
510}
511
512// Deserialization mirror routing through `new_checked` so serde inputs
513// cannot bypass step validation
514#[derive(Deserialize)]
515struct BarSpecificationFields {
516    step: NonZeroUsize,
517    aggregation: BarAggregation,
518    price_type: PriceType,
519}
520
521impl TryFrom<BarSpecificationFields> for BarSpecification {
522    type Error = anyhow::Error;
523
524    fn try_from(fields: BarSpecificationFields) -> Result<Self, Self::Error> {
525        Self::new_checked(fields.step.get(), fields.aggregation, fields.price_type)
526    }
527}
528
529impl BarSpecification {
530    /// Creates a new [`BarSpecification`] instance with correctness checking.
531    ///
532    /// # Errors
533    ///
534    /// Returns an error if `step` is not positive (> 0), if `step` is not
535    /// valid for a fixed-subunit time aggregation, or if a time-aggregated
536    /// `step` overflows the representable duration or nanosecond interval.
537    ///
538    /// # Notes
539    ///
540    /// PyO3 requires a `Result` type for proper error handling and stacktrace printing in Python.
541    pub fn new_checked(
542        step: usize,
543        aggregation: BarAggregation,
544        price_type: PriceType,
545    ) -> anyhow::Result<Self> {
546        let step = NonZeroUsize::new(step)
547            .ok_or_else(|| anyhow::anyhow!("Invalid step: {step} (must be non-zero)"))?;
548        Self::validate_step(step.get(), aggregation)?;
549
550        Ok(Self {
551            step,
552            aggregation,
553            price_type,
554        })
555    }
556
557    fn validate_step(step: usize, aggregation: BarAggregation) -> anyhow::Result<()> {
558        match aggregation {
559            BarAggregation::Millisecond => {
560                Self::validate_periodic_step(step, aggregation, 1000, false)?;
561            }
562            BarAggregation::Second | BarAggregation::Minute => {
563                Self::validate_periodic_step(step, aggregation, 60, false)?;
564            }
565            BarAggregation::Hour => Self::validate_periodic_step(step, aggregation, 24, false)?,
566            // 12-MONTH is allowed (unlike other full-subunit steps) because the shipped
567            // BAR_SPEC_12_MONTH_LAST constant and OKX yearly candles depend on it
568            BarAggregation::Month => Self::validate_periodic_step(step, aggregation, 12, true)?,
569            BarAggregation::Day | BarAggregation::Week | BarAggregation::Year => {}
570            _ => return Ok(()),
571        }
572
573        try_time_interval(step, aggregation).map(|_| ())
574    }
575
576    fn validate_periodic_step(
577        step: usize,
578        aggregation: BarAggregation,
579        subunits: usize,
580        allow_equal: bool,
581    ) -> anyhow::Result<()> {
582        if !subunits.is_multiple_of(step) {
583            anyhow::bail!(
584                "Invalid step in bar_type.spec.step: {step} for aggregation={aggregation}. \
585                 step must evenly divide {subunits} (so it is periodic).",
586            );
587        }
588
589        if !allow_equal && subunits == step {
590            anyhow::bail!(
591                "Invalid step in bar_type.spec.step: {step} for aggregation={aggregation}. \
592                 step must not be {subunits}. Use higher aggregation unit instead.",
593            );
594        }
595
596        Ok(())
597    }
598
599    /// Creates a new [`BarSpecification`] instance.
600    ///
601    /// # Panics
602    ///
603    /// Panics if `step` is not positive (> 0), if `step` is not valid for
604    /// a fixed-subunit time aggregation, or if a time-aggregated `step`
605    /// overflows the representable duration or nanosecond interval.
606    #[must_use]
607    pub fn new(step: usize, aggregation: BarAggregation, price_type: PriceType) -> Self {
608        Self::new_checked(step, aggregation, price_type).expect(FAILED)
609    }
610
611    /// Returns the [`SignedDuration`] interval for this bar specification.
612    ///
613    /// # Notes
614    ///
615    /// For [`BarAggregation::Month`] and [`BarAggregation::Year`], proxy values are used
616    /// (30 days for months, 365 days for years) to estimate their respective durations,
617    /// since months and years have variable lengths.
618    ///
619    /// # Panics
620    ///
621    /// Panics if the aggregation method is not time-based, or if `step` is too
622    /// large for the interval arithmetic.
623    #[must_use]
624    pub fn timedelta(&self) -> SignedDuration {
625        let step = step_to_i64(self.step);
626
627        match self.aggregation {
628            BarAggregation::Millisecond => SignedDuration::from_millis(step),
629            BarAggregation::Second => SignedDuration::from_secs(step),
630            BarAggregation::Minute => SignedDuration::from_mins(step),
631            BarAggregation::Hour => SignedDuration::from_hours(step),
632            BarAggregation::Day => duration_days(step),
633            BarAggregation::Week => {
634                duration_days(step.checked_mul(7).expect("`step` overflows i64 days"))
635            }
636            BarAggregation::Month => {
637                // Proxy for comparing bar lengths
638                duration_days(step.checked_mul(30).expect("`step` overflows i64 days"))
639            }
640            BarAggregation::Year => {
641                // Proxy for comparing bar lengths
642                duration_days(step.checked_mul(365).expect("`step` overflows i64 days"))
643            }
644            _ => panic!(
645                "Timedelta not supported for aggregation type: {:?}",
646                self.aggregation
647            ),
648        }
649    }
650
651    /// Return a value indicating whether the aggregation method is time-driven:
652    ///  - [`BarAggregation::Millisecond`]
653    ///  - [`BarAggregation::Second`]
654    ///  - [`BarAggregation::Minute`]
655    ///  - [`BarAggregation::Hour`]
656    ///  - [`BarAggregation::Day`]
657    ///  - [`BarAggregation::Week`]
658    ///  - [`BarAggregation::Month`]
659    ///  - [`BarAggregation::Year`]
660    #[must_use]
661    pub fn is_time_aggregated(&self) -> bool {
662        matches!(
663            self.aggregation,
664            BarAggregation::Millisecond
665                | BarAggregation::Second
666                | BarAggregation::Minute
667                | BarAggregation::Hour
668                | BarAggregation::Day
669                | BarAggregation::Week
670                | BarAggregation::Month
671                | BarAggregation::Year
672        )
673    }
674
675    /// Return a value indicating whether the aggregation method is threshold-driven:
676    ///  - [`BarAggregation::Tick`]
677    ///  - [`BarAggregation::TickImbalance`]
678    ///  - [`BarAggregation::Volume`]
679    ///  - [`BarAggregation::VolumeImbalance`]
680    ///  - [`BarAggregation::Value`]
681    ///  - [`BarAggregation::ValueImbalance`]
682    #[must_use]
683    pub fn is_threshold_aggregated(&self) -> bool {
684        matches!(
685            self.aggregation,
686            BarAggregation::Tick
687                | BarAggregation::TickImbalance
688                | BarAggregation::Volume
689                | BarAggregation::VolumeImbalance
690                | BarAggregation::Value
691                | BarAggregation::ValueImbalance
692        )
693    }
694
695    /// Return a value indicating whether the aggregation method is information-driven:
696    ///  - [`BarAggregation::TickRuns`]
697    ///  - [`BarAggregation::VolumeRuns`]
698    ///  - [`BarAggregation::ValueRuns`]
699    #[must_use]
700    pub fn is_information_aggregated(&self) -> bool {
701        matches!(
702            self.aggregation,
703            BarAggregation::TickRuns | BarAggregation::VolumeRuns | BarAggregation::ValueRuns
704        )
705    }
706}
707
708impl Display for BarSpecification {
709    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
710        write!(f, "{}-{}-{}", self.step, self.aggregation, self.price_type)
711    }
712}
713
714/// Represents a bar type including the instrument ID, bar specification and
715/// aggregation source.
716#[repr(C)]
717#[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord, Hash)]
718#[cfg_attr(
719    feature = "python",
720    pyo3::pyclass(module = "nautilus_trader.model", from_py_object)
721)]
722#[cfg_attr(
723    feature = "python",
724    pyo3_stub_gen::derive::gen_stub_pyclass_enum(module = "nautilus_trader.model")
725)]
726pub enum BarType {
727    Standard {
728        /// The bar type's instrument ID.
729        instrument_id: InstrumentId,
730        /// The bar type's specification.
731        spec: BarSpecification,
732        /// The bar type's aggregation source.
733        aggregation_source: AggregationSource,
734    },
735    Composite {
736        /// The bar type's instrument ID.
737        instrument_id: InstrumentId,
738        /// The bar type's specification.
739        spec: BarSpecification,
740        /// The bar type's aggregation source.
741        aggregation_source: AggregationSource,
742
743        /// The composite step for binning samples for bar aggregation.
744        composite_step: usize,
745        /// The composite type of bar aggregation.
746        composite_aggregation: BarAggregation,
747        /// The composite bar type's aggregation source.
748        composite_aggregation_source: AggregationSource,
749    },
750}
751
752impl BarType {
753    /// Creates a new [`BarType`] instance.
754    #[must_use]
755    pub fn new(
756        instrument_id: InstrumentId,
757        spec: BarSpecification,
758        aggregation_source: AggregationSource,
759    ) -> Self {
760        Self::Standard {
761            instrument_id,
762            spec,
763            aggregation_source,
764        }
765    }
766
767    /// Creates a new composite [`BarType`] instance with correctness checking.
768    ///
769    /// # Errors
770    ///
771    /// Returns an error if the composite specification is invalid, i.e. `composite_step`
772    /// is not positive (> 0) or is not valid for a fixed-subunit time aggregation.
773    pub fn new_composite_checked(
774        instrument_id: InstrumentId,
775        spec: BarSpecification,
776        aggregation_source: AggregationSource,
777
778        composite_step: usize,
779        composite_aggregation: BarAggregation,
780        composite_aggregation_source: AggregationSource,
781    ) -> anyhow::Result<Self> {
782        // Validate eagerly so `composite()` cannot panic later
783        BarSpecification::new_checked(composite_step, composite_aggregation, spec.price_type)?;
784
785        Ok(Self::Composite {
786            instrument_id,
787            spec,
788            aggregation_source,
789
790            composite_step,
791            composite_aggregation,
792            composite_aggregation_source,
793        })
794    }
795
796    /// Creates a new composite [`BarType`] instance.
797    ///
798    /// # Panics
799    ///
800    /// Panics if the composite specification is invalid, i.e. `composite_step` is not
801    /// positive (> 0) or is not valid for a fixed-subunit time aggregation.
802    #[must_use]
803    pub fn new_composite(
804        instrument_id: InstrumentId,
805        spec: BarSpecification,
806        aggregation_source: AggregationSource,
807
808        composite_step: usize,
809        composite_aggregation: BarAggregation,
810        composite_aggregation_source: AggregationSource,
811    ) -> Self {
812        Self::new_composite_checked(
813            instrument_id,
814            spec,
815            aggregation_source,
816            composite_step,
817            composite_aggregation,
818            composite_aggregation_source,
819        )
820        .expect(FAILED)
821    }
822
823    /// Returns whether this instance is a standard bar type.
824    #[must_use]
825    pub fn is_standard(&self) -> bool {
826        matches!(self, Self::Standard { .. })
827    }
828
829    /// Returns whether this instance is a composite bar type.
830    #[must_use]
831    pub fn is_composite(&self) -> bool {
832        matches!(self, Self::Composite { .. })
833    }
834
835    /// Returns whether the bar aggregation source is `EXTERNAL`.
836    #[must_use]
837    pub fn is_externally_aggregated(&self) -> bool {
838        self.aggregation_source() == AggregationSource::External
839    }
840
841    /// Returns whether the bar aggregation source is `INTERNAL`.
842    #[must_use]
843    pub fn is_internally_aggregated(&self) -> bool {
844        self.aggregation_source() == AggregationSource::Internal
845    }
846
847    /// Returns the standard bar type component.
848    #[must_use]
849    pub fn standard(&self) -> Self {
850        match self {
851            &b @ Self::Standard { .. } => b,
852            Self::Composite {
853                instrument_id,
854                spec,
855                aggregation_source,
856                ..
857            } => Self::new(*instrument_id, *spec, *aggregation_source),
858        }
859    }
860
861    /// Returns any composite bar type component.
862    #[must_use]
863    pub fn composite(&self) -> Self {
864        match self {
865            &b @ Self::Standard { .. } => b, // case shouldn't be used if is_composite is called before
866            Self::Composite {
867                instrument_id,
868                spec,
869                aggregation_source: _,
870
871                composite_step,
872                composite_aggregation,
873                composite_aggregation_source,
874            } => Self::new(
875                *instrument_id,
876                BarSpecification::new(*composite_step, *composite_aggregation, spec.price_type),
877                *composite_aggregation_source,
878            ),
879        }
880    }
881
882    /// Returns the [`InstrumentId`] for this bar type.
883    #[must_use]
884    pub fn instrument_id(&self) -> InstrumentId {
885        match &self {
886            Self::Standard { instrument_id, .. } | Self::Composite { instrument_id, .. } => {
887                *instrument_id
888            }
889        }
890    }
891
892    /// Returns the [`BarSpecification`] for this bar type.
893    #[must_use]
894    pub fn spec(&self) -> BarSpecification {
895        match &self {
896            Self::Standard { spec, .. } | Self::Composite { spec, .. } => *spec,
897        }
898    }
899
900    /// Returns the [`AggregationSource`] for this bar type.
901    #[must_use]
902    pub fn aggregation_source(&self) -> AggregationSource {
903        match &self {
904            Self::Standard {
905                aggregation_source, ..
906            }
907            | Self::Composite {
908                aggregation_source, ..
909            } => *aggregation_source,
910        }
911    }
912
913    /// Returns the instrument ID and bar specification as a tuple key.
914    ///
915    /// Useful as a hashmap key when aggregation source should be ignored,
916    /// such as for indicator registration where INTERNAL and EXTERNAL bars
917    /// should trigger the same indicators.
918    #[must_use]
919    pub fn id_spec_key(&self) -> (InstrumentId, BarSpecification) {
920        (self.instrument_id(), self.spec())
921    }
922}
923
924#[derive(thiserror::Error, Debug)]
925#[error("Error parsing `BarType` from '{input}', invalid token: '{token}' at position {position}")]
926pub struct BarTypeParseError {
927    input: String,
928    token: String,
929    position: usize,
930}
931
932impl FromStr for BarType {
933    type Err = BarTypeParseError;
934
935    fn from_str(s: &str) -> Result<Self, Self::Err> {
936        let mut parts = s.split('@');
937        let standard = parts.next().expect("split always yields a first field");
938        let composite_str = parts.next();
939        if let Some(extra) = parts.next() {
940            return Err(BarTypeParseError {
941                input: s.to_string(),
942                token: extra.to_string(),
943                position: 5,
944            });
945        }
946
947        let mut pieces = standard.rsplitn(5, '-');
948        let mut rev_pieces = [""; 5];
949        for piece in rev_pieces.iter_mut().rev() {
950            *piece = pieces.next().ok_or_else(|| 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 mut composite_pieces = composite_str.rsplitn(3, '-');
996            let mut rev_composite_pieces = [""; 3];
997            for piece in rev_composite_pieces.iter_mut().rev() {
998                *piece = composite_pieces.next().ok_or_else(|| 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 StringInput(s) = Deserialize::deserialize(deserializer)?;
1107        Self::from_str(s.as_ref()).map_err(serde::de::Error::custom)
1108    }
1109}
1110
1111/// Represents an aggregated bar.
1112#[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    /// The bar type for this bar.
1125    pub bar_type: BarType,
1126    /// The bars open price.
1127    pub open: Price,
1128    /// The bars high price.
1129    pub high: Price,
1130    /// The bars low price.
1131    pub low: Price,
1132    /// The bars close price.
1133    pub close: Price,
1134    /// The bars volume.
1135    pub volume: Quantity,
1136    /// UNIX timestamp (nanoseconds) when the data event occurred.
1137    pub ts_event: UnixNanos,
1138    /// UNIX timestamp (nanoseconds) when the instance was created.
1139    pub ts_init: UnixNanos,
1140}
1141
1142// Deserialization mirror routing through `new_checked` so serde inputs
1143// cannot bypass OHLC validation
1144#[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    /// Creates a new [`Bar`] instance with correctness checking.
1175    ///
1176    /// # Errors
1177    ///
1178    /// Returns an error if:
1179    /// - `high` is not >= `open`.
1180    /// - `high` is not >= `low`.
1181    /// - `high` is not >= `close`.
1182    /// - `low` is not <= `open`.
1183    /// - `low` is not <= `close`.
1184    ///
1185    /// # Notes
1186    ///
1187    /// PyO3 requires a `Result` type for proper error handling and stacktrace printing in Python.
1188    #[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    /// Creates a new [`Bar`] instance.
1225    ///
1226    /// # Panics
1227    ///
1228    /// Panics if:
1229    /// - `high` is not >= `open`.
1230    /// - `high` is not >= `low`.
1231    /// - `high` is not >= `close`.
1232    /// - `low` is not <= `open`.
1233    /// - `low` is not <= `close`.
1234    #[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    /// Returns the metadata for the type, for use with serialization formats.
1256    #[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    /// Returns the field map for the type, for use with Arrow schemas.
1272    #[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        // Bypasses `BarSpecification::new_checked` to exercise the conversion guards
1731        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(), // 2022-07-20 20:34:56.123 UTC
1793    BarAggregation::Millisecond,
1794    1,
1795    Timestamp::new(1_658_349_296, 123_000_000).unwrap(), // 2022-07-20 20:34:56.123 UTC
1796    )]
1797    #[rstest]
1798    #[case::millisecond(
1799    Timestamp::new(1_658_349_296, 123_000_000).unwrap(), // 2022-07-20 20:34:56.123 UTC
1800    BarAggregation::Millisecond,
1801    10,
1802    Timestamp::new(1_658_349_296, 120_000_000).unwrap(), // 2022-07-20 20:34:56.120 UTC
1803    )]
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        // Full equality should differ
2167        assert_ne!(bar_type_external, bar_type_internal);
2168
2169        // id_spec_key should be the same
2170        assert_eq!(
2171            bar_type_external.id_spec_key(),
2172            bar_type_internal.id_spec_key()
2173        );
2174
2175        // Verify key components
2176        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")] // First failing predicate reported
2256    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        // BAR_SPEC_12_MONTH_LAST is shipped (OKX yearly candles), so the string
2383        // form must parse back
2384        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            // Month stays out of time_spec_strategy: the alignment proptest uses the
2474            // 30-day proxy interval, which is unsound for calendar months
2475            (
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}