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