1use std::{
19 cmp::Ordering,
20 fmt::{Debug, Display},
21 hash::{Hash, Hasher},
22 ops::{Add, AddAssign, Div, Mul, Neg, Sub, SubAssign},
23 str::FromStr,
24};
25
26use nautilus_core::{
27 correctness::{FAILED, check_in_range_inclusive_f64, check_predicate_true},
28 formatting::Separable,
29};
30use rust_decimal::{Decimal, prelude::ToPrimitive};
31use serde::{Deserialize, Deserializer, Serialize};
32
33#[cfg(not(any(feature = "defi", feature = "high-precision")))]
34use super::fixed::{f64_to_fixed_i64, fixed_i64_to_f64};
35#[cfg(any(feature = "defi", feature = "high-precision"))]
36use super::fixed::{f64_to_fixed_i128, fixed_i128_to_f64};
37#[cfg(feature = "defi")]
38use crate::types::fixed::MAX_FLOAT_PRECISION;
39use crate::types::{
40 Currency,
41 fixed::{FIXED_PRECISION, FIXED_SCALAR, check_fixed_precision},
42};
43
44#[cfg(feature = "high-precision")]
49pub type MoneyRaw = i128;
50
51#[cfg(not(feature = "high-precision"))]
52pub type MoneyRaw = i64;
53
54#[unsafe(no_mangle)]
65#[allow(unsafe_code)]
66pub static MONEY_RAW_MAX: MoneyRaw = (MONEY_MAX * FIXED_SCALAR) as MoneyRaw;
67
68#[unsafe(no_mangle)]
77#[allow(unsafe_code)]
78pub static MONEY_RAW_MIN: MoneyRaw = (MONEY_MIN * FIXED_SCALAR) as MoneyRaw;
79
80#[cfg(feature = "high-precision")]
85pub const MONEY_MAX: f64 = 17_014_118_346_046.0;
87
88#[cfg(not(feature = "high-precision"))]
89pub const MONEY_MAX: f64 = 9_223_372_036.0;
91
92#[cfg(feature = "high-precision")]
97pub const MONEY_MIN: f64 = -17_014_118_346_046.0;
99
100#[cfg(not(feature = "high-precision"))]
101pub const MONEY_MIN: f64 = -9_223_372_036.0;
103
104#[repr(C)]
111#[derive(Clone, Copy, Eq)]
112#[cfg_attr(
113 feature = "python",
114 pyo3::pyclass(module = "nautilus_trader.core.nautilus_pyo3.model", frozen)
115)]
116pub struct Money {
117 pub raw: MoneyRaw,
119 pub currency: Currency,
121}
122
123impl Money {
124 pub fn new_checked(amount: f64, currency: Currency) -> anyhow::Result<Self> {
134 check_in_range_inclusive_f64(amount, MONEY_MIN, MONEY_MAX, "amount")?;
138
139 #[cfg(feature = "defi")]
140 if currency.precision > MAX_FLOAT_PRECISION {
141 anyhow::bail!(
143 "`currency.precision` exceeded maximum float precision ({MAX_FLOAT_PRECISION}), use `Money::from_wei()` for wei values instead"
144 );
145 }
146
147 #[cfg(feature = "high-precision")]
148 let raw = f64_to_fixed_i128(amount, currency.precision);
149
150 #[cfg(not(feature = "high-precision"))]
151 let raw = f64_to_fixed_i64(amount, currency.precision);
152
153 Ok(Self { raw, currency })
154 }
155
156 pub fn new(amount: f64, currency: Currency) -> Self {
162 Self::new_checked(amount, currency).expect(FAILED)
163 }
164
165 #[must_use]
172 pub fn from_raw(raw: MoneyRaw, currency: Currency) -> Self {
173 check_predicate_true(
174 raw >= MONEY_RAW_MIN && raw <= MONEY_RAW_MAX,
175 &format!(
176 "`raw` value {raw} exceeded bounds [{MONEY_RAW_MIN}, {MONEY_RAW_MAX}] for Money"
177 ),
178 )
179 .expect(FAILED);
180 check_fixed_precision(currency.precision).expect(FAILED);
181
182 Self { raw, currency }
192 }
193
194 #[must_use]
200 pub fn zero(currency: Currency) -> Self {
201 Self::new(0.0, currency)
202 }
203
204 #[must_use]
206 pub fn is_zero(&self) -> bool {
207 self.raw == 0
208 }
209
210 #[cfg(feature = "high-precision")]
211 #[must_use]
217 pub fn as_f64(&self) -> f64 {
218 #[cfg(feature = "defi")]
219 if self.currency.precision > MAX_FLOAT_PRECISION {
220 panic!("Invalid f64 conversion beyond `MAX_FLOAT_PRECISION` (16)");
221 }
222
223 fixed_i128_to_f64(self.raw)
224 }
225
226 #[cfg(not(feature = "high-precision"))]
227 #[must_use]
233 pub fn as_f64(&self) -> f64 {
234 #[cfg(feature = "defi")]
235 if self.currency.precision > MAX_FLOAT_PRECISION {
236 panic!("Invalid f64 conversion beyond `MAX_FLOAT_PRECISION` (16)");
237 }
238
239 fixed_i64_to_f64(self.raw)
240 }
241
242 #[must_use]
244 pub fn as_decimal(&self) -> Decimal {
245 let precision = self.currency.precision;
247 let precision_diff = FIXED_PRECISION.saturating_sub(precision);
248
249 let rescaled_raw = self.raw / MoneyRaw::pow(10, u32::from(precision_diff));
252
253 #[allow(clippy::useless_conversion)]
254 Decimal::from_i128_with_scale(i128::from(rescaled_raw), u32::from(precision))
255 }
256
257 #[must_use]
259 pub fn to_formatted_string(&self) -> String {
260 let amount_str = format!("{:.*}", self.currency.precision as usize, self.as_f64())
261 .separate_with_underscores();
262 format!("{} {}", amount_str, self.currency.code)
263 }
264
265 pub fn from_decimal(decimal: Decimal, currency: Currency) -> anyhow::Result<Self> {
276 let precision = currency.precision;
277
278 let scale_factor = Decimal::from(10_i64.pow(precision as u32));
279 let scaled = decimal * scale_factor;
280 let rounded = scaled.round();
281
282 #[cfg(feature = "high-precision")]
283 let raw_at_precision: MoneyRaw = rounded.to_i128().ok_or_else(|| {
284 anyhow::anyhow!("Decimal value '{decimal}' cannot be converted to i128")
285 })?;
286 #[cfg(not(feature = "high-precision"))]
287 let raw_at_precision: MoneyRaw = rounded.to_i64().ok_or_else(|| {
288 anyhow::anyhow!("Decimal value '{decimal}' cannot be converted to i64")
289 })?;
290
291 let scale_up = 10_i64.pow((FIXED_PRECISION - precision) as u32) as MoneyRaw;
292 let raw = raw_at_precision
293 .checked_mul(scale_up)
294 .ok_or_else(|| anyhow::anyhow!("Overflow when scaling to fixed precision"))?;
295
296 Ok(Self { raw, currency })
297 }
298}
299
300impl FromStr for Money {
301 type Err = String;
302
303 fn from_str(value: &str) -> Result<Self, Self::Err> {
304 let parts: Vec<&str> = value.split_whitespace().collect();
305
306 if parts.len() != 2 {
308 return Err(format!(
309 "Error invalid input format '{value}'. Expected '<amount> <currency>'"
310 ));
311 }
312
313 let clean_amount = parts[0].replace('_', "");
314
315 let decimal = if clean_amount.contains('e') || clean_amount.contains('E') {
316 Decimal::from_scientific(&clean_amount)
317 .map_err(|e| format!("Error parsing amount '{}' as Decimal: {e}", parts[0]))?
318 } else {
319 Decimal::from_str(&clean_amount)
320 .map_err(|e| format!("Error parsing amount '{}' as Decimal: {e}", parts[0]))?
321 };
322
323 let currency = Currency::from_str(parts[1]).map_err(|e: anyhow::Error| e.to_string())?;
324 Self::from_decimal(decimal, currency).map_err(|e| e.to_string())
325 }
326}
327
328impl<T: AsRef<str>> From<T> for Money {
329 fn from(value: T) -> Self {
330 Self::from_str(value.as_ref()).expect(FAILED)
331 }
332}
333
334impl From<Money> for f64 {
335 fn from(money: Money) -> Self {
336 money.as_f64()
337 }
338}
339
340impl From<&Money> for f64 {
341 fn from(money: &Money) -> Self {
342 money.as_f64()
343 }
344}
345
346impl Hash for Money {
347 fn hash<H: Hasher>(&self, state: &mut H) {
348 self.raw.hash(state);
349 self.currency.hash(state);
350 }
351}
352
353impl PartialEq for Money {
354 fn eq(&self, other: &Self) -> bool {
355 self.raw == other.raw && self.currency == other.currency
356 }
357}
358
359impl PartialOrd for Money {
360 fn partial_cmp(&self, other: &Self) -> Option<Ordering> {
361 Some(self.cmp(other))
362 }
363
364 fn lt(&self, other: &Self) -> bool {
365 assert_eq!(self.currency, other.currency);
366 self.raw.lt(&other.raw)
367 }
368
369 fn le(&self, other: &Self) -> bool {
370 assert_eq!(self.currency, other.currency);
371 self.raw.le(&other.raw)
372 }
373
374 fn gt(&self, other: &Self) -> bool {
375 assert_eq!(self.currency, other.currency);
376 self.raw.gt(&other.raw)
377 }
378
379 fn ge(&self, other: &Self) -> bool {
380 assert_eq!(self.currency, other.currency);
381 self.raw.ge(&other.raw)
382 }
383}
384
385impl Ord for Money {
386 fn cmp(&self, other: &Self) -> Ordering {
387 assert_eq!(self.currency, other.currency);
388 self.raw.cmp(&other.raw)
389 }
390}
391
392impl Neg for Money {
393 type Output = Self;
394 fn neg(self) -> Self::Output {
395 Self {
396 raw: -self.raw,
397 currency: self.currency,
398 }
399 }
400}
401
402impl Add for Money {
403 type Output = Self;
404 fn add(self, rhs: Self) -> Self::Output {
405 assert_eq!(
406 self.currency, rhs.currency,
407 "Currency mismatch: cannot add {} to {}",
408 rhs.currency.code, self.currency.code
409 );
410 Self {
411 raw: self
412 .raw
413 .checked_add(rhs.raw)
414 .expect("Overflow occurred when adding `Money`"),
415 currency: self.currency,
416 }
417 }
418}
419
420impl Sub for Money {
421 type Output = Self;
422 fn sub(self, rhs: Self) -> Self::Output {
423 assert_eq!(
424 self.currency, rhs.currency,
425 "Currency mismatch: cannot subtract {} from {}",
426 rhs.currency.code, self.currency.code
427 );
428 Self {
429 raw: self
430 .raw
431 .checked_sub(rhs.raw)
432 .expect("Underflow occurred when subtracting `Money`"),
433 currency: self.currency,
434 }
435 }
436}
437
438impl AddAssign for Money {
439 fn add_assign(&mut self, other: Self) {
440 assert_eq!(
441 self.currency, other.currency,
442 "Currency mismatch: cannot add {} to {}",
443 other.currency.code, self.currency.code
444 );
445 self.raw = self
446 .raw
447 .checked_add(other.raw)
448 .expect("Overflow occurred when adding `Money`");
449 }
450}
451
452impl SubAssign for Money {
453 fn sub_assign(&mut self, other: Self) {
454 assert_eq!(
455 self.currency, other.currency,
456 "Currency mismatch: cannot subtract {} from {}",
457 other.currency.code, self.currency.code
458 );
459 self.raw = self
460 .raw
461 .checked_sub(other.raw)
462 .expect("Underflow occurred when subtracting `Money`");
463 }
464}
465
466impl Add<f64> for Money {
467 type Output = f64;
468 fn add(self, rhs: f64) -> Self::Output {
469 self.as_f64() + rhs
470 }
471}
472
473impl Sub<f64> for Money {
474 type Output = f64;
475 fn sub(self, rhs: f64) -> Self::Output {
476 self.as_f64() - rhs
477 }
478}
479
480impl Mul<f64> for Money {
481 type Output = f64;
482 fn mul(self, rhs: f64) -> Self::Output {
483 self.as_f64() * rhs
484 }
485}
486
487impl Div<f64> for Money {
488 type Output = f64;
489 fn div(self, rhs: f64) -> Self::Output {
490 self.as_f64() / rhs
491 }
492}
493
494impl Debug for Money {
495 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
496 if self.currency.precision > crate::types::fixed::MAX_FLOAT_PRECISION {
497 write!(f, "{}({}, {})", stringify!(Money), self.raw, self.currency)
498 } else {
499 write!(
500 f,
501 "{}({:.*}, {})",
502 stringify!(Money),
503 self.currency.precision as usize,
504 self.as_f64(),
505 self.currency
506 )
507 }
508 }
509}
510
511impl Display for Money {
512 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
513 if self.currency.precision > crate::types::fixed::MAX_FLOAT_PRECISION {
514 write!(f, "{} {}", self.raw, self.currency)
515 } else {
516 write!(f, "{} {}", self.as_decimal(), self.currency)
517 }
518 }
519}
520
521impl Serialize for Money {
522 fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
523 where
524 S: serde::Serializer,
525 {
526 serializer.serialize_str(&self.to_string())
527 }
528}
529
530impl<'de> Deserialize<'de> for Money {
531 fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
532 where
533 D: Deserializer<'de>,
534 {
535 let money_str: String = Deserialize::deserialize(deserializer)?;
536 Ok(Self::from(money_str.as_str()))
537 }
538}
539
540#[inline(always)]
546pub fn check_positive_money(value: Money, param: &str) -> anyhow::Result<()> {
547 if value.raw <= 0 {
548 anyhow::bail!("invalid `Money` for '{param}' not positive, was {value}");
549 }
550 Ok(())
551}
552
553#[cfg(test)]
554mod tests {
555 use nautilus_core::approx_eq;
556 use rstest::rstest;
557 use rust_decimal_macros::dec;
558
559 use super::*;
560
561 #[rstest]
562 fn test_debug() {
563 let money = Money::new(1010.12, Currency::USD());
564 let result = format!("{money:?}");
565 let expected = "Money(1010.12, USD)";
566 assert_eq!(result, expected);
567 }
568
569 #[rstest]
570 fn test_display() {
571 let money = Money::new(1010.12, Currency::USD());
572 let result = format!("{money}");
573 let expected = "1010.12 USD";
574 assert_eq!(result, expected);
575 }
576
577 #[rstest]
578 #[case(1010.12, 2, "USD", "Money(1010.12, USD)", "1010.12 USD")] #[case(123.456789, 8, "BTC", "Money(123.45678900, BTC)", "123.45678900 BTC")] fn test_formatting_normal_precision(
581 #[case] value: f64,
582 #[case] precision: u8,
583 #[case] currency_code: &str,
584 #[case] expected_debug: &str,
585 #[case] expected_display: &str,
586 ) {
587 use crate::enums::CurrencyType;
588 let currency = Currency::new(
589 currency_code,
590 precision,
591 0,
592 currency_code,
593 CurrencyType::Fiat,
594 );
595 let money = Money::new(value, currency);
596
597 assert_eq!(format!("{money:?}"), expected_debug);
598 assert_eq!(format!("{money}"), expected_display);
599 }
600
601 #[rstest]
602 #[cfg(feature = "defi")]
603 #[case(
604 1_000_000_000_000_000_000_i128,
605 18,
606 "wei",
607 "Money(1000000000000000000, wei)",
608 "1000000000000000000 wei"
609 )] #[case(
611 2_500_000_000_000_000_000_i128,
612 18,
613 "ETH",
614 "Money(2500000000000000000, ETH)",
615 "2500000000000000000 ETH"
616 )] fn test_formatting_high_precision(
618 #[case] raw_value: i128,
619 #[case] precision: u8,
620 #[case] currency_code: &str,
621 #[case] expected_debug: &str,
622 #[case] expected_display: &str,
623 ) {
624 use crate::enums::CurrencyType;
625 let currency = Currency::new(
626 currency_code,
627 precision,
628 0,
629 currency_code,
630 CurrencyType::Crypto,
631 );
632 let money = Money::from_raw(raw_value, currency);
633
634 assert_eq!(format!("{money:?}"), expected_debug);
635 assert_eq!(format!("{money}"), expected_display);
636 }
637
638 #[rstest]
639 fn test_zero_constructor() {
640 let usd = Currency::USD();
641 let money = Money::zero(usd);
642 assert_eq!(money.raw, 0);
643 assert_eq!(money.currency, usd);
644 }
645
646 #[rstest]
647 #[should_panic]
648 fn test_money_different_currency_addition() {
649 let usd = Money::new(1000.0, Currency::USD());
650 let btc = Money::new(1.0, Currency::BTC());
651 let _result = usd + btc; }
653
654 #[rstest] fn test_with_maximum_value() {
656 let money = Money::new_checked(MONEY_MAX, Currency::USD());
657 assert!(money.is_ok());
658 }
659
660 #[rstest] fn test_with_minimum_value() {
662 let money = Money::new_checked(MONEY_MIN, Currency::USD());
663 assert!(money.is_ok());
664 }
665
666 #[rstest]
667 fn test_money_is_zero() {
668 let zero_usd = Money::new(0.0, Currency::USD());
669 assert!(zero_usd.is_zero());
670 assert_eq!(zero_usd, Money::from("0.0 USD"));
671
672 let non_zero_usd = Money::new(100.0, Currency::USD());
673 assert!(!non_zero_usd.is_zero());
674 }
675
676 #[rstest]
677 fn test_money_comparisons() {
678 let usd = Currency::USD();
679 let m1 = Money::new(100.0, usd);
680 let m2 = Money::new(200.0, usd);
681
682 assert!(m1 < m2);
683 assert!(m2 > m1);
684 assert!(m1 <= m2);
685 assert!(m2 >= m1);
686
687 let m3 = Money::new(100.0, usd);
689 assert!(m1 == m3);
690 }
691
692 #[rstest]
693 fn test_add() {
694 let a = 1000.0;
695 let b = 500.0;
696 let money1 = Money::new(a, Currency::USD());
697 let money2 = Money::new(b, Currency::USD());
698 let money3 = money1 + money2;
699 assert_eq!(money3.raw, Money::new(a + b, Currency::USD()).raw);
700 }
701
702 #[rstest]
703 fn test_add_assign() {
704 let usd = Currency::USD();
705 let mut money = Money::new(100.0, usd);
706 money += Money::new(50.0, usd);
707 assert!(approx_eq!(f64, money.as_f64(), 150.0, epsilon = 1e-9));
708 assert_eq!(money.currency, usd);
709 }
710
711 #[rstest]
712 fn test_sub() {
713 let usd = Currency::USD();
714 let money1 = Money::new(1000.0, usd);
715 let money2 = Money::new(250.0, usd);
716 let result = money1 - money2;
717 assert!(approx_eq!(f64, result.as_f64(), 750.0, epsilon = 1e-9));
718 assert_eq!(result.currency, usd);
719 }
720
721 #[rstest]
722 fn test_sub_assign() {
723 let usd = Currency::USD();
724 let mut money = Money::new(100.0, usd);
725 money -= Money::new(25.0, usd);
726 assert!(approx_eq!(f64, money.as_f64(), 75.0, epsilon = 1e-9));
727 assert_eq!(money.currency, usd);
728 }
729
730 #[rstest]
731 fn test_money_negation() {
732 let money = Money::new(100.0, Currency::USD());
733 let result = -money;
734 assert_eq!(result, Money::from("-100.0 USD"));
735 assert_eq!(result.currency, Currency::USD().clone());
736 }
737
738 #[rstest]
739 fn test_money_multiplication_by_f64() {
740 let money = Money::new(100.0, Currency::USD());
741 let result = money * 1.5;
742 assert!(approx_eq!(f64, result, 150.0, epsilon = 1e-9));
743 }
744
745 #[rstest]
746 fn test_money_division_by_f64() {
747 let money = Money::new(100.0, Currency::USD());
748 let result = money / 4.0;
749 assert!(approx_eq!(f64, result, 25.0, epsilon = 1e-9));
750 }
751
752 #[rstest]
753 fn test_money_new_usd() {
754 let money = Money::new(1000.0, Currency::USD());
755 assert_eq!(money.currency.code.as_str(), "USD");
756 assert_eq!(money.currency.precision, 2);
757 assert_eq!(money.to_string(), "1000.00 USD");
758 assert_eq!(money.to_formatted_string(), "1_000.00 USD");
759 assert_eq!(money.as_decimal(), dec!(1000.00));
760 assert!(approx_eq!(f64, money.as_f64(), 1000.0, epsilon = 0.001));
761 }
762
763 #[rstest]
764 fn test_money_new_btc() {
765 let money = Money::new(10.3, Currency::BTC());
766 assert_eq!(money.currency.code.as_str(), "BTC");
767 assert_eq!(money.currency.precision, 8);
768 assert_eq!(money.to_string(), "10.30000000 BTC");
769 assert_eq!(money.to_formatted_string(), "10.30000000 BTC");
770 }
771
772 #[rstest]
773 #[case("0USD")] #[case("0x00 USD")] #[case("0 US")] #[case("0 USD USD")] #[should_panic]
778 fn test_from_str_invalid_input(#[case] input: &str) {
779 let _ = Money::from(input);
780 }
781
782 #[rstest]
783 #[case("0 USD", Currency::USD(), dec!(0.00))]
784 #[case("1.1 AUD", Currency::AUD(), dec!(1.10))]
785 #[case("1.12345678 BTC", Currency::BTC(), dec!(1.12345678))]
786 #[case("10_000.10 USD", Currency::USD(), dec!(10000.10))]
787 fn test_from_str_valid_input(
788 #[case] input: &str,
789 #[case] expected_currency: Currency,
790 #[case] expected_dec: Decimal,
791 ) {
792 let money = Money::from(input);
793 assert_eq!(money.currency, expected_currency);
794 assert_eq!(money.as_decimal(), expected_dec);
795 }
796
797 #[rstest]
798 fn test_money_from_str_negative() {
799 let money = Money::from("-123.45 USD");
800 assert!(approx_eq!(f64, money.as_f64(), -123.45, epsilon = 1e-9));
801 assert_eq!(money.currency, Currency::USD());
802 }
803
804 #[rstest]
805 #[case("1e7 USD", 10_000_000.0)]
806 #[case("2.5e3 EUR", 2_500.0)]
807 #[case("1.234e-2 GBP", 0.01)] #[case("5E-3 JPY", 0.0)] fn test_from_str_scientific_notation(#[case] input: &str, #[case] expected_value: f64) {
810 let money = Money::from_str(input).unwrap();
811 assert!(approx_eq!(
812 f64,
813 money.as_f64(),
814 expected_value,
815 epsilon = 1e-10
816 ));
817 }
818
819 #[rstest]
820 #[case("1_234.56 USD", 1234.56)]
821 #[case("1_000_000 EUR", 1_000_000.0)]
822 #[case("99_999.99 GBP", 99_999.99)]
823 fn test_from_str_with_underscores(#[case] input: &str, #[case] expected_value: f64) {
824 let money = Money::from_str(input).unwrap();
825 assert!(approx_eq!(
826 f64,
827 money.as_f64(),
828 expected_value,
829 epsilon = 1e-10
830 ));
831 }
832
833 #[rstest]
834 fn test_from_decimal_precision_preservation() {
835 use rust_decimal::Decimal;
836
837 let decimal = Decimal::from_str("123.45").unwrap();
838 let money = Money::from_decimal(decimal, Currency::USD()).unwrap();
839 assert_eq!(money.currency.precision, 2);
840 assert!(approx_eq!(f64, money.as_f64(), 123.45, epsilon = 1e-10));
841
842 let expected_raw = 12345 * 10_i64.pow((FIXED_PRECISION - 2) as u32);
844 assert_eq!(money.raw, expected_raw as MoneyRaw);
845 }
846
847 #[rstest]
848 fn test_from_decimal_rounding() {
849 use rust_decimal::Decimal;
850
851 let decimal = Decimal::from_str("1.005").unwrap();
853 let money = Money::from_decimal(decimal, Currency::USD()).unwrap();
854 assert_eq!(money.as_f64(), 1.0); let decimal = Decimal::from_str("1.015").unwrap();
857 let money = Money::from_decimal(decimal, Currency::USD()).unwrap();
858 assert_eq!(money.as_f64(), 1.02); }
860
861 #[rstest]
862 fn test_money_hash() {
863 use std::{
864 collections::hash_map::DefaultHasher,
865 hash::{Hash, Hasher},
866 };
867
868 let m1 = Money::new(100.0, Currency::USD());
869 let m2 = Money::new(100.0, Currency::USD());
870 let m3 = Money::new(100.0, Currency::AUD());
871
872 let mut s1 = DefaultHasher::new();
873 let mut s2 = DefaultHasher::new();
874 let mut s3 = DefaultHasher::new();
875
876 m1.hash(&mut s1);
877 m2.hash(&mut s2);
878 m3.hash(&mut s3);
879
880 assert_eq!(
881 s1.finish(),
882 s2.finish(),
883 "Same amount + same currency => same hash"
884 );
885 assert_ne!(
886 s1.finish(),
887 s3.finish(),
888 "Same amount + different currency => different hash"
889 );
890 }
891
892 #[rstest]
893 fn test_money_serialization_deserialization() {
894 let money = Money::new(123.45, Currency::USD());
895 let serialized = serde_json::to_string(&money);
896 let deserialized: Money = serde_json::from_str(&serialized.unwrap()).unwrap();
897 assert_eq!(money, deserialized);
898 }
899
900 #[rstest]
901 #[should_panic(expected = "`raw` value")]
902 fn test_money_from_raw_out_of_range_panics() {
903 let usd = Currency::USD();
904 let raw = MONEY_RAW_MAX.saturating_add(1);
905 let _ = Money::from_raw(raw, usd);
906 }
907
908 use proptest::prelude::*;
913
914 fn currency_strategy() -> impl Strategy<Value = Currency> {
915 prop_oneof![
916 Just(Currency::USD()),
917 Just(Currency::EUR()),
918 Just(Currency::GBP()),
919 Just(Currency::JPY()),
920 Just(Currency::AUD()),
921 Just(Currency::CAD()),
922 Just(Currency::CHF()),
923 Just(Currency::BTC()),
924 Just(Currency::ETH()),
925 Just(Currency::USDT()),
926 ]
927 }
928
929 fn money_amount_strategy() -> impl Strategy<Value = f64> {
930 prop_oneof![
932 -1000.0..1000.0,
934 -100_000.0..100_000.0,
936 -1_000_000.0..1_000_000.0,
938 Just(0.0),
940 Just(MONEY_MIN / 2.0),
942 Just(MONEY_MAX / 2.0),
943 Just(MONEY_MIN + 1.0),
944 Just(MONEY_MAX - 1.0),
945 Just(MONEY_MIN),
946 Just(MONEY_MAX),
947 ]
948 }
949
950 fn money_strategy() -> impl Strategy<Value = Money> {
951 (money_amount_strategy(), currency_strategy())
952 .prop_filter_map("constructible money", |(amount, currency)| {
953 Money::new_checked(amount, currency).ok()
954 })
955 }
956
957 proptest! {
958 #[rstest]
959 fn prop_money_construction_roundtrip(
960 amount in money_amount_strategy(),
961 currency in currency_strategy()
962 ) {
963 if let Ok(money) = Money::new_checked(amount, currency) {
965 let roundtrip = money.as_f64();
966 let precision_epsilon = if currency.precision == 0 {
968 1.0 } else {
970 let currency_epsilon = 10.0_f64.powi(-(currency.precision as i32));
971 let magnitude_epsilon = amount.abs() * 1e-10; currency_epsilon.max(magnitude_epsilon)
973 };
974 prop_assert!((roundtrip - amount).abs() <= precision_epsilon,
975 "Roundtrip failed: {} -> {} -> {} (precision: {}, epsilon: {})",
976 amount, money.raw, roundtrip, currency.precision, precision_epsilon);
977 prop_assert_eq!(money.currency, currency);
978 }
979 }
980
981 #[rstest]
982 fn prop_money_addition_commutative(
983 money1 in money_strategy(),
984 money2 in money_strategy(),
985 ) {
986 if money1.currency == money2.currency {
988 if let (Some(_), Some(_)) = (
990 money1.raw.checked_add(money2.raw),
991 money2.raw.checked_add(money1.raw)
992 ) {
993 let sum1 = money1 + money2;
994 let sum2 = money2 + money1;
995 prop_assert_eq!(sum1, sum2, "Addition should be commutative");
996 prop_assert_eq!(sum1.currency, money1.currency);
997 }
998 }
1000 }
1001
1002 #[rstest]
1003 fn prop_money_addition_associative(
1004 money1 in money_strategy(),
1005 money2 in money_strategy(),
1006 money3 in money_strategy(),
1007 ) {
1008 if money1.currency == money2.currency && money2.currency == money3.currency {
1010 if let (Some(sum1), Some(sum2)) = (
1013 money1.raw.checked_add(money2.raw),
1014 money2.raw.checked_add(money3.raw)
1015 )
1016 && let (Some(left), Some(right)) = (
1017 sum1.checked_add(money3.raw),
1018 money1.raw.checked_add(sum2)
1019 ) {
1020 if (MONEY_RAW_MIN..=MONEY_RAW_MAX).contains(&left)
1022 && (MONEY_RAW_MIN..=MONEY_RAW_MAX).contains(&right)
1023 {
1024 let left_result = Money::from_raw(left, money1.currency);
1025 let right_result = Money::from_raw(right, money1.currency);
1026 prop_assert_eq!(left_result, right_result, "Addition should be associative");
1027 }
1028 }
1029 }
1030 }
1031
1032 #[rstest]
1033 fn prop_money_subtraction_inverse(
1034 money1 in money_strategy(),
1035 money2 in money_strategy(),
1036 ) {
1037 if money1.currency == money2.currency {
1039 if let Some(sum_raw) = money1.raw.checked_add(money2.raw)
1041 && (MONEY_RAW_MIN..=MONEY_RAW_MAX).contains(&sum_raw) {
1042 let sum = Money::from_raw(sum_raw, money1.currency);
1043 let diff = sum - money2;
1044 prop_assert_eq!(diff, money1, "Subtraction should be inverse of addition");
1045 }
1046 }
1047 }
1048
1049 #[rstest]
1050 fn prop_money_zero_identity(money in money_strategy()) {
1051 let zero = Money::zero(money.currency);
1053 prop_assert_eq!(money + zero, money, "Zero should be additive identity");
1054 prop_assert_eq!(zero + money, money, "Zero should be additive identity (commutative)");
1055 prop_assert!(zero.is_zero(), "Zero should be recognized as zero");
1056 }
1057
1058 #[rstest]
1059 fn prop_money_negation_inverse(money in money_strategy()) {
1060 let negated = -money;
1062 let double_neg = -negated;
1063 prop_assert_eq!(money, double_neg, "Double negation should equal original");
1064 prop_assert_eq!(negated.currency, money.currency, "Negation preserves currency");
1065
1066 if let Some(sum_raw) = money.raw.checked_add(negated.raw)
1068 && (MONEY_RAW_MIN..=MONEY_RAW_MAX).contains(&sum_raw) {
1069 let sum = Money::from_raw(sum_raw, money.currency);
1070 prop_assert!(sum.is_zero(), "Money + (-Money) should equal zero");
1071 }
1072 }
1073
1074 #[rstest]
1075 fn prop_money_comparison_consistency(
1076 money1 in money_strategy(),
1077 money2 in money_strategy(),
1078 ) {
1079 if money1.currency == money2.currency {
1081 let eq = money1 == money2;
1082 let lt = money1 < money2;
1083 let gt = money1 > money2;
1084 let le = money1 <= money2;
1085 let ge = money1 >= money2;
1086
1087 let exclusive_count = [eq, lt, gt].iter().filter(|&&x| x).count();
1089 prop_assert_eq!(exclusive_count, 1, "Exactly one of ==, <, > should be true");
1090
1091 prop_assert_eq!(le, eq || lt, "<= should equal == || <");
1093 prop_assert_eq!(ge, eq || gt, ">= should equal == || >");
1094 prop_assert_eq!(lt, money2 > money1, "< should be symmetric with >");
1095 prop_assert_eq!(le, money2 >= money1, "<= should be symmetric with >=");
1096 }
1097 }
1098
1099 #[rstest]
1100 fn prop_money_string_roundtrip(money in money_strategy()) {
1101 let string_repr = money.to_string();
1103 let parsed = Money::from_str(&string_repr);
1104 prop_assert!(parsed.is_ok(), "String parsing should succeed for valid money");
1105 if let Ok(parsed_money) = parsed {
1106 prop_assert_eq!(parsed_money.currency, money.currency, "Currency should round-trip");
1107 let diff = (parsed_money.as_f64() - money.as_f64()).abs();
1109 prop_assert!(diff < 0.01, "Amount should round-trip within precision: {} vs {}",
1110 money.as_f64(), parsed_money.as_f64());
1111 }
1112 }
1113
1114 #[rstest]
1115 fn prop_money_decimal_conversion(money in money_strategy()) {
1116 let decimal = money.as_decimal();
1118
1119 #[cfg(feature = "defi")]
1120 {
1121 let decimal_f64: f64 = decimal.try_into().unwrap_or(0.0);
1124 prop_assert!(decimal_f64.is_finite(), "Decimal should convert to finite f64");
1125
1126 prop_assert_eq!(decimal.scale(), u32::from(money.currency.precision));
1128 }
1129 #[cfg(not(feature = "defi"))]
1130 {
1131 let decimal_f64: f64 = decimal.try_into().unwrap_or(0.0);
1132 let original_f64 = money.as_f64();
1133
1134 let base_epsilon = 10.0_f64.powi(-(money.currency.precision as i32));
1136 let precision_epsilon = if cfg!(feature = "high-precision") {
1137 base_epsilon.max(1e-10)
1139 } else {
1140 base_epsilon
1141 };
1142 let diff = (decimal_f64 - original_f64).abs();
1143 prop_assert!(diff <= precision_epsilon,
1144 "Decimal conversion should preserve value within currency precision: {} vs {} (diff: {}, epsilon: {})",
1145 original_f64, decimal_f64, diff, precision_epsilon);
1146 }
1147 }
1148
1149 #[rstest]
1150 fn prop_money_arithmetic_with_f64(
1151 money in money_strategy(),
1152 factor in -1000.0..1000.0_f64,
1153 ) {
1154 if factor != 0.0 {
1156 let original_f64 = money.as_f64();
1157
1158 let mul_result = money * factor;
1159 let expected_mul = original_f64 * factor;
1160 prop_assert!((mul_result - expected_mul).abs() < 0.01,
1161 "Multiplication with f64 should be accurate");
1162
1163 let div_result = money / factor;
1164 let expected_div = original_f64 / factor;
1165 if expected_div.is_finite() {
1166 prop_assert!((div_result - expected_div).abs() < 0.01,
1167 "Division with f64 should be accurate");
1168 }
1169
1170 let add_result = money + factor;
1171 let expected_add = original_f64 + factor;
1172 prop_assert!((add_result - expected_add).abs() < 0.01,
1173 "Addition with f64 should be accurate");
1174
1175 let sub_result = money - factor;
1176 let expected_sub = original_f64 - factor;
1177 prop_assert!((sub_result - expected_sub).abs() < 0.01,
1178 "Subtraction with f64 should be accurate");
1179 }
1180 }
1181 }
1182
1183 #[rstest]
1184 #[case(42.0, true, "positive value")]
1185 #[case(0.0, false, "zero value")]
1186 #[case( -13.5, false, "negative value")]
1187 fn test_check_positive_money(
1188 #[case] amount: f64,
1189 #[case] should_succeed: bool,
1190 #[case] _case_name: &str,
1191 ) {
1192 let money = Money::new(amount, Currency::USD());
1193
1194 let res = check_positive_money(money, "money");
1195
1196 match should_succeed {
1197 true => assert!(res.is_ok(), "expected Ok(..) for {amount}"),
1198 false => {
1199 assert!(res.is_err(), "expected Err(..) for {amount}");
1200 let msg = res.unwrap_err().to_string();
1201 assert!(
1202 msg.contains("not positive"),
1203 "error message should mention positivity; got: {msg:?}"
1204 );
1205 }
1206 }
1207 }
1208}