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