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