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 crate::{
41 data::bar::{Bar, BarSpecification, BarType},
42 enums::{AggregationSource, BarAggregation, PriceType},
43 identifiers::InstrumentId,
44 python::common::PY_MODULE_MODEL,
45 types::{
46 price::{Price, PriceRaw},
47 quantity::{Quantity, QuantityRaw},
48 },
49};
50
51#[pymethods]
52#[pyo3_stub_gen::derive::gen_stub_pymethods]
53impl BarSpecification {
54 #[new]
57 fn py_new(step: usize, aggregation: BarAggregation, price_type: PriceType) -> PyResult<Self> {
58 Self::new_checked(step, aggregation, price_type).map_err(to_pyvalue_err)
59 }
60
61 fn __richcmp__(&self, other: &Self, op: CompareOp, py: Python<'_>) -> Py<PyAny> {
62 match op {
63 CompareOp::Eq => self.eq(other).into_py_any_unwrap(py),
64 CompareOp::Ne => self.ne(other).into_py_any_unwrap(py),
65 _ => py.NotImplemented(),
66 }
67 }
68
69 fn __hash__(&self) -> isize {
70 let mut h = DefaultHasher::new();
71 self.hash(&mut h);
72 h.finish() as isize
73 }
74
75 fn __repr__(&self) -> String {
76 format!("{self:?}")
77 }
78
79 fn __str__(&self) -> String {
80 self.to_string()
81 }
82
83 #[getter]
84 #[pyo3(name = "step")]
85 fn py_step(&self) -> usize {
86 self.step.get()
87 }
88
89 #[getter]
90 #[pyo3(name = "aggregation")]
91 fn py_aggregation(&self) -> BarAggregation {
92 self.aggregation
93 }
94
95 #[getter]
96 #[pyo3(name = "price_type")]
97 fn py_price_type(&self) -> PriceType {
98 self.price_type
99 }
100
101 #[staticmethod]
102 #[pyo3(name = "fully_qualified_name")]
103 fn py_fully_qualified_name() -> String {
104 format!("{}:{}", PY_MODULE_MODEL, stringify!(BarSpecification))
105 }
106
107 #[getter]
115 #[pyo3(name = "timedelta")]
116 fn py_timedelta(&self) -> PyResult<jiff::SignedDuration> {
117 if !self.is_time_aggregated() {
118 return Err(to_pyvalue_err(format!(
119 "Timedelta not supported for aggregation type: {:?}",
120 self.aggregation
121 )));
122 }
123 Ok(self.timedelta())
124 }
125
126 #[pyo3(name = "is_time_aggregated")]
136 fn py_is_time_aggregated(&self) -> bool {
137 self.is_time_aggregated()
138 }
139
140 #[pyo3(name = "is_threshold_aggregated")]
148 fn py_is_threshold_aggregated(&self) -> bool {
149 self.is_threshold_aggregated()
150 }
151
152 #[pyo3(name = "is_information_aggregated")]
157 fn py_is_information_aggregated(&self) -> bool {
158 self.is_information_aggregated()
159 }
160
161 #[pyo3(name = "get_interval_ns")]
163 fn py_get_interval_ns(&self) -> PyResult<u64> {
164 if !self.is_time_aggregated() {
165 return Err(to_pyvalue_err(format!(
166 "Aggregation not time based, was {:?}",
167 self.aggregation
168 )));
169 }
170 let td = self.timedelta();
171 u64::try_from(td.as_nanos())
172 .map_err(|_| to_pyvalue_err(format!("Interval overflows nanoseconds, was {td:?}")))
173 }
174
175 #[staticmethod]
177 #[pyo3(name = "from_timedelta")]
178 fn py_from_timedelta(duration: jiff::SignedDuration, price_type: PriceType) -> PyResult<Self> {
179 if duration.as_millis() <= 0 {
180 return Err(to_pyvalue_err(format!(
181 "Duration must be positive, was {duration:?}"
182 )));
183 }
184 let total_secs_f64 = duration.as_millis() as f64 / 1000.0;
185 let days = duration.as_hours() / 24;
186
187 let (step, aggregation) = if days >= 7 {
188 (days / 7, BarAggregation::Week)
189 } else if days >= 1 {
190 (days, BarAggregation::Day)
191 } else if total_secs_f64 >= 3600.0 {
192 ((total_secs_f64 / 3600.0) as i64, BarAggregation::Hour)
193 } else if total_secs_f64 >= 60.0 {
194 ((total_secs_f64 / 60.0) as i64, BarAggregation::Minute)
195 } else if total_secs_f64 >= 1.0 {
196 (total_secs_f64 as i64, BarAggregation::Second)
197 } else {
198 (
199 (total_secs_f64 * 1000.0) as i64,
200 BarAggregation::Millisecond,
201 )
202 };
203
204 let spec =
205 Self::new_checked(step as usize, aggregation, price_type).map_err(to_pyvalue_err)?;
206
207 let roundtrip = spec.timedelta();
209 if roundtrip != duration {
210 return Err(to_pyvalue_err(format!(
211 "Duration {duration:?} is ambiguous"
212 )));
213 }
214
215 Ok(spec)
216 }
217
218 #[staticmethod]
220 #[pyo3(name = "check_time_aggregated")]
221 fn py_check_time_aggregated(aggregation: BarAggregation) -> bool {
222 matches!(
223 aggregation,
224 BarAggregation::Millisecond
225 | BarAggregation::Second
226 | BarAggregation::Minute
227 | BarAggregation::Hour
228 | BarAggregation::Day
229 | BarAggregation::Week
230 | BarAggregation::Month
231 | BarAggregation::Year
232 )
233 }
234
235 #[staticmethod]
237 #[pyo3(name = "check_threshold_aggregated")]
238 fn py_check_threshold_aggregated(aggregation: BarAggregation) -> bool {
239 matches!(
240 aggregation,
241 BarAggregation::Tick
242 | BarAggregation::TickImbalance
243 | BarAggregation::Volume
244 | BarAggregation::VolumeImbalance
245 | BarAggregation::Value
246 | BarAggregation::ValueImbalance
247 )
248 }
249
250 #[staticmethod]
252 #[pyo3(name = "check_information_aggregated")]
253 fn py_check_information_aggregated(aggregation: BarAggregation) -> bool {
254 matches!(
255 aggregation,
256 BarAggregation::TickRuns | BarAggregation::VolumeRuns | BarAggregation::ValueRuns
257 )
258 }
259
260 fn __reduce__(&self, py: Python) -> PyResult<Py<PyAny>> {
261 let from_str = py.get_type::<Self>().getattr("from_str")?;
262 (from_str, (self.to_string(),)).into_py_any(py)
263 }
264
265 #[staticmethod]
267 #[pyo3(name = "from_str")]
268 fn py_from_str(value: &str) -> PyResult<Self> {
269 let pieces: Vec<&str> = value.rsplitn(3, '-').collect();
270 if pieces.len() != 3 {
271 return Err(to_pyvalue_err(format!(
272 "The `BarSpecification` string value was malformed, was {value}"
273 )));
274 }
275 let step: usize = pieces[2].parse().map_err(to_pyvalue_err)?;
276 let aggregation = BarAggregation::from_str(pieces[1]).map_err(to_pyvalue_err)?;
277 let price_type = PriceType::from_str(pieces[0]).map_err(to_pyvalue_err)?;
278 Self::new_checked(step, aggregation, price_type).map_err(to_pyvalue_err)
279 }
280}
281
282#[pymethods]
283#[pyo3_stub_gen::derive::gen_stub_pymethods]
284impl BarType {
285 #[new]
288 #[pyo3(signature = (instrument_id, spec, aggregation_source = AggregationSource::External)
289 )]
290 fn py_new(
291 instrument_id: InstrumentId,
292 spec: BarSpecification,
293 aggregation_source: AggregationSource,
294 ) -> Self {
295 Self::new(instrument_id, spec, aggregation_source)
296 }
297
298 fn __richcmp__(&self, other: &Self, op: CompareOp, py: Python<'_>) -> Py<PyAny> {
299 match op {
300 CompareOp::Eq => self.eq(other).into_py_any_unwrap(py),
301 CompareOp::Ne => self.ne(other).into_py_any_unwrap(py),
302 _ => py.NotImplemented(),
303 }
304 }
305
306 fn __hash__(&self) -> isize {
307 let mut h = DefaultHasher::new();
308 self.hash(&mut h);
309 h.finish() as isize
310 }
311
312 fn __repr__(&self) -> String {
313 format!("{self:?}")
314 }
315
316 fn __str__(&self) -> String {
317 self.to_string()
318 }
319
320 #[staticmethod]
321 #[pyo3(name = "fully_qualified_name")]
322 fn py_fully_qualified_name() -> String {
323 format!("{}:{}", PY_MODULE_MODEL, stringify!(BarType))
324 }
325
326 #[staticmethod]
327 #[pyo3(name = "from_str")]
328 fn py_from_str(value: &str) -> PyResult<Self> {
329 Self::from_str(value).map_err(to_pyvalue_err)
330 }
331
332 #[staticmethod]
334 #[pyo3(name = "new_composite")]
335 fn py_new_composite(
336 instrument_id: InstrumentId,
337 spec: BarSpecification,
338 aggregation_source: AggregationSource,
339 composite_step: usize,
340 composite_aggregation: BarAggregation,
341 composite_aggregation_source: AggregationSource,
342 ) -> PyResult<Self> {
343 Self::new_composite_checked(
344 instrument_id,
345 spec,
346 aggregation_source,
347 composite_step,
348 composite_aggregation,
349 composite_aggregation_source,
350 )
351 .map_err(to_pyvalue_err)
352 }
353
354 #[pyo3(name = "is_standard")]
356 fn py_is_standard(&self) -> bool {
357 self.is_standard()
358 }
359
360 #[pyo3(name = "is_composite")]
362 fn py_is_composite(&self) -> bool {
363 self.is_composite()
364 }
365
366 #[pyo3(name = "standard")]
368 fn py_standard(&self) -> Self {
369 self.standard()
370 }
371
372 #[pyo3(name = "composite")]
374 fn py_composite(&self) -> Self {
375 self.composite()
376 }
377
378 #[pyo3(name = "id_spec_key")]
384 fn py_id_spec_key(&self) -> (InstrumentId, BarSpecification) {
385 self.id_spec_key()
386 }
387
388 #[pyo3(name = "is_externally_aggregated")]
390 fn py_is_externally_aggregated(&self) -> bool {
391 self.is_externally_aggregated()
392 }
393
394 #[pyo3(name = "is_internally_aggregated")]
396 fn py_is_internally_aggregated(&self) -> bool {
397 self.is_internally_aggregated()
398 }
399
400 #[getter]
402 #[pyo3(name = "instrument_id")]
403 fn py_instrument_id(&self) -> InstrumentId {
404 self.instrument_id()
405 }
406
407 #[getter]
409 #[pyo3(name = "spec")]
410 fn py_spec(&self) -> BarSpecification {
411 self.spec()
412 }
413
414 #[getter]
416 #[pyo3(name = "aggregation_source")]
417 fn py_aggregation_source(&self) -> AggregationSource {
418 self.aggregation_source()
419 }
420
421 fn __reduce__(&self, py: Python) -> PyResult<Py<PyAny>> {
422 let from_str = py.get_type::<Self>().getattr("from_str")?;
423 (from_str, (self.to_string(),)).into_py_any(py)
424 }
425}
426
427#[pymethods]
428#[pyo3_stub_gen::derive::gen_stub_pymethods]
429#[expect(clippy::too_many_arguments)]
430impl Bar {
431 #[new]
433 fn py_new(
434 bar_type: BarType,
435 open: Price,
436 high: Price,
437 low: Price,
438 close: Price,
439 volume: Quantity,
440 ts_event: u64,
441 ts_init: u64,
442 ) -> PyResult<Self> {
443 Self::new_checked(
444 bar_type,
445 open,
446 high,
447 low,
448 close,
449 volume,
450 ts_event.into(),
451 ts_init.into(),
452 )
453 .map_err(to_pyvalue_err)
454 }
455
456 fn __richcmp__(&self, other: &Self, op: CompareOp, py: Python<'_>) -> Py<PyAny> {
457 match op {
458 CompareOp::Eq => self.eq(other).into_py_any_unwrap(py),
459 CompareOp::Ne => self.ne(other).into_py_any_unwrap(py),
460 _ => py.NotImplemented(),
461 }
462 }
463
464 fn __hash__(&self) -> isize {
465 let mut h = DefaultHasher::new();
466 self.hash(&mut h);
467 h.finish() as isize
468 }
469
470 fn __repr__(&self) -> String {
471 format!("{self:?}")
472 }
473
474 fn __str__(&self) -> String {
475 self.to_string()
476 }
477
478 #[getter]
479 #[pyo3(name = "bar_type")]
480 fn py_bar_type(&self) -> BarType {
481 self.bar_type
482 }
483
484 #[getter]
485 #[pyo3(name = "open")]
486 fn py_open(&self) -> Price {
487 self.open
488 }
489
490 #[getter]
491 #[pyo3(name = "high")]
492 fn py_high(&self) -> Price {
493 self.high
494 }
495
496 #[getter]
497 #[pyo3(name = "low")]
498 fn py_low(&self) -> Price {
499 self.low
500 }
501
502 #[getter]
503 #[pyo3(name = "close")]
504 fn py_close(&self) -> Price {
505 self.close
506 }
507
508 #[getter]
509 #[pyo3(name = "volume")]
510 fn py_volume(&self) -> Quantity {
511 self.volume
512 }
513
514 #[getter]
515 #[pyo3(name = "ts_event")]
516 fn py_ts_event(&self) -> u64 {
517 self.ts_event.as_u64()
518 }
519
520 #[getter]
521 #[pyo3(name = "ts_init")]
522 fn py_ts_init(&self) -> u64 {
523 self.ts_init.as_u64()
524 }
525
526 #[staticmethod]
527 #[pyo3(name = "fully_qualified_name")]
528 fn py_fully_qualified_name() -> String {
529 format!("{}:{}", PY_MODULE_MODEL, stringify!(Bar))
530 }
531
532 #[staticmethod]
534 #[pyo3(name = "get_metadata")]
535 fn py_get_metadata(
536 bar_type: &BarType,
537 price_precision: u8,
538 size_precision: u8,
539 ) -> HashMap<String, String> {
540 Self::get_metadata(bar_type, price_precision, size_precision)
541 }
542
543 #[staticmethod]
545 #[pyo3(name = "get_fields")]
546 fn py_get_fields(py: Python<'_>) -> PyResult<Bound<'_, PyDict>> {
547 let py_dict = PyDict::new(py);
548 for (k, v) in Self::get_fields() {
549 py_dict.set_item(k, v)?;
550 }
551
552 Ok(py_dict)
553 }
554
555 #[staticmethod]
557 #[pyo3(name = "from_dict")]
558 fn py_from_dict(py: Python<'_>, values: Py<PyDict>) -> PyResult<Self> {
559 from_dict_pyo3(py, values)
560 }
561
562 #[pyo3(name = "to_dict")]
564 fn py_to_dict(&self, py: Python<'_>) -> PyResult<Py<PyDict>> {
565 to_dict_pyo3(py, self)
566 }
567
568 #[pyo3(name = "to_json_bytes")]
570 fn py_to_json_bytes(&self, py: Python<'_>) -> PyResult<Py<PyAny>> {
571 self.to_json_bytes()
572 .map_err(to_pyvalue_err)?
573 .into_py_any(py)
574 }
575
576 #[pyo3(name = "to_msgpack_bytes")]
578 fn py_to_msgpack_bytes(&self, py: Python<'_>) -> PyResult<Py<PyAny>> {
579 self.to_msgpack_bytes()
580 .map_err(to_pyvalue_err)?
581 .into_py_any(py)
582 }
583
584 fn __setstate__(&mut self, state: &Bound<'_, PyAny>) -> PyResult<()> {
585 let py_tuple: &Bound<'_, PyTuple> = state.cast::<PyTuple>()?;
586 let bar_type_str: String = py_tuple.get_item(0)?.extract()?;
587 let open_raw: PriceRaw = py_tuple.get_item(1)?.extract()?;
588 let open_prec: u8 = py_tuple.get_item(2)?.extract()?;
589 let high_raw: PriceRaw = py_tuple.get_item(3)?.extract()?;
590 let low_raw: PriceRaw = py_tuple.get_item(4)?.extract()?;
591 let close_raw: PriceRaw = py_tuple.get_item(5)?.extract()?;
592 let volume_raw: QuantityRaw = py_tuple.get_item(6)?.extract()?;
593 let volume_prec: u8 = py_tuple.get_item(7)?.extract()?;
594 let ts_event: u64 = py_tuple.get_item(8)?.extract()?;
595 let ts_init: u64 = py_tuple.get_item(9)?.extract()?;
596
597 self.bar_type = BarType::from_str(&bar_type_str).map_err(to_pyvalue_err)?;
598 self.open = Price::from_raw(open_raw, open_prec);
599 self.high = Price::from_raw(high_raw, open_prec);
600 self.low = Price::from_raw(low_raw, open_prec);
601 self.close = Price::from_raw(close_raw, open_prec);
602 self.volume = Quantity::from_raw(volume_raw, volume_prec);
603 self.ts_event = ts_event.into();
604 self.ts_init = ts_init.into();
605 Ok(())
606 }
607
608 fn __getstate__(&self, py: Python) -> PyResult<Py<PyAny>> {
609 (
610 self.bar_type.to_string(),
611 self.open.raw,
612 self.open.precision,
613 self.high.raw,
614 self.low.raw,
615 self.close.raw,
616 self.volume.raw,
617 self.volume.precision,
618 self.ts_event.as_u64(),
619 self.ts_init.as_u64(),
620 )
621 .into_py_any(py)
622 }
623
624 fn __reduce__(&self, py: Python) -> PyResult<Py<PyAny>> {
625 let safe_constructor = py.get_type::<Self>().getattr("_safe_constructor")?;
626 let state = self.__getstate__(py)?;
627 (safe_constructor, PyTuple::empty(py), state).into_py_any(py)
628 }
629
630 #[staticmethod]
631 fn _safe_constructor() -> Self {
632 Self::new(
633 BarType::from("NULL.NULL-1-TICK-LAST-EXTERNAL"),
634 Price::zero(0),
635 Price::zero(0),
636 Price::zero(0),
637 Price::zero(0),
638 Quantity::from(1),
639 0.into(),
640 0.into(),
641 )
642 }
643}
644
645#[pymethods]
646impl Bar {
647 #[staticmethod]
648 #[pyo3(name = "from_json")]
649 fn py_from_json(data: &[u8]) -> PyResult<Self> {
650 Self::from_json_bytes(data).map_err(to_pyvalue_err)
651 }
652
653 #[staticmethod]
654 #[pyo3(name = "from_msgpack")]
655 fn py_from_msgpack(data: &[u8]) -> PyResult<Self> {
656 Self::from_msgpack_bytes(data).map_err(to_pyvalue_err)
657 }
658}
659
660#[cfg(test)]
661mod tests {
662 use pyo3::Python;
663 use rstest::rstest;
664
665 use crate::{
666 data::{Bar, BarType},
667 types::{Price, Quantity},
668 };
669
670 #[rstest]
671 #[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(
677 #[case] open: &str,
678 #[case] high: &str,
679 #[case] low: &str,
680 #[case] close: &str,
681 ) {
682 let bar_type = BarType::from("AUDUSD.SIM-1-MINUTE-LAST-INTERNAL");
683 let open = Price::from(open);
684 let high = Price::from(high);
685 let low = Price::from(low);
686 let close = Price::from(close);
687 let volume = Quantity::from(100_000);
688 let ts_event = 0;
689 let ts_init = 1;
690
691 let result = Bar::py_new(bar_type, open, high, low, close, volume, ts_event, ts_init);
692 assert!(result.is_err());
693 }
694
695 #[rstest]
696 fn test_bar_py_new() {
697 let bar_type = BarType::from("AUDUSD.SIM-1-MINUTE-LAST-INTERNAL");
698 let open = Price::from("1.00005");
699 let high = Price::from("1.00010");
700 let low = Price::from("1.00000");
701 let close = Price::from("1.00007");
702 let volume = Quantity::from(100_000);
703 let ts_event = 0;
704 let ts_init = 1;
705
706 let result = Bar::py_new(bar_type, open, high, low, close, volume, ts_event, ts_init);
707 assert!(result.is_ok());
708 }
709
710 #[rstest]
711 fn test_to_dict() {
712 let bar = Bar::default();
713
714 Python::initialize();
715 Python::attach(|py| {
716 let dict_string = bar.py_to_dict(py).unwrap().to_string();
717 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}";
718 assert_eq!(dict_string, expected_string);
719 });
720 }
721
722 #[rstest]
723 fn test_as_from_dict() {
724 let bar = Bar::default();
725
726 Python::initialize();
727 Python::attach(|py| {
728 let dict = bar.py_to_dict(py).unwrap();
729 let parsed = Bar::py_from_dict(py, dict).unwrap();
730 assert_eq!(parsed, bar);
731 });
732 }
733}