nautilus_model/data/
bar.rs

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