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::{ARROW_TIMESTAMP_NANOSECOND, HasTsInit};
38use crate::{
39    enums::{AggregationSource, BarAggregation, PriceType},
40    identifiers::InstrumentId,
41    types::{Price, Quantity, fixed::FIXED_DECIMAL},
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 `UnixNanos`.
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_DECIMAL.to_string());
1220        metadata.insert("high".to_string(), FIXED_DECIMAL.to_string());
1221        metadata.insert("low".to_string(), FIXED_DECIMAL.to_string());
1222        metadata.insert("close".to_string(), FIXED_DECIMAL.to_string());
1223        metadata.insert("volume".to_string(), FIXED_DECIMAL.to_string());
1224        metadata.insert(
1225            "ts_event".to_string(),
1226            ARROW_TIMESTAMP_NANOSECOND.to_string(),
1227        );
1228        metadata.insert(
1229            "ts_init".to_string(),
1230            ARROW_TIMESTAMP_NANOSECOND.to_string(),
1231        );
1232        metadata
1233    }
1234}
1235
1236impl Display for Bar {
1237    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
1238        write!(
1239            f,
1240            "{},{},{},{},{},{},{}",
1241            self.bar_type, self.open, self.high, self.low, self.close, self.volume, self.ts_event
1242        )
1243    }
1244}
1245
1246impl Serializable for Bar {}
1247
1248impl HasTsInit for Bar {
1249    fn ts_init(&self) -> UnixNanos {
1250        self.ts_init
1251    }
1252}
1253
1254#[cfg(test)]
1255mod tests {
1256    use std::str::FromStr;
1257
1258    use nautilus_core::serialization::msgpack::{FromMsgPack, ToMsgPack};
1259    use rstest::rstest;
1260
1261    use super::*;
1262    use crate::identifiers::{Symbol, Venue};
1263
1264    fn timestamp(value: &str) -> Timestamp {
1265        value.parse().unwrap()
1266    }
1267
1268    #[rstest]
1269    fn test_bar_specification_new_invalid() {
1270        let result = BarSpecification::new_checked(0, BarAggregation::Tick, PriceType::Last);
1271        assert!(
1272            result
1273                .unwrap_err()
1274                .to_string()
1275                .contains("Invalid step: 0 (must be non-zero)")
1276        );
1277    }
1278
1279    #[rstest]
1280    #[should_panic(expected = "Invalid step: 0 (must be non-zero)")]
1281    fn test_bar_specification_new_checked_with_invalid_step_panics() {
1282        let aggregation = BarAggregation::Tick;
1283        let price_type = PriceType::Last;
1284
1285        let _ = BarSpecification::new(0, aggregation, price_type);
1286    }
1287
1288    #[rstest]
1289    #[should_panic(expected = "Invalid step in bar_type.spec.step: 7")]
1290    fn test_bar_specification_new_with_invalid_periodic_step_panics() {
1291        let _ = BarSpecification::new(7, BarAggregation::Minute, PriceType::Last);
1292    }
1293
1294    #[rstest]
1295    #[case(
1296        BarAggregation::Millisecond,
1297        12,
1298        "Invalid step in bar_type.spec.step: 12 for aggregation=MILLISECOND. step must evenly divide 1000"
1299    )]
1300    #[case(
1301        BarAggregation::Millisecond,
1302        1000,
1303        "Invalid step in bar_type.spec.step: 1000 for aggregation=MILLISECOND. step must not be 1000"
1304    )]
1305    #[case(
1306        BarAggregation::Second,
1307        50,
1308        "Invalid step in bar_type.spec.step: 50 for aggregation=SECOND. step must evenly divide 60"
1309    )]
1310    #[case(
1311        BarAggregation::Second,
1312        60,
1313        "Invalid step in bar_type.spec.step: 60 for aggregation=SECOND. step must not be 60"
1314    )]
1315    #[case(
1316        BarAggregation::Minute,
1317        40,
1318        "Invalid step in bar_type.spec.step: 40 for aggregation=MINUTE. step must evenly divide 60"
1319    )]
1320    #[case(
1321        BarAggregation::Minute,
1322        60,
1323        "Invalid step in bar_type.spec.step: 60 for aggregation=MINUTE. step must not be 60"
1324    )]
1325    #[case(
1326        BarAggregation::Hour,
1327        5,
1328        "Invalid step in bar_type.spec.step: 5 for aggregation=HOUR. step must evenly divide 24"
1329    )]
1330    #[case(
1331        BarAggregation::Hour,
1332        13,
1333        "Invalid step in bar_type.spec.step: 13 for aggregation=HOUR. step must evenly divide 24"
1334    )]
1335    #[case(
1336        BarAggregation::Hour,
1337        24,
1338        "Invalid step in bar_type.spec.step: 24 for aggregation=HOUR. step must not be 24"
1339    )]
1340    #[case(
1341        BarAggregation::Month,
1342        5,
1343        "Invalid step in bar_type.spec.step: 5 for aggregation=MONTH. step must evenly divide 12"
1344    )]
1345    fn test_bar_specification_new_checked_invalid_periodic_step(
1346        #[case] aggregation: BarAggregation,
1347        #[case] step: usize,
1348        #[case] expected: &str,
1349    ) {
1350        let result = BarSpecification::new_checked(step, aggregation, PriceType::Last);
1351
1352        assert!(result.unwrap_err().to_string().starts_with(expected));
1353    }
1354
1355    #[rstest]
1356    #[case(BarAggregation::Day)]
1357    #[case(BarAggregation::Week)]
1358    #[case(BarAggregation::Year)]
1359    #[case(BarAggregation::Tick)]
1360    #[case(BarAggregation::TickImbalance)]
1361    #[case(BarAggregation::TickRuns)]
1362    #[case(BarAggregation::Volume)]
1363    #[case(BarAggregation::VolumeImbalance)]
1364    #[case(BarAggregation::VolumeRuns)]
1365    #[case(BarAggregation::Value)]
1366    #[case(BarAggregation::ValueImbalance)]
1367    #[case(BarAggregation::ValueRuns)]
1368    #[case(BarAggregation::Renko)]
1369    fn test_bar_specification_new_checked_allows_non_periodic_steps(
1370        #[case] aggregation: BarAggregation,
1371    ) {
1372        let result = BarSpecification::new_checked(7, aggregation, PriceType::Last);
1373
1374        assert!(result.is_ok());
1375    }
1376
1377    #[rstest]
1378    #[case(BarAggregation::Day, 213_503)]
1379    #[case(BarAggregation::Week, 30_500)]
1380    #[case(BarAggregation::Year, 584)]
1381    fn test_bar_specification_new_checked_accepts_max_interval_step(
1382        #[case] aggregation: BarAggregation,
1383        #[case] step: usize,
1384    ) {
1385        let spec = BarSpecification::new_checked(step, aggregation, PriceType::Last).unwrap();
1386        let interval = spec.timedelta();
1387        let interval_ns = u64::try_from(interval.as_nanos()).unwrap();
1388
1389        assert_eq!(spec.step.get(), step);
1390        assert_eq!(spec.aggregation, aggregation);
1391        assert_eq!(
1392            get_bar_interval_ns(&BarType::new(
1393                InstrumentId::from("BTCUSDT-PERP.BINANCE"),
1394                spec,
1395                AggregationSource::Internal,
1396            ))
1397            .as_u64(),
1398            interval_ns
1399        );
1400    }
1401
1402    #[rstest]
1403    #[case(BarAggregation::Day, 213_504)]
1404    #[case(BarAggregation::Week, 30_501)]
1405    #[case(BarAggregation::Year, 585)]
1406    fn test_bar_specification_new_checked_rejects_unrepresentable_interval(
1407        #[case] aggregation: BarAggregation,
1408        #[case] step: usize,
1409    ) {
1410        let result = BarSpecification::new_checked(step, aggregation, PriceType::Last);
1411
1412        assert!(
1413            result
1414                .unwrap_err()
1415                .to_string()
1416                .contains("interval overflows nanoseconds")
1417        );
1418    }
1419
1420    #[rstest]
1421    #[should_panic(expected = "interval overflows nanoseconds")]
1422    fn test_bar_specification_new_unrepresentable_interval_panics() {
1423        let _ = BarSpecification::new(213_504, BarAggregation::Day, PriceType::Last);
1424    }
1425
1426    #[rstest]
1427    fn test_bar_specification_new_checked_accepts_12_month_interval() {
1428        let spec =
1429            BarSpecification::new_checked(12, BarAggregation::Month, PriceType::Last).unwrap();
1430
1431        assert_eq!(spec, BAR_SPEC_12_MONTH_LAST);
1432        assert_eq!(spec.timedelta(), duration_days(360));
1433        assert_eq!(
1434            u64::try_from(spec.timedelta().as_nanos()).unwrap(),
1435            31_104_000_000_000_000
1436        );
1437    }
1438
1439    #[rstest]
1440    fn test_try_time_interval_covers_derived_multipliers() {
1441        let i64_max = usize::try_from(i64::MAX).unwrap();
1442
1443        assert!(
1444            BarSpecification::new_checked(i64_max, BarAggregation::Week, PriceType::Last)
1445                .unwrap_err()
1446                .to_string()
1447                .contains("step overflows i64 days")
1448        );
1449        assert!(
1450            try_time_interval(usize::MAX, BarAggregation::Day)
1451                .unwrap_err()
1452                .to_string()
1453                .contains("step exceeds i64 range")
1454        );
1455        assert!(
1456            try_time_interval(i64_max, BarAggregation::Week)
1457                .unwrap_err()
1458                .to_string()
1459                .contains("step overflows i64 days")
1460        );
1461        assert!(
1462            try_time_interval(i64_max, BarAggregation::Month)
1463                .unwrap_err()
1464                .to_string()
1465                .contains("step overflows i64 days")
1466        );
1467        assert!(
1468            try_time_interval(i64_max, BarAggregation::Year)
1469                .unwrap_err()
1470                .to_string()
1471                .contains("step overflows i64 days")
1472        );
1473        assert!(
1474            try_duration_days(i64::MAX)
1475                .unwrap_err()
1476                .to_string()
1477                .contains("days overflow i64 hours")
1478        );
1479        assert!(
1480            try_duration_days(i64::MAX / 24)
1481                .unwrap_err()
1482                .to_string()
1483                .contains("days exceed signed duration range")
1484        );
1485    }
1486
1487    #[rstest]
1488    fn test_bar_specification_parse_and_builder_reject_unrepresentable_interval() {
1489        let step = 30_501;
1490        let json = format!(r#"{{"step":{step},"aggregation":"WEEK","price_type":"LAST"}}"#);
1491
1492        assert!(serde_json::from_str::<BarSpecification>(&json).is_err());
1493        assert!(
1494            BarSpecificationBuilder::default()
1495                .step(NonZeroUsize::new(step).unwrap())
1496                .aggregation(BarAggregation::Week)
1497                .price_type(PriceType::Last)
1498                .build()
1499                .is_err()
1500        );
1501        assert!(
1502            BarType::from_str(&format!("BTCUSDT-PERP.BINANCE-{step}-WEEK-LAST-INTERNAL")).is_err()
1503        );
1504        assert_eq!(
1505            BarType::from_str("BTCUSDT-PERP.BINANCE-30500-WEEK-LAST-INTERNAL")
1506                .unwrap()
1507                .spec()
1508                .timedelta(),
1509            duration_days(213_500)
1510        );
1511    }
1512
1513    #[rstest]
1514    #[case(BarAggregation::Millisecond, 1, SignedDuration::from_millis(1))]
1515    #[case(BarAggregation::Millisecond, 10, SignedDuration::from_millis(10))]
1516    #[case(BarAggregation::Second, 1, SignedDuration::from_secs(1))]
1517    #[case(BarAggregation::Second, 15, SignedDuration::from_secs(15))]
1518    #[case(BarAggregation::Minute, 1, SignedDuration::from_mins(1))]
1519    #[case(BarAggregation::Minute, 30, SignedDuration::from_mins(30))]
1520    #[case(BarAggregation::Hour, 1, SignedDuration::from_hours(1))]
1521    #[case(BarAggregation::Hour, 4, SignedDuration::from_hours(4))]
1522    #[case(BarAggregation::Day, 1, duration_days(1))]
1523    #[case(BarAggregation::Day, 2, duration_days(2))]
1524    #[case(BarAggregation::Week, 1, duration_days(7))]
1525    #[case(BarAggregation::Week, 2, duration_days(14))]
1526    #[case(BarAggregation::Month, 1, duration_days(30))]
1527    #[case(BarAggregation::Month, 3, duration_days(90))]
1528    #[case(BarAggregation::Year, 1, duration_days(365))]
1529    #[case(BarAggregation::Year, 2, duration_days(730))]
1530    #[should_panic(expected = "Aggregation not time based")]
1531    #[case(BarAggregation::Tick, 1, SignedDuration::ZERO)]
1532    fn test_get_bar_interval(
1533        #[case] aggregation: BarAggregation,
1534        #[case] step: usize,
1535        #[case] expected: SignedDuration,
1536    ) {
1537        let bar_type = BarType::Standard {
1538            instrument_id: InstrumentId::from("BTCUSDT-PERP.BINANCE"),
1539            spec: BarSpecification::new(step, aggregation, PriceType::Last),
1540            aggregation_source: AggregationSource::Internal,
1541        };
1542
1543        let interval = get_bar_interval(&bar_type);
1544        assert_eq!(interval, expected);
1545    }
1546
1547    #[rstest]
1548    #[case(BarAggregation::Millisecond, 1, DurationNanos::new(1_000_000))]
1549    #[case(BarAggregation::Millisecond, 10, DurationNanos::new(10_000_000))]
1550    #[case(BarAggregation::Second, 1, DurationNanos::new(1_000_000_000))]
1551    #[case(BarAggregation::Second, 10, DurationNanos::new(10_000_000_000))]
1552    #[case(BarAggregation::Minute, 1, DurationNanos::new(60_000_000_000))]
1553    #[case(BarAggregation::Minute, 30, DurationNanos::new(1_800_000_000_000))]
1554    #[case(BarAggregation::Hour, 1, DurationNanos::new(3_600_000_000_000))]
1555    #[case(BarAggregation::Hour, 4, DurationNanos::new(14_400_000_000_000))]
1556    #[case(BarAggregation::Day, 1, DurationNanos::new(86_400_000_000_000))]
1557    #[case(BarAggregation::Day, 2, DurationNanos::new(172_800_000_000_000))]
1558    #[case(BarAggregation::Week, 1, DurationNanos::new(604_800_000_000_000))]
1559    #[case(BarAggregation::Week, 2, DurationNanos::new(1_209_600_000_000_000))]
1560    #[case(BarAggregation::Month, 1, DurationNanos::new(2_592_000_000_000_000))]
1561    #[case(BarAggregation::Month, 3, DurationNanos::new(7_776_000_000_000_000))]
1562    #[case(BarAggregation::Year, 1, DurationNanos::new(31_536_000_000_000_000))]
1563    #[case(BarAggregation::Year, 2, DurationNanos::new(63_072_000_000_000_000))]
1564    #[should_panic(expected = "Aggregation not time based")]
1565    #[case(BarAggregation::Tick, 1, DurationNanos::new(0))]
1566    fn test_get_bar_interval_ns(
1567        #[case] aggregation: BarAggregation,
1568        #[case] step: usize,
1569        #[case] expected: DurationNanos,
1570    ) {
1571        let bar_type = BarType::Standard {
1572            instrument_id: InstrumentId::from("BTCUSDT-PERP.BINANCE"),
1573            spec: BarSpecification::new(step, aggregation, PriceType::Last),
1574            aggregation_source: AggregationSource::Internal,
1575        };
1576
1577        let interval_ns = get_bar_interval_ns(&bar_type);
1578        assert_eq!(interval_ns, expected);
1579    }
1580
1581    fn bar_type_with_raw_step(step: usize, aggregation: BarAggregation) -> BarType {
1582        // Bypasses `BarSpecification::new_checked` to exercise the conversion guards
1583        let spec = BarSpecification {
1584            step: NonZeroUsize::new(step).unwrap(),
1585            aggregation,
1586            price_type: PriceType::Last,
1587        };
1588        BarType::new(
1589            InstrumentId::from("BTCUSDT-PERP.BINANCE"),
1590            spec,
1591            AggregationSource::Internal,
1592        )
1593    }
1594
1595    #[rstest]
1596    #[should_panic(expected = "`step` exceeds i64 range")]
1597    fn test_get_bar_interval_step_exceeds_i64_panics() {
1598        let bar_type = bar_type_with_raw_step(usize::MAX, BarAggregation::Second);
1599        let _ = get_bar_interval(&bar_type);
1600    }
1601
1602    #[rstest]
1603    #[should_panic(expected = "`step` overflows i64 days")]
1604    fn test_get_bar_interval_week_step_overflow_panics() {
1605        let step = usize::try_from(i64::MAX).unwrap();
1606        let bar_type = bar_type_with_raw_step(step, BarAggregation::Week);
1607        let _ = get_bar_interval(&bar_type);
1608    }
1609
1610    #[rstest]
1611    #[should_panic(expected = "`step` overflows i64 days")]
1612    fn test_timedelta_year_step_overflow_panics() {
1613        let step = usize::try_from(i64::MAX).unwrap();
1614        let bar_type = bar_type_with_raw_step(step, BarAggregation::Year);
1615        let _ = bar_type.spec().timedelta();
1616    }
1617
1618    #[rstest]
1619    #[should_panic(expected = "`step` exceeds u32 range for month arithmetic")]
1620    fn test_get_time_bar_start_month_step_exceeds_u32_panics() {
1621        let bar_type = bar_type_with_raw_step(1_usize << 40, BarAggregation::Month);
1622        let now = timestamp("2024-07-21T12:00:00Z");
1623        let _ = get_time_bar_start(now, &bar_type, None);
1624    }
1625
1626    #[rstest]
1627    #[should_panic(expected = "`step` exceeds i32 range for year arithmetic")]
1628    fn test_get_time_bar_start_year_step_exceeds_i32_panics() {
1629        let bar_type = bar_type_with_raw_step(1_usize << 40, BarAggregation::Year);
1630        let now = timestamp("2024-07-21T12:00:00Z");
1631        let _ = get_time_bar_start(now, &bar_type, None);
1632    }
1633
1634    #[rstest]
1635    #[should_panic(expected = "year exceeds Jiff supported range")]
1636    fn test_get_time_bar_start_year_step_exceeds_jiff_range_panics() {
1637        let bar_type = bar_type_with_raw_step(32_000, BarAggregation::Year);
1638        let now = timestamp("2024-07-21T12:00:00Z");
1639        let _ = get_time_bar_start(now, &bar_type, None);
1640    }
1641
1642    #[rstest]
1643    #[case::millisecond(
1644    Timestamp::new(1_658_349_296, 123_000_000).unwrap(), // 2022-07-20 20:34:56.123 UTC
1645    BarAggregation::Millisecond,
1646    1,
1647    Timestamp::new(1_658_349_296, 123_000_000).unwrap(), // 2022-07-20 20:34:56.123 UTC
1648    )]
1649    #[rstest]
1650    #[case::millisecond(
1651    Timestamp::new(1_658_349_296, 123_000_000).unwrap(), // 2022-07-20 20:34:56.123 UTC
1652    BarAggregation::Millisecond,
1653    10,
1654    Timestamp::new(1_658_349_296, 120_000_000).unwrap(), // 2022-07-20 20:34:56.120 UTC
1655    )]
1656    #[case::second(
1657        timestamp("2024-07-21T12:34:56Z"),
1658        BarAggregation::Second,
1659        1,
1660        timestamp("2024-07-21T12:34:56Z")
1661    )]
1662    #[case::second(
1663        timestamp("2024-07-21T12:34:56Z"),
1664        BarAggregation::Second,
1665        5,
1666        timestamp("2024-07-21T12:34:55Z")
1667    )]
1668    #[case::minute(
1669        timestamp("2024-07-21T12:34:56Z"),
1670        BarAggregation::Minute,
1671        1,
1672        timestamp("2024-07-21T12:34:00Z")
1673    )]
1674    #[case::minute(
1675        timestamp("2024-07-21T12:34:56Z"),
1676        BarAggregation::Minute,
1677        5,
1678        timestamp("2024-07-21T12:30:00Z")
1679    )]
1680    #[case::hour(
1681        timestamp("2024-07-21T12:34:56Z"),
1682        BarAggregation::Hour,
1683        1,
1684        timestamp("2024-07-21T12:00:00Z")
1685    )]
1686    #[case::hour(
1687        timestamp("2024-07-21T12:34:56Z"),
1688        BarAggregation::Hour,
1689        2,
1690        timestamp("2024-07-21T12:00:00Z")
1691    )]
1692    #[case::day(
1693        timestamp("2024-07-21T12:34:56Z"),
1694        BarAggregation::Day,
1695        1,
1696        timestamp("2024-07-21T00:00:00Z")
1697    )]
1698    fn test_get_time_bar_start(
1699        #[case] now: Timestamp,
1700        #[case] aggregation: BarAggregation,
1701        #[case] step: usize,
1702        #[case] expected: Timestamp,
1703    ) {
1704        let bar_type = BarType::Standard {
1705            instrument_id: InstrumentId::from("BTCUSDT-PERP.BINANCE"),
1706            spec: BarSpecification::new(step, aggregation, PriceType::Last),
1707            aggregation_source: AggregationSource::Internal,
1708        };
1709
1710        let start_time = get_time_bar_start(now, &bar_type, None);
1711        assert_eq!(start_time, expected);
1712    }
1713
1714    #[rstest]
1715    fn test_bar_spec_string_reprs() {
1716        let bar_spec = BarSpecification::new(1, BarAggregation::Minute, PriceType::Bid);
1717        assert_eq!(bar_spec.to_string(), "1-MINUTE-BID");
1718        assert_eq!(format!("{bar_spec}"), "1-MINUTE-BID");
1719    }
1720
1721    #[rstest]
1722    fn test_bar_type_parse_valid() {
1723        let input = "BTCUSDT-PERP.BINANCE-1-MINUTE-LAST-EXTERNAL";
1724        let bar_type = BarType::from(input);
1725
1726        assert_eq!(
1727            bar_type.instrument_id(),
1728            InstrumentId::from("BTCUSDT-PERP.BINANCE")
1729        );
1730        assert_eq!(
1731            bar_type.spec(),
1732            BarSpecification::new(1, BarAggregation::Minute, PriceType::Last)
1733        );
1734        assert_eq!(bar_type.aggregation_source(), AggregationSource::External);
1735        assert_eq!(bar_type, BarType::from(input));
1736    }
1737
1738    #[rstest]
1739    #[case("BTCUSDT-PERP.BINANCE-1-MINUTE-LAST-EXTERNAL", true, false)]
1740    #[case("BTCUSDT-PERP.BINANCE-1-MINUTE-LAST-INTERNAL", false, true)]
1741    #[case(
1742        "BTCUSDT-PERP.BINANCE-2-MINUTE-LAST-INTERNAL@1-MINUTE-EXTERNAL",
1743        false,
1744        true
1745    )]
1746    #[case(
1747        "BTCUSDT-PERP.BINANCE-2-MINUTE-LAST-EXTERNAL@1-MINUTE-INTERNAL",
1748        true,
1749        false
1750    )]
1751    fn test_bar_type_aggregation_source_predicates(
1752        #[case] input: &str,
1753        #[case] expected_external: bool,
1754        #[case] expected_internal: bool,
1755    ) {
1756        let bar_type = BarType::from(input);
1757        assert_eq!(bar_type.is_externally_aggregated(), expected_external);
1758        assert_eq!(bar_type.is_internally_aggregated(), expected_internal);
1759    }
1760
1761    #[rstest]
1762    fn test_bar_type_composite_aggregation_source_predicates_track_inner() {
1763        let bar_type =
1764            BarType::from("BTCUSDT-PERP.BINANCE-2-MINUTE-LAST-INTERNAL@1-MINUTE-EXTERNAL");
1765
1766        assert!(bar_type.is_internally_aggregated());
1767        assert!(!bar_type.is_externally_aggregated());
1768
1769        let composite = bar_type.composite();
1770        assert!(composite.is_externally_aggregated());
1771        assert!(!composite.is_internally_aggregated());
1772    }
1773
1774    #[rstest]
1775    fn test_bar_type_from_str_with_utf8_symbol() {
1776        let non_ascii_instrument = "TËST-PÉRP.BINANCE";
1777        let non_ascii_bar_type = "TËST-PÉRP.BINANCE-1-MINUTE-LAST-EXTERNAL";
1778
1779        let bar_type = BarType::from_str(non_ascii_bar_type).unwrap();
1780
1781        assert_eq!(
1782            bar_type.instrument_id(),
1783            InstrumentId::from_str(non_ascii_instrument).unwrap()
1784        );
1785        assert_eq!(
1786            bar_type.spec(),
1787            BarSpecification::new(1, BarAggregation::Minute, PriceType::Last)
1788        );
1789        assert_eq!(bar_type.aggregation_source(), AggregationSource::External);
1790        assert_eq!(bar_type.to_string(), non_ascii_bar_type);
1791    }
1792
1793    #[rstest]
1794    fn test_bar_type_composite_parse_valid() {
1795        let input = "BTCUSDT-PERP.BINANCE-2-MINUTE-LAST-INTERNAL@1-MINUTE-EXTERNAL";
1796        let bar_type = BarType::from(input);
1797        let standard = bar_type.standard();
1798
1799        assert_eq!(
1800            bar_type.instrument_id(),
1801            InstrumentId::from("BTCUSDT-PERP.BINANCE")
1802        );
1803        assert_eq!(
1804            bar_type.spec(),
1805            BarSpecification::new(2, BarAggregation::Minute, PriceType::Last,)
1806        );
1807        assert_eq!(bar_type.aggregation_source(), AggregationSource::Internal);
1808        assert_eq!(bar_type, BarType::from(input));
1809        assert!(bar_type.is_composite());
1810
1811        assert_eq!(
1812            standard.instrument_id(),
1813            InstrumentId::from("BTCUSDT-PERP.BINANCE")
1814        );
1815        assert_eq!(
1816            standard.spec(),
1817            BarSpecification::new(2, BarAggregation::Minute, PriceType::Last,)
1818        );
1819        assert_eq!(standard.aggregation_source(), AggregationSource::Internal);
1820        assert!(standard.is_standard());
1821
1822        let composite = bar_type.composite();
1823        let composite_input = "BTCUSDT-PERP.BINANCE-1-MINUTE-LAST-EXTERNAL";
1824
1825        assert_eq!(
1826            composite.instrument_id(),
1827            InstrumentId::from("BTCUSDT-PERP.BINANCE")
1828        );
1829        assert_eq!(
1830            composite.spec(),
1831            BarSpecification::new(1, BarAggregation::Minute, PriceType::Last,)
1832        );
1833        assert_eq!(composite.aggregation_source(), AggregationSource::External);
1834        assert_eq!(composite, BarType::from(composite_input));
1835        assert!(composite.is_standard());
1836    }
1837
1838    #[rstest]
1839    fn test_bar_type_parse_invalid_token_pos_0() {
1840        let input = "BTCUSDT-PERP-1-MINUTE-LAST-INTERNAL";
1841        let result = BarType::from_str(input);
1842
1843        assert_eq!(
1844            result.unwrap_err().to_string(),
1845            format!(
1846                "Error parsing `BarType` from '{input}', invalid token: 'BTCUSDT-PERP' at position 0"
1847            )
1848        );
1849    }
1850
1851    #[rstest]
1852    fn test_bar_type_parse_invalid_token_pos_1() {
1853        let input = "BTCUSDT-PERP.BINANCE-INVALID-MINUTE-LAST-INTERNAL";
1854        let result = BarType::from_str(input);
1855
1856        assert_eq!(
1857            result.unwrap_err().to_string(),
1858            format!(
1859                "Error parsing `BarType` from '{input}', invalid token: 'INVALID' at position 1"
1860            )
1861        );
1862    }
1863
1864    #[rstest]
1865    fn test_bar_type_parse_invalid_spec_step() {
1866        let input = "BTCUSDT-PERP.BINANCE-60-MINUTE-LAST-INTERNAL";
1867        let result = BarType::from_str(input);
1868
1869        assert_eq!(
1870            result.unwrap_err().to_string(),
1871            format!("Error parsing `BarType` from '{input}', invalid token: '60' at position 1")
1872        );
1873    }
1874
1875    #[rstest]
1876    fn test_bar_type_parse_invalid_token_pos_2() {
1877        let input = "BTCUSDT-PERP.BINANCE-1-INVALID-LAST-INTERNAL";
1878        let result = BarType::from_str(input);
1879
1880        assert_eq!(
1881            result.unwrap_err().to_string(),
1882            format!(
1883                "Error parsing `BarType` from '{input}', invalid token: 'INVALID' at position 2"
1884            )
1885        );
1886    }
1887
1888    #[rstest]
1889    fn test_bar_type_parse_invalid_token_pos_3() {
1890        let input = "BTCUSDT-PERP.BINANCE-1-MINUTE-INVALID-INTERNAL";
1891        let result = BarType::from_str(input);
1892
1893        assert_eq!(
1894            result.unwrap_err().to_string(),
1895            format!(
1896                "Error parsing `BarType` from '{input}', invalid token: 'INVALID' at position 3"
1897            )
1898        );
1899    }
1900
1901    #[rstest]
1902    fn test_bar_type_parse_invalid_token_pos_4() {
1903        let input = "BTCUSDT-PERP.BINANCE-1-MINUTE-BID-INVALID";
1904        let result = BarType::from_str(input);
1905
1906        assert!(result.is_err());
1907        assert_eq!(
1908            result.unwrap_err().to_string(),
1909            format!(
1910                "Error parsing `BarType` from '{input}', invalid token: 'INVALID' at position 4"
1911            )
1912        );
1913    }
1914
1915    #[rstest]
1916    fn test_bar_type_parse_invalid_token_pos_5() {
1917        let input = "BTCUSDT-PERP.BINANCE-2-MINUTE-LAST-INTERNAL@INVALID-MINUTE-EXTERNAL";
1918        let result = BarType::from_str(input);
1919
1920        assert!(result.is_err());
1921        assert_eq!(
1922            result.unwrap_err().to_string(),
1923            format!(
1924                "Error parsing `BarType` from '{input}', invalid token: 'INVALID' at position 5"
1925            )
1926        );
1927    }
1928
1929    #[rstest]
1930    fn test_bar_type_parse_invalid_composite_spec_step() {
1931        let input = "BTCUSDT-PERP.BINANCE-2-MINUTE-LAST-INTERNAL@60-MINUTE-EXTERNAL";
1932        let result = BarType::from_str(input);
1933
1934        assert!(result.is_err());
1935        assert_eq!(
1936            result.unwrap_err().to_string(),
1937            format!("Error parsing `BarType` from '{input}', invalid token: '60' at position 5")
1938        );
1939    }
1940
1941    #[rstest]
1942    fn test_bar_type_parse_invalid_token_pos_6() {
1943        let input = "BTCUSDT-PERP.BINANCE-2-MINUTE-LAST-INTERNAL@1-INVALID-EXTERNAL";
1944        let result = BarType::from_str(input);
1945
1946        assert!(result.is_err());
1947        assert_eq!(
1948            result.unwrap_err().to_string(),
1949            format!(
1950                "Error parsing `BarType` from '{input}', invalid token: 'INVALID' at position 6"
1951            )
1952        );
1953    }
1954
1955    #[rstest]
1956    fn test_bar_type_parse_invalid_token_pos_7() {
1957        let input = "BTCUSDT-PERP.BINANCE-2-MINUTE-LAST-INTERNAL@1-MINUTE-INVALID";
1958        let result = BarType::from_str(input);
1959
1960        assert!(result.is_err());
1961        assert_eq!(
1962            result.unwrap_err().to_string(),
1963            format!(
1964                "Error parsing `BarType` from '{input}', invalid token: 'INVALID' at position 7"
1965            )
1966        );
1967    }
1968
1969    #[rstest]
1970    fn test_bar_type_parse_rejects_extra_composite_segment() {
1971        let input = "BTCUSDT-PERP.BINANCE-2-MINUTE-LAST-INTERNAL@1-MINUTE-EXTERNAL@1-HOUR-EXTERNAL";
1972        let result = BarType::from_str(input);
1973
1974        assert_eq!(
1975            result.unwrap_err().to_string(),
1976            format!(
1977                "Error parsing `BarType` from '{input}', invalid token: '1-HOUR-EXTERNAL' at position 5"
1978            )
1979        );
1980    }
1981
1982    #[rstest]
1983    fn test_bar_type_equality() {
1984        let instrument_id1 = InstrumentId {
1985            symbol: Symbol::new("AUD/USD"),
1986            venue: Venue::new("SIM"),
1987        };
1988        let instrument_id2 = InstrumentId {
1989            symbol: Symbol::new("GBP/USD"),
1990            venue: Venue::new("SIM"),
1991        };
1992        let bar_spec = BarSpecification::new(1, BarAggregation::Minute, PriceType::Bid);
1993        let bar_type1 = BarType::Standard {
1994            instrument_id: instrument_id1,
1995            spec: bar_spec,
1996            aggregation_source: AggregationSource::External,
1997        };
1998        let bar_type2 = BarType::Standard {
1999            instrument_id: instrument_id1,
2000            spec: bar_spec,
2001            aggregation_source: AggregationSource::External,
2002        };
2003        let bar_type3 = BarType::Standard {
2004            instrument_id: instrument_id2,
2005            spec: bar_spec,
2006            aggregation_source: AggregationSource::External,
2007        };
2008        assert_eq!(bar_type1, bar_type1);
2009        assert_eq!(bar_type1, bar_type2);
2010        assert_ne!(bar_type1, bar_type3);
2011    }
2012
2013    #[rstest]
2014    fn test_bar_type_id_spec_key_ignores_aggregation_source() {
2015        let bar_type_external = BarType::from_str("ESM4.XCME-1-MINUTE-LAST-EXTERNAL").unwrap();
2016        let bar_type_internal = BarType::from_str("ESM4.XCME-1-MINUTE-LAST-INTERNAL").unwrap();
2017
2018        // Full equality should differ
2019        assert_ne!(bar_type_external, bar_type_internal);
2020
2021        // id_spec_key should be the same
2022        assert_eq!(
2023            bar_type_external.id_spec_key(),
2024            bar_type_internal.id_spec_key()
2025        );
2026
2027        // Verify key components
2028        let (instrument_id, spec) = bar_type_external.id_spec_key();
2029        assert_eq!(instrument_id, bar_type_external.instrument_id());
2030        assert_eq!(spec, bar_type_external.spec());
2031    }
2032
2033    #[rstest]
2034    fn test_bar_type_comparison() {
2035        let instrument_id1 = InstrumentId {
2036            symbol: Symbol::new("AUD/USD"),
2037            venue: Venue::new("SIM"),
2038        };
2039
2040        let instrument_id2 = InstrumentId {
2041            symbol: Symbol::new("GBP/USD"),
2042            venue: Venue::new("SIM"),
2043        };
2044        let bar_spec = BarSpecification::new(1, BarAggregation::Minute, PriceType::Bid);
2045        let bar_spec2 = BarSpecification::new(2, BarAggregation::Minute, PriceType::Bid);
2046        let bar_type1 = BarType::Standard {
2047            instrument_id: instrument_id1,
2048            spec: bar_spec,
2049            aggregation_source: AggregationSource::External,
2050        };
2051        let bar_type2 = BarType::Standard {
2052            instrument_id: instrument_id1,
2053            spec: bar_spec,
2054            aggregation_source: AggregationSource::External,
2055        };
2056        let bar_type3 = BarType::Standard {
2057            instrument_id: instrument_id2,
2058            spec: bar_spec,
2059            aggregation_source: AggregationSource::External,
2060        };
2061        let bar_type4 = BarType::Composite {
2062            instrument_id: instrument_id2,
2063            spec: bar_spec2,
2064            aggregation_source: AggregationSource::Internal,
2065
2066            composite_step: 1,
2067            composite_aggregation: BarAggregation::Minute,
2068            composite_aggregation_source: AggregationSource::External,
2069        };
2070
2071        assert!(bar_type1 <= bar_type2);
2072        assert!(bar_type1 < bar_type3);
2073        assert!(bar_type3 > bar_type1);
2074        assert!(bar_type3 >= bar_type1);
2075        assert!(bar_type4 >= bar_type1);
2076    }
2077
2078    #[rstest]
2079    fn test_bar_new() {
2080        let bar_type = BarType::from("AAPL.XNAS-1-MINUTE-LAST-INTERNAL");
2081        let open = Price::from("100.0");
2082        let high = Price::from("105.0");
2083        let low = Price::from("95.0");
2084        let close = Price::from("102.0");
2085        let volume = Quantity::from("1000");
2086        let ts_event = UnixNanos::from(1_000_000);
2087        let ts_init = UnixNanos::from(2_000_000);
2088
2089        let bar = Bar::new(bar_type, open, high, low, close, volume, ts_event, ts_init);
2090
2091        assert_eq!(bar.bar_type, bar_type);
2092        assert_eq!(bar.open, open);
2093        assert_eq!(bar.high, high);
2094        assert_eq!(bar.low, low);
2095        assert_eq!(bar.close, close);
2096        assert_eq!(bar.volume, volume);
2097        assert_eq!(bar.ts_event, ts_event);
2098        assert_eq!(bar.ts_init, ts_init);
2099    }
2100
2101    #[rstest]
2102    #[case("100.0", "90.0", "95.0", "92.0", "high >= open")]
2103    #[case("100.0", "105.0", "110.0", "102.0", "high >= low")]
2104    #[case("100.0", "105.0", "95.0", "110.0", "high >= close")]
2105    #[case("100.0", "105.0", "95.0", "90.0", "low <= close")]
2106    #[case("100.0", "110.0", "105.0", "108.0", "low <= open")]
2107    #[case("100.0", "90.0", "110.0", "120.0", "high >= open")] // First failing predicate reported
2108    fn test_bar_new_checked_conditions(
2109        #[case] open: &str,
2110        #[case] high: &str,
2111        #[case] low: &str,
2112        #[case] close: &str,
2113        #[case] expected: &str,
2114    ) {
2115        let bar_type = BarType::from("AAPL.XNAS-1-MINUTE-LAST-INTERNAL");
2116        let open = Price::from(open);
2117        let high = Price::from(high);
2118        let low = Price::from(low);
2119        let close = Price::from(close);
2120        let volume = Quantity::from("1000");
2121        let ts_event = UnixNanos::from(1_000_000);
2122        let ts_init = UnixNanos::from(2_000_000);
2123
2124        let result = Bar::new_checked(bar_type, open, high, low, close, volume, ts_event, ts_init);
2125
2126        let error = result.unwrap_err();
2127        assert!(
2128            error.to_string().contains(expected),
2129            "unexpected message: {error}"
2130        );
2131    }
2132
2133    #[rstest]
2134    fn test_bar_equality() {
2135        let instrument_id = InstrumentId {
2136            symbol: Symbol::new("AUDUSD"),
2137            venue: Venue::new("SIM"),
2138        };
2139        let bar_spec = BarSpecification::new(1, BarAggregation::Minute, PriceType::Bid);
2140        let bar_type = BarType::Standard {
2141            instrument_id,
2142            spec: bar_spec,
2143            aggregation_source: AggregationSource::External,
2144        };
2145        let bar1 = Bar {
2146            bar_type,
2147            open: Price::from("1.00001"),
2148            high: Price::from("1.00004"),
2149            low: Price::from("1.00002"),
2150            close: Price::from("1.00003"),
2151            volume: Quantity::from("100000"),
2152            ts_event: UnixNanos::default(),
2153            ts_init: UnixNanos::from(1),
2154        };
2155
2156        let bar2 = Bar {
2157            bar_type,
2158            open: Price::from("1.00000"),
2159            high: Price::from("1.00004"),
2160            low: Price::from("1.00002"),
2161            close: Price::from("1.00003"),
2162            volume: Quantity::from("100000"),
2163            ts_event: UnixNanos::default(),
2164            ts_init: UnixNanos::from(1),
2165        };
2166        assert_eq!(bar1, bar1);
2167        assert_ne!(bar1, bar2);
2168    }
2169
2170    #[rstest]
2171    fn test_json_serialization() {
2172        let bar = Bar::default();
2173        let serialized = bar.to_json_bytes().unwrap();
2174        let deserialized = Bar::from_json_bytes(serialized.as_ref()).unwrap();
2175        assert_eq!(deserialized, bar);
2176    }
2177
2178    #[rstest]
2179    fn test_msgpack_serialization() {
2180        let bar = Bar::default();
2181        let serialized = bar.to_msgpack_bytes().unwrap();
2182        let deserialized = Bar::from_msgpack_bytes(serialized.as_ref()).unwrap();
2183        assert_eq!(deserialized, bar);
2184    }
2185
2186    #[rstest]
2187    fn test_bar_deserialization_rejects_invalid_ohlc() {
2188        let json = r#"{
2189            "type": "Bar",
2190            "bar_type": "AUD/USD.SIM-1-MINUTE-BID-EXTERNAL",
2191            "open": "1.00010",
2192            "high": "1.00000",
2193            "low": "1.00020",
2194            "close": "1.00010",
2195            "volume": "100000",
2196            "ts_event": 0,
2197            "ts_init": 0
2198        }"#;
2199
2200        let result = Bar::from_json_bytes(json.as_bytes());
2201        assert!(
2202            result.is_err(),
2203            "high < low must fail deserialization, was {result:?}"
2204        );
2205    }
2206
2207    #[rstest]
2208    fn test_bar_specification_deserialization_rejects_invalid_step() {
2209        let json = r#"{"step":7,"aggregation":"MINUTE","price_type":"LAST"}"#;
2210
2211        let result = serde_json::from_str::<BarSpecification>(json);
2212        assert!(
2213            result.is_err(),
2214            "non-periodic step must fail deserialization, was {result:?}"
2215        );
2216    }
2217
2218    #[rstest]
2219    fn test_bar_specification_builder_rejects_invalid_step() {
2220        let result = BarSpecificationBuilder::default()
2221            .step(NonZeroUsize::new(7).unwrap())
2222            .aggregation(BarAggregation::Minute)
2223            .price_type(PriceType::Last)
2224            .build();
2225
2226        assert!(
2227            result.is_err(),
2228            "non-periodic step must fail builder validation, was {result:?}"
2229        );
2230    }
2231
2232    #[rstest]
2233    fn test_bar_spec_12_month_round_trips() {
2234        // BAR_SPEC_12_MONTH_LAST is shipped (OKX yearly candles), so the string
2235        // form must parse back
2236        let bar_type = BarType::new(
2237            InstrumentId::from("BTC-USDT.OKX"),
2238            BAR_SPEC_12_MONTH_LAST,
2239            AggregationSource::External,
2240        );
2241
2242        let parsed = BarType::from_str(&bar_type.to_string()).unwrap();
2243        assert_eq!(parsed, bar_type);
2244        assert_eq!(
2245            BarSpecification::new_checked(12, BarAggregation::Month, PriceType::Last).unwrap(),
2246            BAR_SPEC_12_MONTH_LAST,
2247        );
2248    }
2249
2250    #[rstest]
2251    fn test_bar_type_new_composite_checked_invalid_step() {
2252        let instrument_id = InstrumentId::from("AUD/USD.SIM");
2253        let spec = BarSpecification::new(5, BarAggregation::Minute, PriceType::Bid);
2254
2255        let result = BarType::new_composite_checked(
2256            instrument_id,
2257            spec,
2258            AggregationSource::Internal,
2259            0,
2260            BarAggregation::Minute,
2261            AggregationSource::External,
2262        );
2263
2264        assert!(
2265            result.is_err(),
2266            "zero composite step must fail, was {result:?}"
2267        );
2268    }
2269}
2270
2271#[cfg(test)]
2272mod property_tests {
2273    use std::str::FromStr;
2274
2275    use proptest::prelude::*;
2276    use rstest::rstest;
2277
2278    use super::*;
2279    use crate::identifiers::{Symbol, Venue};
2280
2281    fn symbol_strategy() -> impl Strategy<Value = &'static str> {
2282        prop::sample::select(vec![
2283            "AAPL",
2284            "BTC-PERP",
2285            "EUR/USD",
2286            "ES-MINI-4",
2287            "MSFT.OQ",
2288            "6E",
2289        ])
2290    }
2291
2292    fn venue_strategy() -> impl Strategy<Value = &'static str> {
2293        prop::sample::select(vec!["SIM", "XNAS", "GLBX", "BINANCE"])
2294    }
2295
2296    fn time_spec_strategy() -> impl Strategy<Value = (BarAggregation, usize)> {
2297        prop_oneof![
2298            (
2299                Just(BarAggregation::Millisecond),
2300                prop::sample::select(vec![1usize, 2, 5, 10, 25, 50, 100, 250, 500]),
2301            ),
2302            (
2303                Just(BarAggregation::Second),
2304                prop::sample::select(vec![1usize, 2, 3, 5, 10, 15, 30]),
2305            ),
2306            (
2307                Just(BarAggregation::Minute),
2308                prop::sample::select(vec![1usize, 2, 5, 15, 30]),
2309            ),
2310            (
2311                Just(BarAggregation::Hour),
2312                prop::sample::select(vec![1usize, 2, 4, 12]),
2313            ),
2314            (
2315                Just(BarAggregation::Day),
2316                prop::sample::select(vec![1usize, 2, 3]),
2317            ),
2318            (Just(BarAggregation::Week), Just(1usize)),
2319        ]
2320    }
2321
2322    fn spec_strategy() -> impl Strategy<Value = (BarAggregation, usize)> {
2323        prop_oneof![
2324            time_spec_strategy(),
2325            // Month stays out of time_spec_strategy: the alignment proptest uses the
2326            // 30-day proxy interval, which is unsound for calendar months
2327            (
2328                Just(BarAggregation::Month),
2329                prop::sample::select(vec![1usize, 2, 3, 4, 6, 12]),
2330            ),
2331            (Just(BarAggregation::Tick), 1usize..=10_000),
2332            (Just(BarAggregation::Volume), 1usize..=10_000),
2333            (Just(BarAggregation::Value), 1usize..=10_000),
2334        ]
2335    }
2336
2337    fn price_type_strategy() -> impl Strategy<Value = PriceType> {
2338        prop::sample::select(vec![
2339            PriceType::Bid,
2340            PriceType::Ask,
2341            PriceType::Mid,
2342            PriceType::Last,
2343        ])
2344    }
2345
2346    fn source_strategy() -> impl Strategy<Value = AggregationSource> {
2347        prop_oneof![
2348            Just(AggregationSource::Internal),
2349            Just(AggregationSource::External),
2350        ]
2351    }
2352
2353    proptest! {
2354        #[rstest]
2355        fn prop_bar_type_string_round_trip(
2356            symbol in symbol_strategy(),
2357            venue in venue_strategy(),
2358            (aggregation, step) in spec_strategy(),
2359            price_type in price_type_strategy(),
2360            source in source_strategy(),
2361            composite in prop::option::of((time_spec_strategy(), source_strategy())),
2362        ) {
2363            let instrument_id = InstrumentId::new(Symbol::from(symbol), Venue::from(venue));
2364            let spec = BarSpecification::new(step, aggregation, price_type);
2365
2366            let bar_type = match composite {
2367                None => BarType::new(instrument_id, spec, source),
2368                Some(((composite_aggregation, composite_step), composite_source)) => {
2369                    BarType::new_composite(
2370                        instrument_id,
2371                        spec,
2372                        source,
2373                        composite_step,
2374                        composite_aggregation,
2375                        composite_source,
2376                    )
2377                }
2378            };
2379
2380            let parsed = BarType::from_str(&bar_type.to_string());
2381            prop_assert!(parsed.is_ok(), "failed to parse '{bar_type}': {parsed:?}");
2382            prop_assert_eq!(parsed.unwrap(), bar_type);
2383        }
2384
2385        #[rstest]
2386        fn prop_get_time_bar_start_alignment(
2387            (aggregation, step) in time_spec_strategy(),
2388            epoch_secs in 946_684_800i64..2_524_608_000i64,
2389            subsec_nanos in 0u32..1_000_000_000u32,
2390        ) {
2391            let instrument_id = InstrumentId::from("AAPL.XNAS");
2392            let spec = BarSpecification::new(step, aggregation, PriceType::Last);
2393            let bar_type = BarType::new(instrument_id, spec, AggregationSource::Internal);
2394
2395            let now = Timestamp::new(epoch_secs, subsec_nanos.cast_signed()).unwrap();
2396            let start = get_time_bar_start(now, &bar_type, None);
2397            let interval = get_bar_interval(&bar_type);
2398
2399            prop_assert!(start <= now, "start {start} must not be after now {now}");
2400            prop_assert!(
2401                now.duration_since(start) < interval,
2402                "now {now} must fall within one interval of start {start}"
2403            );
2404        }
2405    }
2406}