1use 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 #[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 #[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 #[pyo3(name = "is_time_aggregated")]
140 fn py_is_time_aggregated(&self) -> bool {
141 self.is_time_aggregated()
142 }
143
144 #[pyo3(name = "is_threshold_aggregated")]
152 fn py_is_threshold_aggregated(&self) -> bool {
153 self.is_threshold_aggregated()
154 }
155
156 #[pyo3(name = "is_information_aggregated")]
161 fn py_is_information_aggregated(&self) -> bool {
162 self.is_information_aggregated()
163 }
164
165 #[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 #[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 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 #[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 #[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 #[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 #[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 #[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 #[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 #[pyo3(name = "is_standard")]
358 fn py_is_standard(&self) -> bool {
359 self.is_standard()
360 }
361
362 #[pyo3(name = "is_composite")]
364 fn py_is_composite(&self) -> bool {
365 self.is_composite()
366 }
367
368 #[pyo3(name = "standard")]
370 fn py_standard(&self) -> Self {
371 self.standard()
372 }
373
374 #[pyo3(name = "composite")]
376 fn py_composite(&self) -> Self {
377 self.composite()
378 }
379
380 #[pyo3(name = "id_spec_key")]
386 fn py_id_spec_key(&self) -> (InstrumentId, BarSpecification) {
387 self.id_spec_key()
388 }
389
390 #[pyo3(name = "is_externally_aggregated")]
392 fn py_is_externally_aggregated(&self) -> bool {
393 self.aggregation_source() == AggregationSource::External
394 }
395
396 #[pyo3(name = "is_internally_aggregated")]
398 fn py_is_internally_aggregated(&self) -> bool {
399 self.aggregation_source() == AggregationSource::Internal
400 }
401
402 #[getter]
404 #[pyo3(name = "instrument_id")]
405 fn py_instrument_id(&self) -> InstrumentId {
406 self.instrument_id()
407 }
408
409 #[getter]
411 #[pyo3(name = "spec")]
412 fn py_spec(&self) -> BarSpecification {
413 self.spec()
414 }
415
416 #[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 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 #[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 #[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 #[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 #[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 #[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 #[pyo3(name = "to_dict")]
635 fn py_to_dict(&self, py: Python<'_>) -> PyResult<Py<PyDict>> {
636 to_dict_pyo3(py, self)
637 }
638
639 #[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 #[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")] #[case("10.0000", "10.0010", "10.0005", "10.0030")] #[case("10.0000", "9.9990", "9.9980", "9.9995")] #[case("10.0000", "10.0010", "10.0015", "10.0020")] #[case("10.0000", "10.0000", "10.0001", "10.0002")] 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}