Skip to main content

nautilus_model/data/
bar.rs

1// -------------------------------------------------------------------------------------------------
2//  Copyright (C) 2015-2026 Nautech Systems Pty Ltd. All rights reserved.
3//  https://nautechsystems.io
4//
5//  Licensed under the GNU Lesser General Public License Version 3.0 (the "License");
6//  You may not use this file except in compliance with the License.
7//  You may obtain a copy of the License at https://www.gnu.org/licenses/lgpl-3.0.en.html
8//
9//  Unless required by applicable law or agreed to in writing, software
10//  distributed under the License is distributed on an "AS IS" BASIS,
11//  WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12//  See the License for the specific language governing permissions and
13//  limitations under the License.
14// -------------------------------------------------------------------------------------------------
15
16//! Bar aggregate structures, data types, and functionality.
17
18use std::{
19    collections::HashMap,
20    fmt::{Debug, Display},
21    hash::Hash,
22    num::{NonZero, NonZeroUsize},
23    str::FromStr,
24};
25
26use derive_builder::Builder;
27use indexmap::IndexMap;
28use jiff::{SignedDuration, Timestamp, civil::Date, tz::Offset};
29use nautilus_core::{
30    DurationNanos, 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};
49
50pub const BAR_SPEC_1_MINUTE_LAST: BarSpecification = BarSpecification {
51    step: NonZero::new(1).unwrap(),
52    aggregation: BarAggregation::Minute,
53    price_type: PriceType::Last,
54};
55
56pub const BAR_SPEC_3_MINUTE_LAST: BarSpecification = BarSpecification {
57    step: NonZero::new(3).unwrap(),
58    aggregation: BarAggregation::Minute,
59    price_type: PriceType::Last,
60};
61
62pub const BAR_SPEC_5_MINUTE_LAST: BarSpecification = BarSpecification {
63    step: NonZero::new(5).unwrap(),
64    aggregation: BarAggregation::Minute,
65    price_type: PriceType::Last,
66};
67
68pub const BAR_SPEC_15_MINUTE_LAST: BarSpecification = BarSpecification {
69    step: NonZero::new(15).unwrap(),
70    aggregation: BarAggregation::Minute,
71    price_type: PriceType::Last,
72};
73
74pub const BAR_SPEC_30_MINUTE_LAST: BarSpecification = BarSpecification {
75    step: NonZero::new(30).unwrap(),
76    aggregation: BarAggregation::Minute,
77    price_type: PriceType::Last,
78};
79
80pub const BAR_SPEC_1_HOUR_LAST: BarSpecification = BarSpecification {
81    step: NonZero::new(1).unwrap(),
82    aggregation: BarAggregation::Hour,
83    price_type: PriceType::Last,
84};
85
86pub const BAR_SPEC_2_HOUR_LAST: BarSpecification = BarSpecification {
87    step: NonZero::new(2).unwrap(),
88    aggregation: BarAggregation::Hour,
89    price_type: PriceType::Last,
90};
91
92pub const BAR_SPEC_4_HOUR_LAST: BarSpecification = BarSpecification {
93    step: NonZero::new(4).unwrap(),
94    aggregation: BarAggregation::Hour,
95    price_type: PriceType::Last,
96};
97
98pub const BAR_SPEC_6_HOUR_LAST: BarSpecification = BarSpecification {
99    step: NonZero::new(6).unwrap(),
100    aggregation: BarAggregation::Hour,
101    price_type: PriceType::Last,
102};
103
104pub const BAR_SPEC_12_HOUR_LAST: BarSpecification = BarSpecification {
105    step: NonZero::new(12).unwrap(),
106    aggregation: BarAggregation::Hour,
107    price_type: PriceType::Last,
108};
109
110pub const BAR_SPEC_1_DAY_LAST: BarSpecification = BarSpecification {
111    step: NonZero::new(1).unwrap(),
112    aggregation: BarAggregation::Day,
113    price_type: PriceType::Last,
114};
115
116pub const BAR_SPEC_2_DAY_LAST: BarSpecification = BarSpecification {
117    step: NonZero::new(2).unwrap(),
118    aggregation: BarAggregation::Day,
119    price_type: PriceType::Last,
120};
121
122pub const BAR_SPEC_3_DAY_LAST: BarSpecification = BarSpecification {
123    step: NonZero::new(3).unwrap(),
124    aggregation: BarAggregation::Day,
125    price_type: PriceType::Last,
126};
127
128pub const BAR_SPEC_5_DAY_LAST: BarSpecification = BarSpecification {
129    step: NonZero::new(5).unwrap(),
130    aggregation: BarAggregation::Day,
131    price_type: PriceType::Last,
132};
133
134pub const BAR_SPEC_1_WEEK_LAST: BarSpecification = BarSpecification {
135    step: NonZero::new(1).unwrap(),
136    aggregation: BarAggregation::Week,
137    price_type: PriceType::Last,
138};
139
140pub const BAR_SPEC_1_MONTH_LAST: BarSpecification = BarSpecification {
141    step: NonZero::new(1).unwrap(),
142    aggregation: BarAggregation::Month,
143    price_type: PriceType::Last,
144};
145
146pub const BAR_SPEC_3_MONTH_LAST: BarSpecification = BarSpecification {
147    step: NonZero::new(3).unwrap(),
148    aggregation: BarAggregation::Month,
149    price_type: PriceType::Last,
150};
151
152pub const BAR_SPEC_6_MONTH_LAST: BarSpecification = BarSpecification {
153    step: NonZero::new(6).unwrap(),
154    aggregation: BarAggregation::Month,
155    price_type: PriceType::Last,
156};
157
158pub const BAR_SPEC_12_MONTH_LAST: BarSpecification = BarSpecification {
159    step: NonZero::new(12).unwrap(),
160    aggregation: BarAggregation::Month,
161    price_type: PriceType::Last,
162};
163
164/// Returns the bar interval as a [`SignedDuration`].
165///
166/// # Panics
167///
168/// Panics if the aggregation method of the given `bar_type` is not time based,
169/// or if `step` is too large for the interval arithmetic.
170#[must_use]
171pub fn get_bar_interval(bar_type: &BarType) -> SignedDuration {
172    let spec = bar_type.spec();
173    let step = step_to_i64(spec.step);
174
175    match spec.aggregation {
176        BarAggregation::Millisecond => SignedDuration::from_millis(step),
177        BarAggregation::Second => SignedDuration::from_secs(step),
178        BarAggregation::Minute => SignedDuration::from_mins(step),
179        BarAggregation::Hour => SignedDuration::from_hours(step),
180        BarAggregation::Day => duration_days(step),
181        BarAggregation::Week => {
182            duration_days(step.checked_mul(7).expect("`step` overflows i64 days"))
183        }
184        BarAggregation::Month => {
185            // Proxy for comparing bar lengths
186            duration_days(step.checked_mul(30).expect("`step` overflows i64 days"))
187        }
188        BarAggregation::Year => {
189            // Proxy for comparing bar lengths
190            duration_days(step.checked_mul(365).expect("`step` overflows i64 days"))
191        }
192        _ => panic!("Aggregation not time based"),
193    }
194}
195
196/// Returns the bar interval as [`DurationNanos`].
197///
198/// # Panics
199///
200/// Panics if the aggregation method of the given `bar_type` is not time based.
201#[must_use]
202pub fn get_bar_interval_ns(bar_type: &BarType) -> DurationNanos {
203    DurationNanos::try_from(get_bar_interval(bar_type)).expect("Invalid bar interval")
204}
205
206/// Returns the time bar start as a timezone-aware `Timestamp`.
207///
208/// # Panics
209///
210/// Panics if computing the base civil date or datetime from `now` fails,
211/// if `step` cannot be represented for the calendar arithmetic,
212/// or if the aggregation type is unsupported.
213#[must_use]
214pub fn get_time_bar_start(
215    now: Timestamp,
216    bar_type: &BarType,
217    time_bars_origin: Option<SignedDuration>,
218) -> Timestamp {
219    let spec = bar_type.spec();
220    let step = step_to_i64(spec.step);
221    let origin_offset = time_bars_origin.unwrap_or(SignedDuration::ZERO);
222
223    match spec.aggregation {
224        BarAggregation::Millisecond => {
225            find_closest_smaller_time(now, origin_offset, SignedDuration::from_millis(step))
226        }
227        BarAggregation::Second => {
228            find_closest_smaller_time(now, origin_offset, SignedDuration::from_secs(step))
229        }
230        BarAggregation::Minute => {
231            find_closest_smaller_time(now, origin_offset, SignedDuration::from_mins(step))
232        }
233        BarAggregation::Hour => {
234            find_closest_smaller_time(now, origin_offset, SignedDuration::from_hours(step))
235        }
236        BarAggregation::Day => find_closest_smaller_time(now, origin_offset, duration_days(step)),
237        BarAggregation::Week => {
238            let now_civil = Offset::UTC.to_datetime(now);
239            let days_from_monday = i64::from(now_civil.weekday().to_monday_zero_offset());
240            let week_start_date = now_civil
241                .date()
242                .checked_sub(jiff::Span::new().days(days_from_monday))
243                .expect("valid week start");
244            let mut start_time = Offset::UTC
245                .to_timestamp(week_start_date.at(0, 0, 0, 0))
246                .expect("valid UTC week start");
247            start_time += origin_offset;
248
249            if now < start_time {
250                start_time -=
251                    duration_days(step.checked_mul(7).expect("`step` overflows i64 days"));
252            }
253
254            start_time
255        }
256        BarAggregation::Month => {
257            // Set to the first day of the year
258            let now_civil = Offset::UTC.to_datetime(now);
259            let mut start_time = Offset::UTC
260                .to_timestamp(
261                    Date::new(now_civil.year(), 1, 1)
262                        .expect("valid year start date")
263                        .at(0, 0, 0, 0),
264                )
265                .expect("valid UTC year start");
266            start_time += origin_offset;
267
268            if now < start_time {
269                start_time =
270                    subtract_n_months(start_time, 12).expect("Failed to subtract 12 months");
271            }
272
273            let months_step =
274                u32::try_from(step).expect("`step` exceeds u32 range for month arithmetic");
275
276            while start_time <= now {
277                start_time =
278                    add_n_months(start_time, months_step).expect("Failed to add months in loop");
279            }
280
281            start_time =
282                subtract_n_months(start_time, months_step).expect("Failed to subtract months_step");
283            start_time
284        }
285        BarAggregation::Year => {
286            let step_i32 =
287                i32::try_from(step).expect("`step` exceeds i32 range for year arithmetic");
288
289            // Reconstruct from Jan 1 + origin each time to avoid leap-day drift
290            let year_start = |year: i32| {
291                let year = i16::try_from(year).expect("year exceeds Jiff supported range");
292                Offset::UTC
293                    .to_timestamp(
294                        Date::new(year, 1, 1)
295                            .expect("valid year start date")
296                            .at(0, 0, 0, 0),
297                    )
298                    .expect("valid UTC year start")
299                    + origin_offset
300            };
301
302            let mut year = i32::from(Offset::UTC.to_datetime(now).year());
303            if year_start(year) > now {
304                year = year
305                    .checked_sub(step_i32)
306                    .expect("year arithmetic underflow");
307            }
308
309            loop {
310                let next_year = year
311                    .checked_add(step_i32)
312                    .expect("year arithmetic overflow");
313
314                if year_start(next_year) > now {
315                    break;
316                }
317                year = next_year;
318            }
319
320            year_start(year)
321        }
322        _ => panic!(
323            "Aggregation type {} not supported for time bars",
324            spec.aggregation
325        ),
326    }
327}
328
329/// Finds the closest smaller time based on a daily time origin and period.
330///
331/// This function calculates the most recent time that is aligned with the given period
332/// and is less than or equal to the current time.
333fn find_closest_smaller_time(
334    now: Timestamp,
335    daily_time_origin: SignedDuration,
336    period: SignedDuration,
337) -> Timestamp {
338    // Floor to start of day
339    let day_start = Offset::UTC
340        .to_timestamp(Offset::UTC.to_datetime(now).date().at(0, 0, 0, 0))
341        .expect("valid UTC day start");
342    let base_time = day_start + daily_time_origin;
343
344    let time_difference = base_time.duration_until(now);
345    let period_ns = period.as_nanos();
346    debug_assert_ne!(period_ns, 0, "bar period must be non-zero");
347
348    // Use div_euclid for floor division (rounds toward -inf, not zero)
349    // so negative deltas (now before origin) yield the previous period boundary
350    let num_periods = time_difference.as_nanos().div_euclid(period_ns);
351
352    base_time + SignedDuration::from_nanos_i128(num_periods * period_ns)
353}
354
355fn duration_days(days: i64) -> SignedDuration {
356    try_duration_days(days).unwrap_or_else(|e| panic!("{e}"))
357}
358
359fn try_duration_days(days: i64) -> anyhow::Result<SignedDuration> {
360    let hours = days
361        .checked_mul(24)
362        .ok_or_else(|| anyhow::anyhow!("days overflow i64 hours"))?;
363    SignedDuration::try_from_hours(hours)
364        .ok_or_else(|| anyhow::anyhow!("days exceed signed duration range"))
365}
366
367fn try_time_interval(step: usize, aggregation: BarAggregation) -> anyhow::Result<SignedDuration> {
368    let step_i64 = i64::try_from(step)
369        .map_err(|_| invalid_interval_step(step, aggregation, "step exceeds i64 range"))?;
370
371    let duration = match aggregation {
372        BarAggregation::Millisecond => SignedDuration::from_millis(step_i64),
373        BarAggregation::Second => SignedDuration::from_secs(step_i64),
374        BarAggregation::Minute => SignedDuration::try_from_mins(step_i64).ok_or_else(|| {
375            invalid_interval_step(step, aggregation, "step exceeds signed duration range")
376        })?,
377        BarAggregation::Hour => SignedDuration::try_from_hours(step_i64).ok_or_else(|| {
378            invalid_interval_step(step, aggregation, "step exceeds signed duration range")
379        })?,
380        BarAggregation::Day => try_scaled_days(step, aggregation, step_i64, 1)?,
381        BarAggregation::Week => try_scaled_days(step, aggregation, step_i64, 7)?,
382        BarAggregation::Month => try_scaled_days(step, aggregation, step_i64, 30)?,
383        BarAggregation::Year => try_scaled_days(step, aggregation, step_i64, 365)?,
384        _ => anyhow::bail!("Timedelta not supported for aggregation type: {aggregation:?}"),
385    };
386
387    u64::try_from(duration.as_nanos())
388        .map_err(|_| invalid_interval_step(step, aggregation, "interval overflows nanoseconds"))?;
389
390    Ok(duration)
391}
392
393fn try_scaled_days(
394    step: usize,
395    aggregation: BarAggregation,
396    step_i64: i64,
397    multiplier: i64,
398) -> anyhow::Result<SignedDuration> {
399    let days = step_i64
400        .checked_mul(multiplier)
401        .ok_or_else(|| invalid_interval_step(step, aggregation, "step overflows i64 days"))?;
402    try_duration_days(days).map_err(|e| invalid_interval_step(step, aggregation, &e.to_string()))
403}
404
405fn invalid_interval_step(step: usize, aggregation: BarAggregation, reason: &str) -> anyhow::Error {
406    anyhow::anyhow!(
407        "Invalid step in bar_type.spec.step: {step} for aggregation={aggregation}. {reason}"
408    )
409}
410
411/// Converts a bar specification step to `i64` for time arithmetic.
412///
413/// # Panics
414///
415/// Panics if `step` exceeds the `i64` range.
416fn step_to_i64(step: NonZeroUsize) -> i64 {
417    i64::try_from(step.get()).expect("`step` exceeds i64 range")
418}
419
420/// Represents a bar aggregation specification including a step, aggregation
421/// method/rule and price type.
422#[repr(C)]
423#[derive(
424    Clone, Copy, Hash, PartialEq, Eq, PartialOrd, Ord, Debug, Serialize, Deserialize, Builder,
425)]
426#[builder(build_fn(validate = "Self::validate"))]
427#[serde(try_from = "BarSpecificationFields")]
428#[cfg_attr(
429    feature = "python",
430    pyo3::pyclass(module = "nautilus_trader.model", from_py_object)
431)]
432#[cfg_attr(
433    feature = "python",
434    pyo3_stub_gen::derive::gen_stub_pyclass(module = "nautilus_trader.model")
435)]
436pub struct BarSpecification {
437    /// The step for binning samples for bar aggregation.
438    pub step: NonZeroUsize,
439    /// The type of bar aggregation.
440    pub aggregation: BarAggregation,
441    /// The price type to use for aggregation.
442    pub price_type: PriceType,
443}
444
445impl BarSpecificationBuilder {
446    fn validate(&self) -> Result<(), String> {
447        if let (Some(step), Some(aggregation)) = (self.step, self.aggregation) {
448            BarSpecification::validate_step(step.get(), aggregation).map_err(|e| e.to_string())?;
449        }
450
451        Ok(())
452    }
453}
454
455// Deserialization mirror routing through `new_checked` so serde inputs
456// cannot bypass step validation
457#[derive(Deserialize)]
458struct BarSpecificationFields {
459    step: NonZeroUsize,
460    aggregation: BarAggregation,
461    price_type: PriceType,
462}
463
464impl TryFrom<BarSpecificationFields> for BarSpecification {
465    type Error = anyhow::Error;
466
467    fn try_from(fields: BarSpecificationFields) -> Result<Self, Self::Error> {
468        Self::new_checked(fields.step.get(), fields.aggregation, fields.price_type)
469    }
470}
471
472impl BarSpecification {
473    /// Creates a new [`BarSpecification`] instance with correctness checking.
474    ///
475    /// # Errors
476    ///
477    /// Returns an error if `step` is not positive (> 0), if `step` is not
478    /// valid for a fixed-subunit time aggregation, or if a time-aggregated
479    /// `step` overflows the representable duration or nanosecond interval.
480    ///
481    /// # Notes
482    ///
483    /// PyO3 requires a `Result` type for proper error handling and stacktrace printing in Python.
484    pub fn new_checked(
485        step: usize,
486        aggregation: BarAggregation,
487        price_type: PriceType,
488    ) -> anyhow::Result<Self> {
489        let step = NonZeroUsize::new(step)
490            .ok_or(anyhow::anyhow!("Invalid step: {step} (must be non-zero)"))?;
491        Self::validate_step(step.get(), aggregation)?;
492
493        Ok(Self {
494            step,
495            aggregation,
496            price_type,
497        })
498    }
499
500    fn validate_step(step: usize, aggregation: BarAggregation) -> anyhow::Result<()> {
501        match aggregation {
502            BarAggregation::Millisecond => {
503                Self::validate_periodic_step(step, aggregation, 1000, false)?;
504            }
505            BarAggregation::Second | BarAggregation::Minute => {
506                Self::validate_periodic_step(step, aggregation, 60, false)?;
507            }
508            BarAggregation::Hour => Self::validate_periodic_step(step, aggregation, 24, false)?,
509            // 12-MONTH is allowed (unlike other full-subunit steps) because the shipped
510            // BAR_SPEC_12_MONTH_LAST constant and OKX yearly candles depend on it
511            BarAggregation::Month => Self::validate_periodic_step(step, aggregation, 12, true)?,
512            BarAggregation::Day | BarAggregation::Week | BarAggregation::Year => {}
513            _ => return Ok(()),
514        }
515
516        try_time_interval(step, aggregation).map(|_| ())
517    }
518
519    fn validate_periodic_step(
520        step: usize,
521        aggregation: BarAggregation,
522        subunits: usize,
523        allow_equal: bool,
524    ) -> anyhow::Result<()> {
525        if !subunits.is_multiple_of(step) {
526            anyhow::bail!(
527                "Invalid step in bar_type.spec.step: {step} for aggregation={aggregation}. \
528                 step must evenly divide {subunits} (so it is periodic).",
529            );
530        }
531
532        if !allow_equal && subunits == step {
533            anyhow::bail!(
534                "Invalid step in bar_type.spec.step: {step} for aggregation={aggregation}. \
535                 step must not be {subunits}. Use higher aggregation unit instead.",
536            );
537        }
538
539        Ok(())
540    }
541
542    /// Creates a new [`BarSpecification`] instance.
543    ///
544    /// # Panics
545    ///
546    /// Panics if `step` is not positive (> 0), if `step` is not valid for
547    /// a fixed-subunit time aggregation, or if a time-aggregated `step`
548    /// overflows the representable duration or nanosecond interval.
549    #[must_use]
550    pub fn new(step: usize, aggregation: BarAggregation, price_type: PriceType) -> Self {
551        Self::new_checked(step, aggregation, price_type).expect(FAILED)
552    }
553
554    /// Returns the [`SignedDuration`] interval for this bar specification.
555    ///
556    /// # Notes
557    ///
558    /// For [`BarAggregation::Month`] and [`BarAggregation::Year`], proxy values are used
559    /// (30 days for months, 365 days for years) to estimate their respective durations,
560    /// since months and years have variable lengths.
561    ///
562    /// # Panics
563    ///
564    /// Panics if the aggregation method is not time-based, or if `step` is too
565    /// large for the interval arithmetic.
566    #[must_use]
567    pub fn timedelta(&self) -> SignedDuration {
568        let step = step_to_i64(self.step);
569
570        match self.aggregation {
571            BarAggregation::Millisecond => SignedDuration::from_millis(step),
572            BarAggregation::Second => SignedDuration::from_secs(step),
573            BarAggregation::Minute => SignedDuration::from_mins(step),
574            BarAggregation::Hour => SignedDuration::from_hours(step),
575            BarAggregation::Day => duration_days(step),
576            BarAggregation::Week => {
577                duration_days(step.checked_mul(7).expect("`step` overflows i64 days"))
578            }
579            BarAggregation::Month => {
580                // Proxy for comparing bar lengths
581                duration_days(step.checked_mul(30).expect("`step` overflows i64 days"))
582            }
583            BarAggregation::Year => {
584                // Proxy for comparing bar lengths
585                duration_days(step.checked_mul(365).expect("`step` overflows i64 days"))
586            }
587            _ => panic!(
588                "Timedelta not supported for aggregation type: {:?}",
589                self.aggregation
590            ),
591        }
592    }
593
594    /// Return a value indicating whether the aggregation method is time-driven:
595    ///  - [`BarAggregation::Millisecond`]
596    ///  - [`BarAggregation::Second`]
597    ///  - [`BarAggregation::Minute`]
598    ///  - [`BarAggregation::Hour`]
599    ///  - [`BarAggregation::Day`]
600    ///  - [`BarAggregation::Week`]
601    ///  - [`BarAggregation::Month`]
602    ///  - [`BarAggregation::Year`]
603    #[must_use]
604    pub fn is_time_aggregated(&self) -> bool {
605        matches!(
606            self.aggregation,
607            BarAggregation::Millisecond
608                | BarAggregation::Second
609                | BarAggregation::Minute
610                | BarAggregation::Hour
611                | BarAggregation::Day
612                | BarAggregation::Week
613                | BarAggregation::Month
614                | BarAggregation::Year
615        )
616    }
617
618    /// Return a value indicating whether the aggregation method is threshold-driven:
619    ///  - [`BarAggregation::Tick`]
620    ///  - [`BarAggregation::TickImbalance`]
621    ///  - [`BarAggregation::Volume`]
622    ///  - [`BarAggregation::VolumeImbalance`]
623    ///  - [`BarAggregation::Value`]
624    ///  - [`BarAggregation::ValueImbalance`]
625    #[must_use]
626    pub fn is_threshold_aggregated(&self) -> bool {
627        matches!(
628            self.aggregation,
629            BarAggregation::Tick
630                | BarAggregation::TickImbalance
631                | BarAggregation::Volume
632                | BarAggregation::VolumeImbalance
633                | BarAggregation::Value
634                | BarAggregation::ValueImbalance
635        )
636    }
637
638    /// Return a value indicating whether the aggregation method is information-driven:
639    ///  - [`BarAggregation::TickRuns`]
640    ///  - [`BarAggregation::VolumeRuns`]
641    ///  - [`BarAggregation::ValueRuns`]
642    #[must_use]
643    pub fn is_information_aggregated(&self) -> bool {
644        matches!(
645            self.aggregation,
646            BarAggregation::TickRuns | BarAggregation::VolumeRuns | BarAggregation::ValueRuns
647        )
648    }
649}
650
651impl Display for BarSpecification {
652    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
653        write!(f, "{}-{}-{}", self.step, self.aggregation, self.price_type)
654    }
655}
656
657/// Represents a bar type including the instrument ID, bar specification and
658/// aggregation source.
659#[repr(C)]
660#[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord, Hash)]
661#[cfg_attr(
662    feature = "python",
663    pyo3::pyclass(module = "nautilus_trader.model", from_py_object)
664)]
665#[cfg_attr(
666    feature = "python",
667    pyo3_stub_gen::derive::gen_stub_pyclass_enum(module = "nautilus_trader.model")
668)]
669pub enum BarType {
670    Standard {
671        /// The bar type's instrument ID.
672        instrument_id: InstrumentId,
673        /// The bar type's specification.
674        spec: BarSpecification,
675        /// The bar type's aggregation source.
676        aggregation_source: AggregationSource,
677    },
678    Composite {
679        /// The bar type's instrument ID.
680        instrument_id: InstrumentId,
681        /// The bar type's specification.
682        spec: BarSpecification,
683        /// The bar type's aggregation source.
684        aggregation_source: AggregationSource,
685
686        /// The composite step for binning samples for bar aggregation.
687        composite_step: usize,
688        /// The composite type of bar aggregation.
689        composite_aggregation: BarAggregation,
690        /// The composite bar type's aggregation source.
691        composite_aggregation_source: AggregationSource,
692    },
693}
694
695impl BarType {
696    /// Creates a new [`BarType`] instance.
697    #[must_use]
698    pub fn new(
699        instrument_id: InstrumentId,
700        spec: BarSpecification,
701        aggregation_source: AggregationSource,
702    ) -> Self {
703        Self::Standard {
704            instrument_id,
705            spec,
706            aggregation_source,
707        }
708    }
709
710    /// Creates a new composite [`BarType`] instance with correctness checking.
711    ///
712    /// # Errors
713    ///
714    /// Returns an error if the composite specification is invalid, i.e. `composite_step`
715    /// is not positive (> 0) or is not valid for a fixed-subunit time aggregation.
716    pub fn new_composite_checked(
717        instrument_id: InstrumentId,
718        spec: BarSpecification,
719        aggregation_source: AggregationSource,
720
721        composite_step: usize,
722        composite_aggregation: BarAggregation,
723        composite_aggregation_source: AggregationSource,
724    ) -> anyhow::Result<Self> {
725        // Validate eagerly so `composite()` cannot panic later
726        BarSpecification::new_checked(composite_step, composite_aggregation, spec.price_type)?;
727
728        Ok(Self::Composite {
729            instrument_id,
730            spec,
731            aggregation_source,
732
733            composite_step,
734            composite_aggregation,
735            composite_aggregation_source,
736        })
737    }
738
739    /// Creates a new composite [`BarType`] instance.
740    ///
741    /// # Panics
742    ///
743    /// Panics if the composite specification is invalid, i.e. `composite_step` is not
744    /// positive (> 0) or is not valid for a fixed-subunit time aggregation.
745    #[must_use]
746    pub fn new_composite(
747        instrument_id: InstrumentId,
748        spec: BarSpecification,
749        aggregation_source: AggregationSource,
750
751        composite_step: usize,
752        composite_aggregation: BarAggregation,
753        composite_aggregation_source: AggregationSource,
754    ) -> Self {
755        Self::new_composite_checked(
756            instrument_id,
757            spec,
758            aggregation_source,
759            composite_step,
760            composite_aggregation,
761            composite_aggregation_source,
762        )
763        .expect(FAILED)
764    }
765
766    /// Returns whether this instance is a standard bar type.
767    #[must_use]
768    pub fn is_standard(&self) -> bool {
769        matches!(self, Self::Standard { .. })
770    }
771
772    /// Returns whether this instance is a composite bar type.
773    #[must_use]
774    pub fn is_composite(&self) -> bool {
775        matches!(self, Self::Composite { .. })
776    }
777
778    /// Returns whether the bar aggregation source is `EXTERNAL`.
779    #[must_use]
780    pub fn is_externally_aggregated(&self) -> bool {
781        self.aggregation_source() == AggregationSource::External
782    }
783
784    /// Returns whether the bar aggregation source is `INTERNAL`.
785    #[must_use]
786    pub fn is_internally_aggregated(&self) -> bool {
787        self.aggregation_source() == AggregationSource::Internal
788    }
789
790    /// Returns the standard bar type component.
791    #[must_use]
792    pub fn standard(&self) -> Self {
793        match self {
794            &b @ Self::Standard { .. } => b,
795            Self::Composite {
796                instrument_id,
797                spec,
798                aggregation_source,
799                ..
800            } => Self::new(*instrument_id, *spec, *aggregation_source),
801        }
802    }
803
804    /// Returns any composite bar type component.
805    #[must_use]
806    pub fn composite(&self) -> Self {
807        match self {
808            &b @ Self::Standard { .. } => b, // case shouldn't be used if is_composite is called before
809            Self::Composite {
810                instrument_id,
811                spec,
812                aggregation_source: _,
813
814                composite_step,
815                composite_aggregation,
816                composite_aggregation_source,
817            } => Self::new(
818                *instrument_id,
819                BarSpecification::new(*composite_step, *composite_aggregation, spec.price_type),
820                *composite_aggregation_source,
821            ),
822        }
823    }
824
825    /// Returns the [`InstrumentId`] for this bar type.
826    #[must_use]
827    pub fn instrument_id(&self) -> InstrumentId {
828        match &self {
829            Self::Standard { instrument_id, .. } | Self::Composite { instrument_id, .. } => {
830                *instrument_id
831            }
832        }
833    }
834
835    /// Returns the [`BarSpecification`] for this bar type.
836    #[must_use]
837    pub fn spec(&self) -> BarSpecification {
838        match &self {
839            Self::Standard { spec, .. } | Self::Composite { spec, .. } => *spec,
840        }
841    }
842
843    /// Returns the [`AggregationSource`] for this bar type.
844    #[must_use]
845    pub fn aggregation_source(&self) -> AggregationSource {
846        match &self {
847            Self::Standard {
848                aggregation_source, ..
849            }
850            | Self::Composite {
851                aggregation_source, ..
852            } => *aggregation_source,
853        }
854    }
855
856    /// Returns the instrument ID and bar specification as a tuple key.
857    ///
858    /// Useful as a hashmap key when aggregation source should be ignored,
859    /// such as for indicator registration where INTERNAL and EXTERNAL bars
860    /// should trigger the same indicators.
861    #[must_use]
862    pub fn id_spec_key(&self) -> (InstrumentId, BarSpecification) {
863        (self.instrument_id(), self.spec())
864    }
865}
866
867#[derive(thiserror::Error, Debug)]
868#[error("Error parsing `BarType` from '{input}', invalid token: '{token}' at position {position}")]
869pub struct BarTypeParseError {
870    input: String,
871    token: String,
872    position: usize,
873}
874
875impl FromStr for BarType {
876    type Err = BarTypeParseError;
877
878    #[expect(clippy::needless_collect)] // Collect needed for .rev() and indexing
879    fn from_str(s: &str) -> Result<Self, Self::Err> {
880        let parts: Vec<&str> = s.split('@').collect();
881        if parts.len() > 2 {
882            return Err(BarTypeParseError {
883                input: s.to_string(),
884                token: parts[2].to_string(),
885                position: 5,
886            });
887        }
888        let standard = parts[0];
889        let composite_str = parts.get(1);
890
891        let pieces: Vec<&str> = standard.rsplitn(5, '-').collect();
892        let rev_pieces: Vec<&str> = pieces.into_iter().rev().collect();
893        if rev_pieces.len() != 5 {
894            return Err(BarTypeParseError {
895                input: s.to_string(),
896                token: String::new(),
897                position: 0,
898            });
899        }
900
901        let instrument_id =
902            InstrumentId::from_str(rev_pieces[0]).map_err(|_| BarTypeParseError {
903                input: s.to_string(),
904                token: rev_pieces[0].to_string(),
905                position: 0,
906            })?;
907
908        let step = rev_pieces[1].parse().map_err(|_| BarTypeParseError {
909            input: s.to_string(),
910            token: rev_pieces[1].to_string(),
911            position: 1,
912        })?;
913        let aggregation =
914            BarAggregation::from_str(rev_pieces[2]).map_err(|_| BarTypeParseError {
915                input: s.to_string(),
916                token: rev_pieces[2].to_string(),
917                position: 2,
918            })?;
919        let price_type = PriceType::from_str(rev_pieces[3]).map_err(|_| BarTypeParseError {
920            input: s.to_string(),
921            token: rev_pieces[3].to_string(),
922            position: 3,
923        })?;
924        let aggregation_source =
925            AggregationSource::from_str(rev_pieces[4]).map_err(|_| BarTypeParseError {
926                input: s.to_string(),
927                token: rev_pieces[4].to_string(),
928                position: 4,
929            })?;
930        let spec = BarSpecification::new_checked(step, aggregation, price_type).map_err(|_| {
931            BarTypeParseError {
932                input: s.to_string(),
933                token: rev_pieces[1].to_string(),
934                position: 1,
935            }
936        })?;
937
938        if let Some(composite_str) = composite_str {
939            let composite_pieces: Vec<&str> = composite_str.rsplitn(3, '-').collect();
940            let rev_composite_pieces: Vec<&str> = composite_pieces.into_iter().rev().collect();
941            if rev_composite_pieces.len() != 3 {
942                return Err(BarTypeParseError {
943                    input: s.to_string(),
944                    token: String::new(),
945                    position: 5,
946                });
947            }
948
949            let composite_step =
950                rev_composite_pieces[0]
951                    .parse()
952                    .map_err(|_| BarTypeParseError {
953                        input: s.to_string(),
954                        token: rev_composite_pieces[0].to_string(),
955                        position: 5,
956                    })?;
957            let composite_aggregation =
958                BarAggregation::from_str(rev_composite_pieces[1]).map_err(|_| {
959                    BarTypeParseError {
960                        input: s.to_string(),
961                        token: rev_composite_pieces[1].to_string(),
962                        position: 6,
963                    }
964                })?;
965            let composite_aggregation_source = AggregationSource::from_str(rev_composite_pieces[2])
966                .map_err(|_| BarTypeParseError {
967                    input: s.to_string(),
968                    token: rev_composite_pieces[2].to_string(),
969                    position: 7,
970                })?;
971            BarSpecification::new_checked(composite_step, composite_aggregation, price_type)
972                .map_err(|_| BarTypeParseError {
973                    input: s.to_string(),
974                    token: rev_composite_pieces[0].to_string(),
975                    position: 5,
976                })?;
977
978            Ok(Self::new_composite(
979                instrument_id,
980                spec,
981                aggregation_source,
982                composite_step,
983                composite_aggregation,
984                composite_aggregation_source,
985            ))
986        } else {
987            Ok(Self::Standard {
988                instrument_id,
989                spec,
990                aggregation_source,
991            })
992        }
993    }
994}
995
996impl<T: AsRef<str>> From<T> for BarType {
997    fn from(value: T) -> Self {
998        Self::from_str(value.as_ref()).expect(FAILED)
999    }
1000}
1001
1002impl Display for BarType {
1003    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
1004        match &self {
1005            Self::Standard {
1006                instrument_id,
1007                spec,
1008                aggregation_source,
1009            } => {
1010                write!(f, "{instrument_id}-{spec}-{aggregation_source}")
1011            }
1012            Self::Composite {
1013                instrument_id,
1014                spec,
1015                aggregation_source,
1016
1017                composite_step,
1018                composite_aggregation,
1019                composite_aggregation_source,
1020            } => {
1021                write!(
1022                    f,
1023                    "{}-{}-{}@{}-{}-{}",
1024                    instrument_id,
1025                    spec,
1026                    aggregation_source,
1027                    *composite_step,
1028                    *composite_aggregation,
1029                    *composite_aggregation_source
1030                )
1031            }
1032        }
1033    }
1034}
1035
1036impl Serialize for BarType {
1037    fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
1038    where
1039        S: Serializer,
1040    {
1041        serializer.serialize_str(&self.to_string())
1042    }
1043}
1044
1045impl<'de> Deserialize<'de> for BarType {
1046    fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
1047    where
1048        D: Deserializer<'de>,
1049    {
1050        let s: std::borrow::Cow<'de, str> = Deserialize::deserialize(deserializer)?;
1051        Self::from_str(s.as_ref()).map_err(serde::de::Error::custom)
1052    }
1053}
1054
1055/// Represents an aggregated bar.
1056#[repr(C)]
1057#[derive(Clone, Copy, Hash, PartialEq, Eq, Debug, Serialize, Deserialize)]
1058#[serde(tag = "type", try_from = "BarFields")]
1059#[cfg_attr(
1060    feature = "python",
1061    pyo3::pyclass(module = "nautilus_trader.model", from_py_object)
1062)]
1063#[cfg_attr(
1064    feature = "python",
1065    pyo3_stub_gen::derive::gen_stub_pyclass(module = "nautilus_trader.model")
1066)]
1067pub struct Bar {
1068    /// The bar type for this bar.
1069    pub bar_type: BarType,
1070    /// The bars open price.
1071    pub open: Price,
1072    /// The bars high price.
1073    pub high: Price,
1074    /// The bars low price.
1075    pub low: Price,
1076    /// The bars close price.
1077    pub close: Price,
1078    /// The bars volume.
1079    pub volume: Quantity,
1080    /// UNIX timestamp (nanoseconds) when the data event occurred.
1081    pub ts_event: UnixNanos,
1082    /// UNIX timestamp (nanoseconds) when the instance was created.
1083    pub ts_init: UnixNanos,
1084}
1085
1086// Deserialization mirror routing through `new_checked` so serde inputs
1087// cannot bypass OHLC validation
1088#[derive(Deserialize)]
1089struct BarFields {
1090    bar_type: BarType,
1091    open: Price,
1092    high: Price,
1093    low: Price,
1094    close: Price,
1095    volume: Quantity,
1096    ts_event: UnixNanos,
1097    ts_init: UnixNanos,
1098}
1099
1100impl TryFrom<BarFields> for Bar {
1101    type Error = anyhow::Error;
1102
1103    fn try_from(fields: BarFields) -> Result<Self, Self::Error> {
1104        Self::new_checked(
1105            fields.bar_type,
1106            fields.open,
1107            fields.high,
1108            fields.low,
1109            fields.close,
1110            fields.volume,
1111            fields.ts_event,
1112            fields.ts_init,
1113        )
1114    }
1115}
1116
1117impl Bar {
1118    /// Creates a new [`Bar`] instance with correctness checking.
1119    ///
1120    /// # Errors
1121    ///
1122    /// Returns an error if:
1123    /// - `high` is not >= `open`.
1124    /// - `high` is not >= `low`.
1125    /// - `high` is not >= `close`.
1126    /// - `low` is not <= `open`.
1127    /// - `low` is not <= `close`.
1128    ///
1129    /// # Notes
1130    ///
1131    /// PyO3 requires a `Result` type for proper error handling and stacktrace printing in Python.
1132    #[expect(clippy::too_many_arguments)]
1133    pub fn new_checked(
1134        bar_type: BarType,
1135        open: Price,
1136        high: Price,
1137        low: Price,
1138        close: Price,
1139        volume: Quantity,
1140        ts_event: UnixNanos,
1141        ts_init: UnixNanos,
1142    ) -> anyhow::Result<Self> {
1143        check_predicate_true(high >= open, "high >= open")?;
1144        check_predicate_true(high >= low, "high >= low")?;
1145        check_predicate_true(high >= close, "high >= close")?;
1146        check_predicate_true(low <= close, "low <= close")?;
1147        check_predicate_true(low <= open, "low <= open")?;
1148
1149        debug_assert!(
1150            open.precision == high.precision
1151                && open.precision == low.precision
1152                && open.precision == close.precision,
1153            "Bar prices must share a uniform precision (Arrow encoding assumes it)"
1154        );
1155
1156        Ok(Self {
1157            bar_type,
1158            open,
1159            high,
1160            low,
1161            close,
1162            volume,
1163            ts_event,
1164            ts_init,
1165        })
1166    }
1167
1168    /// Creates a new [`Bar`] instance.
1169    ///
1170    /// # Panics
1171    ///
1172    /// This function panics if:
1173    /// - `high` is not >= `open`.
1174    /// - `high` is not >= `low`.
1175    /// - `high` is not >= `close`.
1176    /// - `low` is not <= `open`.
1177    /// - `low` is not <= `close`.
1178    #[expect(clippy::too_many_arguments)]
1179    #[must_use]
1180    pub fn new(
1181        bar_type: BarType,
1182        open: Price,
1183        high: Price,
1184        low: Price,
1185        close: Price,
1186        volume: Quantity,
1187        ts_event: UnixNanos,
1188        ts_init: UnixNanos,
1189    ) -> Self {
1190        Self::new_checked(bar_type, open, high, low, close, volume, ts_event, ts_init)
1191            .expect(FAILED)
1192    }
1193
1194    #[must_use]
1195    pub fn instrument_id(&self) -> InstrumentId {
1196        self.bar_type.instrument_id()
1197    }
1198
1199    /// Returns the metadata for the type, for use with serialization formats.
1200    #[must_use]
1201    pub fn get_metadata(
1202        bar_type: &BarType,
1203        price_precision: u8,
1204        size_precision: u8,
1205    ) -> HashMap<String, String> {
1206        let mut metadata = HashMap::new();
1207        let instrument_id = bar_type.instrument_id();
1208        metadata.insert("bar_type".to_string(), bar_type.to_string());
1209        metadata.insert("instrument_id".to_string(), instrument_id.to_string());
1210        metadata.insert("price_precision".to_string(), price_precision.to_string());
1211        metadata.insert("size_precision".to_string(), size_precision.to_string());
1212        metadata
1213    }
1214
1215    /// Returns the field map for the type, for use with Arrow schemas.
1216    #[must_use]
1217    pub fn get_fields() -> IndexMap<String, String> {
1218        let mut metadata = IndexMap::new();
1219        metadata.insert("open".to_string(), FIXED_SIZE_BINARY.to_string());
1220        metadata.insert("high".to_string(), FIXED_SIZE_BINARY.to_string());
1221        metadata.insert("low".to_string(), FIXED_SIZE_BINARY.to_string());
1222        metadata.insert("close".to_string(), FIXED_SIZE_BINARY.to_string());
1223        metadata.insert("volume".to_string(), FIXED_SIZE_BINARY.to_string());
1224        metadata.insert("ts_event".to_string(), "UInt64".to_string());
1225        metadata.insert("ts_init".to_string(), "UInt64".to_string());
1226        metadata
1227    }
1228}
1229
1230impl Display for Bar {
1231    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
1232        write!(
1233            f,
1234            "{},{},{},{},{},{},{}",
1235            self.bar_type, self.open, self.high, self.low, self.close, self.volume, self.ts_event
1236        )
1237    }
1238}
1239
1240impl Serializable for Bar {}
1241
1242impl HasTsInit for Bar {
1243    fn ts_init(&self) -> UnixNanos {
1244        self.ts_init
1245    }
1246}
1247
1248#[cfg(test)]
1249mod tests {
1250    use std::str::FromStr;
1251
1252    use nautilus_core::serialization::msgpack::{FromMsgPack, ToMsgPack};
1253    use rstest::rstest;
1254
1255    use super::*;
1256    use crate::identifiers::{Symbol, Venue};
1257
1258    fn timestamp(value: &str) -> Timestamp {
1259        value.parse().unwrap()
1260    }
1261
1262    #[rstest]
1263    fn test_bar_specification_new_invalid() {
1264        let result = BarSpecification::new_checked(0, BarAggregation::Tick, PriceType::Last);
1265        assert!(
1266            result
1267                .unwrap_err()
1268                .to_string()
1269                .contains("Invalid step: 0 (must be non-zero)")
1270        );
1271    }
1272
1273    #[rstest]
1274    #[should_panic(expected = "Invalid step: 0 (must be non-zero)")]
1275    fn test_bar_specification_new_checked_with_invalid_step_panics() {
1276        let aggregation = BarAggregation::Tick;
1277        let price_type = PriceType::Last;
1278
1279        let _ = BarSpecification::new(0, aggregation, price_type);
1280    }
1281
1282    #[rstest]
1283    #[should_panic(expected = "Invalid step in bar_type.spec.step: 7")]
1284    fn test_bar_specification_new_with_invalid_periodic_step_panics() {
1285        let _ = BarSpecification::new(7, BarAggregation::Minute, PriceType::Last);
1286    }
1287
1288    #[rstest]
1289    #[case(
1290        BarAggregation::Millisecond,
1291        12,
1292        "Invalid step in bar_type.spec.step: 12 for aggregation=MILLISECOND. step must evenly divide 1000"
1293    )]
1294    #[case(
1295        BarAggregation::Millisecond,
1296        1000,
1297        "Invalid step in bar_type.spec.step: 1000 for aggregation=MILLISECOND. step must not be 1000"
1298    )]
1299    #[case(
1300        BarAggregation::Second,
1301        50,
1302        "Invalid step in bar_type.spec.step: 50 for aggregation=SECOND. step must evenly divide 60"
1303    )]
1304    #[case(
1305        BarAggregation::Second,
1306        60,
1307        "Invalid step in bar_type.spec.step: 60 for aggregation=SECOND. step must not be 60"
1308    )]
1309    #[case(
1310        BarAggregation::Minute,
1311        40,
1312        "Invalid step in bar_type.spec.step: 40 for aggregation=MINUTE. step must evenly divide 60"
1313    )]
1314    #[case(
1315        BarAggregation::Minute,
1316        60,
1317        "Invalid step in bar_type.spec.step: 60 for aggregation=MINUTE. step must not be 60"
1318    )]
1319    #[case(
1320        BarAggregation::Hour,
1321        5,
1322        "Invalid step in bar_type.spec.step: 5 for aggregation=HOUR. step must evenly divide 24"
1323    )]
1324    #[case(
1325        BarAggregation::Hour,
1326        13,
1327        "Invalid step in bar_type.spec.step: 13 for aggregation=HOUR. step must evenly divide 24"
1328    )]
1329    #[case(
1330        BarAggregation::Hour,
1331        24,
1332        "Invalid step in bar_type.spec.step: 24 for aggregation=HOUR. step must not be 24"
1333    )]
1334    #[case(
1335        BarAggregation::Month,
1336        5,
1337        "Invalid step in bar_type.spec.step: 5 for aggregation=MONTH. step must evenly divide 12"
1338    )]
1339    fn test_bar_specification_new_checked_invalid_periodic_step(
1340        #[case] aggregation: BarAggregation,
1341        #[case] step: usize,
1342        #[case] expected: &str,
1343    ) {
1344        let result = BarSpecification::new_checked(step, aggregation, PriceType::Last);
1345
1346        assert!(result.unwrap_err().to_string().starts_with(expected));
1347    }
1348
1349    #[rstest]
1350    #[case(BarAggregation::Day)]
1351    #[case(BarAggregation::Week)]
1352    #[case(BarAggregation::Year)]
1353    #[case(BarAggregation::Tick)]
1354    #[case(BarAggregation::TickImbalance)]
1355    #[case(BarAggregation::TickRuns)]
1356    #[case(BarAggregation::Volume)]
1357    #[case(BarAggregation::VolumeImbalance)]
1358    #[case(BarAggregation::VolumeRuns)]
1359    #[case(BarAggregation::Value)]
1360    #[case(BarAggregation::ValueImbalance)]
1361    #[case(BarAggregation::ValueRuns)]
1362    #[case(BarAggregation::Renko)]
1363    fn test_bar_specification_new_checked_allows_non_periodic_steps(
1364        #[case] aggregation: BarAggregation,
1365    ) {
1366        let result = BarSpecification::new_checked(7, aggregation, PriceType::Last);
1367
1368        assert!(result.is_ok());
1369    }
1370
1371    #[rstest]
1372    #[case(BarAggregation::Day, 213_503)]
1373    #[case(BarAggregation::Week, 30_500)]
1374    #[case(BarAggregation::Year, 584)]
1375    fn test_bar_specification_new_checked_accepts_max_interval_step(
1376        #[case] aggregation: BarAggregation,
1377        #[case] step: usize,
1378    ) {
1379        let spec = BarSpecification::new_checked(step, aggregation, PriceType::Last).unwrap();
1380        let interval = spec.timedelta();
1381        let interval_ns = u64::try_from(interval.as_nanos()).unwrap();
1382
1383        assert_eq!(spec.step.get(), step);
1384        assert_eq!(spec.aggregation, aggregation);
1385        assert_eq!(
1386            get_bar_interval_ns(&BarType::new(
1387                InstrumentId::from("BTCUSDT-PERP.BINANCE"),
1388                spec,
1389                AggregationSource::Internal,
1390            ))
1391            .as_u64(),
1392            interval_ns
1393        );
1394    }
1395
1396    #[rstest]
1397    #[case(BarAggregation::Day, 213_504)]
1398    #[case(BarAggregation::Week, 30_501)]
1399    #[case(BarAggregation::Year, 585)]
1400    fn test_bar_specification_new_checked_rejects_unrepresentable_interval(
1401        #[case] aggregation: BarAggregation,
1402        #[case] step: usize,
1403    ) {
1404        let result = BarSpecification::new_checked(step, aggregation, PriceType::Last);
1405
1406        assert!(
1407            result
1408                .unwrap_err()
1409                .to_string()
1410                .contains("interval overflows nanoseconds")
1411        );
1412    }
1413
1414    #[rstest]
1415    #[should_panic(expected = "interval overflows nanoseconds")]
1416    fn test_bar_specification_new_unrepresentable_interval_panics() {
1417        let _ = BarSpecification::new(213_504, BarAggregation::Day, PriceType::Last);
1418    }
1419
1420    #[rstest]
1421    fn test_bar_specification_new_checked_accepts_12_month_interval() {
1422        let spec =
1423            BarSpecification::new_checked(12, BarAggregation::Month, PriceType::Last).unwrap();
1424
1425        assert_eq!(spec, BAR_SPEC_12_MONTH_LAST);
1426        assert_eq!(spec.timedelta(), duration_days(360));
1427        assert_eq!(
1428            u64::try_from(spec.timedelta().as_nanos()).unwrap(),
1429            31_104_000_000_000_000
1430        );
1431    }
1432
1433    #[rstest]
1434    fn test_try_time_interval_covers_derived_multipliers() {
1435        let i64_max = usize::try_from(i64::MAX).unwrap();
1436
1437        assert!(
1438            BarSpecification::new_checked(i64_max, BarAggregation::Week, PriceType::Last)
1439                .unwrap_err()
1440                .to_string()
1441                .contains("step overflows i64 days")
1442        );
1443        assert!(
1444            try_time_interval(usize::MAX, BarAggregation::Day)
1445                .unwrap_err()
1446                .to_string()
1447                .contains("step exceeds i64 range")
1448        );
1449        assert!(
1450            try_time_interval(i64_max, BarAggregation::Week)
1451                .unwrap_err()
1452                .to_string()
1453                .contains("step overflows i64 days")
1454        );
1455        assert!(
1456            try_time_interval(i64_max, BarAggregation::Month)
1457                .unwrap_err()
1458                .to_string()
1459                .contains("step overflows i64 days")
1460        );
1461        assert!(
1462            try_time_interval(i64_max, BarAggregation::Year)
1463                .unwrap_err()
1464                .to_string()
1465                .contains("step overflows i64 days")
1466        );
1467        assert!(
1468            try_duration_days(i64::MAX)
1469                .unwrap_err()
1470                .to_string()
1471                .contains("days overflow i64 hours")
1472        );
1473        assert!(
1474            try_duration_days(i64::MAX / 24)
1475                .unwrap_err()
1476                .to_string()
1477                .contains("days exceed signed duration range")
1478        );
1479    }
1480
1481    #[rstest]
1482    fn test_bar_specification_parse_and_builder_reject_unrepresentable_interval() {
1483        let step = 30_501;
1484        let json = format!(r#"{{"step":{step},"aggregation":"WEEK","price_type":"LAST"}}"#);
1485
1486        assert!(serde_json::from_str::<BarSpecification>(&json).is_err());
1487        assert!(
1488            BarSpecificationBuilder::default()
1489                .step(NonZeroUsize::new(step).unwrap())
1490                .aggregation(BarAggregation::Week)
1491                .price_type(PriceType::Last)
1492                .build()
1493                .is_err()
1494        );
1495        assert!(
1496            BarType::from_str(&format!("BTCUSDT-PERP.BINANCE-{step}-WEEK-LAST-INTERNAL")).is_err()
1497        );
1498        assert_eq!(
1499            BarType::from_str("BTCUSDT-PERP.BINANCE-30500-WEEK-LAST-INTERNAL")
1500                .unwrap()
1501                .spec()
1502                .timedelta(),
1503            duration_days(213_500)
1504        );
1505    }
1506
1507    #[rstest]
1508    #[case(BarAggregation::Millisecond, 1, SignedDuration::from_millis(1))]
1509    #[case(BarAggregation::Millisecond, 10, SignedDuration::from_millis(10))]
1510    #[case(BarAggregation::Second, 1, SignedDuration::from_secs(1))]
1511    #[case(BarAggregation::Second, 15, SignedDuration::from_secs(15))]
1512    #[case(BarAggregation::Minute, 1, SignedDuration::from_mins(1))]
1513    #[case(BarAggregation::Minute, 30, SignedDuration::from_mins(30))]
1514    #[case(BarAggregation::Hour, 1, SignedDuration::from_hours(1))]
1515    #[case(BarAggregation::Hour, 4, SignedDuration::from_hours(4))]
1516    #[case(BarAggregation::Day, 1, duration_days(1))]
1517    #[case(BarAggregation::Day, 2, duration_days(2))]
1518    #[case(BarAggregation::Week, 1, duration_days(7))]
1519    #[case(BarAggregation::Week, 2, duration_days(14))]
1520    #[case(BarAggregation::Month, 1, duration_days(30))]
1521    #[case(BarAggregation::Month, 3, duration_days(90))]
1522    #[case(BarAggregation::Year, 1, duration_days(365))]
1523    #[case(BarAggregation::Year, 2, duration_days(730))]
1524    #[should_panic(expected = "Aggregation not time based")]
1525    #[case(BarAggregation::Tick, 1, SignedDuration::ZERO)]
1526    fn test_get_bar_interval(
1527        #[case] aggregation: BarAggregation,
1528        #[case] step: usize,
1529        #[case] expected: SignedDuration,
1530    ) {
1531        let bar_type = BarType::Standard {
1532            instrument_id: InstrumentId::from("BTCUSDT-PERP.BINANCE"),
1533            spec: BarSpecification::new(step, aggregation, PriceType::Last),
1534            aggregation_source: AggregationSource::Internal,
1535        };
1536
1537        let interval = get_bar_interval(&bar_type);
1538        assert_eq!(interval, expected);
1539    }
1540
1541    #[rstest]
1542    #[case(BarAggregation::Millisecond, 1, DurationNanos::from_millis(1))]
1543    #[case(BarAggregation::Millisecond, 10, DurationNanos::from_millis(10))]
1544    #[case(BarAggregation::Second, 1, DurationNanos::from_secs(1))]
1545    #[case(BarAggregation::Second, 10, DurationNanos::from_secs(10))]
1546    #[case(BarAggregation::Minute, 1, DurationNanos::from_mins(1))]
1547    #[case(BarAggregation::Minute, 30, DurationNanos::from_mins(30))]
1548    #[case(BarAggregation::Hour, 1, DurationNanos::from_hours(1))]
1549    #[case(BarAggregation::Hour, 4, DurationNanos::from_hours(4))]
1550    #[case(BarAggregation::Day, 1, DurationNanos::from_days(1))]
1551    #[case(BarAggregation::Day, 2, DurationNanos::from_hours(48))]
1552    #[case(BarAggregation::Week, 1, DurationNanos::from_hours(168))]
1553    #[case(BarAggregation::Week, 2, DurationNanos::from_hours(336))]
1554    #[case(BarAggregation::Month, 1, DurationNanos::from_hours(720))]
1555    #[case(BarAggregation::Month, 3, DurationNanos::from_hours(2_160))]
1556    #[case(BarAggregation::Year, 1, DurationNanos::from_hours(8_760))]
1557    #[case(BarAggregation::Year, 2, DurationNanos::from_hours(17_520))]
1558    #[should_panic(expected = "Aggregation not time based")]
1559    #[case(BarAggregation::Tick, 1, DurationNanos::ZERO)]
1560    fn test_get_bar_interval_ns(
1561        #[case] aggregation: BarAggregation,
1562        #[case] step: usize,
1563        #[case] expected: DurationNanos,
1564    ) {
1565        let bar_type = BarType::Standard {
1566            instrument_id: InstrumentId::from("BTCUSDT-PERP.BINANCE"),
1567            spec: BarSpecification::new(step, aggregation, PriceType::Last),
1568            aggregation_source: AggregationSource::Internal,
1569        };
1570
1571        let interval_ns = get_bar_interval_ns(&bar_type);
1572        assert_eq!(interval_ns, expected);
1573    }
1574
1575    fn bar_type_with_raw_step(step: usize, aggregation: BarAggregation) -> BarType {
1576        // Bypasses `BarSpecification::new_checked` to exercise the conversion guards
1577        let spec = BarSpecification {
1578            step: NonZeroUsize::new(step).unwrap(),
1579            aggregation,
1580            price_type: PriceType::Last,
1581        };
1582        BarType::new(
1583            InstrumentId::from("BTCUSDT-PERP.BINANCE"),
1584            spec,
1585            AggregationSource::Internal,
1586        )
1587    }
1588
1589    #[rstest]
1590    #[should_panic(expected = "`step` exceeds i64 range")]
1591    fn test_get_bar_interval_step_exceeds_i64_panics() {
1592        let bar_type = bar_type_with_raw_step(usize::MAX, BarAggregation::Second);
1593        let _ = get_bar_interval(&bar_type);
1594    }
1595
1596    #[rstest]
1597    #[should_panic(expected = "`step` overflows i64 days")]
1598    fn test_get_bar_interval_week_step_overflow_panics() {
1599        let step = usize::try_from(i64::MAX).unwrap();
1600        let bar_type = bar_type_with_raw_step(step, BarAggregation::Week);
1601        let _ = get_bar_interval(&bar_type);
1602    }
1603
1604    #[rstest]
1605    #[should_panic(expected = "`step` overflows i64 days")]
1606    fn test_timedelta_year_step_overflow_panics() {
1607        let step = usize::try_from(i64::MAX).unwrap();
1608        let bar_type = bar_type_with_raw_step(step, BarAggregation::Year);
1609        let _ = bar_type.spec().timedelta();
1610    }
1611
1612    #[rstest]
1613    #[should_panic(expected = "`step` exceeds u32 range for month arithmetic")]
1614    fn test_get_time_bar_start_month_step_exceeds_u32_panics() {
1615        let bar_type = bar_type_with_raw_step(1_usize << 40, BarAggregation::Month);
1616        let now = timestamp("2024-07-21T12:00:00Z");
1617        let _ = get_time_bar_start(now, &bar_type, None);
1618    }
1619
1620    #[rstest]
1621    #[should_panic(expected = "`step` exceeds i32 range for year arithmetic")]
1622    fn test_get_time_bar_start_year_step_exceeds_i32_panics() {
1623        let bar_type = bar_type_with_raw_step(1_usize << 40, BarAggregation::Year);
1624        let now = timestamp("2024-07-21T12:00:00Z");
1625        let _ = get_time_bar_start(now, &bar_type, None);
1626    }
1627
1628    #[rstest]
1629    #[should_panic(expected = "year exceeds Jiff supported range")]
1630    fn test_get_time_bar_start_year_step_exceeds_jiff_range_panics() {
1631        let bar_type = bar_type_with_raw_step(32_000, BarAggregation::Year);
1632        let now = timestamp("2024-07-21T12:00:00Z");
1633        let _ = get_time_bar_start(now, &bar_type, None);
1634    }
1635
1636    #[rstest]
1637    #[case::millisecond(
1638    Timestamp::new(1_658_349_296, 123_000_000).unwrap(), // 2022-07-20 20:34:56.123 UTC
1639    BarAggregation::Millisecond,
1640    1,
1641    Timestamp::new(1_658_349_296, 123_000_000).unwrap(), // 2022-07-20 20:34:56.123 UTC
1642    )]
1643    #[rstest]
1644    #[case::millisecond(
1645    Timestamp::new(1_658_349_296, 123_000_000).unwrap(), // 2022-07-20 20:34:56.123 UTC
1646    BarAggregation::Millisecond,
1647    10,
1648    Timestamp::new(1_658_349_296, 120_000_000).unwrap(), // 2022-07-20 20:34:56.120 UTC
1649    )]
1650    #[case::second(
1651        timestamp("2024-07-21T12:34:56Z"),
1652        BarAggregation::Second,
1653        1,
1654        timestamp("2024-07-21T12:34:56Z")
1655    )]
1656    #[case::second(
1657        timestamp("2024-07-21T12:34:56Z"),
1658        BarAggregation::Second,
1659        5,
1660        timestamp("2024-07-21T12:34:55Z")
1661    )]
1662    #[case::minute(
1663        timestamp("2024-07-21T12:34:56Z"),
1664        BarAggregation::Minute,
1665        1,
1666        timestamp("2024-07-21T12:34:00Z")
1667    )]
1668    #[case::minute(
1669        timestamp("2024-07-21T12:34:56Z"),
1670        BarAggregation::Minute,
1671        5,
1672        timestamp("2024-07-21T12:30:00Z")
1673    )]
1674    #[case::hour(
1675        timestamp("2024-07-21T12:34:56Z"),
1676        BarAggregation::Hour,
1677        1,
1678        timestamp("2024-07-21T12:00:00Z")
1679    )]
1680    #[case::hour(
1681        timestamp("2024-07-21T12:34:56Z"),
1682        BarAggregation::Hour,
1683        2,
1684        timestamp("2024-07-21T12:00:00Z")
1685    )]
1686    #[case::day(
1687        timestamp("2024-07-21T12:34:56Z"),
1688        BarAggregation::Day,
1689        1,
1690        timestamp("2024-07-21T00:00:00Z")
1691    )]
1692    fn test_get_time_bar_start(
1693        #[case] now: Timestamp,
1694        #[case] aggregation: BarAggregation,
1695        #[case] step: usize,
1696        #[case] expected: Timestamp,
1697    ) {
1698        let bar_type = BarType::Standard {
1699            instrument_id: InstrumentId::from("BTCUSDT-PERP.BINANCE"),
1700            spec: BarSpecification::new(step, aggregation, PriceType::Last),
1701            aggregation_source: AggregationSource::Internal,
1702        };
1703
1704        let start_time = get_time_bar_start(now, &bar_type, None);
1705        assert_eq!(start_time, expected);
1706    }
1707
1708    #[rstest]
1709    fn test_bar_spec_string_reprs() {
1710        let bar_spec = BarSpecification::new(1, BarAggregation::Minute, PriceType::Bid);
1711        assert_eq!(bar_spec.to_string(), "1-MINUTE-BID");
1712        assert_eq!(format!("{bar_spec}"), "1-MINUTE-BID");
1713    }
1714
1715    #[rstest]
1716    fn test_bar_type_parse_valid() {
1717        let input = "BTCUSDT-PERP.BINANCE-1-MINUTE-LAST-EXTERNAL";
1718        let bar_type = BarType::from(input);
1719
1720        assert_eq!(
1721            bar_type.instrument_id(),
1722            InstrumentId::from("BTCUSDT-PERP.BINANCE")
1723        );
1724        assert_eq!(
1725            bar_type.spec(),
1726            BarSpecification::new(1, BarAggregation::Minute, PriceType::Last)
1727        );
1728        assert_eq!(bar_type.aggregation_source(), AggregationSource::External);
1729        assert_eq!(bar_type, BarType::from(input));
1730    }
1731
1732    #[rstest]
1733    #[case("BTCUSDT-PERP.BINANCE-1-MINUTE-LAST-EXTERNAL", true, false)]
1734    #[case("BTCUSDT-PERP.BINANCE-1-MINUTE-LAST-INTERNAL", false, true)]
1735    #[case(
1736        "BTCUSDT-PERP.BINANCE-2-MINUTE-LAST-INTERNAL@1-MINUTE-EXTERNAL",
1737        false,
1738        true
1739    )]
1740    #[case(
1741        "BTCUSDT-PERP.BINANCE-2-MINUTE-LAST-EXTERNAL@1-MINUTE-INTERNAL",
1742        true,
1743        false
1744    )]
1745    fn test_bar_type_aggregation_source_predicates(
1746        #[case] input: &str,
1747        #[case] expected_external: bool,
1748        #[case] expected_internal: bool,
1749    ) {
1750        let bar_type = BarType::from(input);
1751        assert_eq!(bar_type.is_externally_aggregated(), expected_external);
1752        assert_eq!(bar_type.is_internally_aggregated(), expected_internal);
1753    }
1754
1755    #[rstest]
1756    fn test_bar_type_composite_aggregation_source_predicates_track_inner() {
1757        let bar_type =
1758            BarType::from("BTCUSDT-PERP.BINANCE-2-MINUTE-LAST-INTERNAL@1-MINUTE-EXTERNAL");
1759
1760        assert!(bar_type.is_internally_aggregated());
1761        assert!(!bar_type.is_externally_aggregated());
1762
1763        let composite = bar_type.composite();
1764        assert!(composite.is_externally_aggregated());
1765        assert!(!composite.is_internally_aggregated());
1766    }
1767
1768    #[rstest]
1769    fn test_bar_type_from_str_with_utf8_symbol() {
1770        let non_ascii_instrument = "TËST-PÉRP.BINANCE";
1771        let non_ascii_bar_type = "TËST-PÉRP.BINANCE-1-MINUTE-LAST-EXTERNAL";
1772
1773        let bar_type = BarType::from_str(non_ascii_bar_type).unwrap();
1774
1775        assert_eq!(
1776            bar_type.instrument_id(),
1777            InstrumentId::from_str(non_ascii_instrument).unwrap()
1778        );
1779        assert_eq!(
1780            bar_type.spec(),
1781            BarSpecification::new(1, BarAggregation::Minute, PriceType::Last)
1782        );
1783        assert_eq!(bar_type.aggregation_source(), AggregationSource::External);
1784        assert_eq!(bar_type.to_string(), non_ascii_bar_type);
1785    }
1786
1787    #[rstest]
1788    fn test_bar_type_composite_parse_valid() {
1789        let input = "BTCUSDT-PERP.BINANCE-2-MINUTE-LAST-INTERNAL@1-MINUTE-EXTERNAL";
1790        let bar_type = BarType::from(input);
1791        let standard = bar_type.standard();
1792
1793        assert_eq!(
1794            bar_type.instrument_id(),
1795            InstrumentId::from("BTCUSDT-PERP.BINANCE")
1796        );
1797        assert_eq!(
1798            bar_type.spec(),
1799            BarSpecification::new(2, BarAggregation::Minute, PriceType::Last,)
1800        );
1801        assert_eq!(bar_type.aggregation_source(), AggregationSource::Internal);
1802        assert_eq!(bar_type, BarType::from(input));
1803        assert!(bar_type.is_composite());
1804
1805        assert_eq!(
1806            standard.instrument_id(),
1807            InstrumentId::from("BTCUSDT-PERP.BINANCE")
1808        );
1809        assert_eq!(
1810            standard.spec(),
1811            BarSpecification::new(2, BarAggregation::Minute, PriceType::Last,)
1812        );
1813        assert_eq!(standard.aggregation_source(), AggregationSource::Internal);
1814        assert!(standard.is_standard());
1815
1816        let composite = bar_type.composite();
1817        let composite_input = "BTCUSDT-PERP.BINANCE-1-MINUTE-LAST-EXTERNAL";
1818
1819        assert_eq!(
1820            composite.instrument_id(),
1821            InstrumentId::from("BTCUSDT-PERP.BINANCE")
1822        );
1823        assert_eq!(
1824            composite.spec(),
1825            BarSpecification::new(1, BarAggregation::Minute, PriceType::Last,)
1826        );
1827        assert_eq!(composite.aggregation_source(), AggregationSource::External);
1828        assert_eq!(composite, BarType::from(composite_input));
1829        assert!(composite.is_standard());
1830    }
1831
1832    #[rstest]
1833    fn test_bar_type_parse_invalid_token_pos_0() {
1834        let input = "BTCUSDT-PERP-1-MINUTE-LAST-INTERNAL";
1835        let result = BarType::from_str(input);
1836
1837        assert_eq!(
1838            result.unwrap_err().to_string(),
1839            format!(
1840                "Error parsing `BarType` from '{input}', invalid token: 'BTCUSDT-PERP' at position 0"
1841            )
1842        );
1843    }
1844
1845    #[rstest]
1846    fn test_bar_type_parse_invalid_token_pos_1() {
1847        let input = "BTCUSDT-PERP.BINANCE-INVALID-MINUTE-LAST-INTERNAL";
1848        let result = BarType::from_str(input);
1849
1850        assert_eq!(
1851            result.unwrap_err().to_string(),
1852            format!(
1853                "Error parsing `BarType` from '{input}', invalid token: 'INVALID' at position 1"
1854            )
1855        );
1856    }
1857
1858    #[rstest]
1859    fn test_bar_type_parse_invalid_spec_step() {
1860        let input = "BTCUSDT-PERP.BINANCE-60-MINUTE-LAST-INTERNAL";
1861        let result = BarType::from_str(input);
1862
1863        assert_eq!(
1864            result.unwrap_err().to_string(),
1865            format!("Error parsing `BarType` from '{input}', invalid token: '60' at position 1")
1866        );
1867    }
1868
1869    #[rstest]
1870    fn test_bar_type_parse_invalid_token_pos_2() {
1871        let input = "BTCUSDT-PERP.BINANCE-1-INVALID-LAST-INTERNAL";
1872        let result = BarType::from_str(input);
1873
1874        assert_eq!(
1875            result.unwrap_err().to_string(),
1876            format!(
1877                "Error parsing `BarType` from '{input}', invalid token: 'INVALID' at position 2"
1878            )
1879        );
1880    }
1881
1882    #[rstest]
1883    fn test_bar_type_parse_invalid_token_pos_3() {
1884        let input = "BTCUSDT-PERP.BINANCE-1-MINUTE-INVALID-INTERNAL";
1885        let result = BarType::from_str(input);
1886
1887        assert_eq!(
1888            result.unwrap_err().to_string(),
1889            format!(
1890                "Error parsing `BarType` from '{input}', invalid token: 'INVALID' at position 3"
1891            )
1892        );
1893    }
1894
1895    #[rstest]
1896    fn test_bar_type_parse_invalid_token_pos_4() {
1897        let input = "BTCUSDT-PERP.BINANCE-1-MINUTE-BID-INVALID";
1898        let result = BarType::from_str(input);
1899
1900        assert!(result.is_err());
1901        assert_eq!(
1902            result.unwrap_err().to_string(),
1903            format!(
1904                "Error parsing `BarType` from '{input}', invalid token: 'INVALID' at position 4"
1905            )
1906        );
1907    }
1908
1909    #[rstest]
1910    fn test_bar_type_parse_invalid_token_pos_5() {
1911        let input = "BTCUSDT-PERP.BINANCE-2-MINUTE-LAST-INTERNAL@INVALID-MINUTE-EXTERNAL";
1912        let result = BarType::from_str(input);
1913
1914        assert!(result.is_err());
1915        assert_eq!(
1916            result.unwrap_err().to_string(),
1917            format!(
1918                "Error parsing `BarType` from '{input}', invalid token: 'INVALID' at position 5"
1919            )
1920        );
1921    }
1922
1923    #[rstest]
1924    fn test_bar_type_parse_invalid_composite_spec_step() {
1925        let input = "BTCUSDT-PERP.BINANCE-2-MINUTE-LAST-INTERNAL@60-MINUTE-EXTERNAL";
1926        let result = BarType::from_str(input);
1927
1928        assert!(result.is_err());
1929        assert_eq!(
1930            result.unwrap_err().to_string(),
1931            format!("Error parsing `BarType` from '{input}', invalid token: '60' at position 5")
1932        );
1933    }
1934
1935    #[rstest]
1936    fn test_bar_type_parse_invalid_token_pos_6() {
1937        let input = "BTCUSDT-PERP.BINANCE-2-MINUTE-LAST-INTERNAL@1-INVALID-EXTERNAL";
1938        let result = BarType::from_str(input);
1939
1940        assert!(result.is_err());
1941        assert_eq!(
1942            result.unwrap_err().to_string(),
1943            format!(
1944                "Error parsing `BarType` from '{input}', invalid token: 'INVALID' at position 6"
1945            )
1946        );
1947    }
1948
1949    #[rstest]
1950    fn test_bar_type_parse_invalid_token_pos_7() {
1951        let input = "BTCUSDT-PERP.BINANCE-2-MINUTE-LAST-INTERNAL@1-MINUTE-INVALID";
1952        let result = BarType::from_str(input);
1953
1954        assert!(result.is_err());
1955        assert_eq!(
1956            result.unwrap_err().to_string(),
1957            format!(
1958                "Error parsing `BarType` from '{input}', invalid token: 'INVALID' at position 7"
1959            )
1960        );
1961    }
1962
1963    #[rstest]
1964    fn test_bar_type_parse_rejects_extra_composite_segment() {
1965        let input = "BTCUSDT-PERP.BINANCE-2-MINUTE-LAST-INTERNAL@1-MINUTE-EXTERNAL@1-HOUR-EXTERNAL";
1966        let result = BarType::from_str(input);
1967
1968        assert_eq!(
1969            result.unwrap_err().to_string(),
1970            format!(
1971                "Error parsing `BarType` from '{input}', invalid token: '1-HOUR-EXTERNAL' at position 5"
1972            )
1973        );
1974    }
1975
1976    #[rstest]
1977    fn test_bar_type_equality() {
1978        let instrument_id1 = InstrumentId {
1979            symbol: Symbol::new("AUD/USD"),
1980            venue: Venue::new("SIM"),
1981        };
1982        let instrument_id2 = InstrumentId {
1983            symbol: Symbol::new("GBP/USD"),
1984            venue: Venue::new("SIM"),
1985        };
1986        let bar_spec = BarSpecification::new(1, BarAggregation::Minute, PriceType::Bid);
1987        let bar_type1 = BarType::Standard {
1988            instrument_id: instrument_id1,
1989            spec: bar_spec,
1990            aggregation_source: AggregationSource::External,
1991        };
1992        let bar_type2 = BarType::Standard {
1993            instrument_id: instrument_id1,
1994            spec: bar_spec,
1995            aggregation_source: AggregationSource::External,
1996        };
1997        let bar_type3 = BarType::Standard {
1998            instrument_id: instrument_id2,
1999            spec: bar_spec,
2000            aggregation_source: AggregationSource::External,
2001        };
2002        assert_eq!(bar_type1, bar_type1);
2003        assert_eq!(bar_type1, bar_type2);
2004        assert_ne!(bar_type1, bar_type3);
2005    }
2006
2007    #[rstest]
2008    fn test_bar_type_id_spec_key_ignores_aggregation_source() {
2009        let bar_type_external = BarType::from_str("ESM4.XCME-1-MINUTE-LAST-EXTERNAL").unwrap();
2010        let bar_type_internal = BarType::from_str("ESM4.XCME-1-MINUTE-LAST-INTERNAL").unwrap();
2011
2012        // Full equality should differ
2013        assert_ne!(bar_type_external, bar_type_internal);
2014
2015        // id_spec_key should be the same
2016        assert_eq!(
2017            bar_type_external.id_spec_key(),
2018            bar_type_internal.id_spec_key()
2019        );
2020
2021        // Verify key components
2022        let (instrument_id, spec) = bar_type_external.id_spec_key();
2023        assert_eq!(instrument_id, bar_type_external.instrument_id());
2024        assert_eq!(spec, bar_type_external.spec());
2025    }
2026
2027    #[rstest]
2028    fn test_bar_type_comparison() {
2029        let instrument_id1 = InstrumentId {
2030            symbol: Symbol::new("AUD/USD"),
2031            venue: Venue::new("SIM"),
2032        };
2033
2034        let instrument_id2 = InstrumentId {
2035            symbol: Symbol::new("GBP/USD"),
2036            venue: Venue::new("SIM"),
2037        };
2038        let bar_spec = BarSpecification::new(1, BarAggregation::Minute, PriceType::Bid);
2039        let bar_spec2 = BarSpecification::new(2, BarAggregation::Minute, PriceType::Bid);
2040        let bar_type1 = BarType::Standard {
2041            instrument_id: instrument_id1,
2042            spec: bar_spec,
2043            aggregation_source: AggregationSource::External,
2044        };
2045        let bar_type2 = BarType::Standard {
2046            instrument_id: instrument_id1,
2047            spec: bar_spec,
2048            aggregation_source: AggregationSource::External,
2049        };
2050        let bar_type3 = BarType::Standard {
2051            instrument_id: instrument_id2,
2052            spec: bar_spec,
2053            aggregation_source: AggregationSource::External,
2054        };
2055        let bar_type4 = BarType::Composite {
2056            instrument_id: instrument_id2,
2057            spec: bar_spec2,
2058            aggregation_source: AggregationSource::Internal,
2059
2060            composite_step: 1,
2061            composite_aggregation: BarAggregation::Minute,
2062            composite_aggregation_source: AggregationSource::External,
2063        };
2064
2065        assert!(bar_type1 <= bar_type2);
2066        assert!(bar_type1 < bar_type3);
2067        assert!(bar_type3 > bar_type1);
2068        assert!(bar_type3 >= bar_type1);
2069        assert!(bar_type4 >= bar_type1);
2070    }
2071
2072    #[rstest]
2073    fn test_bar_new() {
2074        let bar_type = BarType::from("AAPL.XNAS-1-MINUTE-LAST-INTERNAL");
2075        let open = Price::from("100.0");
2076        let high = Price::from("105.0");
2077        let low = Price::from("95.0");
2078        let close = Price::from("102.0");
2079        let volume = Quantity::from("1000");
2080        let ts_event = UnixNanos::from(1_000_000);
2081        let ts_init = UnixNanos::from(2_000_000);
2082
2083        let bar = Bar::new(bar_type, open, high, low, close, volume, ts_event, ts_init);
2084
2085        assert_eq!(bar.bar_type, bar_type);
2086        assert_eq!(bar.open, open);
2087        assert_eq!(bar.high, high);
2088        assert_eq!(bar.low, low);
2089        assert_eq!(bar.close, close);
2090        assert_eq!(bar.volume, volume);
2091        assert_eq!(bar.ts_event, ts_event);
2092        assert_eq!(bar.ts_init, ts_init);
2093    }
2094
2095    #[rstest]
2096    #[case("100.0", "90.0", "95.0", "92.0", "high >= open")]
2097    #[case("100.0", "105.0", "110.0", "102.0", "high >= low")]
2098    #[case("100.0", "105.0", "95.0", "110.0", "high >= close")]
2099    #[case("100.0", "105.0", "95.0", "90.0", "low <= close")]
2100    #[case("100.0", "110.0", "105.0", "108.0", "low <= open")]
2101    #[case("100.0", "90.0", "110.0", "120.0", "high >= open")] // First failing predicate reported
2102    fn test_bar_new_checked_conditions(
2103        #[case] open: &str,
2104        #[case] high: &str,
2105        #[case] low: &str,
2106        #[case] close: &str,
2107        #[case] expected: &str,
2108    ) {
2109        let bar_type = BarType::from("AAPL.XNAS-1-MINUTE-LAST-INTERNAL");
2110        let open = Price::from(open);
2111        let high = Price::from(high);
2112        let low = Price::from(low);
2113        let close = Price::from(close);
2114        let volume = Quantity::from("1000");
2115        let ts_event = UnixNanos::from(1_000_000);
2116        let ts_init = UnixNanos::from(2_000_000);
2117
2118        let result = Bar::new_checked(bar_type, open, high, low, close, volume, ts_event, ts_init);
2119
2120        let error = result.unwrap_err();
2121        assert!(
2122            error.to_string().contains(expected),
2123            "unexpected message: {error}"
2124        );
2125    }
2126
2127    #[rstest]
2128    fn test_bar_equality() {
2129        let instrument_id = InstrumentId {
2130            symbol: Symbol::new("AUDUSD"),
2131            venue: Venue::new("SIM"),
2132        };
2133        let bar_spec = BarSpecification::new(1, BarAggregation::Minute, PriceType::Bid);
2134        let bar_type = BarType::Standard {
2135            instrument_id,
2136            spec: bar_spec,
2137            aggregation_source: AggregationSource::External,
2138        };
2139        let bar1 = Bar {
2140            bar_type,
2141            open: Price::from("1.00001"),
2142            high: Price::from("1.00004"),
2143            low: Price::from("1.00002"),
2144            close: Price::from("1.00003"),
2145            volume: Quantity::from("100000"),
2146            ts_event: UnixNanos::default(),
2147            ts_init: UnixNanos::from(1),
2148        };
2149
2150        let bar2 = Bar {
2151            bar_type,
2152            open: Price::from("1.00000"),
2153            high: Price::from("1.00004"),
2154            low: Price::from("1.00002"),
2155            close: Price::from("1.00003"),
2156            volume: Quantity::from("100000"),
2157            ts_event: UnixNanos::default(),
2158            ts_init: UnixNanos::from(1),
2159        };
2160        assert_eq!(bar1, bar1);
2161        assert_ne!(bar1, bar2);
2162    }
2163
2164    #[rstest]
2165    fn test_json_serialization() {
2166        let bar = Bar::default();
2167        let serialized = bar.to_json_bytes().unwrap();
2168        let deserialized = Bar::from_json_bytes(serialized.as_ref()).unwrap();
2169        assert_eq!(deserialized, bar);
2170    }
2171
2172    #[rstest]
2173    fn test_msgpack_serialization() {
2174        let bar = Bar::default();
2175        let serialized = bar.to_msgpack_bytes().unwrap();
2176        let deserialized = Bar::from_msgpack_bytes(serialized.as_ref()).unwrap();
2177        assert_eq!(deserialized, bar);
2178    }
2179
2180    #[rstest]
2181    fn test_bar_deserialization_rejects_invalid_ohlc() {
2182        let json = r#"{
2183            "type": "Bar",
2184            "bar_type": "AUD/USD.SIM-1-MINUTE-BID-EXTERNAL",
2185            "open": "1.00010",
2186            "high": "1.00000",
2187            "low": "1.00020",
2188            "close": "1.00010",
2189            "volume": "100000",
2190            "ts_event": 0,
2191            "ts_init": 0
2192        }"#;
2193
2194        let result = Bar::from_json_bytes(json.as_bytes());
2195        assert!(
2196            result.is_err(),
2197            "high < low must fail deserialization, was {result:?}"
2198        );
2199    }
2200
2201    #[rstest]
2202    fn test_bar_specification_deserialization_rejects_invalid_step() {
2203        let json = r#"{"step":7,"aggregation":"MINUTE","price_type":"LAST"}"#;
2204
2205        let result = serde_json::from_str::<BarSpecification>(json);
2206        assert!(
2207            result.is_err(),
2208            "non-periodic step must fail deserialization, was {result:?}"
2209        );
2210    }
2211
2212    #[rstest]
2213    fn test_bar_specification_builder_rejects_invalid_step() {
2214        let result = BarSpecificationBuilder::default()
2215            .step(NonZeroUsize::new(7).unwrap())
2216            .aggregation(BarAggregation::Minute)
2217            .price_type(PriceType::Last)
2218            .build();
2219
2220        assert!(
2221            result.is_err(),
2222            "non-periodic step must fail builder validation, was {result:?}"
2223        );
2224    }
2225
2226    #[rstest]
2227    fn test_bar_spec_12_month_round_trips() {
2228        // BAR_SPEC_12_MONTH_LAST is shipped (OKX yearly candles), so the string
2229        // form must parse back
2230        let bar_type = BarType::new(
2231            InstrumentId::from("BTC-USDT.OKX"),
2232            BAR_SPEC_12_MONTH_LAST,
2233            AggregationSource::External,
2234        );
2235
2236        let parsed = BarType::from_str(&bar_type.to_string()).unwrap();
2237        assert_eq!(parsed, bar_type);
2238        assert_eq!(
2239            BarSpecification::new_checked(12, BarAggregation::Month, PriceType::Last).unwrap(),
2240            BAR_SPEC_12_MONTH_LAST,
2241        );
2242    }
2243
2244    #[rstest]
2245    fn test_bar_type_new_composite_checked_invalid_step() {
2246        let instrument_id = InstrumentId::from("AUD/USD.SIM");
2247        let spec = BarSpecification::new(5, BarAggregation::Minute, PriceType::Bid);
2248
2249        let result = BarType::new_composite_checked(
2250            instrument_id,
2251            spec,
2252            AggregationSource::Internal,
2253            0,
2254            BarAggregation::Minute,
2255            AggregationSource::External,
2256        );
2257
2258        assert!(
2259            result.is_err(),
2260            "zero composite step must fail, was {result:?}"
2261        );
2262    }
2263}
2264
2265#[cfg(test)]
2266mod property_tests {
2267    use std::str::FromStr;
2268
2269    use proptest::prelude::*;
2270    use rstest::rstest;
2271
2272    use super::*;
2273    use crate::identifiers::{Symbol, Venue};
2274
2275    fn symbol_strategy() -> impl Strategy<Value = &'static str> {
2276        prop::sample::select(vec![
2277            "AAPL",
2278            "BTC-PERP",
2279            "EUR/USD",
2280            "ES-MINI-4",
2281            "MSFT.OQ",
2282            "6E",
2283        ])
2284    }
2285
2286    fn venue_strategy() -> impl Strategy<Value = &'static str> {
2287        prop::sample::select(vec!["SIM", "XNAS", "GLBX", "BINANCE"])
2288    }
2289
2290    fn time_spec_strategy() -> impl Strategy<Value = (BarAggregation, usize)> {
2291        prop_oneof![
2292            (
2293                Just(BarAggregation::Millisecond),
2294                prop::sample::select(vec![1usize, 2, 5, 10, 25, 50, 100, 250, 500]),
2295            ),
2296            (
2297                Just(BarAggregation::Second),
2298                prop::sample::select(vec![1usize, 2, 3, 5, 10, 15, 30]),
2299            ),
2300            (
2301                Just(BarAggregation::Minute),
2302                prop::sample::select(vec![1usize, 2, 5, 15, 30]),
2303            ),
2304            (
2305                Just(BarAggregation::Hour),
2306                prop::sample::select(vec![1usize, 2, 4, 12]),
2307            ),
2308            (
2309                Just(BarAggregation::Day),
2310                prop::sample::select(vec![1usize, 2, 3]),
2311            ),
2312            (Just(BarAggregation::Week), Just(1usize)),
2313        ]
2314    }
2315
2316    fn spec_strategy() -> impl Strategy<Value = (BarAggregation, usize)> {
2317        prop_oneof![
2318            time_spec_strategy(),
2319            // Month stays out of time_spec_strategy: the alignment proptest uses the
2320            // 30-day proxy interval, which is unsound for calendar months
2321            (
2322                Just(BarAggregation::Month),
2323                prop::sample::select(vec![1usize, 2, 3, 4, 6, 12]),
2324            ),
2325            (Just(BarAggregation::Tick), 1usize..=10_000),
2326            (Just(BarAggregation::Volume), 1usize..=10_000),
2327            (Just(BarAggregation::Value), 1usize..=10_000),
2328        ]
2329    }
2330
2331    fn price_type_strategy() -> impl Strategy<Value = PriceType> {
2332        prop::sample::select(vec![
2333            PriceType::Bid,
2334            PriceType::Ask,
2335            PriceType::Mid,
2336            PriceType::Last,
2337        ])
2338    }
2339
2340    fn source_strategy() -> impl Strategy<Value = AggregationSource> {
2341        prop_oneof![
2342            Just(AggregationSource::Internal),
2343            Just(AggregationSource::External),
2344        ]
2345    }
2346
2347    proptest! {
2348        #[rstest]
2349        fn prop_bar_type_string_round_trip(
2350            symbol in symbol_strategy(),
2351            venue in venue_strategy(),
2352            (aggregation, step) in spec_strategy(),
2353            price_type in price_type_strategy(),
2354            source in source_strategy(),
2355            composite in prop::option::of((time_spec_strategy(), source_strategy())),
2356        ) {
2357            let instrument_id = InstrumentId::new(Symbol::from(symbol), Venue::from(venue));
2358            let spec = BarSpecification::new(step, aggregation, price_type);
2359
2360            let bar_type = match composite {
2361                None => BarType::new(instrument_id, spec, source),
2362                Some(((composite_aggregation, composite_step), composite_source)) => {
2363                    BarType::new_composite(
2364                        instrument_id,
2365                        spec,
2366                        source,
2367                        composite_step,
2368                        composite_aggregation,
2369                        composite_source,
2370                    )
2371                }
2372            };
2373
2374            let parsed = BarType::from_str(&bar_type.to_string());
2375            prop_assert!(parsed.is_ok(), "failed to parse '{bar_type}': {parsed:?}");
2376            prop_assert_eq!(parsed.unwrap(), bar_type);
2377        }
2378
2379        #[rstest]
2380        fn prop_get_time_bar_start_alignment(
2381            (aggregation, step) in time_spec_strategy(),
2382            epoch_secs in 946_684_800i64..2_524_608_000i64,
2383            subsec_nanos in 0u32..1_000_000_000u32,
2384        ) {
2385            let instrument_id = InstrumentId::from("AAPL.XNAS");
2386            let spec = BarSpecification::new(step, aggregation, PriceType::Last);
2387            let bar_type = BarType::new(instrument_id, spec, AggregationSource::Internal);
2388
2389            let now = Timestamp::new(epoch_secs, subsec_nanos.cast_signed()).unwrap();
2390            let start = get_time_bar_start(now, &bar_type, None);
2391            let interval = get_bar_interval(&bar_type);
2392
2393            prop_assert!(start <= now, "start {start} must not be after now {now}");
2394            prop_assert!(
2395                now.duration_since(start) < interval,
2396                "now {now} must fall within one interval of start {start}"
2397            );
2398        }
2399    }
2400}