nautilus_model/python/data/
bar.rs

1// -------------------------------------------------------------------------------------------------
2//  Copyright (C) 2015-2025 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::Serializable,
29};
30use pyo3::{prelude::*, pyclass::CompareOp, types::PyDict};
31
32use super::data_to_pycapsule;
33use crate::{
34    data::{
35        Data,
36        bar::{Bar, BarSpecification, BarType},
37    },
38    enums::{AggregationSource, BarAggregation, PriceType},
39    identifiers::InstrumentId,
40    python::common::PY_MODULE_MODEL,
41    types::{
42        price::{Price, PriceRaw},
43        quantity::{Quantity, QuantityRaw},
44    },
45};
46
47#[pymethods]
48impl BarSpecification {
49    #[new]
50    fn py_new(step: usize, aggregation: BarAggregation, price_type: PriceType) -> PyResult<Self> {
51        Self::new_checked(step, aggregation, price_type).map_err(to_pyvalue_err)
52    }
53
54    fn __richcmp__(&self, other: &Self, op: CompareOp, py: Python<'_>) -> Py<PyAny> {
55        match op {
56            CompareOp::Eq => self.eq(other).into_py_any_unwrap(py),
57            CompareOp::Ne => self.ne(other).into_py_any_unwrap(py),
58            _ => py.NotImplemented(),
59        }
60    }
61
62    fn __hash__(&self) -> isize {
63        let mut h = DefaultHasher::new();
64        self.hash(&mut h);
65        h.finish() as isize
66    }
67
68    fn __repr__(&self) -> String {
69        format!("{self:?}")
70    }
71
72    fn __str__(&self) -> String {
73        self.to_string()
74    }
75
76    #[staticmethod]
77    #[pyo3(name = "fully_qualified_name")]
78    fn py_fully_qualified_name() -> String {
79        format!("{}:{}", PY_MODULE_MODEL, stringify!(BarSpecification))
80    }
81
82    #[getter]
83    #[pyo3(name = "timedelta")]
84    fn py_timedelta(&self) -> PyResult<chrono::TimeDelta> {
85        match self.aggregation {
86            BarAggregation::Millisecond
87            | BarAggregation::Second
88            | BarAggregation::Minute
89            | BarAggregation::Hour
90            | BarAggregation::Day => Ok(self.timedelta()),
91            _ => Err(to_pyvalue_err(format!(
92                "Timedelta not supported for aggregation type: {:?}",
93                self.aggregation
94            ))),
95        }
96    }
97}
98
99#[pymethods]
100impl BarType {
101    #[new]
102    #[pyo3(signature = (instrument_id, spec, aggregation_source = AggregationSource::External)
103    )]
104    fn py_new(
105        instrument_id: InstrumentId,
106        spec: BarSpecification,
107        aggregation_source: AggregationSource,
108    ) -> Self {
109        Self::new(instrument_id, spec, aggregation_source)
110    }
111
112    fn __richcmp__(&self, other: &Self, op: CompareOp, py: Python<'_>) -> Py<PyAny> {
113        match op {
114            CompareOp::Eq => self.eq(other).into_py_any_unwrap(py),
115            CompareOp::Ne => self.ne(other).into_py_any_unwrap(py),
116            _ => py.NotImplemented(),
117        }
118    }
119
120    fn __hash__(&self) -> isize {
121        let mut h = DefaultHasher::new();
122        self.hash(&mut h);
123        h.finish() as isize
124    }
125
126    fn __repr__(&self) -> String {
127        format!("{self:?}")
128    }
129
130    fn __str__(&self) -> String {
131        self.to_string()
132    }
133
134    #[staticmethod]
135    #[pyo3(name = "fully_qualified_name")]
136    fn py_fully_qualified_name() -> String {
137        format!("{}:{}", PY_MODULE_MODEL, stringify!(BarType))
138    }
139
140    #[staticmethod]
141    #[pyo3(name = "from_str")]
142    fn py_from_str(value: &str) -> PyResult<Self> {
143        Self::from_str(value).map_err(to_pyvalue_err)
144    }
145
146    #[staticmethod]
147    #[pyo3(name = "new_composite")]
148    fn py_new_composite(
149        instrument_id: InstrumentId,
150        spec: BarSpecification,
151        aggregation_source: AggregationSource,
152        composite_step: usize,
153        composite_aggregation: BarAggregation,
154        composite_aggregation_source: AggregationSource,
155    ) -> Self {
156        Self::new_composite(
157            instrument_id,
158            spec,
159            aggregation_source,
160            composite_step,
161            composite_aggregation,
162            composite_aggregation_source,
163        )
164    }
165
166    #[pyo3(name = "is_standard")]
167    fn py_is_standard(&self) -> bool {
168        self.is_standard()
169    }
170
171    #[pyo3(name = "is_composite")]
172    fn py_is_composite(&self) -> bool {
173        self.is_composite()
174    }
175
176    #[pyo3(name = "standard")]
177    fn py_standard(&self) -> Self {
178        self.standard()
179    }
180
181    #[pyo3(name = "composite")]
182    fn py_composite(&self) -> Self {
183        self.composite()
184    }
185}
186
187impl Bar {
188    /// Creates a Rust `Bar` instance from a Python object.
189    ///
190    /// # Errors
191    ///
192    /// Returns a `PyErr` if retrieving any attribute or converting types fails.
193    pub fn from_pyobject(obj: &Bound<'_, PyAny>) -> PyResult<Self> {
194        let bar_type_obj: Bound<'_, PyAny> = obj.getattr("bar_type")?.extract()?;
195        let bar_type_str: String = bar_type_obj.call_method0("__str__")?.extract()?;
196        let bar_type = BarType::from(bar_type_str.as_str());
197
198        let open_py: Bound<'_, PyAny> = obj.getattr("open")?;
199        let price_prec: u8 = open_py.getattr("precision")?.extract()?;
200        let open_raw: PriceRaw = open_py.getattr("raw")?.extract()?;
201        let open = Price::from_raw(open_raw, price_prec);
202
203        let high_py: Bound<'_, PyAny> = obj.getattr("high")?;
204        let high_raw: PriceRaw = high_py.getattr("raw")?.extract()?;
205        let high = Price::from_raw(high_raw, price_prec);
206
207        let low_py: Bound<'_, PyAny> = obj.getattr("low")?;
208        let low_raw: PriceRaw = low_py.getattr("raw")?.extract()?;
209        let low = Price::from_raw(low_raw, price_prec);
210
211        let close_py: Bound<'_, PyAny> = obj.getattr("close")?;
212        let close_raw: PriceRaw = close_py.getattr("raw")?.extract()?;
213        let close = Price::from_raw(close_raw, price_prec);
214
215        let volume_py: Bound<'_, PyAny> = obj.getattr("volume")?;
216        let volume_raw: QuantityRaw = volume_py.getattr("raw")?.extract()?;
217        let volume_prec: u8 = volume_py.getattr("precision")?.extract()?;
218        let volume = Quantity::from_raw(volume_raw, volume_prec);
219
220        let ts_event: u64 = obj.getattr("ts_event")?.extract()?;
221        let ts_init: u64 = obj.getattr("ts_init")?.extract()?;
222
223        Ok(Self::new(
224            bar_type,
225            open,
226            high,
227            low,
228            close,
229            volume,
230            ts_event.into(),
231            ts_init.into(),
232        ))
233    }
234}
235
236#[pymethods]
237#[allow(clippy::too_many_arguments)]
238impl Bar {
239    #[new]
240    fn py_new(
241        bar_type: BarType,
242        open: Price,
243        high: Price,
244        low: Price,
245        close: Price,
246        volume: Quantity,
247        ts_event: u64,
248        ts_init: u64,
249    ) -> PyResult<Self> {
250        Self::new_checked(
251            bar_type,
252            open,
253            high,
254            low,
255            close,
256            volume,
257            ts_event.into(),
258            ts_init.into(),
259        )
260        .map_err(to_pyvalue_err)
261    }
262
263    fn __richcmp__(&self, other: &Self, op: CompareOp, py: Python<'_>) -> Py<PyAny> {
264        match op {
265            CompareOp::Eq => self.eq(other).into_py_any_unwrap(py),
266            CompareOp::Ne => self.ne(other).into_py_any_unwrap(py),
267            _ => py.NotImplemented(),
268        }
269    }
270
271    fn __hash__(&self) -> isize {
272        let mut h = DefaultHasher::new();
273        self.hash(&mut h);
274        h.finish() as isize
275    }
276
277    fn __repr__(&self) -> String {
278        format!("{self:?}")
279    }
280
281    fn __str__(&self) -> String {
282        self.to_string()
283    }
284
285    #[getter]
286    #[pyo3(name = "bar_type")]
287    fn py_bar_type(&self) -> BarType {
288        self.bar_type
289    }
290
291    #[getter]
292    #[pyo3(name = "open")]
293    fn py_open(&self) -> Price {
294        self.open
295    }
296
297    #[getter]
298    #[pyo3(name = "high")]
299    fn py_high(&self) -> Price {
300        self.high
301    }
302
303    #[getter]
304    #[pyo3(name = "low")]
305    fn py_low(&self) -> Price {
306        self.low
307    }
308
309    #[getter]
310    #[pyo3(name = "close")]
311    fn py_close(&self) -> Price {
312        self.close
313    }
314
315    #[getter]
316    #[pyo3(name = "volume")]
317    fn py_volume(&self) -> Quantity {
318        self.volume
319    }
320
321    #[getter]
322    #[pyo3(name = "ts_event")]
323    fn py_ts_event(&self) -> u64 {
324        self.ts_event.as_u64()
325    }
326
327    #[getter]
328    #[pyo3(name = "ts_init")]
329    fn py_ts_init(&self) -> u64 {
330        self.ts_init.as_u64()
331    }
332
333    #[staticmethod]
334    #[pyo3(name = "fully_qualified_name")]
335    fn py_fully_qualified_name() -> String {
336        format!("{}:{}", PY_MODULE_MODEL, stringify!(Bar))
337    }
338
339    #[staticmethod]
340    #[pyo3(name = "get_metadata")]
341    fn py_get_metadata(
342        bar_type: &BarType,
343        price_precision: u8,
344        size_precision: u8,
345    ) -> PyResult<HashMap<String, String>> {
346        Ok(Self::get_metadata(
347            bar_type,
348            price_precision,
349            size_precision,
350        ))
351    }
352
353    #[staticmethod]
354    #[pyo3(name = "get_fields")]
355    fn py_get_fields(py: Python<'_>) -> PyResult<Bound<'_, PyDict>> {
356        let py_dict = PyDict::new(py);
357        for (k, v) in Self::get_fields() {
358            py_dict.set_item(k, v)?;
359        }
360
361        Ok(py_dict)
362    }
363
364    /// Returns a new object from the given dictionary representation.
365    #[staticmethod]
366    #[pyo3(name = "from_dict")]
367    fn py_from_dict(py: Python<'_>, values: Py<PyDict>) -> PyResult<Self> {
368        from_dict_pyo3(py, values)
369    }
370
371    #[staticmethod]
372    #[pyo3(name = "from_json")]
373    fn py_from_json(data: Vec<u8>) -> PyResult<Self> {
374        Self::from_json_bytes(&data).map_err(to_pyvalue_err)
375    }
376
377    #[staticmethod]
378    #[pyo3(name = "from_msgpack")]
379    fn py_from_msgpack(data: Vec<u8>) -> PyResult<Self> {
380        Self::from_msgpack_bytes(&data).map_err(to_pyvalue_err)
381    }
382
383    /// Creates a `PyCapsule` containing a raw pointer to a `Data::Bar` object.
384    ///
385    /// This function takes the current object (assumed to be of a type that can be represented as
386    /// `Data::Bar`), and encapsulates a raw pointer to it within a `PyCapsule`.
387    ///
388    /// # Safety
389    ///
390    /// This function is safe as long as the following conditions are met:
391    /// - The `Data::Delta` object pointed to by the capsule must remain valid for the lifetime of the capsule.
392    /// - The consumer of the capsule must ensure proper handling to avoid dereferencing a dangling pointer.
393    ///
394    /// # Panics
395    ///
396    /// The function will panic if the `PyCapsule` creation fails, which can occur if the
397    /// `Data::Bar` object cannot be converted into a raw pointer.
398    #[pyo3(name = "as_pycapsule")]
399    fn py_as_pycapsule(&self, py: Python<'_>) -> Py<PyAny> {
400        data_to_pycapsule(py, Data::Bar(*self))
401    }
402
403    /// Return a dictionary representation of the object.
404    #[pyo3(name = "to_dict")]
405    fn py_to_dict(&self, py: Python<'_>) -> PyResult<Py<PyDict>> {
406        to_dict_pyo3(py, self)
407    }
408
409    /// Return JSON encoded bytes representation of the object.
410    #[pyo3(name = "to_json_bytes")]
411    fn py_to_json_bytes(&self, py: Python<'_>) -> Py<PyAny> {
412        // SAFETY: Unwrap safe when serializing a valid object
413        self.to_json_bytes().unwrap().into_py_any_unwrap(py)
414    }
415
416    /// Return MsgPack encoded bytes representation of the object.
417    #[pyo3(name = "to_msgpack_bytes")]
418    fn py_to_msgpack_bytes(&self, py: Python<'_>) -> Py<PyAny> {
419        // SAFETY: Unwrap safe when serializing a valid object
420        self.to_msgpack_bytes().unwrap().into_py_any_unwrap(py)
421    }
422}
423
424////////////////////////////////////////////////////////////////////////////////
425// Tests
426////////////////////////////////////////////////////////////////////////////////
427#[cfg(test)]
428mod tests {
429    use nautilus_core::python::IntoPyObjectNautilusExt;
430    use pyo3::Python;
431    use rstest::rstest;
432
433    use crate::{
434        data::{Bar, BarType},
435        types::{Price, Quantity},
436    };
437
438    #[rstest]
439    #[case("10.0000", "10.0010", "10.0020", "10.0005")] // low > high
440    #[case("10.0000", "10.0010", "10.0005", "10.0030")] // close > high
441    #[case("10.0000", "9.9990", "9.9980", "9.9995")] // high < open
442    #[case("10.0000", "10.0010", "10.0015", "10.0020")] // low > close
443    #[case("10.0000", "10.0000", "10.0001", "10.0002")] // low > high (equal high/open edge case)
444    fn test_bar_py_new_invalid(
445        #[case] open: &str,
446        #[case] high: &str,
447        #[case] low: &str,
448        #[case] close: &str,
449    ) {
450        let bar_type = BarType::from("AUDUSD.SIM-1-MINUTE-LAST-INTERNAL");
451        let open = Price::from(open);
452        let high = Price::from(high);
453        let low = Price::from(low);
454        let close = Price::from(close);
455        let volume = Quantity::from(100_000);
456        let ts_event = 0;
457        let ts_init = 1;
458
459        let result = Bar::py_new(bar_type, open, high, low, close, volume, ts_event, ts_init);
460        assert!(result.is_err());
461    }
462
463    #[rstest]
464    fn test_bar_py_new() {
465        let bar_type = BarType::from("AUDUSD.SIM-1-MINUTE-LAST-INTERNAL");
466        let open = Price::from("1.00005");
467        let high = Price::from("1.00010");
468        let low = Price::from("1.00000");
469        let close = Price::from("1.00007");
470        let volume = Quantity::from(100_000);
471        let ts_event = 0;
472        let ts_init = 1;
473
474        let result = Bar::py_new(bar_type, open, high, low, close, volume, ts_event, ts_init);
475        assert!(result.is_ok());
476    }
477
478    #[rstest]
479    fn test_to_dict() {
480        let bar = Bar::default();
481
482        Python::initialize();
483        Python::attach(|py| {
484            let dict_string = bar.py_to_dict(py).unwrap().to_string();
485            let expected_string = r"{'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}";
486            assert_eq!(dict_string, expected_string);
487        });
488    }
489
490    #[rstest]
491    fn test_as_from_dict() {
492        let bar = Bar::default();
493
494        Python::initialize();
495        Python::attach(|py| {
496            let dict = bar.py_to_dict(py).unwrap();
497            let parsed = Bar::py_from_dict(py, dict).unwrap();
498            assert_eq!(parsed, bar);
499        });
500    }
501
502    #[rstest]
503    fn test_from_pyobject() {
504        let bar = Bar::default();
505
506        Python::initialize();
507        Python::attach(|py| {
508            let bar_pyobject = bar.into_py_any_unwrap(py);
509            let parsed_bar = Bar::from_pyobject(bar_pyobject.bind(py)).unwrap();
510            assert_eq!(parsed_bar, bar);
511        });
512    }
513}