nautilus_model/data/
bar.rs

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