1use std::{
17 collections::hash_map::DefaultHasher,
18 hash::{Hash, Hasher},
19 str::FromStr,
20};
21
22use nautilus_core::python::{
23 correctness_error_to_pyvalue_err, get_pytype_name, to_pytype_err, to_pyvalue_err,
24};
25use pyo3::{IntoPyObjectExt, basic::CompareOp, prelude::*, types::PyFloat};
26use rust_decimal::{Decimal, RoundingStrategy};
27
28use super::fixed::{
29 ArithmeticError, ArithmeticOperation, FloatArithmetic, check_raw_scales,
30 extract_arithmetic_decimal,
31};
32use crate::types::{Currency, Money, fixed::raw_scale, money::MoneyRaw};
33
34#[pymethods]
35#[pyo3_stub_gen::derive::gen_stub_pymethods]
36impl Money {
37 #[new]
45 fn py_new(amount: f64, currency: Currency) -> PyResult<Self> {
46 Self::new_checked(amount, currency).map_err(to_pyvalue_err)
47 }
48
49 fn __reduce__(&self, py: Python) -> PyResult<Py<PyAny>> {
50 let from_raw = py.get_type::<Self>().getattr("from_raw")?;
51 let args = (self.raw(), self.currency).into_py_any(py)?;
52 (from_raw, args).into_py_any(py)
53 }
54
55 fn __richcmp__(
56 &self,
57 other: &Bound<'_, PyAny>,
58 op: CompareOp,
59 py: Python<'_>,
60 ) -> PyResult<Py<PyAny>> {
61 if let Ok(other_money) = other.extract::<Self>() {
62 if self.currency != other_money.currency {
63 return Err(to_pyvalue_err(format!(
64 "Cannot compare Money with different currencies: {} vs {}",
65 self.currency.code, other_money.currency.code
66 )));
67 }
68 let result = match op {
69 CompareOp::Eq => self.eq(&other_money),
70 CompareOp::Ne => self.ne(&other_money),
71 CompareOp::Ge => self.ge(&other_money),
72 CompareOp::Gt => self.gt(&other_money),
73 CompareOp::Le => self.le(&other_money),
74 CompareOp::Lt => self.lt(&other_money),
75 };
76 result.into_py_any(py)
77 } else {
78 Ok(py.NotImplemented())
79 }
80 }
81
82 fn __hash__(&self) -> isize {
83 let mut h = DefaultHasher::new();
84 self.hash(&mut h);
85 h.finish() as isize
86 }
87
88 fn __add__(&self, other: &Bound<'_, PyAny>, py: Python) -> PyResult<Py<PyAny>> {
89 if other.is_instance_of::<PyFloat>() {
90 let other_float: f64 = other.extract::<f64>()?;
91 ArithmeticOperation::Add
92 .checked_f64(self.as_f64_checked()?, other_float)?
93 .into_py_any(py)
94 } else if let Ok(other_money) = other.extract::<Self>() {
95 check_money_currency(self, &other_money)?;
96 check_raw_scales(self.currency.precision, other_money.currency.precision)?;
97 (*self)
98 .checked_add(other_money)
99 .ok_or(ArithmeticError::Overflow)?
100 .into_py_any(py)
101 } else if let Some(other_dec) = extract_arithmetic_decimal(other) {
102 ArithmeticOperation::Add
103 .checked_decimal(self.as_decimal(), other_dec)?
104 .into_py_any(py)
105 } else {
106 let pytype_name = get_pytype_name(other)?;
107 Err(to_pytype_err(format!(
108 "Unsupported type for __add__, was `{pytype_name}`"
109 )))
110 }
111 }
112
113 fn __radd__(&self, other: &Bound<'_, PyAny>, py: Python) -> PyResult<Py<PyAny>> {
114 if other.is_instance_of::<PyFloat>() {
115 let other_float: f64 = other.extract()?;
116 ArithmeticOperation::Add
117 .checked_f64(other_float, self.as_f64_checked()?)?
118 .into_py_any(py)
119 } else if let Ok(other_money) = other.extract::<Self>() {
120 check_money_currency(&other_money, self)?;
121 check_raw_scales(other_money.currency.precision, self.currency.precision)?;
122 other_money
123 .checked_add(*self)
124 .ok_or(ArithmeticError::Overflow)?
125 .into_py_any(py)
126 } else if let Some(other_dec) = extract_arithmetic_decimal(other) {
127 ArithmeticOperation::Add
128 .checked_decimal(other_dec, self.as_decimal())?
129 .into_py_any(py)
130 } else {
131 let pytype_name = get_pytype_name(other)?;
132 Err(to_pytype_err(format!(
133 "Unsupported type for __radd__, was `{pytype_name}`"
134 )))
135 }
136 }
137
138 fn __sub__(&self, other: &Bound<'_, PyAny>, py: Python) -> PyResult<Py<PyAny>> {
139 if other.is_instance_of::<PyFloat>() {
140 let other_float: f64 = other.extract()?;
141 ArithmeticOperation::Sub
142 .checked_f64(self.as_f64_checked()?, other_float)?
143 .into_py_any(py)
144 } else if let Ok(other_money) = other.extract::<Self>() {
145 check_money_currency(self, &other_money)?;
146 check_raw_scales(self.currency.precision, other_money.currency.precision)?;
147 (*self)
148 .checked_sub(other_money)
149 .ok_or(ArithmeticError::Overflow)?
150 .into_py_any(py)
151 } else if let Some(other_dec) = extract_arithmetic_decimal(other) {
152 ArithmeticOperation::Sub
153 .checked_decimal(self.as_decimal(), other_dec)?
154 .into_py_any(py)
155 } else {
156 let pytype_name = get_pytype_name(other)?;
157 Err(to_pytype_err(format!(
158 "Unsupported type for __sub__, was `{pytype_name}`"
159 )))
160 }
161 }
162
163 fn __rsub__(&self, other: &Bound<'_, PyAny>, py: Python) -> PyResult<Py<PyAny>> {
164 if other.is_instance_of::<PyFloat>() {
165 let other_float: f64 = other.extract()?;
166 ArithmeticOperation::Sub
167 .checked_f64(other_float, self.as_f64_checked()?)?
168 .into_py_any(py)
169 } else if let Ok(other_money) = other.extract::<Self>() {
170 check_money_currency(&other_money, self)?;
171 check_raw_scales(other_money.currency.precision, self.currency.precision)?;
172 other_money
173 .checked_sub(*self)
174 .ok_or(ArithmeticError::Overflow)?
175 .into_py_any(py)
176 } else if let Some(other_dec) = extract_arithmetic_decimal(other) {
177 ArithmeticOperation::Sub
178 .checked_decimal(other_dec, self.as_decimal())?
179 .into_py_any(py)
180 } else {
181 let pytype_name = get_pytype_name(other)?;
182 Err(to_pytype_err(format!(
183 "Unsupported type for __rsub__, was `{pytype_name}`"
184 )))
185 }
186 }
187
188 fn __mul__(&self, other: &Bound<'_, PyAny>, py: Python) -> PyResult<Py<PyAny>> {
189 if other.is_instance_of::<PyFloat>() {
190 let other_float: f64 = other.extract()?;
191 ArithmeticOperation::Mul
192 .checked_f64(self.as_f64_checked()?, other_float)?
193 .into_py_any(py)
194 } else if let Ok(other_money) = other.extract::<Self>() {
195 check_money_currency(self, &other_money)?;
196 ArithmeticOperation::Mul
197 .checked_decimal(self.as_decimal(), other_money.as_decimal())?
198 .into_py_any(py)
199 } else if let Some(other_dec) = extract_arithmetic_decimal(other) {
200 ArithmeticOperation::Mul
201 .checked_decimal(self.as_decimal(), other_dec)?
202 .into_py_any(py)
203 } else {
204 let pytype_name = get_pytype_name(other)?;
205 Err(to_pytype_err(format!(
206 "Unsupported type for __mul__, was `{pytype_name}`"
207 )))
208 }
209 }
210
211 fn __rmul__(&self, other: &Bound<'_, PyAny>, py: Python) -> PyResult<Py<PyAny>> {
212 if other.is_instance_of::<PyFloat>() {
213 let other_float: f64 = other.extract()?;
214 ArithmeticOperation::Mul
215 .checked_f64(other_float, self.as_f64_checked()?)?
216 .into_py_any(py)
217 } else if let Ok(other_money) = other.extract::<Self>() {
218 check_money_currency(&other_money, self)?;
219 ArithmeticOperation::Mul
220 .checked_decimal(other_money.as_decimal(), self.as_decimal())?
221 .into_py_any(py)
222 } else if let Some(other_dec) = extract_arithmetic_decimal(other) {
223 ArithmeticOperation::Mul
224 .checked_decimal(other_dec, self.as_decimal())?
225 .into_py_any(py)
226 } else {
227 let pytype_name = get_pytype_name(other)?;
228 Err(to_pytype_err(format!(
229 "Unsupported type for __rmul__, was `{pytype_name}`"
230 )))
231 }
232 }
233
234 fn __truediv__(&self, other: &Bound<'_, PyAny>, py: Python) -> PyResult<Py<PyAny>> {
235 if other.is_instance_of::<PyFloat>() {
236 let other_float: f64 = other.extract()?;
237 ArithmeticOperation::Div
238 .checked_f64(self.as_f64_checked()?, other_float)?
239 .into_py_any(py)
240 } else if let Ok(other_money) = other.extract::<Self>() {
241 check_money_currency(self, &other_money)?;
242 ArithmeticOperation::Div
243 .checked_decimal(self.as_decimal(), other_money.as_decimal())?
244 .into_py_any(py)
245 } else if let Some(other_dec) = extract_arithmetic_decimal(other) {
246 ArithmeticOperation::Div
247 .checked_decimal(self.as_decimal(), other_dec)?
248 .into_py_any(py)
249 } else {
250 let pytype_name = get_pytype_name(other)?;
251 Err(to_pytype_err(format!(
252 "Unsupported type for __truediv__, was `{pytype_name}`"
253 )))
254 }
255 }
256
257 fn __rtruediv__(&self, other: &Bound<'_, PyAny>, py: Python) -> PyResult<Py<PyAny>> {
258 if other.is_instance_of::<PyFloat>() {
259 let other_float: f64 = other.extract()?;
260 ArithmeticOperation::Div
261 .checked_f64(other_float, self.as_f64_checked()?)?
262 .into_py_any(py)
263 } else if let Ok(other_money) = other.extract::<Self>() {
264 check_money_currency(&other_money, self)?;
265 ArithmeticOperation::Div
266 .checked_decimal(other_money.as_decimal(), self.as_decimal())?
267 .into_py_any(py)
268 } else if let Some(other_dec) = extract_arithmetic_decimal(other) {
269 ArithmeticOperation::Div
270 .checked_decimal(other_dec, self.as_decimal())?
271 .into_py_any(py)
272 } else {
273 let pytype_name = get_pytype_name(other)?;
274 Err(to_pytype_err(format!(
275 "Unsupported type for __rtruediv__, was `{pytype_name}`"
276 )))
277 }
278 }
279
280 fn __floordiv__(&self, other: &Bound<'_, PyAny>, py: Python) -> PyResult<Py<PyAny>> {
281 if other.is_instance_of::<PyFloat>() {
282 let other_float: f64 = other.extract()?;
283 ArithmeticOperation::Div
284 .checked_f64(self.as_f64_checked()?, other_float)?
285 .floor()
286 .into_py_any(py)
287 } else if let Ok(other_money) = other.extract::<Self>() {
288 check_money_currency(self, &other_money)?;
289 ArithmeticOperation::Div
290 .checked_decimal(self.as_decimal(), other_money.as_decimal())?
291 .floor()
292 .into_py_any(py)
293 } else if let Some(other_dec) = extract_arithmetic_decimal(other) {
294 ArithmeticOperation::Div
295 .checked_decimal(self.as_decimal(), other_dec)?
296 .floor()
297 .into_py_any(py)
298 } else {
299 let pytype_name = get_pytype_name(other)?;
300 Err(to_pytype_err(format!(
301 "Unsupported type for __floordiv__, was `{pytype_name}`"
302 )))
303 }
304 }
305
306 fn __rfloordiv__(&self, other: &Bound<'_, PyAny>, py: Python) -> PyResult<Py<PyAny>> {
307 if other.is_instance_of::<PyFloat>() {
308 let other_float: f64 = other.extract()?;
309 ArithmeticOperation::Div
310 .checked_f64(other_float, self.as_f64_checked()?)?
311 .floor()
312 .into_py_any(py)
313 } else if let Ok(other_money) = other.extract::<Self>() {
314 check_money_currency(&other_money, self)?;
315 ArithmeticOperation::Div
316 .checked_decimal(other_money.as_decimal(), self.as_decimal())?
317 .floor()
318 .into_py_any(py)
319 } else if let Some(other_dec) = extract_arithmetic_decimal(other) {
320 ArithmeticOperation::Div
321 .checked_decimal(other_dec, self.as_decimal())?
322 .floor()
323 .into_py_any(py)
324 } else {
325 let pytype_name = get_pytype_name(other)?;
326 Err(to_pytype_err(format!(
327 "Unsupported type for __rfloordiv__, was `{pytype_name}`"
328 )))
329 }
330 }
331
332 fn __mod__(&self, other: &Bound<'_, PyAny>, py: Python) -> PyResult<Py<PyAny>> {
333 if other.is_instance_of::<PyFloat>() {
334 let other_float: f64 = other.extract()?;
335 ArithmeticOperation::Rem
336 .checked_f64(self.as_f64_checked()?, other_float)?
337 .into_py_any(py)
338 } else if let Ok(other_money) = other.extract::<Self>() {
339 check_money_currency(self, &other_money)?;
340 ArithmeticOperation::Rem
341 .checked_decimal(self.as_decimal(), other_money.as_decimal())?
342 .into_py_any(py)
343 } else if let Some(other_dec) = extract_arithmetic_decimal(other) {
344 ArithmeticOperation::Rem
345 .checked_decimal(self.as_decimal(), other_dec)?
346 .into_py_any(py)
347 } else {
348 let pytype_name = get_pytype_name(other)?;
349 Err(to_pytype_err(format!(
350 "Unsupported type for __mod__, was `{pytype_name}`"
351 )))
352 }
353 }
354
355 fn __rmod__(&self, other: &Bound<'_, PyAny>, py: Python) -> PyResult<Py<PyAny>> {
356 if other.is_instance_of::<PyFloat>() {
357 let other_float: f64 = other.extract()?;
358 ArithmeticOperation::Rem
359 .checked_f64(other_float, self.as_f64_checked()?)?
360 .into_py_any(py)
361 } else if let Ok(other_money) = other.extract::<Self>() {
362 check_money_currency(&other_money, self)?;
363 ArithmeticOperation::Rem
364 .checked_decimal(other_money.as_decimal(), self.as_decimal())?
365 .into_py_any(py)
366 } else if let Some(other_dec) = extract_arithmetic_decimal(other) {
367 ArithmeticOperation::Rem
368 .checked_decimal(other_dec, self.as_decimal())?
369 .into_py_any(py)
370 } else {
371 let pytype_name = get_pytype_name(other)?;
372 Err(to_pytype_err(format!(
373 "Unsupported type for __rmod__, was `{pytype_name}`"
374 )))
375 }
376 }
377
378 fn __neg__(&self) -> Self {
379 -*self
380 }
381
382 fn __pos__(&self) -> Self {
383 *self
384 }
385
386 fn __abs__(&self) -> Self {
387 self.abs()
388 }
389
390 fn __int__(&self) -> MoneyRaw {
391 let scale = MoneyRaw::try_from(raw_scale(self.currency.precision))
392 .expect("effective raw scale should fit in MoneyRaw");
393 self.raw() / scale
394 }
395
396 fn __float__(&self) -> PyResult<f64> {
397 self.as_f64_checked()
398 }
399
400 #[pyo3(signature = (ndigits=None))]
401 fn __round__(&self, ndigits: Option<u32>) -> Decimal {
402 self.as_decimal()
403 .round_dp_with_strategy(ndigits.unwrap_or(0), RoundingStrategy::MidpointNearestEven)
404 }
405
406 fn __repr__(&self) -> String {
407 format!("{self:?}")
408 }
409
410 fn __str__(&self) -> String {
411 self.to_string()
412 }
413
414 #[getter(raw)]
427 fn py_raw(&self) -> MoneyRaw {
428 self.raw()
429 }
430
431 #[getter]
432 fn currency(&self) -> Currency {
433 self.currency
434 }
435
436 #[staticmethod]
438 #[pyo3(name = "zero")]
439 fn py_zero(currency: Currency) -> PyResult<Self> {
440 Self::from_raw_checked(0, currency).map_err(correctness_error_to_pyvalue_err)
441 }
442
443 #[staticmethod]
445 #[pyo3(name = "from_raw")]
446 fn py_from_raw(raw: MoneyRaw, currency: Currency) -> PyResult<Self> {
447 Self::from_raw_checked(raw, currency).map_err(correctness_error_to_pyvalue_err)
448 }
449
450 #[staticmethod]
461 #[pyo3(name = "from_decimal")]
462 fn py_from_decimal(value: Decimal, currency: Currency) -> PyResult<Self> {
463 Self::from_decimal(value, currency).map_err(to_pyvalue_err)
464 }
465
466 #[staticmethod]
467 #[pyo3(name = "from_str")]
468 fn py_from_str(value: &str) -> PyResult<Self> {
469 Self::from_str(value).map_err(to_pyvalue_err)
470 }
471
472 #[staticmethod]
482 #[pyo3(name = "from_decimal_exact")]
483 fn py_from_decimal_exact(
484 #[gen_stub(override_type(type_repr = "decimal.Decimal", imports = ("decimal",)))]
485 value: &Bound<'_, PyAny>,
486 currency: Currency,
487 ) -> PyResult<Self> {
488 let decimal_type = value.py().import("decimal")?.getattr("Decimal")?;
489 if !value.is_instance(&decimal_type)? {
490 return Err(to_pytype_err("value must be a Decimal"));
491 }
492
493 let amount = value.str()?;
494 Self::from_decimal_str_exact(&amount.to_cow()?, currency).map_err(to_pyvalue_err)
495 }
496
497 #[staticmethod]
504 #[pyo3(name = "from_str_exact")]
505 fn py_from_str_exact(value: &str) -> PyResult<Self> {
506 Self::from_str_exact(value).map_err(to_pyvalue_err)
507 }
508
509 #[pyo3(name = "is_zero")]
511 fn py_is_zero(&self) -> bool {
512 self.is_zero()
513 }
514
515 #[pyo3(name = "is_positive")]
517 fn py_is_positive(&self) -> bool {
518 self.is_positive()
519 }
520
521 #[pyo3(name = "as_decimal")]
526 fn py_as_decimal(&self) -> Decimal {
527 self.as_decimal()
528 }
529
530 #[pyo3(name = "as_double")]
531 fn py_as_double(&self) -> PyResult<f64> {
532 self.as_f64_checked()
533 }
534
535 #[pyo3(name = "to_formatted_str")]
536 fn py_to_formatted_str(&self) -> String {
537 self.to_formatted_string()
538 }
539
540 #[pyo3(name = "checked_add")]
545 fn py_checked_add(&self, other: Self) -> PyResult<Option<Self>> {
546 if self.currency != other.currency {
547 return Err(to_pyvalue_err(format!(
548 "Currency mismatch: cannot add {} to {}",
549 other.currency.code, self.currency.code
550 )));
551 }
552 Ok(self.checked_add(other))
553 }
554
555 #[pyo3(name = "checked_sub")]
560 fn py_checked_sub(&self, other: Self) -> PyResult<Option<Self>> {
561 if self.currency != other.currency {
562 return Err(to_pyvalue_err(format!(
563 "Currency mismatch: cannot subtract {} from {}",
564 other.currency.code, self.currency.code
565 )));
566 }
567 Ok(self.checked_sub(other))
568 }
569}
570
571fn check_money_currency(lhs: &Money, rhs: &Money) -> PyResult<()> {
572 if lhs.currency != rhs.currency {
573 return Err(to_pyvalue_err(format!(
574 "Currency mismatch: {} vs {}",
575 lhs.currency.code, rhs.currency.code
576 )));
577 }
578 Ok(())
579}
580
581#[cfg(test)]
582mod tests {
583 use pyo3::Python;
584 use rstest::rstest;
585 use rust_decimal::Decimal;
586
587 use super::*;
588 use crate::{
589 enums::CurrencyType,
590 types::{
591 fixed::FIXED_PRECISION,
592 money::{MONEY_RAW_MAX, MONEY_RAW_MIN},
593 },
594 };
595
596 #[rstest]
597 #[case("0", 0)]
598 #[case("0.000000001", 0)]
599 #[case("-0.000000001", 0)]
600 #[case("1.999999999", 1)]
601 #[case("-1.999999999", -1)]
602 #[case("50.25", 50)]
603 #[case("9007199253.999999999", 9_007_199_253)]
604 fn test_int_uses_exact_raw_value(#[case] value: &str, #[case] expected: i64) {
605 let currency = Currency::new("TST9", 9, 0, "Test 9dp", CurrencyType::Crypto);
606 let money = Money::from_decimal(Decimal::from_str(value).unwrap(), currency).unwrap();
607
608 assert_eq!(money.__int__(), MoneyRaw::from(expected));
609 }
610
611 #[rstest]
612 fn test_int_preserves_domain_boundaries() {
613 let currency = Currency::new(
614 "TST",
615 FIXED_PRECISION,
616 0,
617 "Test fixed precision",
618 CurrencyType::Crypto,
619 );
620 let expected_max: MoneyRaw = if cfg!(feature = "high-precision") {
621 17_014_118_346_046
622 } else {
623 9_223_372_036
624 };
625 let expected_min = -expected_max;
626
627 assert_eq!(
628 Money::from_raw(MONEY_RAW_MAX, currency).__int__(),
629 expected_max
630 );
631 assert_eq!(
632 Money::from_raw(MONEY_RAW_MIN, currency).__int__(),
633 expected_min
634 );
635 }
636
637 #[rstest]
638 #[cfg(feature = "defi")]
639 fn test_int_uses_defi_raw_scale() {
640 let currency = Currency::new(
641 "TST18",
642 crate::defi::WEI_PRECISION,
643 0,
644 "Test 18dp",
645 CurrencyType::Crypto,
646 );
647 let money = Money::from_raw(1_999_999_999_999_999_999, currency);
648
649 assert_eq!(money.__int__(), 1);
650 }
651
652 #[rstest]
653 fn test_py_from_raw_rejects_out_of_range_raw_value() {
654 Python::initialize();
655 Python::attach(|_| {
656 let raw = MONEY_RAW_MAX.saturating_add(1);
657 let error = Money::py_from_raw(raw, Currency::USD()).unwrap_err();
658
659 assert_eq!(
660 error.to_string(),
661 format!(
662 "ValueError: `raw` value {raw} exceeded bounds [{MONEY_RAW_MIN}, {MONEY_RAW_MAX}] for Money"
663 )
664 );
665 });
666 }
667
668 #[rstest]
669 fn test_py_zero_handles_currency_precision() {
670 Python::initialize();
671 Python::attach(|_| {
672 #[cfg(feature = "defi")]
673 let max_precision = crate::defi::WEI_PRECISION;
674 #[cfg(not(feature = "defi"))]
675 let max_precision = FIXED_PRECISION;
676
677 let currency = Currency::new(
678 "TST",
679 max_precision,
680 0,
681 "Test currency",
682 CurrencyType::Crypto,
683 );
684 let money = Money::py_zero(currency).unwrap();
685 let invalid_precision = max_precision + 1;
686 let mut invalid_currency = currency;
687 invalid_currency.precision = invalid_precision;
688 let precision_error = Money::py_zero(invalid_currency).unwrap_err();
689 let precision_name = if cfg!(feature = "defi") {
690 "WEI_PRECISION"
691 } else {
692 "FIXED_PRECISION"
693 };
694
695 assert_eq!(money.raw(), 0);
696 assert_eq!(money.currency, currency);
697 assert_eq!(
698 precision_error.to_string(),
699 format!(
700 "ValueError: `precision` exceeded maximum `{precision_name}` ({max_precision}), was {invalid_precision}"
701 )
702 );
703 });
704 }
705}