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