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