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#[must_use]
151pub fn get_bar_interval(bar_type: &BarType) -> TimeDelta {
152    let spec = bar_type.spec();
153
154    match spec.aggregation {
155        BarAggregation::Millisecond => TimeDelta::milliseconds(spec.step.get() as i64),
156        BarAggregation::Second => TimeDelta::seconds(spec.step.get() as i64),
157        BarAggregation::Minute => TimeDelta::minutes(spec.step.get() as i64),
158        BarAggregation::Hour => TimeDelta::hours(spec.step.get() as i64),
159        BarAggregation::Day => TimeDelta::days(spec.step.get() as i64),
160        BarAggregation::Week => TimeDelta::days(7 * spec.step.get() as i64),
161        BarAggregation::Month => TimeDelta::days(30 * spec.step.get() as i64), // Proxy for comparing bar lengths
162        BarAggregation::Year => TimeDelta::days(365 * spec.step.get() as i64), // Proxy for comparing bar lengths
163        _ => panic!("Aggregation not time based"),
164    }
165}
166
167/// Returns the bar interval as `UnixNanos`.
168///
169/// # Panics
170///
171/// Panics if the aggregation method of the given `bar_type` is not time based.
172#[must_use]
173pub fn get_bar_interval_ns(bar_type: &BarType) -> UnixNanos {
174    let interval_ns = get_bar_interval(bar_type)
175        .num_nanoseconds()
176        .expect("Invalid bar interval") as u64;
177    UnixNanos::from(interval_ns)
178}
179
180/// Returns the time bar start as a timezone-aware `DateTime<Utc>`.
181///
182/// # Panics
183///
184/// Panics if computing the base `NaiveDate` or `DateTime` from `now` fails,
185/// or if the aggregation type is unsupported.
186pub fn get_time_bar_start(
187    now: DateTime<Utc>,
188    bar_type: &BarType,
189    time_bars_origin: Option<TimeDelta>,
190) -> DateTime<Utc> {
191    let spec = bar_type.spec();
192    let step = spec.step.get() as i64;
193    let origin_offset: TimeDelta = time_bars_origin.unwrap_or_else(TimeDelta::zero);
194
195    match spec.aggregation {
196        BarAggregation::Millisecond => {
197            find_closest_smaller_time(now, origin_offset, Duration::milliseconds(step))
198        }
199        BarAggregation::Second => {
200            find_closest_smaller_time(now, origin_offset, Duration::seconds(step))
201        }
202        BarAggregation::Minute => {
203            find_closest_smaller_time(now, origin_offset, Duration::minutes(step))
204        }
205        BarAggregation::Hour => {
206            find_closest_smaller_time(now, origin_offset, Duration::hours(step))
207        }
208        BarAggregation::Day => find_closest_smaller_time(now, origin_offset, Duration::days(step)),
209        BarAggregation::Week => {
210            let mut start_time = now.trunc_subsecs(0)
211                - Duration::seconds(i64::from(now.second()))
212                - Duration::minutes(i64::from(now.minute()))
213                - Duration::hours(i64::from(now.hour()))
214                - TimeDelta::days(i64::from(now.weekday().num_days_from_monday()));
215            start_time += origin_offset;
216
217            if now < start_time {
218                start_time -= Duration::weeks(step);
219            }
220
221            start_time
222        }
223        BarAggregation::Month => {
224            // Set to the first day of the year
225            let mut start_time = DateTime::from_naive_utc_and_offset(
226                chrono::NaiveDate::from_ymd_opt(now.year(), 1, 1)
227                    .expect("valid date")
228                    .and_hms_opt(0, 0, 0)
229                    .expect("valid time"),
230                Utc,
231            );
232            start_time += origin_offset;
233
234            if now < start_time {
235                start_time =
236                    subtract_n_months(start_time, 12).expect("Failed to subtract 12 months");
237            }
238
239            let months_step = step as u32;
240
241            while start_time <= now {
242                start_time =
243                    add_n_months(start_time, months_step).expect("Failed to add months in loop");
244            }
245
246            start_time =
247                subtract_n_months(start_time, months_step).expect("Failed to subtract months_step");
248            start_time
249        }
250        BarAggregation::Year => {
251            let step_i32 = step as i32;
252
253            // Reconstruct from Jan 1 + origin each time to avoid leap-day drift
254            let year_start = |y: i32| {
255                DateTime::from_naive_utc_and_offset(
256                    chrono::NaiveDate::from_ymd_opt(y, 1, 1)
257                        .expect("valid date")
258                        .and_hms_opt(0, 0, 0)
259                        .expect("valid time"),
260                    Utc,
261                ) + origin_offset
262            };
263
264            let mut year = now.year();
265            if year_start(year) > now {
266                year -= step_i32;
267            }
268
269            while year_start(year + step_i32) <= now {
270                year += step_i32;
271            }
272
273            year_start(year)
274        }
275        _ => panic!(
276            "Aggregation type {} not supported for time bars",
277            spec.aggregation
278        ),
279    }
280}
281
282/// Finds the closest smaller time based on a daily time origin and period.
283///
284/// This function calculates the most recent time that is aligned with the given period
285/// and is less than or equal to the current time.
286fn find_closest_smaller_time(
287    now: DateTime<Utc>,
288    daily_time_origin: TimeDelta,
289    period: TimeDelta,
290) -> DateTime<Utc> {
291    // Floor to start of day
292    let day_start = now.trunc_subsecs(0)
293        - Duration::seconds(i64::from(now.second()))
294        - Duration::minutes(i64::from(now.minute()))
295        - Duration::hours(i64::from(now.hour()));
296    let base_time = day_start + daily_time_origin;
297
298    let time_difference = now - base_time;
299    let period_ns = period.num_nanoseconds().unwrap_or(1);
300
301    // Use div_euclid for floor division (rounds toward -inf, not zero)
302    // so negative deltas (now before origin) yield the previous period boundary
303    let num_periods = time_difference
304        .num_nanoseconds()
305        .unwrap_or(0)
306        .div_euclid(period_ns);
307
308    base_time + TimeDelta::nanoseconds(num_periods * period_ns)
309}
310
311/// Represents a bar aggregation specification including a step, aggregation
312/// method/rule and price type.
313#[repr(C)]
314#[derive(
315    Clone, Copy, Hash, PartialEq, Eq, PartialOrd, Ord, Debug, Serialize, Deserialize, Builder,
316)]
317#[cfg_attr(
318    feature = "python",
319    pyo3::pyclass(module = "nautilus_trader.core.nautilus_pyo3.model", from_py_object)
320)]
321#[cfg_attr(
322    feature = "python",
323    pyo3_stub_gen::derive::gen_stub_pyclass(module = "nautilus_trader.model")
324)]
325pub struct BarSpecification {
326    /// The step for binning samples for bar aggregation.
327    pub step: NonZeroUsize,
328    /// The type of bar aggregation.
329    pub aggregation: BarAggregation,
330    /// The price type to use for aggregation.
331    pub price_type: PriceType,
332}
333
334impl BarSpecification {
335    /// Creates a new [`BarSpecification`] instance with correctness checking.
336    ///
337    /// # Errors
338    ///
339    /// Returns an error if `step` is not positive (> 0).
340    ///
341    /// # Notes
342    ///
343    /// PyO3 requires a `Result` type for proper error handling and stacktrace printing in Python.
344    pub fn new_checked(
345        step: usize,
346        aggregation: BarAggregation,
347        price_type: PriceType,
348    ) -> anyhow::Result<Self> {
349        let step = NonZeroUsize::new(step)
350            .ok_or(anyhow::anyhow!("Invalid step: {step} (must be non-zero)"))?;
351        Ok(Self {
352            step,
353            aggregation,
354            price_type,
355        })
356    }
357
358    /// Creates a new [`BarSpecification`] instance.
359    ///
360    /// # Panics
361    ///
362    /// Panics if `step` is not positive (> 0).
363    #[must_use]
364    pub fn new(step: usize, aggregation: BarAggregation, price_type: PriceType) -> Self {
365        Self::new_checked(step, aggregation, price_type).expect(FAILED)
366    }
367
368    /// Returns the `TimeDelta` interval for this bar specification.
369    ///
370    /// # Notes
371    ///
372    /// For [`BarAggregation::Month`] and [`BarAggregation::Year`], proxy values are used
373    /// (30 days for months, 365 days for years) to estimate their respective durations,
374    /// since months and years have variable lengths.
375    ///
376    /// # Panics
377    ///
378    /// Panics if the aggregation method is not time-based.
379    #[must_use]
380    pub fn timedelta(&self) -> TimeDelta {
381        match self.aggregation {
382            BarAggregation::Millisecond => Duration::milliseconds(self.step.get() as i64),
383            BarAggregation::Second => Duration::seconds(self.step.get() as i64),
384            BarAggregation::Minute => Duration::minutes(self.step.get() as i64),
385            BarAggregation::Hour => Duration::hours(self.step.get() as i64),
386            BarAggregation::Day => Duration::days(self.step.get() as i64),
387            BarAggregation::Week => Duration::days(self.step.get() as i64 * 7),
388            BarAggregation::Month => Duration::days(self.step.get() as i64 * 30), // Proxy for comparing bar lengths
389            BarAggregation::Year => Duration::days(self.step.get() as i64 * 365), // Proxy for comparing bar lengths
390            _ => panic!(
391                "Timedelta not supported for aggregation type: {:?}",
392                self.aggregation
393            ),
394        }
395    }
396
397    /// Return a value indicating whether the aggregation method is time-driven:
398    ///  - [`BarAggregation::Millisecond`]
399    ///  - [`BarAggregation::Second`]
400    ///  - [`BarAggregation::Minute`]
401    ///  - [`BarAggregation::Hour`]
402    ///  - [`BarAggregation::Day`]
403    ///  - [`BarAggregation::Week`]
404    ///  - [`BarAggregation::Month`]
405    ///  - [`BarAggregation::Year`]
406    #[must_use]
407    pub fn is_time_aggregated(&self) -> bool {
408        matches!(
409            self.aggregation,
410            BarAggregation::Millisecond
411                | BarAggregation::Second
412                | BarAggregation::Minute
413                | BarAggregation::Hour
414                | BarAggregation::Day
415                | BarAggregation::Week
416                | BarAggregation::Month
417                | BarAggregation::Year
418        )
419    }
420
421    /// Return a value indicating whether the aggregation method is threshold-driven:
422    ///  - [`BarAggregation::Tick`]
423    ///  - [`BarAggregation::TickImbalance`]
424    ///  - [`BarAggregation::Volume`]
425    ///  - [`BarAggregation::VolumeImbalance`]
426    ///  - [`BarAggregation::Value`]
427    ///  - [`BarAggregation::ValueImbalance`]
428    #[must_use]
429    pub fn is_threshold_aggregated(&self) -> bool {
430        matches!(
431            self.aggregation,
432            BarAggregation::Tick
433                | BarAggregation::TickImbalance
434                | BarAggregation::Volume
435                | BarAggregation::VolumeImbalance
436                | BarAggregation::Value
437                | BarAggregation::ValueImbalance
438        )
439    }
440
441    /// Return a value indicating whether the aggregation method is information-driven:
442    ///  - [`BarAggregation::TickRuns`]
443    ///  - [`BarAggregation::VolumeRuns`]
444    ///  - [`BarAggregation::ValueRuns`]
445    #[must_use]
446    pub fn is_information_aggregated(&self) -> bool {
447        matches!(
448            self.aggregation,
449            BarAggregation::TickRuns | BarAggregation::VolumeRuns | BarAggregation::ValueRuns
450        )
451    }
452}
453
454impl Display for BarSpecification {
455    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
456        write!(f, "{}-{}-{}", self.step, self.aggregation, self.price_type)
457    }
458}
459
460/// Represents a bar type including the instrument ID, bar specification and
461/// aggregation source.
462#[repr(C)]
463#[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord, Hash)]
464#[cfg_attr(
465    feature = "python",
466    pyo3::pyclass(module = "nautilus_trader.core.nautilus_pyo3.model", from_py_object)
467)]
468#[cfg_attr(
469    feature = "python",
470    pyo3_stub_gen::derive::gen_stub_pyclass_enum(module = "nautilus_trader.model")
471)]
472pub enum BarType {
473    Standard {
474        /// The bar type's instrument ID.
475        instrument_id: InstrumentId,
476        /// The bar type's specification.
477        spec: BarSpecification,
478        /// The bar type's aggregation source.
479        aggregation_source: AggregationSource,
480    },
481    Composite {
482        /// The bar type's instrument ID.
483        instrument_id: InstrumentId,
484        /// The bar type's specification.
485        spec: BarSpecification,
486        /// The bar type's aggregation source.
487        aggregation_source: AggregationSource,
488
489        /// The composite step for binning samples for bar aggregation.
490        composite_step: usize,
491        /// The composite type of bar aggregation.
492        composite_aggregation: BarAggregation,
493        /// The composite bar type's aggregation source.
494        composite_aggregation_source: AggregationSource,
495    },
496}
497
498impl BarType {
499    /// Creates a new [`BarType`] instance.
500    #[must_use]
501    pub fn new(
502        instrument_id: InstrumentId,
503        spec: BarSpecification,
504        aggregation_source: AggregationSource,
505    ) -> Self {
506        Self::Standard {
507            instrument_id,
508            spec,
509            aggregation_source,
510        }
511    }
512
513    /// Creates a new composite [`BarType`] instance.
514    #[must_use]
515    pub fn new_composite(
516        instrument_id: InstrumentId,
517        spec: BarSpecification,
518        aggregation_source: AggregationSource,
519
520        composite_step: usize,
521        composite_aggregation: BarAggregation,
522        composite_aggregation_source: AggregationSource,
523    ) -> Self {
524        Self::Composite {
525            instrument_id,
526            spec,
527            aggregation_source,
528
529            composite_step,
530            composite_aggregation,
531            composite_aggregation_source,
532        }
533    }
534
535    /// Returns whether this instance is a standard bar type.
536    #[must_use]
537    pub fn is_standard(&self) -> bool {
538        match &self {
539            Self::Standard { .. } => true,
540            Self::Composite { .. } => false,
541        }
542    }
543
544    /// Returns whether this instance is a composite bar type.
545    #[must_use]
546    pub fn is_composite(&self) -> bool {
547        match &self {
548            Self::Standard { .. } => false,
549            Self::Composite { .. } => true,
550        }
551    }
552
553    /// Returns the standard bar type component.
554    #[must_use]
555    pub fn standard(&self) -> Self {
556        match self {
557            &b @ Self::Standard { .. } => b,
558            Self::Composite {
559                instrument_id,
560                spec,
561                aggregation_source,
562                ..
563            } => Self::new(*instrument_id, *spec, *aggregation_source),
564        }
565    }
566
567    /// Returns any composite bar type component.
568    #[must_use]
569    pub fn composite(&self) -> Self {
570        match self {
571            &b @ Self::Standard { .. } => b, // case shouldn't be used if is_composite is called before
572            Self::Composite {
573                instrument_id,
574                spec,
575                aggregation_source: _,
576
577                composite_step,
578                composite_aggregation,
579                composite_aggregation_source,
580            } => Self::new(
581                *instrument_id,
582                BarSpecification::new(*composite_step, *composite_aggregation, spec.price_type),
583                *composite_aggregation_source,
584            ),
585        }
586    }
587
588    /// Returns the [`InstrumentId`] for this bar type.
589    #[must_use]
590    pub fn instrument_id(&self) -> InstrumentId {
591        match &self {
592            Self::Standard { instrument_id, .. } | Self::Composite { instrument_id, .. } => {
593                *instrument_id
594            }
595        }
596    }
597
598    /// Returns the [`BarSpecification`] for this bar type.
599    #[must_use]
600    pub fn spec(&self) -> BarSpecification {
601        match &self {
602            Self::Standard { spec, .. } | Self::Composite { spec, .. } => *spec,
603        }
604    }
605
606    /// Returns the [`AggregationSource`] for this bar type.
607    #[must_use]
608    pub fn aggregation_source(&self) -> AggregationSource {
609        match &self {
610            Self::Standard {
611                aggregation_source, ..
612            }
613            | Self::Composite {
614                aggregation_source, ..
615            } => *aggregation_source,
616        }
617    }
618
619    /// Returns the instrument ID and bar specification as a tuple key.
620    ///
621    /// Useful as a hashmap key when aggregation source should be ignored,
622    /// such as for indicator registration where INTERNAL and EXTERNAL bars
623    /// should trigger the same indicators.
624    #[must_use]
625    pub fn id_spec_key(&self) -> (InstrumentId, BarSpecification) {
626        (self.instrument_id(), self.spec())
627    }
628}
629
630#[derive(thiserror::Error, Debug)]
631#[error("Error parsing `BarType` from '{input}', invalid token: '{token}' at position {position}")]
632pub struct BarTypeParseError {
633    input: String,
634    token: String,
635    position: usize,
636}
637
638impl FromStr for BarType {
639    type Err = BarTypeParseError;
640
641    #[expect(clippy::needless_collect)] // Collect needed for .rev() and indexing
642    fn from_str(s: &str) -> Result<Self, Self::Err> {
643        let parts: Vec<&str> = s.split('@').collect();
644        let standard = parts[0];
645        let composite_str = parts.get(1);
646
647        let pieces: Vec<&str> = standard.rsplitn(5, '-').collect();
648        let rev_pieces: Vec<&str> = pieces.into_iter().rev().collect();
649        if rev_pieces.len() != 5 {
650            return Err(BarTypeParseError {
651                input: s.to_string(),
652                token: String::new(),
653                position: 0,
654            });
655        }
656
657        let instrument_id =
658            InstrumentId::from_str(rev_pieces[0]).map_err(|_| BarTypeParseError {
659                input: s.to_string(),
660                token: rev_pieces[0].to_string(),
661                position: 0,
662            })?;
663
664        let step = rev_pieces[1].parse().map_err(|_| BarTypeParseError {
665            input: s.to_string(),
666            token: rev_pieces[1].to_string(),
667            position: 1,
668        })?;
669        let aggregation =
670            BarAggregation::from_str(rev_pieces[2]).map_err(|_| BarTypeParseError {
671                input: s.to_string(),
672                token: rev_pieces[2].to_string(),
673                position: 2,
674            })?;
675        let price_type = PriceType::from_str(rev_pieces[3]).map_err(|_| BarTypeParseError {
676            input: s.to_string(),
677            token: rev_pieces[3].to_string(),
678            position: 3,
679        })?;
680        let aggregation_source =
681            AggregationSource::from_str(rev_pieces[4]).map_err(|_| BarTypeParseError {
682                input: s.to_string(),
683                token: rev_pieces[4].to_string(),
684                position: 4,
685            })?;
686
687        if let Some(composite_str) = composite_str {
688            let composite_pieces: Vec<&str> = composite_str.rsplitn(3, '-').collect();
689            let rev_composite_pieces: Vec<&str> = composite_pieces.into_iter().rev().collect();
690            if rev_composite_pieces.len() != 3 {
691                return Err(BarTypeParseError {
692                    input: s.to_string(),
693                    token: String::new(),
694                    position: 5,
695                });
696            }
697
698            let composite_step =
699                rev_composite_pieces[0]
700                    .parse()
701                    .map_err(|_| BarTypeParseError {
702                        input: s.to_string(),
703                        token: rev_composite_pieces[0].to_string(),
704                        position: 5,
705                    })?;
706            let composite_aggregation =
707                BarAggregation::from_str(rev_composite_pieces[1]).map_err(|_| {
708                    BarTypeParseError {
709                        input: s.to_string(),
710                        token: rev_composite_pieces[1].to_string(),
711                        position: 6,
712                    }
713                })?;
714            let composite_aggregation_source = AggregationSource::from_str(rev_composite_pieces[2])
715                .map_err(|_| BarTypeParseError {
716                    input: s.to_string(),
717                    token: rev_composite_pieces[2].to_string(),
718                    position: 7,
719                })?;
720
721            Ok(Self::new_composite(
722                instrument_id,
723                BarSpecification::new(step, aggregation, price_type),
724                aggregation_source,
725                composite_step,
726                composite_aggregation,
727                composite_aggregation_source,
728            ))
729        } else {
730            Ok(Self::Standard {
731                instrument_id,
732                spec: BarSpecification::new(step, aggregation, price_type),
733                aggregation_source,
734            })
735        }
736    }
737}
738
739impl<T: AsRef<str>> From<T> for BarType {
740    fn from(value: T) -> Self {
741        Self::from_str(value.as_ref()).expect(FAILED)
742    }
743}
744
745impl Display for BarType {
746    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
747        match &self {
748            Self::Standard {
749                instrument_id,
750                spec,
751                aggregation_source,
752            } => {
753                write!(f, "{instrument_id}-{spec}-{aggregation_source}")
754            }
755            Self::Composite {
756                instrument_id,
757                spec,
758                aggregation_source,
759
760                composite_step,
761                composite_aggregation,
762                composite_aggregation_source,
763            } => {
764                write!(
765                    f,
766                    "{}-{}-{}@{}-{}-{}",
767                    instrument_id,
768                    spec,
769                    aggregation_source,
770                    *composite_step,
771                    *composite_aggregation,
772                    *composite_aggregation_source
773                )
774            }
775        }
776    }
777}
778
779impl Serialize for BarType {
780    fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
781    where
782        S: Serializer,
783    {
784        serializer.serialize_str(&self.to_string())
785    }
786}
787
788impl<'de> Deserialize<'de> for BarType {
789    fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
790    where
791        D: Deserializer<'de>,
792    {
793        let s: String = Deserialize::deserialize(deserializer)?;
794        Self::from_str(&s).map_err(serde::de::Error::custom)
795    }
796}
797
798/// Represents an aggregated bar.
799#[repr(C)]
800#[derive(Clone, Copy, Hash, PartialEq, Eq, Debug, Serialize, Deserialize)]
801#[serde(tag = "type")]
802#[cfg_attr(
803    feature = "python",
804    pyo3::pyclass(module = "nautilus_trader.core.nautilus_pyo3.model", from_py_object)
805)]
806#[cfg_attr(
807    feature = "python",
808    pyo3_stub_gen::derive::gen_stub_pyclass(module = "nautilus_trader.model")
809)]
810pub struct Bar {
811    /// The bar type for this bar.
812    pub bar_type: BarType,
813    /// The bars open price.
814    pub open: Price,
815    /// The bars high price.
816    pub high: Price,
817    /// The bars low price.
818    pub low: Price,
819    /// The bars close price.
820    pub close: Price,
821    /// The bars volume.
822    pub volume: Quantity,
823    /// UNIX timestamp (nanoseconds) when the data event occurred.
824    pub ts_event: UnixNanos,
825    /// UNIX timestamp (nanoseconds) when the instance was created.
826    pub ts_init: UnixNanos,
827}
828
829impl Bar {
830    /// Creates a new [`Bar`] instance with correctness checking.
831    ///
832    /// # Errors
833    ///
834    /// Returns an error if:
835    /// - `high` is not >= `low`.
836    /// - `high` is not >= `close`.
837    /// - `low` is not <= `close`.
838    ///
839    /// # Notes
840    ///
841    /// PyO3 requires a `Result` type for proper error handling and stacktrace printing in Python.
842    #[expect(clippy::too_many_arguments)]
843    pub fn new_checked(
844        bar_type: BarType,
845        open: Price,
846        high: Price,
847        low: Price,
848        close: Price,
849        volume: Quantity,
850        ts_event: UnixNanos,
851        ts_init: UnixNanos,
852    ) -> anyhow::Result<Self> {
853        check_predicate_true(high >= open, "high >= open")?;
854        check_predicate_true(high >= low, "high >= low")?;
855        check_predicate_true(high >= close, "high >= close")?;
856        check_predicate_true(low <= close, "low <= close")?;
857        check_predicate_true(low <= open, "low <= open")?;
858
859        Ok(Self {
860            bar_type,
861            open,
862            high,
863            low,
864            close,
865            volume,
866            ts_event,
867            ts_init,
868        })
869    }
870
871    /// Creates a new [`Bar`] instance.
872    ///
873    /// # Panics
874    ///
875    /// This function panics if:
876    /// - `high` is not >= `low`.
877    /// - `high` is not >= `close`.
878    /// - `low` is not <= `close`.
879    #[expect(clippy::too_many_arguments)]
880    #[must_use]
881    pub fn new(
882        bar_type: BarType,
883        open: Price,
884        high: Price,
885        low: Price,
886        close: Price,
887        volume: Quantity,
888        ts_event: UnixNanos,
889        ts_init: UnixNanos,
890    ) -> Self {
891        Self::new_checked(bar_type, open, high, low, close, volume, ts_event, ts_init)
892            .expect(FAILED)
893    }
894
895    #[must_use]
896    pub fn instrument_id(&self) -> InstrumentId {
897        self.bar_type.instrument_id()
898    }
899
900    /// Returns the metadata for the type, for use with serialization formats.
901    #[must_use]
902    pub fn get_metadata(
903        bar_type: &BarType,
904        price_precision: u8,
905        size_precision: u8,
906    ) -> HashMap<String, String> {
907        let mut metadata = HashMap::new();
908        let instrument_id = bar_type.instrument_id();
909        metadata.insert("bar_type".to_string(), bar_type.to_string());
910        metadata.insert("instrument_id".to_string(), instrument_id.to_string());
911        metadata.insert("price_precision".to_string(), price_precision.to_string());
912        metadata.insert("size_precision".to_string(), size_precision.to_string());
913        metadata
914    }
915
916    /// Returns the field map for the type, for use with Arrow schemas.
917    #[must_use]
918    pub fn get_fields() -> IndexMap<String, String> {
919        let mut metadata = IndexMap::new();
920        metadata.insert("open".to_string(), FIXED_SIZE_BINARY.to_string());
921        metadata.insert("high".to_string(), FIXED_SIZE_BINARY.to_string());
922        metadata.insert("low".to_string(), FIXED_SIZE_BINARY.to_string());
923        metadata.insert("close".to_string(), FIXED_SIZE_BINARY.to_string());
924        metadata.insert("volume".to_string(), FIXED_SIZE_BINARY.to_string());
925        metadata.insert("ts_event".to_string(), "UInt64".to_string());
926        metadata.insert("ts_init".to_string(), "UInt64".to_string());
927        metadata
928    }
929}
930
931impl Display for Bar {
932    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
933        write!(
934            f,
935            "{},{},{},{},{},{},{}",
936            self.bar_type, self.open, self.high, self.low, self.close, self.volume, self.ts_event
937        )
938    }
939}
940
941impl Serializable for Bar {}
942
943impl HasTsInit for Bar {
944    fn ts_init(&self) -> UnixNanos {
945        self.ts_init
946    }
947}
948
949#[cfg(test)]
950mod tests {
951    use std::str::FromStr;
952
953    use chrono::TimeZone;
954    use nautilus_core::serialization::msgpack::{FromMsgPack, ToMsgPack};
955    use rstest::rstest;
956
957    use super::*;
958    use crate::identifiers::{Symbol, Venue};
959
960    #[rstest]
961    fn test_bar_specification_new_invalid() {
962        let result = BarSpecification::new_checked(0, BarAggregation::Tick, PriceType::Last);
963        assert!(result.is_err());
964    }
965
966    #[rstest]
967    #[should_panic(expected = "Invalid step: 0 (must be non-zero)")]
968    fn test_bar_specification_new_checked_with_invalid_step_panics() {
969        let aggregation = BarAggregation::Tick;
970        let price_type = PriceType::Last;
971
972        let _ = BarSpecification::new(0, aggregation, price_type);
973    }
974
975    #[rstest]
976    #[case(BarAggregation::Millisecond, 1, TimeDelta::milliseconds(1))]
977    #[case(BarAggregation::Millisecond, 10, TimeDelta::milliseconds(10))]
978    #[case(BarAggregation::Second, 1, TimeDelta::seconds(1))]
979    #[case(BarAggregation::Second, 15, TimeDelta::seconds(15))]
980    #[case(BarAggregation::Minute, 1, TimeDelta::minutes(1))]
981    #[case(BarAggregation::Minute, 60, TimeDelta::minutes(60))]
982    #[case(BarAggregation::Hour, 1, TimeDelta::hours(1))]
983    #[case(BarAggregation::Hour, 4, TimeDelta::hours(4))]
984    #[case(BarAggregation::Day, 1, TimeDelta::days(1))]
985    #[case(BarAggregation::Day, 2, TimeDelta::days(2))]
986    #[case(BarAggregation::Week, 1, TimeDelta::days(7))]
987    #[case(BarAggregation::Week, 2, TimeDelta::days(14))]
988    #[case(BarAggregation::Month, 1, TimeDelta::days(30))]
989    #[case(BarAggregation::Month, 3, TimeDelta::days(90))]
990    #[case(BarAggregation::Year, 1, TimeDelta::days(365))]
991    #[case(BarAggregation::Year, 2, TimeDelta::days(730))]
992    #[should_panic(expected = "Aggregation not time based")]
993    #[case(BarAggregation::Tick, 1, TimeDelta::zero())]
994    fn test_get_bar_interval(
995        #[case] aggregation: BarAggregation,
996        #[case] step: usize,
997        #[case] expected: TimeDelta,
998    ) {
999        let bar_type = BarType::Standard {
1000            instrument_id: InstrumentId::from("BTCUSDT-PERP.BINANCE"),
1001            spec: BarSpecification::new(step, aggregation, PriceType::Last),
1002            aggregation_source: AggregationSource::Internal,
1003        };
1004
1005        let interval = get_bar_interval(&bar_type);
1006        assert_eq!(interval, expected);
1007    }
1008
1009    #[rstest]
1010    #[case(BarAggregation::Millisecond, 1, UnixNanos::from(1_000_000))]
1011    #[case(BarAggregation::Millisecond, 10, UnixNanos::from(10_000_000))]
1012    #[case(BarAggregation::Second, 1, UnixNanos::from(1_000_000_000))]
1013    #[case(BarAggregation::Second, 10, UnixNanos::from(10_000_000_000))]
1014    #[case(BarAggregation::Minute, 1, UnixNanos::from(60_000_000_000))]
1015    #[case(BarAggregation::Minute, 60, UnixNanos::from(3_600_000_000_000))]
1016    #[case(BarAggregation::Hour, 1, UnixNanos::from(3_600_000_000_000))]
1017    #[case(BarAggregation::Hour, 4, UnixNanos::from(14_400_000_000_000))]
1018    #[case(BarAggregation::Day, 1, UnixNanos::from(86_400_000_000_000))]
1019    #[case(BarAggregation::Day, 2, UnixNanos::from(172_800_000_000_000))]
1020    #[case(BarAggregation::Week, 1, UnixNanos::from(604_800_000_000_000))]
1021    #[case(BarAggregation::Week, 2, UnixNanos::from(1_209_600_000_000_000))]
1022    #[case(BarAggregation::Month, 1, UnixNanos::from(2_592_000_000_000_000))]
1023    #[case(BarAggregation::Month, 3, UnixNanos::from(7_776_000_000_000_000))]
1024    #[case(BarAggregation::Year, 1, UnixNanos::from(31_536_000_000_000_000))]
1025    #[case(BarAggregation::Year, 2, UnixNanos::from(63_072_000_000_000_000))]
1026    #[should_panic(expected = "Aggregation not time based")]
1027    #[case(BarAggregation::Tick, 1, UnixNanos::from(0))]
1028    fn test_get_bar_interval_ns(
1029        #[case] aggregation: BarAggregation,
1030        #[case] step: usize,
1031        #[case] expected: UnixNanos,
1032    ) {
1033        let bar_type = BarType::Standard {
1034            instrument_id: InstrumentId::from("BTCUSDT-PERP.BINANCE"),
1035            spec: BarSpecification::new(step, aggregation, PriceType::Last),
1036            aggregation_source: AggregationSource::Internal,
1037        };
1038
1039        let interval_ns = get_bar_interval_ns(&bar_type);
1040        assert_eq!(interval_ns, expected);
1041    }
1042
1043    #[rstest]
1044    #[case::millisecond(
1045    Utc.timestamp_opt(1_658_349_296, 123_000_000).unwrap(), // 2024-07-21 12:34:56.123 UTC
1046    BarAggregation::Millisecond,
1047    1,
1048    Utc.timestamp_opt(1_658_349_296, 123_000_000).unwrap(),  // 2024-07-21 12:34:56.123 UTC
1049    )]
1050    #[rstest]
1051    #[case::millisecond(
1052    Utc.timestamp_opt(1_658_349_296, 123_000_000).unwrap(), // 2024-07-21 12:34:56.123 UTC
1053    BarAggregation::Millisecond,
1054    10,
1055    Utc.timestamp_opt(1_658_349_296, 120_000_000).unwrap(),  // 2024-07-21 12:34:56.120 UTC
1056    )]
1057    #[case::second(
1058    Utc.with_ymd_and_hms(2024, 7, 21, 12, 34, 56).unwrap(),
1059    BarAggregation::Millisecond,
1060    1000,
1061    Utc.with_ymd_and_hms(2024, 7, 21, 12, 34, 56).unwrap()
1062    )]
1063    #[case::second(
1064    Utc.with_ymd_and_hms(2024, 7, 21, 12, 34, 56).unwrap(),
1065    BarAggregation::Second,
1066    1,
1067    Utc.with_ymd_and_hms(2024, 7, 21, 12, 34, 56).unwrap()
1068    )]
1069    #[case::second(
1070    Utc.with_ymd_and_hms(2024, 7, 21, 12, 34, 56).unwrap(),
1071    BarAggregation::Second,
1072    5,
1073    Utc.with_ymd_and_hms(2024, 7, 21, 12, 34, 55).unwrap()
1074    )]
1075    #[case::second(
1076    Utc.with_ymd_and_hms(2024, 7, 21, 12, 34, 56).unwrap(),
1077    BarAggregation::Second,
1078    60,
1079    Utc.with_ymd_and_hms(2024, 7, 21, 12, 34, 0).unwrap()
1080    )]
1081    #[case::minute(
1082    Utc.with_ymd_and_hms(2024, 7, 21, 12, 34, 56).unwrap(),
1083    BarAggregation::Minute,
1084    1,
1085    Utc.with_ymd_and_hms(2024, 7, 21, 12, 34, 0).unwrap()
1086    )]
1087    #[case::minute(
1088    Utc.with_ymd_and_hms(2024, 7, 21, 12, 34, 56).unwrap(),
1089    BarAggregation::Minute,
1090    5,
1091    Utc.with_ymd_and_hms(2024, 7, 21, 12, 30, 0).unwrap()
1092    )]
1093    #[case::minute(
1094    Utc.with_ymd_and_hms(2024, 7, 21, 12, 34, 56).unwrap(),
1095    BarAggregation::Minute,
1096    60,
1097    Utc.with_ymd_and_hms(2024, 7, 21, 12, 0, 0).unwrap()
1098    )]
1099    #[case::hour(
1100    Utc.with_ymd_and_hms(2024, 7, 21, 12, 34, 56).unwrap(),
1101    BarAggregation::Hour,
1102    1,
1103    Utc.with_ymd_and_hms(2024, 7, 21, 12, 0, 0).unwrap()
1104    )]
1105    #[case::hour(
1106    Utc.with_ymd_and_hms(2024, 7, 21, 12, 34, 56).unwrap(),
1107    BarAggregation::Hour,
1108    2,
1109    Utc.with_ymd_and_hms(2024, 7, 21, 12, 0, 0).unwrap()
1110    )]
1111    #[case::day(
1112    Utc.with_ymd_and_hms(2024, 7, 21, 12, 34, 56).unwrap(),
1113    BarAggregation::Day,
1114    1,
1115    Utc.with_ymd_and_hms(2024, 7, 21, 0, 0, 0).unwrap()
1116    )]
1117    fn test_get_time_bar_start(
1118        #[case] now: DateTime<Utc>,
1119        #[case] aggregation: BarAggregation,
1120        #[case] step: usize,
1121        #[case] expected: DateTime<Utc>,
1122    ) {
1123        let bar_type = BarType::Standard {
1124            instrument_id: InstrumentId::from("BTCUSDT-PERP.BINANCE"),
1125            spec: BarSpecification::new(step, aggregation, PriceType::Last),
1126            aggregation_source: AggregationSource::Internal,
1127        };
1128
1129        let start_time = get_time_bar_start(now, &bar_type, None);
1130        assert_eq!(start_time, expected);
1131    }
1132
1133    #[rstest]
1134    fn test_bar_spec_string_reprs() {
1135        let bar_spec = BarSpecification::new(1, BarAggregation::Minute, PriceType::Bid);
1136        assert_eq!(bar_spec.to_string(), "1-MINUTE-BID");
1137        assert_eq!(format!("{bar_spec}"), "1-MINUTE-BID");
1138    }
1139
1140    #[rstest]
1141    fn test_bar_type_parse_valid() {
1142        let input = "BTCUSDT-PERP.BINANCE-1-MINUTE-LAST-EXTERNAL";
1143        let bar_type = BarType::from(input);
1144
1145        assert_eq!(
1146            bar_type.instrument_id(),
1147            InstrumentId::from("BTCUSDT-PERP.BINANCE")
1148        );
1149        assert_eq!(
1150            bar_type.spec(),
1151            BarSpecification::new(1, BarAggregation::Minute, PriceType::Last)
1152        );
1153        assert_eq!(bar_type.aggregation_source(), AggregationSource::External);
1154        assert_eq!(bar_type, BarType::from(input));
1155    }
1156
1157    #[rstest]
1158    fn test_bar_type_from_str_with_utf8_symbol() {
1159        let non_ascii_instrument = "TËST-PÉRP.BINANCE";
1160        let non_ascii_bar_type = "TËST-PÉRP.BINANCE-1-MINUTE-LAST-EXTERNAL";
1161
1162        let bar_type = BarType::from_str(non_ascii_bar_type).unwrap();
1163
1164        assert_eq!(
1165            bar_type.instrument_id(),
1166            InstrumentId::from_str(non_ascii_instrument).unwrap()
1167        );
1168        assert_eq!(
1169            bar_type.spec(),
1170            BarSpecification::new(1, BarAggregation::Minute, PriceType::Last)
1171        );
1172        assert_eq!(bar_type.aggregation_source(), AggregationSource::External);
1173        assert_eq!(bar_type.to_string(), non_ascii_bar_type);
1174    }
1175
1176    #[rstest]
1177    fn test_bar_type_composite_parse_valid() {
1178        let input = "BTCUSDT-PERP.BINANCE-2-MINUTE-LAST-INTERNAL@1-MINUTE-EXTERNAL";
1179        let bar_type = BarType::from(input);
1180        let standard = bar_type.standard();
1181
1182        assert_eq!(
1183            bar_type.instrument_id(),
1184            InstrumentId::from("BTCUSDT-PERP.BINANCE")
1185        );
1186        assert_eq!(
1187            bar_type.spec(),
1188            BarSpecification::new(2, BarAggregation::Minute, PriceType::Last,)
1189        );
1190        assert_eq!(bar_type.aggregation_source(), AggregationSource::Internal);
1191        assert_eq!(bar_type, BarType::from(input));
1192        assert!(bar_type.is_composite());
1193
1194        assert_eq!(
1195            standard.instrument_id(),
1196            InstrumentId::from("BTCUSDT-PERP.BINANCE")
1197        );
1198        assert_eq!(
1199            standard.spec(),
1200            BarSpecification::new(2, BarAggregation::Minute, PriceType::Last,)
1201        );
1202        assert_eq!(standard.aggregation_source(), AggregationSource::Internal);
1203        assert!(standard.is_standard());
1204
1205        let composite = bar_type.composite();
1206        let composite_input = "BTCUSDT-PERP.BINANCE-1-MINUTE-LAST-EXTERNAL";
1207
1208        assert_eq!(
1209            composite.instrument_id(),
1210            InstrumentId::from("BTCUSDT-PERP.BINANCE")
1211        );
1212        assert_eq!(
1213            composite.spec(),
1214            BarSpecification::new(1, BarAggregation::Minute, PriceType::Last,)
1215        );
1216        assert_eq!(composite.aggregation_source(), AggregationSource::External);
1217        assert_eq!(composite, BarType::from(composite_input));
1218        assert!(composite.is_standard());
1219    }
1220
1221    #[rstest]
1222    fn test_bar_type_parse_invalid_token_pos_0() {
1223        let input = "BTCUSDT-PERP-1-MINUTE-LAST-INTERNAL";
1224        let result = BarType::from_str(input);
1225
1226        assert_eq!(
1227            result.unwrap_err().to_string(),
1228            format!(
1229                "Error parsing `BarType` from '{input}', invalid token: 'BTCUSDT-PERP' at position 0"
1230            )
1231        );
1232    }
1233
1234    #[rstest]
1235    fn test_bar_type_parse_invalid_token_pos_1() {
1236        let input = "BTCUSDT-PERP.BINANCE-INVALID-MINUTE-LAST-INTERNAL";
1237        let result = BarType::from_str(input);
1238
1239        assert_eq!(
1240            result.unwrap_err().to_string(),
1241            format!(
1242                "Error parsing `BarType` from '{input}', invalid token: 'INVALID' at position 1"
1243            )
1244        );
1245    }
1246
1247    #[rstest]
1248    fn test_bar_type_parse_invalid_token_pos_2() {
1249        let input = "BTCUSDT-PERP.BINANCE-1-INVALID-LAST-INTERNAL";
1250        let result = BarType::from_str(input);
1251
1252        assert_eq!(
1253            result.unwrap_err().to_string(),
1254            format!(
1255                "Error parsing `BarType` from '{input}', invalid token: 'INVALID' at position 2"
1256            )
1257        );
1258    }
1259
1260    #[rstest]
1261    fn test_bar_type_parse_invalid_token_pos_3() {
1262        let input = "BTCUSDT-PERP.BINANCE-1-MINUTE-INVALID-INTERNAL";
1263        let result = BarType::from_str(input);
1264
1265        assert_eq!(
1266            result.unwrap_err().to_string(),
1267            format!(
1268                "Error parsing `BarType` from '{input}', invalid token: 'INVALID' at position 3"
1269            )
1270        );
1271    }
1272
1273    #[rstest]
1274    fn test_bar_type_parse_invalid_token_pos_4() {
1275        let input = "BTCUSDT-PERP.BINANCE-1-MINUTE-BID-INVALID";
1276        let result = BarType::from_str(input);
1277
1278        assert!(result.is_err());
1279        assert_eq!(
1280            result.unwrap_err().to_string(),
1281            format!(
1282                "Error parsing `BarType` from '{input}', invalid token: 'INVALID' at position 4"
1283            )
1284        );
1285    }
1286
1287    #[rstest]
1288    fn test_bar_type_parse_invalid_token_pos_5() {
1289        let input = "BTCUSDT-PERP.BINANCE-2-MINUTE-LAST-INTERNAL@INVALID-MINUTE-EXTERNAL";
1290        let result = BarType::from_str(input);
1291
1292        assert!(result.is_err());
1293        assert_eq!(
1294            result.unwrap_err().to_string(),
1295            format!(
1296                "Error parsing `BarType` from '{input}', invalid token: 'INVALID' at position 5"
1297            )
1298        );
1299    }
1300
1301    #[rstest]
1302    fn test_bar_type_parse_invalid_token_pos_6() {
1303        let input = "BTCUSDT-PERP.BINANCE-2-MINUTE-LAST-INTERNAL@1-INVALID-EXTERNAL";
1304        let result = BarType::from_str(input);
1305
1306        assert!(result.is_err());
1307        assert_eq!(
1308            result.unwrap_err().to_string(),
1309            format!(
1310                "Error parsing `BarType` from '{input}', invalid token: 'INVALID' at position 6"
1311            )
1312        );
1313    }
1314
1315    #[rstest]
1316    fn test_bar_type_parse_invalid_token_pos_7() {
1317        let input = "BTCUSDT-PERP.BINANCE-2-MINUTE-LAST-INTERNAL@1-MINUTE-INVALID";
1318        let result = BarType::from_str(input);
1319
1320        assert!(result.is_err());
1321        assert_eq!(
1322            result.unwrap_err().to_string(),
1323            format!(
1324                "Error parsing `BarType` from '{input}', invalid token: 'INVALID' at position 7"
1325            )
1326        );
1327    }
1328
1329    #[rstest]
1330    fn test_bar_type_equality() {
1331        let instrument_id1 = InstrumentId {
1332            symbol: Symbol::new("AUD/USD"),
1333            venue: Venue::new("SIM"),
1334        };
1335        let instrument_id2 = InstrumentId {
1336            symbol: Symbol::new("GBP/USD"),
1337            venue: Venue::new("SIM"),
1338        };
1339        let bar_spec = BarSpecification::new(1, BarAggregation::Minute, PriceType::Bid);
1340        let bar_type1 = BarType::Standard {
1341            instrument_id: instrument_id1,
1342            spec: bar_spec,
1343            aggregation_source: AggregationSource::External,
1344        };
1345        let bar_type2 = BarType::Standard {
1346            instrument_id: instrument_id1,
1347            spec: bar_spec,
1348            aggregation_source: AggregationSource::External,
1349        };
1350        let bar_type3 = BarType::Standard {
1351            instrument_id: instrument_id2,
1352            spec: bar_spec,
1353            aggregation_source: AggregationSource::External,
1354        };
1355        assert_eq!(bar_type1, bar_type1);
1356        assert_eq!(bar_type1, bar_type2);
1357        assert_ne!(bar_type1, bar_type3);
1358    }
1359
1360    #[rstest]
1361    fn test_bar_type_id_spec_key_ignores_aggregation_source() {
1362        let bar_type_external = BarType::from_str("ESM4.XCME-1-MINUTE-LAST-EXTERNAL").unwrap();
1363        let bar_type_internal = BarType::from_str("ESM4.XCME-1-MINUTE-LAST-INTERNAL").unwrap();
1364
1365        // Full equality should differ
1366        assert_ne!(bar_type_external, bar_type_internal);
1367
1368        // id_spec_key should be the same
1369        assert_eq!(
1370            bar_type_external.id_spec_key(),
1371            bar_type_internal.id_spec_key()
1372        );
1373
1374        // Verify key components
1375        let (instrument_id, spec) = bar_type_external.id_spec_key();
1376        assert_eq!(instrument_id, bar_type_external.instrument_id());
1377        assert_eq!(spec, bar_type_external.spec());
1378    }
1379
1380    #[rstest]
1381    fn test_bar_type_comparison() {
1382        let instrument_id1 = InstrumentId {
1383            symbol: Symbol::new("AUD/USD"),
1384            venue: Venue::new("SIM"),
1385        };
1386
1387        let instrument_id2 = InstrumentId {
1388            symbol: Symbol::new("GBP/USD"),
1389            venue: Venue::new("SIM"),
1390        };
1391        let bar_spec = BarSpecification::new(1, BarAggregation::Minute, PriceType::Bid);
1392        let bar_spec2 = BarSpecification::new(2, BarAggregation::Minute, PriceType::Bid);
1393        let bar_type1 = BarType::Standard {
1394            instrument_id: instrument_id1,
1395            spec: bar_spec,
1396            aggregation_source: AggregationSource::External,
1397        };
1398        let bar_type2 = BarType::Standard {
1399            instrument_id: instrument_id1,
1400            spec: bar_spec,
1401            aggregation_source: AggregationSource::External,
1402        };
1403        let bar_type3 = BarType::Standard {
1404            instrument_id: instrument_id2,
1405            spec: bar_spec,
1406            aggregation_source: AggregationSource::External,
1407        };
1408        let bar_type4 = BarType::Composite {
1409            instrument_id: instrument_id2,
1410            spec: bar_spec2,
1411            aggregation_source: AggregationSource::Internal,
1412
1413            composite_step: 1,
1414            composite_aggregation: BarAggregation::Minute,
1415            composite_aggregation_source: AggregationSource::External,
1416        };
1417
1418        assert!(bar_type1 <= bar_type2);
1419        assert!(bar_type1 < bar_type3);
1420        assert!(bar_type3 > bar_type1);
1421        assert!(bar_type3 >= bar_type1);
1422        assert!(bar_type4 >= bar_type1);
1423    }
1424
1425    #[rstest]
1426    fn test_bar_new() {
1427        let bar_type = BarType::from("AAPL.XNAS-1-MINUTE-LAST-INTERNAL");
1428        let open = Price::from("100.0");
1429        let high = Price::from("105.0");
1430        let low = Price::from("95.0");
1431        let close = Price::from("102.0");
1432        let volume = Quantity::from("1000");
1433        let ts_event = UnixNanos::from(1_000_000);
1434        let ts_init = UnixNanos::from(2_000_000);
1435
1436        let bar = Bar::new(bar_type, open, high, low, close, volume, ts_event, ts_init);
1437
1438        assert_eq!(bar.bar_type, bar_type);
1439        assert_eq!(bar.open, open);
1440        assert_eq!(bar.high, high);
1441        assert_eq!(bar.low, low);
1442        assert_eq!(bar.close, close);
1443        assert_eq!(bar.volume, volume);
1444        assert_eq!(bar.ts_event, ts_event);
1445        assert_eq!(bar.ts_init, ts_init);
1446    }
1447
1448    #[rstest]
1449    #[case("100.0", "90.0", "95.0", "92.0")] // high < open
1450    #[case("100.0", "105.0", "110.0", "102.0")] // high < low
1451    #[case("100.0", "105.0", "95.0", "110.0")] // high < close
1452    #[case("100.0", "105.0", "95.0", "90.0")] // low > close
1453    #[case("100.0", "110.0", "105.0", "108.0")] // low > open
1454    #[case("100.0", "90.0", "110.0", "120.0")] // high < open, high < close, low > close
1455    fn test_bar_new_checked_conditions(
1456        #[case] open: &str,
1457        #[case] high: &str,
1458        #[case] low: &str,
1459        #[case] close: &str,
1460    ) {
1461        let bar_type = BarType::from("AAPL.XNAS-1-MINUTE-LAST-INTERNAL");
1462        let open = Price::from(open);
1463        let high = Price::from(high);
1464        let low = Price::from(low);
1465        let close = Price::from(close);
1466        let volume = Quantity::from("1000");
1467        let ts_event = UnixNanos::from(1_000_000);
1468        let ts_init = UnixNanos::from(2_000_000);
1469
1470        let result = Bar::new_checked(bar_type, open, high, low, close, volume, ts_event, ts_init);
1471
1472        assert!(result.is_err());
1473    }
1474
1475    #[rstest]
1476    fn test_bar_equality() {
1477        let instrument_id = InstrumentId {
1478            symbol: Symbol::new("AUDUSD"),
1479            venue: Venue::new("SIM"),
1480        };
1481        let bar_spec = BarSpecification::new(1, BarAggregation::Minute, PriceType::Bid);
1482        let bar_type = BarType::Standard {
1483            instrument_id,
1484            spec: bar_spec,
1485            aggregation_source: AggregationSource::External,
1486        };
1487        let bar1 = Bar {
1488            bar_type,
1489            open: Price::from("1.00001"),
1490            high: Price::from("1.00004"),
1491            low: Price::from("1.00002"),
1492            close: Price::from("1.00003"),
1493            volume: Quantity::from("100000"),
1494            ts_event: UnixNanos::default(),
1495            ts_init: UnixNanos::from(1),
1496        };
1497
1498        let bar2 = Bar {
1499            bar_type,
1500            open: Price::from("1.00000"),
1501            high: Price::from("1.00004"),
1502            low: Price::from("1.00002"),
1503            close: Price::from("1.00003"),
1504            volume: Quantity::from("100000"),
1505            ts_event: UnixNanos::default(),
1506            ts_init: UnixNanos::from(1),
1507        };
1508        assert_eq!(bar1, bar1);
1509        assert_ne!(bar1, bar2);
1510    }
1511
1512    #[rstest]
1513    fn test_json_serialization() {
1514        let bar = Bar::default();
1515        let serialized = bar.to_json_bytes().unwrap();
1516        let deserialized = Bar::from_json_bytes(serialized.as_ref()).unwrap();
1517        assert_eq!(deserialized, bar);
1518    }
1519
1520    #[rstest]
1521    fn test_msgpack_serialization() {
1522        let bar = Bar::default();
1523        let serialized = bar.to_msgpack_bytes().unwrap();
1524        let deserialized = Bar::from_msgpack_bytes(serialized.as_ref()).unwrap();
1525        assert_eq!(deserialized, bar);
1526    }
1527}