Skip to main content

nautilus_model/python/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
16use std::{
17    collections::{HashMap, hash_map::DefaultHasher},
18    hash::{Hash, Hasher},
19    str::FromStr,
20};
21
22use nautilus_core::{
23    python::{
24        IntoPyObjectNautilusExt,
25        serialization::{from_dict_pyo3, to_dict_pyo3},
26        to_pyvalue_err,
27    },
28    serialization::{
29        Serializable,
30        msgpack::{FromMsgPack, ToMsgPack},
31    },
32};
33use pyo3::{
34    IntoPyObjectExt,
35    prelude::*,
36    pyclass::CompareOp,
37    types::{PyDict, PyTuple},
38};
39
40use super::data_to_pycapsule;
41use crate::{
42    data::{
43        Data,
44        bar::{Bar, BarSpecification, BarType},
45    },
46    enums::{AggregationSource, BarAggregation, PriceType},
47    identifiers::InstrumentId,
48    python::common::PY_MODULE_MODEL,
49    types::{
50        price::{Price, PriceRaw},
51        quantity::{Quantity, QuantityRaw},
52    },
53};
54
55#[pymethods]
56#[pyo3_stub_gen::derive::gen_stub_pymethods]
57impl BarSpecification {
58    /// Represents a bar aggregation specification including a step, aggregation
59    /// method/rule and price type.
60    #[new]
61    fn py_new(step: usize, aggregation: BarAggregation, price_type: PriceType) -> PyResult<Self> {
62        Self::new_checked(step, aggregation, price_type).map_err(to_pyvalue_err)
63    }
64
65    fn __richcmp__(&self, other: &Self, op: CompareOp, py: Python<'_>) -> Py<PyAny> {
66        match op {
67            CompareOp::Eq => self.eq(other).into_py_any_unwrap(py),
68            CompareOp::Ne => self.ne(other).into_py_any_unwrap(py),
69            _ => py.NotImplemented(),
70        }
71    }
72
73    fn __hash__(&self) -> isize {
74        let mut h = DefaultHasher::new();
75        self.hash(&mut h);
76        h.finish() as isize
77    }
78
79    fn __repr__(&self) -> String {
80        format!("{self:?}")
81    }
82
83    fn __str__(&self) -> String {
84        self.to_string()
85    }
86
87    #[getter]
88    #[pyo3(name = "step")]
89    fn py_step(&self) -> usize {
90        self.step.get()
91    }
92
93    #[getter]
94    #[pyo3(name = "aggregation")]
95    fn py_aggregation(&self) -> BarAggregation {
96        self.aggregation
97    }
98
99    #[getter]
100    #[pyo3(name = "price_type")]
101    fn py_price_type(&self) -> PriceType {
102        self.price_type
103    }
104
105    #[staticmethod]
106    #[pyo3(name = "fully_qualified_name")]
107    fn py_fully_qualified_name() -> String {
108        format!("{}:{}", PY_MODULE_MODEL, stringify!(BarSpecification))
109    }
110
111    /// Returns the `TimeDelta` interval for this bar specification.
112    ///
113    /// # Notes
114    ///
115    /// For `BarAggregation.Month` and `BarAggregation.Year`, proxy values are used
116    /// (30 days for months, 365 days for years) to estimate their respective durations,
117    /// since months and years have variable lengths.
118    #[getter]
119    #[pyo3(name = "timedelta")]
120    fn py_timedelta(&self) -> PyResult<chrono::TimeDelta> {
121        if !self.is_time_aggregated() {
122            return Err(to_pyvalue_err(format!(
123                "Timedelta not supported for aggregation type: {:?}",
124                self.aggregation
125            )));
126        }
127        Ok(self.timedelta())
128    }
129
130    /// Return a value indicating whether the aggregation method is time-driven:
131    ///  - `BarAggregation.Millisecond`
132    ///  - `BarAggregation.Second`
133    ///  - `BarAggregation.Minute`
134    ///  - `BarAggregation.Hour`
135    ///  - `BarAggregation.Day`
136    ///  - `BarAggregation.Week`
137    ///  - `BarAggregation.Month`
138    ///  - `BarAggregation.Year`
139    #[pyo3(name = "is_time_aggregated")]
140    fn py_is_time_aggregated(&self) -> bool {
141        self.is_time_aggregated()
142    }
143
144    /// Return a value indicating whether the aggregation method is threshold-driven:
145    ///  - `BarAggregation.Tick`
146    ///  - `BarAggregation.TickImbalance`
147    ///  - `BarAggregation.Volume`
148    ///  - `BarAggregation.VolumeImbalance`
149    ///  - `BarAggregation.Value`
150    ///  - `BarAggregation.ValueImbalance`
151    #[pyo3(name = "is_threshold_aggregated")]
152    fn py_is_threshold_aggregated(&self) -> bool {
153        self.is_threshold_aggregated()
154    }
155
156    /// Return a value indicating whether the aggregation method is information-driven:
157    ///  - `BarAggregation.TickRuns`
158    ///  - `BarAggregation.VolumeRuns`
159    ///  - `BarAggregation.ValueRuns`
160    #[pyo3(name = "is_information_aggregated")]
161    fn py_is_information_aggregated(&self) -> bool {
162        self.is_information_aggregated()
163    }
164
165    /// Returns the interval length in nanoseconds for time-based bar specifications.
166    #[pyo3(name = "get_interval_ns")]
167    fn py_get_interval_ns(&self) -> PyResult<u64> {
168        if !self.is_time_aggregated() {
169            return Err(to_pyvalue_err(format!(
170                "Aggregation not time based, was {:?}",
171                self.aggregation
172            )));
173        }
174        let td = self.timedelta();
175        Ok(td.num_nanoseconds().unwrap() as u64)
176    }
177
178    /// Creates a `BarSpecification` from a Python `timedelta` and price type.
179    #[staticmethod]
180    #[pyo3(name = "from_timedelta")]
181    fn py_from_timedelta(duration: chrono::TimeDelta, price_type: PriceType) -> PyResult<Self> {
182        if duration.num_milliseconds() <= 0 {
183            return Err(to_pyvalue_err(format!(
184                "Duration must be positive, was {duration:?}"
185            )));
186        }
187        let total_secs_f64 = duration.num_milliseconds() as f64 / 1000.0;
188        let days = duration.num_days();
189
190        let (step, aggregation) = if days >= 7 {
191            (days / 7, BarAggregation::Week)
192        } else if days >= 1 {
193            (days, BarAggregation::Day)
194        } else if total_secs_f64 >= 3600.0 {
195            ((total_secs_f64 / 3600.0) as i64, BarAggregation::Hour)
196        } else if total_secs_f64 >= 60.0 {
197            ((total_secs_f64 / 60.0) as i64, BarAggregation::Minute)
198        } else if total_secs_f64 >= 1.0 {
199            (total_secs_f64 as i64, BarAggregation::Second)
200        } else {
201            (
202                (total_secs_f64 * 1000.0) as i64,
203                BarAggregation::Millisecond,
204            )
205        };
206
207        let spec =
208            Self::new_checked(step as usize, aggregation, price_type).map_err(to_pyvalue_err)?;
209
210        // Validate roundtrip
211        let roundtrip = spec.timedelta();
212        if roundtrip != duration {
213            return Err(to_pyvalue_err(format!(
214                "Duration {duration:?} is ambiguous"
215            )));
216        }
217
218        Ok(spec)
219    }
220
221    /// Returns whether the given aggregation is time-based.
222    #[staticmethod]
223    #[pyo3(name = "check_time_aggregated")]
224    fn py_check_time_aggregated(aggregation: BarAggregation) -> bool {
225        matches!(
226            aggregation,
227            BarAggregation::Millisecond
228                | BarAggregation::Second
229                | BarAggregation::Minute
230                | BarAggregation::Hour
231                | BarAggregation::Day
232                | BarAggregation::Week
233                | BarAggregation::Month
234                | BarAggregation::Year
235        )
236    }
237
238    /// Returns whether the given aggregation is threshold-based.
239    #[staticmethod]
240    #[pyo3(name = "check_threshold_aggregated")]
241    fn py_check_threshold_aggregated(aggregation: BarAggregation) -> bool {
242        matches!(
243            aggregation,
244            BarAggregation::Tick
245                | BarAggregation::TickImbalance
246                | BarAggregation::Volume
247                | BarAggregation::VolumeImbalance
248                | BarAggregation::Value
249                | BarAggregation::ValueImbalance
250        )
251    }
252
253    /// Returns whether the given aggregation is information-based.
254    #[staticmethod]
255    #[pyo3(name = "check_information_aggregated")]
256    fn py_check_information_aggregated(aggregation: BarAggregation) -> bool {
257        matches!(
258            aggregation,
259            BarAggregation::TickRuns | BarAggregation::VolumeRuns | BarAggregation::ValueRuns
260        )
261    }
262
263    fn __reduce__(&self, py: Python) -> PyResult<Py<PyAny>> {
264        let from_str = py.get_type::<Self>().getattr("from_str")?;
265        (from_str, (self.to_string(),)).into_py_any(py)
266    }
267
268    /// Creates a `BarSpecification` from a string representation.
269    #[staticmethod]
270    #[pyo3(name = "from_str")]
271    fn py_from_str(value: &str) -> PyResult<Self> {
272        let pieces: Vec<&str> = value.rsplitn(3, '-').collect();
273        if pieces.len() != 3 {
274            return Err(to_pyvalue_err(format!(
275                "The `BarSpecification` string value was malformed, was {value}"
276            )));
277        }
278        let step: usize = pieces[2].parse().map_err(to_pyvalue_err)?;
279        let aggregation = BarAggregation::from_str(pieces[1]).map_err(to_pyvalue_err)?;
280        let price_type = PriceType::from_str(pieces[0]).map_err(to_pyvalue_err)?;
281        Self::new_checked(step, aggregation, price_type).map_err(to_pyvalue_err)
282    }
283}
284
285#[pymethods]
286#[pyo3_stub_gen::derive::gen_stub_pymethods]
287impl BarType {
288    /// Represents a bar type including the instrument ID, bar specification and
289    /// aggregation source.
290    #[new]
291    #[pyo3(signature = (instrument_id, spec, aggregation_source = AggregationSource::External)
292    )]
293    fn py_new(
294        instrument_id: InstrumentId,
295        spec: BarSpecification,
296        aggregation_source: AggregationSource,
297    ) -> Self {
298        Self::new(instrument_id, spec, aggregation_source)
299    }
300
301    fn __richcmp__(&self, other: &Self, op: CompareOp, py: Python<'_>) -> Py<PyAny> {
302        match op {
303            CompareOp::Eq => self.eq(other).into_py_any_unwrap(py),
304            CompareOp::Ne => self.ne(other).into_py_any_unwrap(py),
305            _ => py.NotImplemented(),
306        }
307    }
308
309    fn __hash__(&self) -> isize {
310        let mut h = DefaultHasher::new();
311        self.hash(&mut h);
312        h.finish() as isize
313    }
314
315    fn __repr__(&self) -> String {
316        format!("{self:?}")
317    }
318
319    fn __str__(&self) -> String {
320        self.to_string()
321    }
322
323    #[staticmethod]
324    #[pyo3(name = "fully_qualified_name")]
325    fn py_fully_qualified_name() -> String {
326        format!("{}:{}", PY_MODULE_MODEL, stringify!(BarType))
327    }
328
329    #[staticmethod]
330    #[pyo3(name = "from_str")]
331    fn py_from_str(value: &str) -> PyResult<Self> {
332        Self::from_str(value).map_err(to_pyvalue_err)
333    }
334
335    /// Creates a new composite `BarType` instance.
336    #[staticmethod]
337    #[pyo3(name = "new_composite")]
338    fn py_new_composite(
339        instrument_id: InstrumentId,
340        spec: BarSpecification,
341        aggregation_source: AggregationSource,
342        composite_step: usize,
343        composite_aggregation: BarAggregation,
344        composite_aggregation_source: AggregationSource,
345    ) -> Self {
346        Self::new_composite(
347            instrument_id,
348            spec,
349            aggregation_source,
350            composite_step,
351            composite_aggregation,
352            composite_aggregation_source,
353        )
354    }
355
356    /// Returns whether this instance is a standard bar type.
357    #[pyo3(name = "is_standard")]
358    fn py_is_standard(&self) -> bool {
359        self.is_standard()
360    }
361
362    /// Returns whether this instance is a composite bar type.
363    #[pyo3(name = "is_composite")]
364    fn py_is_composite(&self) -> bool {
365        self.is_composite()
366    }
367
368    /// Returns the standard bar type component.
369    #[pyo3(name = "standard")]
370    fn py_standard(&self) -> Self {
371        self.standard()
372    }
373
374    /// Returns any composite bar type component.
375    #[pyo3(name = "composite")]
376    fn py_composite(&self) -> Self {
377        self.composite()
378    }
379
380    /// Returns the instrument ID and bar specification as a tuple key.
381    ///
382    /// Useful as a hashmap key when aggregation source should be ignored,
383    /// such as for indicator registration where INTERNAL and EXTERNAL bars
384    /// should trigger the same indicators.
385    #[pyo3(name = "id_spec_key")]
386    fn py_id_spec_key(&self) -> (InstrumentId, BarSpecification) {
387        self.id_spec_key()
388    }
389
390    /// Returns whether this bar type is externally aggregated.
391    #[pyo3(name = "is_externally_aggregated")]
392    fn py_is_externally_aggregated(&self) -> bool {
393        self.aggregation_source() == AggregationSource::External
394    }
395
396    /// Returns whether this bar type is internally aggregated.
397    #[pyo3(name = "is_internally_aggregated")]
398    fn py_is_internally_aggregated(&self) -> bool {
399        self.aggregation_source() == AggregationSource::Internal
400    }
401
402    /// Returns the `InstrumentId` for this bar type.
403    #[getter]
404    #[pyo3(name = "instrument_id")]
405    fn py_instrument_id(&self) -> InstrumentId {
406        self.instrument_id()
407    }
408
409    /// Returns the `BarSpecification` for this bar type.
410    #[getter]
411    #[pyo3(name = "spec")]
412    fn py_spec(&self) -> BarSpecification {
413        self.spec()
414    }
415
416    /// Returns the `AggregationSource` for this bar type.
417    #[getter]
418    #[pyo3(name = "aggregation_source")]
419    fn py_aggregation_source(&self) -> AggregationSource {
420        self.aggregation_source()
421    }
422
423    fn __reduce__(&self, py: Python) -> PyResult<Py<PyAny>> {
424        let from_str = py.get_type::<Self>().getattr("from_str")?;
425        (from_str, (self.to_string(),)).into_py_any(py)
426    }
427}
428
429impl Bar {
430    /// Creates a Rust `Bar` instance from a Python object.
431    ///
432    /// # Errors
433    ///
434    /// Returns a `PyErr` if retrieving any attribute or converting types fails.
435    pub fn from_pyobject(obj: &Bound<'_, PyAny>) -> PyResult<Self> {
436        let bar_type_obj: Bound<'_, PyAny> = obj.getattr("bar_type")?.extract()?;
437        let bar_type_str: String = bar_type_obj.call_method0("__str__")?.extract()?;
438        let bar_type = BarType::from(bar_type_str);
439
440        let open_py: Bound<'_, PyAny> = obj.getattr("open")?;
441        let price_prec: u8 = open_py.getattr("precision")?.extract()?;
442        let open_raw: PriceRaw = open_py.getattr("raw")?.extract()?;
443        let open = Price::from_raw(open_raw, price_prec);
444
445        let high_py: Bound<'_, PyAny> = obj.getattr("high")?;
446        let high_raw: PriceRaw = high_py.getattr("raw")?.extract()?;
447        let high = Price::from_raw(high_raw, price_prec);
448
449        let low_py: Bound<'_, PyAny> = obj.getattr("low")?;
450        let low_raw: PriceRaw = low_py.getattr("raw")?.extract()?;
451        let low = Price::from_raw(low_raw, price_prec);
452
453        let close_py: Bound<'_, PyAny> = obj.getattr("close")?;
454        let close_raw: PriceRaw = close_py.getattr("raw")?.extract()?;
455        let close = Price::from_raw(close_raw, price_prec);
456
457        let volume_py: Bound<'_, PyAny> = obj.getattr("volume")?;
458        let volume_raw: QuantityRaw = volume_py.getattr("raw")?.extract()?;
459        let volume_prec: u8 = volume_py.getattr("precision")?.extract()?;
460        let volume = Quantity::from_raw(volume_raw, volume_prec);
461
462        let ts_event: u64 = obj.getattr("ts_event")?.extract()?;
463        let ts_init: u64 = obj.getattr("ts_init")?.extract()?;
464
465        Ok(Self::new(
466            bar_type,
467            open,
468            high,
469            low,
470            close,
471            volume,
472            ts_event.into(),
473            ts_init.into(),
474        ))
475    }
476}
477
478#[pymethods]
479#[pyo3_stub_gen::derive::gen_stub_pymethods]
480#[expect(clippy::too_many_arguments)]
481impl Bar {
482    /// Represents an aggregated bar.
483    #[new]
484    fn py_new(
485        bar_type: BarType,
486        open: Price,
487        high: Price,
488        low: Price,
489        close: Price,
490        volume: Quantity,
491        ts_event: u64,
492        ts_init: u64,
493    ) -> PyResult<Self> {
494        Self::new_checked(
495            bar_type,
496            open,
497            high,
498            low,
499            close,
500            volume,
501            ts_event.into(),
502            ts_init.into(),
503        )
504        .map_err(to_pyvalue_err)
505    }
506
507    fn __richcmp__(&self, other: &Self, op: CompareOp, py: Python<'_>) -> Py<PyAny> {
508        match op {
509            CompareOp::Eq => self.eq(other).into_py_any_unwrap(py),
510            CompareOp::Ne => self.ne(other).into_py_any_unwrap(py),
511            _ => py.NotImplemented(),
512        }
513    }
514
515    fn __hash__(&self) -> isize {
516        let mut h = DefaultHasher::new();
517        self.hash(&mut h);
518        h.finish() as isize
519    }
520
521    fn __repr__(&self) -> String {
522        format!("{self:?}")
523    }
524
525    fn __str__(&self) -> String {
526        self.to_string()
527    }
528
529    #[getter]
530    #[pyo3(name = "bar_type")]
531    fn py_bar_type(&self) -> BarType {
532        self.bar_type
533    }
534
535    #[getter]
536    #[pyo3(name = "open")]
537    fn py_open(&self) -> Price {
538        self.open
539    }
540
541    #[getter]
542    #[pyo3(name = "high")]
543    fn py_high(&self) -> Price {
544        self.high
545    }
546
547    #[getter]
548    #[pyo3(name = "low")]
549    fn py_low(&self) -> Price {
550        self.low
551    }
552
553    #[getter]
554    #[pyo3(name = "close")]
555    fn py_close(&self) -> Price {
556        self.close
557    }
558
559    #[getter]
560    #[pyo3(name = "volume")]
561    fn py_volume(&self) -> Quantity {
562        self.volume
563    }
564
565    #[getter]
566    #[pyo3(name = "ts_event")]
567    fn py_ts_event(&self) -> u64 {
568        self.ts_event.as_u64()
569    }
570
571    #[getter]
572    #[pyo3(name = "ts_init")]
573    fn py_ts_init(&self) -> u64 {
574        self.ts_init.as_u64()
575    }
576
577    #[staticmethod]
578    #[pyo3(name = "fully_qualified_name")]
579    fn py_fully_qualified_name() -> String {
580        format!("{}:{}", PY_MODULE_MODEL, stringify!(Bar))
581    }
582
583    /// Returns the metadata for the type, for use with serialization formats.
584    #[staticmethod]
585    #[pyo3(name = "get_metadata")]
586    fn py_get_metadata(
587        bar_type: &BarType,
588        price_precision: u8,
589        size_precision: u8,
590    ) -> HashMap<String, String> {
591        Self::get_metadata(bar_type, price_precision, size_precision)
592    }
593
594    /// Returns the field map for the type, for use with Arrow schemas.
595    #[staticmethod]
596    #[pyo3(name = "get_fields")]
597    fn py_get_fields(py: Python<'_>) -> PyResult<Bound<'_, PyDict>> {
598        let py_dict = PyDict::new(py);
599        for (k, v) in Self::get_fields() {
600            py_dict.set_item(k, v)?;
601        }
602
603        Ok(py_dict)
604    }
605
606    /// Returns a new object from the given dictionary representation.
607    #[staticmethod]
608    #[pyo3(name = "from_dict")]
609    fn py_from_dict(py: Python<'_>, values: Py<PyDict>) -> PyResult<Self> {
610        from_dict_pyo3(py, values)
611    }
612
613    /// Creates a `PyCapsule` containing a raw pointer to a `Data::Bar` object.
614    ///
615    /// This function takes the current object (assumed to be of a type that can be represented as
616    /// `Data::Bar`), and encapsulates a raw pointer to it within a `PyCapsule`.
617    ///
618    /// # Safety
619    ///
620    /// This function is safe as long as the following conditions are met:
621    /// - The `Data::Delta` object pointed to by the capsule must remain valid for the lifetime of the capsule.
622    /// - The consumer of the capsule must ensure proper handling to avoid dereferencing a dangling pointer.
623    ///
624    /// # Panics
625    ///
626    /// The function will panic if the `PyCapsule` creation fails, which can occur if the
627    /// `Data::Bar` object cannot be converted into a raw pointer.
628    #[pyo3(name = "as_pycapsule")]
629    fn py_as_pycapsule(&self, py: Python<'_>) -> Py<PyAny> {
630        data_to_pycapsule(py, Data::Bar(*self))
631    }
632
633    /// Return a dictionary representation of the object.
634    #[pyo3(name = "to_dict")]
635    fn py_to_dict(&self, py: Python<'_>) -> PyResult<Py<PyDict>> {
636        to_dict_pyo3(py, self)
637    }
638
639    /// Return JSON encoded bytes representation of the object.
640    #[pyo3(name = "to_json_bytes")]
641    fn py_to_json_bytes(&self, py: Python<'_>) -> Py<PyAny> {
642        self.to_json_bytes().unwrap().into_py_any_unwrap(py)
643    }
644
645    /// Return `MsgPack` encoded bytes representation of the object.
646    #[pyo3(name = "to_msgpack_bytes")]
647    fn py_to_msgpack_bytes(&self, py: Python<'_>) -> Py<PyAny> {
648        self.to_msgpack_bytes().unwrap().into_py_any_unwrap(py)
649    }
650
651    fn __setstate__(&mut self, state: &Bound<'_, PyAny>) -> PyResult<()> {
652        let py_tuple: &Bound<'_, PyTuple> = state.cast::<PyTuple>()?;
653        let bar_type_str: String = py_tuple.get_item(0)?.extract()?;
654        let open_raw: PriceRaw = py_tuple.get_item(1)?.extract()?;
655        let open_prec: u8 = py_tuple.get_item(2)?.extract()?;
656        let high_raw: PriceRaw = py_tuple.get_item(3)?.extract()?;
657        let low_raw: PriceRaw = py_tuple.get_item(4)?.extract()?;
658        let close_raw: PriceRaw = py_tuple.get_item(5)?.extract()?;
659        let volume_raw: QuantityRaw = py_tuple.get_item(6)?.extract()?;
660        let volume_prec: u8 = py_tuple.get_item(7)?.extract()?;
661        let ts_event: u64 = py_tuple.get_item(8)?.extract()?;
662        let ts_init: u64 = py_tuple.get_item(9)?.extract()?;
663
664        self.bar_type = BarType::from_str(&bar_type_str).map_err(to_pyvalue_err)?;
665        self.open = Price::from_raw(open_raw, open_prec);
666        self.high = Price::from_raw(high_raw, open_prec);
667        self.low = Price::from_raw(low_raw, open_prec);
668        self.close = Price::from_raw(close_raw, open_prec);
669        self.volume = Quantity::from_raw(volume_raw, volume_prec);
670        self.ts_event = ts_event.into();
671        self.ts_init = ts_init.into();
672        Ok(())
673    }
674
675    fn __getstate__(&self, py: Python) -> PyResult<Py<PyAny>> {
676        (
677            self.bar_type.to_string(),
678            self.open.raw,
679            self.open.precision,
680            self.high.raw,
681            self.low.raw,
682            self.close.raw,
683            self.volume.raw,
684            self.volume.precision,
685            self.ts_event.as_u64(),
686            self.ts_init.as_u64(),
687        )
688            .into_py_any(py)
689    }
690
691    fn __reduce__(&self, py: Python) -> PyResult<Py<PyAny>> {
692        let safe_constructor = py.get_type::<Self>().getattr("_safe_constructor")?;
693        let state = self.__getstate__(py)?;
694        (safe_constructor, PyTuple::empty(py), state).into_py_any(py)
695    }
696
697    #[staticmethod]
698    fn _safe_constructor() -> Self {
699        Self::new(
700            BarType::from("NULL.NULL-1-TICK-LAST-EXTERNAL"),
701            Price::zero(0),
702            Price::zero(0),
703            Price::zero(0),
704            Price::zero(0),
705            Quantity::from(1),
706            0.into(),
707            0.into(),
708        )
709    }
710}
711
712#[pymethods]
713impl Bar {
714    #[staticmethod]
715    #[pyo3(name = "from_json")]
716    fn py_from_json(data: &[u8]) -> PyResult<Self> {
717        Self::from_json_bytes(data).map_err(to_pyvalue_err)
718    }
719
720    #[staticmethod]
721    #[pyo3(name = "from_msgpack")]
722    fn py_from_msgpack(data: &[u8]) -> PyResult<Self> {
723        Self::from_msgpack_bytes(data).map_err(to_pyvalue_err)
724    }
725}
726
727#[cfg(test)]
728mod tests {
729    use nautilus_core::python::IntoPyObjectNautilusExt;
730    use pyo3::Python;
731    use rstest::rstest;
732
733    use crate::{
734        data::{Bar, BarType},
735        types::{Price, Quantity},
736    };
737
738    #[rstest]
739    #[case("10.0000", "10.0010", "10.0020", "10.0005")] // low > high
740    #[case("10.0000", "10.0010", "10.0005", "10.0030")] // close > high
741    #[case("10.0000", "9.9990", "9.9980", "9.9995")] // high < open
742    #[case("10.0000", "10.0010", "10.0015", "10.0020")] // low > close
743    #[case("10.0000", "10.0000", "10.0001", "10.0002")] // low > high (equal high/open edge case)
744    fn test_bar_py_new_invalid(
745        #[case] open: &str,
746        #[case] high: &str,
747        #[case] low: &str,
748        #[case] close: &str,
749    ) {
750        let bar_type = BarType::from("AUDUSD.SIM-1-MINUTE-LAST-INTERNAL");
751        let open = Price::from(open);
752        let high = Price::from(high);
753        let low = Price::from(low);
754        let close = Price::from(close);
755        let volume = Quantity::from(100_000);
756        let ts_event = 0;
757        let ts_init = 1;
758
759        let result = Bar::py_new(bar_type, open, high, low, close, volume, ts_event, ts_init);
760        assert!(result.is_err());
761    }
762
763    #[rstest]
764    fn test_bar_py_new() {
765        let bar_type = BarType::from("AUDUSD.SIM-1-MINUTE-LAST-INTERNAL");
766        let open = Price::from("1.00005");
767        let high = Price::from("1.00010");
768        let low = Price::from("1.00000");
769        let close = Price::from("1.00007");
770        let volume = Quantity::from(100_000);
771        let ts_event = 0;
772        let ts_init = 1;
773
774        let result = Bar::py_new(bar_type, open, high, low, close, volume, ts_event, ts_init);
775        assert!(result.is_ok());
776    }
777
778    #[rstest]
779    fn test_to_dict() {
780        let bar = Bar::default();
781
782        Python::initialize();
783        Python::attach(|py| {
784            let dict_string = bar.py_to_dict(py).unwrap().to_string();
785            let expected_string = "{'type': 'Bar', 'bar_type': 'AUDUSD.SIM-1-MINUTE-LAST-INTERNAL', 'open': '1.00010', 'high': '1.00020', 'low': '1.00000', 'close': '1.00010', 'volume': '100000', 'ts_event': 0, 'ts_init': 0}";
786            assert_eq!(dict_string, expected_string);
787        });
788    }
789
790    #[rstest]
791    fn test_as_from_dict() {
792        let bar = Bar::default();
793
794        Python::initialize();
795        Python::attach(|py| {
796            let dict = bar.py_to_dict(py).unwrap();
797            let parsed = Bar::py_from_dict(py, dict).unwrap();
798            assert_eq!(parsed, bar);
799        });
800    }
801
802    #[rstest]
803    fn test_from_pyobject() {
804        let bar = Bar::default();
805
806        Python::initialize();
807        Python::attach(|py| {
808            let bar_pyobject = bar.into_py_any_unwrap(py);
809            let parsed_bar = Bar::from_pyobject(bar_pyobject.bind(py)).unwrap();
810            assert_eq!(parsed_bar, bar);
811        });
812    }
813}