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