Skip to main content

nautilus_model/types/
money.rs

1// -------------------------------------------------------------------------------------------------
2//  Copyright (C) 2015-2026 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
16//! Represents an amount of money in a specified currency denomination.
17//!
18//! [`Money`] is an immutable value type for representing monetary amounts with an associated
19//! currency. It supports both positive and negative values (for debits, losses, etc.) and
20//! enforces currency consistency in arithmetic operations.
21//!
22//! # Arithmetic behavior
23//!
24//! | Operation         | Result    | Notes                             |
25//! |-------------------|-----------|-----------------------------------|
26//! | `Money + Money`   | `Money`   | Panics if currencies don't match. |
27//! | `Money - Money`   | `Money`   | Panics if currencies don't match. |
28//! | `Money + Decimal` | `Decimal` |                                   |
29//! | `Money - Decimal` | `Decimal` |                                   |
30//! | `Money * Decimal` | `Decimal` |                                   |
31//! | `Money / Decimal` | `Decimal` |                                   |
32//! | `Money + f64`     | `f64`     |                                   |
33//! | `Money - f64`     | `f64`     |                                   |
34//! | `Money * f64`     | `f64`     |                                   |
35//! | `Money / f64`     | `f64`     |                                   |
36//! | `-Money`          | `Money`   |                                   |
37//!
38//! # Currency constraints
39//!
40//! When performing arithmetic between two `Money` values, both must have the same currency.
41//! Attempting to add or subtract money with different currencies raises an error.
42//!
43//! # Immutability
44//!
45//! `Money` is immutable. All arithmetic operations return new instances.
46
47use std::{
48    cmp::Ordering,
49    fmt::{Debug, Display},
50    hash::{Hash, Hasher},
51    ops::{Add, Div, Mul, Neg, Sub},
52    str::FromStr,
53};
54
55use nautilus_core::{
56    correctness::{
57        CorrectnessError, CorrectnessResult, CorrectnessResultExt, FAILED,
58        check_in_range_inclusive_f64,
59    },
60    string::formatting::Separable,
61};
62use rust_decimal::Decimal;
63use serde::{Deserialize, Deserializer, Serialize};
64
65#[cfg(not(any(feature = "defi", feature = "high-precision")))]
66use super::fixed::{f64_to_fixed_i64, fixed_i64_to_f64};
67#[cfg(any(feature = "defi", feature = "high-precision"))]
68use super::fixed::{f64_to_fixed_i128, fixed_i128_to_f64};
69#[cfg(feature = "defi")]
70use crate::types::fixed::MAX_FLOAT_PRECISION;
71use crate::types::{
72    Currency,
73    fixed::{
74        FIXED_PRECISION, FIXED_SCALAR, check_fixed_precision, mantissa_exponent_to_fixed_i128,
75        raw_scales_match,
76    },
77};
78
79// -----------------------------------------------------------------------------
80// MoneyRaw
81// -----------------------------------------------------------------------------
82
83#[cfg(feature = "high-precision")]
84pub type MoneyRaw = i128;
85
86#[cfg(not(feature = "high-precision"))]
87pub type MoneyRaw = i64;
88
89// -----------------------------------------------------------------------------
90
91/// The maximum raw money integer value.
92///
93/// # Safety
94///
95/// This value is computed at compile time from `MONEY_MAX` * `FIXED_SCALAR`.
96/// The multiplication is guaranteed not to overflow because `MONEY_MAX` and `FIXED_SCALAR`
97/// are chosen such that their product fits within `MoneyRaw`'s range in both
98/// high-precision (i128) and standard-precision (i64) modes.
99#[unsafe(no_mangle)]
100#[allow(unsafe_code)]
101pub static MONEY_RAW_MAX: MoneyRaw = (MONEY_MAX * FIXED_SCALAR) as MoneyRaw;
102
103/// The minimum raw money integer value.
104///
105/// # Safety
106///
107/// This value is computed at compile time from `MONEY_MIN` * `FIXED_SCALAR`.
108/// The multiplication is guaranteed not to overflow because `MONEY_MIN` and `FIXED_SCALAR`
109/// are chosen such that their product fits within `MoneyRaw`'s range in both
110/// high-precision (i128) and standard-precision (i64) modes.
111#[unsafe(no_mangle)]
112#[allow(unsafe_code)]
113pub static MONEY_RAW_MIN: MoneyRaw = (MONEY_MIN * FIXED_SCALAR) as MoneyRaw;
114
115// -----------------------------------------------------------------------------
116// MONEY_MAX
117// -----------------------------------------------------------------------------
118
119#[cfg(feature = "high-precision")]
120/// The maximum valid money amount that can be represented.
121pub const MONEY_MAX: f64 = 17_014_118_346_046.0;
122
123#[cfg(not(feature = "high-precision"))]
124/// The maximum valid money amount that can be represented.
125pub const MONEY_MAX: f64 = 9_223_372_036.0;
126
127// -----------------------------------------------------------------------------
128// MONEY_MIN
129// -----------------------------------------------------------------------------
130
131#[cfg(feature = "high-precision")]
132/// The minimum valid money amount that can be represented.
133pub const MONEY_MIN: f64 = -17_014_118_346_046.0;
134
135#[cfg(not(feature = "high-precision"))]
136/// The minimum valid money amount that can be represented.
137pub const MONEY_MIN: f64 = -9_223_372_036.0;
138
139// -----------------------------------------------------------------------------
140
141/// Represents an amount of money in a specified currency denomination.
142///
143/// - [`MONEY_MAX`] - Maximum representable money amount
144/// - [`MONEY_MIN`] - Minimum representable money amount
145#[repr(C)]
146#[derive(Clone, Copy, Eq)]
147#[cfg_attr(
148    feature = "python",
149    pyo3::pyclass(
150        module = "nautilus_trader.core.nautilus_pyo3.model",
151        frozen,
152        from_py_object
153    )
154)]
155#[cfg_attr(
156    feature = "python",
157    pyo3_stub_gen::derive::gen_stub_pyclass(module = "nautilus_trader.model")
158)]
159pub struct Money {
160    /// Represents the raw fixed-point amount, with `currency.precision` defining the number of decimal places.
161    pub raw: MoneyRaw,
162    /// The currency denomination associated with the monetary amount.
163    pub currency: Currency,
164}
165
166impl Money {
167    /// Creates a new [`Money`] instance with correctness checking.
168    ///
169    /// # Errors
170    ///
171    /// Returns an error if `amount` is invalid outside the representable range [`MONEY_MIN`, `MONEY_MAX`].
172    ///
173    /// # Notes
174    ///
175    /// PyO3 requires a `Result` type for proper error handling and stacktrace printing in Python.
176    pub fn new_checked(amount: f64, currency: Currency) -> CorrectnessResult<Self> {
177        // check_in_range_inclusive_f64 already validates that amount is finite
178        // (not NaN or infinite) as part of its range validation logic, so no additional
179        // infinity checks are needed here.
180        check_in_range_inclusive_f64(amount, MONEY_MIN, MONEY_MAX, "amount")?;
181
182        #[cfg(feature = "defi")]
183        if currency.precision > MAX_FLOAT_PRECISION {
184            // Floats are only reliable up to ~16 decimal digits of precision regardless of feature flags
185            return Err(CorrectnessError::PredicateViolation {
186                message: format!(
187                    "`currency.precision` exceeded maximum float precision ({MAX_FLOAT_PRECISION}), use `Money::from_wei()` for wei values instead"
188                ),
189            });
190        }
191
192        #[cfg(feature = "high-precision")]
193        let raw = f64_to_fixed_i128(amount, currency.precision);
194
195        #[cfg(not(feature = "high-precision"))]
196        let raw = f64_to_fixed_i64(amount, currency.precision);
197
198        Ok(Self { raw, currency })
199    }
200
201    /// Creates a new [`Money`] instance.
202    ///
203    /// # Panics
204    ///
205    /// Panics if a correctness check fails. See [`Money::new_checked`] for more details.
206    #[must_use]
207    pub fn new(amount: f64, currency: Currency) -> Self {
208        Self::new_checked(amount, currency).expect_display(FAILED)
209    }
210
211    /// Creates a new [`Money`] instance from the given `raw` fixed-point value and the specified `currency`.
212    ///
213    /// # Panics
214    ///
215    /// Panics if a correctness check fails. See [`Money::from_raw_checked`] for more details.
216    #[must_use]
217    pub fn from_raw(raw: MoneyRaw, currency: Currency) -> Self {
218        Self::from_raw_checked(raw, currency).expect_display(FAILED)
219    }
220
221    /// Creates a new [`Money`] instance from the given `raw` fixed-point value and the specified
222    /// `currency` with correctness checking.
223    ///
224    /// # Errors
225    ///
226    /// Returns an error if:
227    /// - `raw` is outside the representable range [`MONEY_RAW_MIN`, `MONEY_RAW_MAX`].
228    /// - `currency.precision` exceeds the maximum fixed precision.
229    pub fn from_raw_checked(raw: MoneyRaw, currency: Currency) -> CorrectnessResult<Self> {
230        if raw < MONEY_RAW_MIN || raw > MONEY_RAW_MAX {
231            return Err(CorrectnessError::PredicateViolation {
232                message: format!(
233                    "`raw` value {raw} exceeded bounds [{MONEY_RAW_MIN}, {MONEY_RAW_MAX}] for Money"
234                ),
235            });
236        }
237
238        check_fixed_precision(currency.precision)?;
239
240        // TODO: Enforce spurious bits validation in v2
241        // Validate raw value has no spurious bits beyond the precision scale
242        // if raw != 0 {
243        //     #[cfg(feature = "high-precision")]
244        //     super::fixed::check_fixed_raw_i128(raw, currency.precision)?;
245        //     #[cfg(not(feature = "high-precision"))]
246        //     super::fixed::check_fixed_raw_i64(raw, currency.precision)?;
247        // }
248
249        Ok(Self { raw, currency })
250    }
251
252    /// Creates a new [`Money`] from a mantissa/exponent pair using pure integer arithmetic.
253    ///
254    /// The value is `mantissa * 10^exponent`. This avoids all floating-point and Decimal
255    /// operations, making it ideal for exchange data that arrives as mantissa/exponent pairs.
256    ///
257    /// # Panics
258    ///
259    /// Panics if the resulting raw value exceeds [`MONEY_RAW_MAX`] or [`MONEY_RAW_MIN`].
260    #[must_use]
261    pub fn from_mantissa_exponent(mantissa: i64, exponent: i8, currency: Currency) -> Self {
262        check_fixed_precision(currency.precision).expect_display(FAILED);
263
264        if mantissa == 0 {
265            return Self { raw: 0, currency };
266        }
267
268        let raw_i128 =
269            mantissa_exponent_to_fixed_i128(i128::from(mantissa), exponent, currency.precision)
270                .expect("Overflow in Money::from_mantissa_exponent");
271
272        #[allow(
273            clippy::useless_conversion,
274            reason = "i128 to MoneyRaw is real when not high-precision"
275        )]
276        let raw: MoneyRaw = raw_i128
277            .try_into()
278            .expect("Raw value exceeds MoneyRaw range in Money::from_mantissa_exponent");
279        assert!(
280            raw >= MONEY_RAW_MIN && raw <= MONEY_RAW_MAX,
281            "`raw` value {raw} exceeded bounds [{MONEY_RAW_MIN}, {MONEY_RAW_MAX}] for Money"
282        );
283
284        Self { raw, currency }
285    }
286
287    /// Creates a new [`Money`] instance with a value of zero with the given [`Currency`].
288    ///
289    /// # Panics
290    ///
291    /// Panics if a correctness check fails. See [`Money::new_checked`] for more details.
292    #[must_use]
293    pub fn zero(currency: Currency) -> Self {
294        Self::new(0.0, currency)
295    }
296
297    /// Returns a copy with raw value rounded to currency precision,
298    /// stripping any sub-scale bits.
299    #[must_use]
300    pub fn normalized(&self) -> Self {
301        #[cfg(feature = "high-precision")]
302        let raw = super::fixed::correct_raw_i128(self.raw, self.currency.precision);
303
304        #[cfg(not(feature = "high-precision"))]
305        let raw = super::fixed::correct_raw_i64(self.raw, self.currency.precision);
306
307        Self {
308            raw,
309            currency: self.currency,
310        }
311    }
312
313    /// Returns `true` if the value of this instance is zero.
314    #[must_use]
315    pub fn is_zero(&self) -> bool {
316        self.raw == 0
317    }
318
319    /// Returns `true` if the value of this instance is positive (> 0).
320    #[must_use]
321    pub fn is_positive(&self) -> bool {
322        self.raw > 0
323    }
324
325    /// Performs a checked addition, returning `None` on raw integer overflow, when
326    /// the result falls outside `[MONEY_RAW_MIN, MONEY_RAW_MAX]`, or when the operands
327    /// have mixed raw scales (e.g. a wei-scaled `Money` and a `FIXED_SCALAR`-scaled
328    /// `Money`, even if their currency codes match).
329    ///
330    /// # Panics
331    ///
332    /// Panics if `self.currency` and `rhs.currency` differ by code (currency mismatch
333    /// is a type-system invariant violation, not a recoverable arithmetic condition).
334    #[must_use]
335    pub fn checked_add(self, rhs: Self) -> Option<Self> {
336        assert_eq!(
337            self.currency, rhs.currency,
338            "Currency mismatch: cannot add {} to {}",
339            rhs.currency.code, self.currency.code
340        );
341
342        if !raw_scales_match(self.currency.precision, rhs.currency.precision) {
343            return None;
344        }
345        let raw = self.raw.checked_add(rhs.raw)?;
346        if raw < MONEY_RAW_MIN || raw > MONEY_RAW_MAX {
347            return None;
348        }
349        Some(Self {
350            raw,
351            currency: self.currency,
352        })
353    }
354
355    /// Performs a checked subtraction, returning `None` on raw integer underflow, when
356    /// the result falls outside `[MONEY_RAW_MIN, MONEY_RAW_MAX]`, or when the operands
357    /// have mixed raw scales (e.g. a wei-scaled `Money` and a `FIXED_SCALAR`-scaled
358    /// `Money`, even if their currency codes match).
359    ///
360    /// # Panics
361    ///
362    /// Panics if `self.currency` and `rhs.currency` differ by code (currency mismatch
363    /// is a type-system invariant violation, not a recoverable arithmetic condition).
364    #[must_use]
365    pub fn checked_sub(self, rhs: Self) -> Option<Self> {
366        assert_eq!(
367            self.currency, rhs.currency,
368            "Currency mismatch: cannot subtract {} from {}",
369            rhs.currency.code, self.currency.code
370        );
371
372        if !raw_scales_match(self.currency.precision, rhs.currency.precision) {
373            return None;
374        }
375        let raw = self.raw.checked_sub(rhs.raw)?;
376        if raw < MONEY_RAW_MIN || raw > MONEY_RAW_MAX {
377            return None;
378        }
379        Some(Self {
380            raw,
381            currency: self.currency,
382        })
383    }
384
385    #[cfg(feature = "high-precision")]
386    /// Returns the value of this instance as an `f64`.
387    ///
388    /// # Panics
389    ///
390    /// Panics if precision is beyond `MAX_FLOAT_PRECISION` (16).
391    #[must_use]
392    pub fn as_f64(&self) -> f64 {
393        #[cfg(feature = "defi")]
394        assert!(
395            self.currency.precision <= MAX_FLOAT_PRECISION,
396            "Invalid f64 conversion beyond `MAX_FLOAT_PRECISION` (16)"
397        );
398
399        fixed_i128_to_f64(self.raw)
400    }
401
402    #[cfg(not(feature = "high-precision"))]
403    /// Returns the value of this instance as an `f64`.
404    ///
405    /// # Panics
406    ///
407    /// Panics if precision is beyond `MAX_FLOAT_PRECISION` (16).
408    #[must_use]
409    pub fn as_f64(&self) -> f64 {
410        #[cfg(feature = "defi")]
411        if self.currency.precision > MAX_FLOAT_PRECISION {
412            panic!("Invalid f64 conversion beyond `MAX_FLOAT_PRECISION` (16)");
413        }
414
415        fixed_i64_to_f64(self.raw)
416    }
417
418    /// Returns the value of this instance as a `Decimal`.
419    #[must_use]
420    pub fn as_decimal(&self) -> Decimal {
421        // Scale down the raw value to match the precision
422        let precision = self.currency.precision;
423        let precision_diff = FIXED_PRECISION.saturating_sub(precision);
424
425        // Money's raw value is stored at fixed precision scale, but needs to be adjusted
426        // to the currency's actual precision for decimal conversion.
427        let rescaled_raw = self.raw / MoneyRaw::pow(10, u32::from(precision_diff));
428
429        #[allow(
430            clippy::useless_conversion,
431            reason = "i128::from is real when MoneyRaw is i64"
432        )]
433        Decimal::from_i128_with_scale(i128::from(rescaled_raw), u32::from(precision))
434    }
435
436    /// Returns a formatted string representation of this instance.
437    #[must_use]
438    pub fn to_formatted_string(&self) -> String {
439        let amount_str = format!("{:.*}", self.currency.precision as usize, self.as_f64())
440            .separate_with_underscores();
441        format!("{} {}", amount_str, self.currency.code)
442    }
443
444    /// Creates a new [`Money`] from a `Decimal` value with specified currency.
445    ///
446    /// This method provides more reliable parsing by using Decimal arithmetic
447    /// to avoid floating-point precision issues during conversion.
448    ///
449    /// # Errors
450    ///
451    /// Returns an error if:
452    /// - The decimal value cannot be converted to the raw representation.
453    /// - Overflow occurs during scaling.
454    pub fn from_decimal(decimal: Decimal, currency: Currency) -> CorrectnessResult<Self> {
455        let exponent = -(decimal.scale() as i8);
456        let raw_i128 =
457            mantissa_exponent_to_fixed_i128(decimal.mantissa(), exponent, currency.precision)?;
458
459        #[allow(
460            clippy::useless_conversion,
461            reason = "i128 to MoneyRaw is real when not high-precision"
462        )]
463        let raw: MoneyRaw =
464            raw_i128
465                .try_into()
466                .map_err(|_| CorrectnessError::PredicateViolation {
467                    message: format!(
468                        "Decimal value exceeds MoneyRaw range [{MONEY_RAW_MIN}, {MONEY_RAW_MAX}]"
469                    ),
470                })?;
471
472        if !(raw >= MONEY_RAW_MIN && raw <= MONEY_RAW_MAX) {
473            return Err(CorrectnessError::PredicateViolation {
474                message: format!(
475                    "Raw value {raw} exceeded bounds [{MONEY_RAW_MIN}, {MONEY_RAW_MAX}] for Money"
476                ),
477            });
478        }
479
480        Ok(Self { raw, currency })
481    }
482}
483
484impl FromStr for Money {
485    type Err = String;
486
487    fn from_str(value: &str) -> Result<Self, Self::Err> {
488        let parts: Vec<&str> = value.split_whitespace().collect();
489
490        // Ensure we have both the amount and currency
491        if parts.len() != 2 {
492            return Err(format!(
493                "Error invalid input format '{value}'. Expected '<amount> <currency>'"
494            ));
495        }
496
497        let clean_amount = parts[0].replace('_', "");
498
499        let decimal = if clean_amount.contains('e') || clean_amount.contains('E') {
500            Decimal::from_scientific(&clean_amount)
501                .map_err(|e| format!("Error parsing amount '{}' as Decimal: {e}", parts[0]))?
502        } else {
503            Decimal::from_str(&clean_amount)
504                .map_err(|e| format!("Error parsing amount '{}' as Decimal: {e}", parts[0]))?
505        };
506
507        let currency = Currency::from_str(parts[1]).map_err(|e: anyhow::Error| e.to_string())?;
508        Self::from_decimal(decimal, currency).map_err(|e| e.to_string())
509    }
510}
511
512impl<T: AsRef<str>> From<T> for Money {
513    fn from(value: T) -> Self {
514        Self::from_str(value.as_ref()).expect(FAILED)
515    }
516}
517
518impl From<Money> for f64 {
519    fn from(money: Money) -> Self {
520        money.as_f64()
521    }
522}
523
524impl From<&Money> for f64 {
525    fn from(money: &Money) -> Self {
526        money.as_f64()
527    }
528}
529
530impl Hash for Money {
531    fn hash<H: Hasher>(&self, state: &mut H) {
532        self.raw.hash(state);
533        self.currency.hash(state);
534    }
535}
536
537impl PartialEq for Money {
538    fn eq(&self, other: &Self) -> bool {
539        self.raw == other.raw && self.currency == other.currency
540    }
541}
542
543impl PartialOrd for Money {
544    fn partial_cmp(&self, other: &Self) -> Option<Ordering> {
545        Some(self.cmp(other))
546    }
547
548    fn lt(&self, other: &Self) -> bool {
549        assert_eq!(self.currency, other.currency);
550        self.raw.lt(&other.raw)
551    }
552
553    fn le(&self, other: &Self) -> bool {
554        assert_eq!(self.currency, other.currency);
555        self.raw.le(&other.raw)
556    }
557
558    fn gt(&self, other: &Self) -> bool {
559        assert_eq!(self.currency, other.currency);
560        self.raw.gt(&other.raw)
561    }
562
563    fn ge(&self, other: &Self) -> bool {
564        assert_eq!(self.currency, other.currency);
565        self.raw.ge(&other.raw)
566    }
567}
568
569impl Ord for Money {
570    fn cmp(&self, other: &Self) -> Ordering {
571        assert_eq!(self.currency, other.currency);
572        self.raw.cmp(&other.raw)
573    }
574}
575
576impl Neg for Money {
577    type Output = Self;
578    fn neg(self) -> Self::Output {
579        Self {
580            raw: -self.raw,
581            currency: self.currency,
582        }
583    }
584}
585
586impl Add for Money {
587    type Output = Self;
588    fn add(self, rhs: Self) -> Self::Output {
589        assert_eq!(
590            self.currency, rhs.currency,
591            "Currency mismatch: cannot add {} to {}",
592            rhs.currency.code, self.currency.code
593        );
594        Self {
595            raw: self
596                .raw
597                .checked_add(rhs.raw)
598                .expect("Overflow occurred when adding `Money`"),
599            currency: self.currency,
600        }
601    }
602}
603
604impl Sub for Money {
605    type Output = Self;
606    fn sub(self, rhs: Self) -> Self::Output {
607        assert_eq!(
608            self.currency, rhs.currency,
609            "Currency mismatch: cannot subtract {} from {}",
610            rhs.currency.code, self.currency.code
611        );
612        Self {
613            raw: self
614                .raw
615                .checked_sub(rhs.raw)
616                .expect("Underflow occurred when subtracting `Money`"),
617            currency: self.currency,
618        }
619    }
620}
621
622impl Add<Decimal> for Money {
623    type Output = Decimal;
624    fn add(self, rhs: Decimal) -> Self::Output {
625        self.as_decimal() + rhs
626    }
627}
628
629impl Sub<Decimal> for Money {
630    type Output = Decimal;
631    fn sub(self, rhs: Decimal) -> Self::Output {
632        self.as_decimal() - rhs
633    }
634}
635
636impl Mul<Decimal> for Money {
637    type Output = Decimal;
638    fn mul(self, rhs: Decimal) -> Self::Output {
639        self.as_decimal() * rhs
640    }
641}
642
643impl Div<Decimal> for Money {
644    type Output = Decimal;
645    fn div(self, rhs: Decimal) -> Self::Output {
646        self.as_decimal() / rhs
647    }
648}
649
650impl Add<f64> for Money {
651    type Output = f64;
652    fn add(self, rhs: f64) -> Self::Output {
653        self.as_f64() + rhs
654    }
655}
656
657impl Sub<f64> for Money {
658    type Output = f64;
659    fn sub(self, rhs: f64) -> Self::Output {
660        self.as_f64() - rhs
661    }
662}
663
664impl Mul<f64> for Money {
665    type Output = f64;
666    fn mul(self, rhs: f64) -> Self::Output {
667        self.as_f64() * rhs
668    }
669}
670
671impl Div<f64> for Money {
672    type Output = f64;
673    fn div(self, rhs: f64) -> Self::Output {
674        self.as_f64() / rhs
675    }
676}
677
678impl Debug for Money {
679    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
680        if self.currency.precision > crate::types::fixed::MAX_FLOAT_PRECISION {
681            write!(f, "{}({}, {})", stringify!(Money), self.raw, self.currency)
682        } else {
683            write!(
684                f,
685                "{}({:.*}, {})",
686                stringify!(Money),
687                self.currency.precision as usize,
688                self.as_f64(),
689                self.currency
690            )
691        }
692    }
693}
694
695impl Display for Money {
696    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
697        if self.currency.precision > crate::types::fixed::MAX_FLOAT_PRECISION {
698            write!(f, "{} {}", self.raw, self.currency)
699        } else {
700            write!(f, "{} {}", self.as_decimal(), self.currency)
701        }
702    }
703}
704
705impl Serialize for Money {
706    fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
707    where
708        S: serde::Serializer,
709    {
710        serializer.serialize_str(&self.to_string())
711    }
712}
713
714impl<'de> Deserialize<'de> for Money {
715    fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
716    where
717        D: Deserializer<'de>,
718    {
719        let money_str: String = Deserialize::deserialize(deserializer)?;
720        Ok(Self::from(money_str.as_str()))
721    }
722}
723
724/// Checks if the money `value` is positive.
725///
726/// # Errors
727///
728/// Returns an error if `value` is not positive.
729#[inline(always)]
730pub fn check_positive_money(value: Money, param: &str) -> CorrectnessResult<()> {
731    if value.raw <= 0 {
732        return Err(CorrectnessError::NotPositive {
733            param: param.to_string(),
734            value: value.to_string(),
735            type_name: "`Money`",
736        });
737    }
738    Ok(())
739}
740
741#[cfg(test)]
742mod tests {
743    use nautilus_core::{approx_eq, correctness::CorrectnessError};
744    use rstest::rstest;
745    use rust_decimal_macros::dec;
746
747    use super::*;
748
749    #[rstest]
750    fn test_debug() {
751        let money = Money::new(1010.12, Currency::USD());
752        let result = format!("{money:?}");
753        let expected = "Money(1010.12, USD)";
754        assert_eq!(result, expected);
755    }
756
757    #[rstest]
758    fn test_display() {
759        let money = Money::new(1010.12, Currency::USD());
760        let result = format!("{money}");
761        let expected = "1010.12 USD";
762        assert_eq!(result, expected);
763    }
764
765    #[rstest]
766    #[case(1010.12, 2, "USD", "Money(1010.12, USD)", "1010.12 USD")] // Normal precision
767    #[case(123.456_789, 8, "BTC", "Money(123.45678900, BTC)", "123.45678900 BTC")] // At max normal precision
768    fn test_formatting_normal_precision(
769        #[case] value: f64,
770        #[case] precision: u8,
771        #[case] currency_code: &str,
772        #[case] expected_debug: &str,
773        #[case] expected_display: &str,
774    ) {
775        use crate::enums::CurrencyType;
776        let currency = Currency::new(
777            currency_code,
778            precision,
779            0,
780            currency_code,
781            CurrencyType::Fiat,
782        );
783        let money = Money::new(value, currency);
784
785        assert_eq!(format!("{money:?}"), expected_debug);
786        assert_eq!(format!("{money}"), expected_display);
787    }
788
789    #[rstest]
790    #[cfg(feature = "defi")]
791    #[case(
792        1_000_000_000_000_000_000_i128,
793        18,
794        "wei",
795        "Money(1000000000000000000, wei)",
796        "1000000000000000000 wei"
797    )] // High precision
798    #[case(
799        2_500_000_000_000_000_000_i128,
800        18,
801        "ETH",
802        "Money(2500000000000000000, ETH)",
803        "2500000000000000000 ETH"
804    )] // High precision
805    fn test_formatting_high_precision(
806        #[case] raw_value: i128,
807        #[case] precision: u8,
808        #[case] currency_code: &str,
809        #[case] expected_debug: &str,
810        #[case] expected_display: &str,
811    ) {
812        use crate::enums::CurrencyType;
813        let currency = Currency::new(
814            currency_code,
815            precision,
816            0,
817            currency_code,
818            CurrencyType::Crypto,
819        );
820        let money = Money::from_raw(raw_value, currency);
821
822        assert_eq!(format!("{money:?}"), expected_debug);
823        assert_eq!(format!("{money}"), expected_display);
824    }
825
826    #[rstest]
827    fn test_zero_constructor() {
828        let usd = Currency::USD();
829        let money = Money::zero(usd);
830        assert_eq!(money.raw, 0);
831        assert_eq!(money.currency, usd);
832    }
833
834    #[rstest]
835    #[should_panic(expected = "Currency mismatch")]
836    fn test_money_different_currency_addition() {
837        let usd = Money::new(1000.0, Currency::USD());
838        let btc = Money::new(1.0, Currency::BTC());
839        let _ = usd + btc; // This should panic since currencies are different
840    }
841
842    #[rstest] // Test does not panic rather than exact value
843    fn test_with_maximum_value() {
844        let money = Money::new_checked(MONEY_MAX, Currency::USD());
845        assert!(money.is_ok());
846    }
847
848    #[rstest] // Test does not panic rather than exact value
849    fn test_with_minimum_value() {
850        let money = Money::new_checked(MONEY_MIN, Currency::USD());
851        assert!(money.is_ok());
852    }
853
854    #[rstest]
855    fn test_new_checked_returns_typed_error_with_stable_display() {
856        let error = Money::new_checked(MONEY_MAX + 1.0, Currency::USD()).unwrap_err();
857
858        assert!(matches!(error, CorrectnessError::OutOfRange { .. }));
859        assert_eq!(
860            error.to_string(),
861            format!(
862                "invalid f64 for 'amount' not in range [{MONEY_MIN}, {MONEY_MAX}], was {}",
863                MONEY_MAX + 1.0
864            )
865        );
866    }
867
868    #[rstest]
869    fn test_money_is_zero() {
870        let zero_usd = Money::new(0.0, Currency::USD());
871        assert!(zero_usd.is_zero());
872        assert_eq!(zero_usd, Money::from("0.0 USD"));
873
874        let non_zero_usd = Money::new(100.0, Currency::USD());
875        assert!(!non_zero_usd.is_zero());
876    }
877
878    #[rstest]
879    fn test_money_is_positive() {
880        let usd = Currency::USD();
881        assert!(Money::new(100.0, usd).is_positive());
882        assert!(!Money::new(0.0, usd).is_positive());
883        assert!(!Money::new(-100.0, usd).is_positive());
884    }
885
886    #[rstest]
887    fn test_money_comparisons() {
888        let usd = Currency::USD();
889        let m1 = Money::new(100.0, usd);
890        let m2 = Money::new(200.0, usd);
891
892        assert!(m1 < m2);
893        assert!(m2 > m1);
894        assert!(m1 <= m2);
895        assert!(m2 >= m1);
896
897        // Equality
898        let m3 = Money::new(100.0, usd);
899        assert!(m1 == m3);
900    }
901
902    #[rstest]
903    fn test_add() {
904        let a = 1000.0;
905        let b = 500.0;
906        let money1 = Money::new(a, Currency::USD());
907        let money2 = Money::new(b, Currency::USD());
908        let money3 = money1 + money2;
909        assert_eq!(money3.raw, Money::new(a + b, Currency::USD()).raw);
910    }
911
912    #[rstest]
913    fn test_sub() {
914        let usd = Currency::USD();
915        let money1 = Money::new(1000.0, usd);
916        let money2 = Money::new(250.0, usd);
917        let result = money1 - money2;
918        assert!(approx_eq!(f64, result.as_f64(), 750.0, epsilon = 1e-9));
919        assert_eq!(result.currency, usd);
920    }
921
922    #[rstest]
923    fn test_money_checked_add_within_bounds() {
924        let usd = Currency::USD();
925        let a = Money::new(100.0, usd);
926        let b = Money::new(50.0, usd);
927        assert_eq!(a.checked_add(b), Some(Money::new(150.0, usd)));
928    }
929
930    #[rstest]
931    fn test_money_checked_add_above_max_returns_none() {
932        let usd = Currency::USD();
933        let near_max = Money::from_raw(MONEY_RAW_MAX, usd);
934        let one = Money::new(1.0, usd);
935        assert_eq!(near_max.checked_add(one), None);
936    }
937
938    #[rstest]
939    fn test_money_checked_sub_within_bounds() {
940        let usd = Currency::USD();
941        let a = Money::new(100.0, usd);
942        let b = Money::new(40.0, usd);
943        assert_eq!(a.checked_sub(b), Some(Money::new(60.0, usd)));
944    }
945
946    #[rstest]
947    fn test_money_checked_sub_below_min_returns_none() {
948        let usd = Currency::USD();
949        let near_min = Money::from_raw(MONEY_RAW_MIN, usd);
950        let one = Money::new(1.0, usd);
951        assert_eq!(near_min.checked_sub(one), None);
952    }
953
954    #[rstest]
955    #[should_panic(expected = "Currency mismatch")]
956    fn test_money_checked_add_currency_mismatch_panics() {
957        let usd = Money::new(100.0, Currency::USD());
958        let aud = Money::new(50.0, Currency::AUD());
959        let _ = usd.checked_add(aud);
960    }
961
962    #[rstest]
963    #[should_panic(expected = "Currency mismatch")]
964    fn test_money_checked_sub_currency_mismatch_panics() {
965        let usd = Money::new(100.0, Currency::USD());
966        let aud = Money::new(50.0, Currency::AUD());
967        let _ = usd.checked_sub(aud);
968    }
969
970    #[rstest]
971    fn test_money_checked_add_at_exact_max_returns_some() {
972        let usd = Currency::USD();
973        let near_max = Money::from_raw(MONEY_RAW_MAX - 1, usd);
974        let one_unit = Money::from_raw(1, usd);
975        assert_eq!(
976            near_max.checked_add(one_unit),
977            Some(Money::from_raw(MONEY_RAW_MAX, usd)),
978        );
979    }
980
981    #[rstest]
982    fn test_money_checked_sub_at_exact_min_returns_some() {
983        let usd = Currency::USD();
984        let near_min = Money::from_raw(MONEY_RAW_MIN + 1, usd);
985        let one_unit = Money::from_raw(1, usd);
986        assert_eq!(
987            near_min.checked_sub(one_unit),
988            Some(Money::from_raw(MONEY_RAW_MIN, usd)),
989        );
990    }
991
992    #[rstest]
993    fn test_money_negation() {
994        let money = Money::new(100.0, Currency::USD());
995        let result = -money;
996        assert_eq!(result, Money::from("-100.0 USD"));
997        assert_eq!(result.currency, Currency::USD().clone());
998    }
999
1000    #[rstest]
1001    fn test_money_addition_decimal() {
1002        let money = Money::new(100.0, Currency::USD());
1003        let result = money + dec!(50.25);
1004        assert_eq!(result, dec!(150.25));
1005    }
1006
1007    #[rstest]
1008    fn test_money_subtraction_decimal() {
1009        let money = Money::new(100.0, Currency::USD());
1010        let result = money - dec!(30.50);
1011        assert_eq!(result, dec!(69.50));
1012    }
1013
1014    #[rstest]
1015    fn test_money_multiplication_decimal() {
1016        let money = Money::new(100.0, Currency::USD());
1017        let result = money * dec!(1.5);
1018        assert_eq!(result, dec!(150.00));
1019    }
1020
1021    #[rstest]
1022    fn test_money_division_decimal() {
1023        let money = Money::new(100.0, Currency::USD());
1024        let result = money / dec!(4);
1025        assert_eq!(result, dec!(25.00));
1026    }
1027
1028    #[rstest]
1029    fn test_money_addition_f64() {
1030        let money = Money::new(100.0, Currency::USD());
1031        let result = money + 50.25;
1032        assert!(approx_eq!(f64, result, 150.25, epsilon = 1e-9));
1033    }
1034
1035    #[rstest]
1036    fn test_money_subtraction_f64() {
1037        let money = Money::new(100.0, Currency::USD());
1038        let result = money - 30.50;
1039        assert!(approx_eq!(f64, result, 69.50, epsilon = 1e-9));
1040    }
1041
1042    #[rstest]
1043    fn test_money_multiplication_f64() {
1044        let money = Money::new(100.0, Currency::USD());
1045        let result = money * 1.5;
1046        assert!(approx_eq!(f64, result, 150.0, epsilon = 1e-9));
1047    }
1048
1049    #[rstest]
1050    fn test_money_division_f64() {
1051        let money = Money::new(100.0, Currency::USD());
1052        let result = money / 4.0;
1053        assert!(approx_eq!(f64, result, 25.0, epsilon = 1e-9));
1054    }
1055
1056    #[rstest]
1057    fn test_money_new_usd() {
1058        let money = Money::new(1000.0, Currency::USD());
1059        assert_eq!(money.currency.code.as_str(), "USD");
1060        assert_eq!(money.currency.precision, 2);
1061        assert_eq!(money.to_string(), "1000.00 USD");
1062        assert_eq!(money.to_formatted_string(), "1_000.00 USD");
1063        assert_eq!(money.as_decimal(), dec!(1000.00));
1064        assert!(approx_eq!(f64, money.as_f64(), 1000.0, epsilon = 0.001));
1065    }
1066
1067    #[rstest]
1068    fn test_money_new_btc() {
1069        let money = Money::new(10.3, Currency::BTC());
1070        assert_eq!(money.currency.code.as_str(), "BTC");
1071        assert_eq!(money.currency.precision, 8);
1072        assert_eq!(money.to_string(), "10.30000000 BTC");
1073        assert_eq!(money.to_formatted_string(), "10.30000000 BTC");
1074    }
1075
1076    #[rstest]
1077    #[case("0USD")] // <-- No whitespace separator
1078    #[case("0x00 USD")] // <-- Invalid float
1079    #[case("0 US")] // <-- Invalid currency
1080    #[case("0 USD USD")] // <-- Too many parts
1081    #[should_panic(expected = "Condition failed")]
1082    fn test_from_str_invalid_input(#[case] input: &str) {
1083        let _ = Money::from(input);
1084    }
1085
1086    #[rstest]
1087    #[case("0 USD", Currency::USD(), dec!(0.00))]
1088    #[case("1.1 AUD", Currency::AUD(), dec!(1.10))]
1089    #[case("1.12345678 BTC", Currency::BTC(), dec!(1.12345678))]
1090    #[case("10_000.10 USD", Currency::USD(), dec!(10000.10))]
1091    fn test_from_str_valid_input(
1092        #[case] input: &str,
1093        #[case] expected_currency: Currency,
1094        #[case] expected_dec: Decimal,
1095    ) {
1096        let money = Money::from(input);
1097        assert_eq!(money.currency, expected_currency);
1098        assert_eq!(money.as_decimal(), expected_dec);
1099    }
1100
1101    #[rstest]
1102    fn test_money_from_str_negative() {
1103        let money = Money::from("-123.45 USD");
1104        assert!(approx_eq!(f64, money.as_f64(), -123.45, epsilon = 1e-9));
1105        assert_eq!(money.currency, Currency::USD());
1106    }
1107
1108    #[rstest]
1109    #[case("1e7 USD", 10_000_000.0)]
1110    #[case("2.5e3 EUR", 2_500.0)]
1111    #[case("1.234e-2 GBP", 0.01)] // GBP has 2 decimal places, so 0.01234 becomes 0.01
1112    #[case("5E-3 JPY", 0.0)] // JPY has 0 decimal places, so 0.005 becomes 0
1113    fn test_from_str_scientific_notation(#[case] input: &str, #[case] expected_value: f64) {
1114        let money = Money::from_str(input).unwrap();
1115        assert!(approx_eq!(
1116            f64,
1117            money.as_f64(),
1118            expected_value,
1119            epsilon = 1e-10
1120        ));
1121    }
1122
1123    #[rstest]
1124    #[case("1_234.56 USD", 1234.56)]
1125    #[case("1_000_000 EUR", 1_000_000.0)]
1126    #[case("99_999.99 GBP", 99_999.99)]
1127    fn test_from_str_with_underscores(#[case] input: &str, #[case] expected_value: f64) {
1128        let money = Money::from_str(input).unwrap();
1129        assert!(approx_eq!(
1130            f64,
1131            money.as_f64(),
1132            expected_value,
1133            epsilon = 1e-10
1134        ));
1135    }
1136
1137    #[rstest]
1138    fn test_from_decimal_precision_preservation() {
1139        use rust_decimal::Decimal;
1140
1141        let decimal = Decimal::from_str("123.45").unwrap();
1142        let money = Money::from_decimal(decimal, Currency::USD()).unwrap();
1143        assert_eq!(money.currency.precision, 2);
1144        assert!(approx_eq!(f64, money.as_f64(), 123.45, epsilon = 1e-10));
1145
1146        // Verify raw value is exact for USD (2 decimal places)
1147        let expected_raw = 12345 * 10_i64.pow(u32::from(FIXED_PRECISION - 2));
1148        assert_eq!(money.raw, MoneyRaw::from(expected_raw));
1149    }
1150
1151    #[rstest]
1152    fn test_from_decimal_rounding() {
1153        use rust_decimal::Decimal;
1154
1155        // Test banker's rounding with USD (2 decimal places)
1156        let decimal = Decimal::from_str("1.005").unwrap();
1157        let money = Money::from_decimal(decimal, Currency::USD()).unwrap();
1158        assert_eq!(money.as_f64(), 1.0); // 1.005 rounds to 1.00 (even)
1159
1160        let decimal = Decimal::from_str("1.015").unwrap();
1161        let money = Money::from_decimal(decimal, Currency::USD()).unwrap();
1162        assert_eq!(money.as_f64(), 1.02); // 1.015 rounds to 1.02 (even)
1163    }
1164
1165    #[rstest]
1166    fn test_money_hash() {
1167        use std::{
1168            collections::hash_map::DefaultHasher,
1169            hash::{Hash, Hasher},
1170        };
1171
1172        let m1 = Money::new(100.0, Currency::USD());
1173        let m2 = Money::new(100.0, Currency::USD());
1174        let m3 = Money::new(100.0, Currency::AUD());
1175
1176        let mut s1 = DefaultHasher::new();
1177        let mut s2 = DefaultHasher::new();
1178        let mut s3 = DefaultHasher::new();
1179
1180        m1.hash(&mut s1);
1181        m2.hash(&mut s2);
1182        m3.hash(&mut s3);
1183
1184        assert_eq!(
1185            s1.finish(),
1186            s2.finish(),
1187            "Same amount + same currency => same hash"
1188        );
1189        assert_ne!(
1190            s1.finish(),
1191            s3.finish(),
1192            "Same amount + different currency => different hash"
1193        );
1194    }
1195
1196    #[rstest]
1197    fn test_money_serialization_deserialization() {
1198        let money = Money::new(123.45, Currency::USD());
1199        let serialized = serde_json::to_string(&money);
1200        let deserialized: Money = serde_json::from_str(&serialized.unwrap()).unwrap();
1201        assert_eq!(money, deserialized);
1202    }
1203
1204    #[rstest]
1205    #[should_panic(expected = "`raw` value")]
1206    fn test_money_from_raw_out_of_range_panics() {
1207        let usd = Currency::USD();
1208        let raw = MONEY_RAW_MAX.saturating_add(1);
1209        let _ = Money::from_raw(raw, usd);
1210    }
1211
1212    #[rstest]
1213    fn test_money_from_raw_checked_valid() {
1214        let usd = Currency::USD();
1215        let money = Money::from_raw_checked(123_450_000_000, usd).unwrap();
1216        assert_eq!(money.currency, usd);
1217    }
1218
1219    #[rstest]
1220    fn test_money_from_raw_checked_above_max_returns_error() {
1221        let usd = Currency::USD();
1222        let raw = MONEY_RAW_MAX.saturating_add(1);
1223        let error = Money::from_raw_checked(raw, usd).unwrap_err();
1224        assert!(matches!(error, CorrectnessError::PredicateViolation { .. }));
1225    }
1226
1227    #[rstest]
1228    fn test_money_from_raw_checked_below_min_returns_error() {
1229        let usd = Currency::USD();
1230        let raw = MONEY_RAW_MIN.saturating_sub(1);
1231        let error = Money::from_raw_checked(raw, usd).unwrap_err();
1232        assert!(matches!(error, CorrectnessError::PredicateViolation { .. }));
1233    }
1234
1235    #[rstest]
1236    fn test_from_decimal_rejects_out_of_range() {
1237        let huge = Decimal::from_str("99999999999999999999.99").unwrap();
1238        let result = Money::from_decimal(huge, Currency::USD());
1239        assert!(result.is_err());
1240    }
1241
1242    #[rstest]
1243    fn test_from_decimal_out_of_range_returns_typed_error_with_stable_display() {
1244        let huge = Decimal::from_str("99999999999999999999.99").unwrap();
1245        let error = Money::from_decimal(huge, Currency::USD()).unwrap_err();
1246        match error {
1247            CorrectnessError::PredicateViolation { ref message } => {
1248                assert!(
1249                    message.contains("MoneyRaw range") || message.contains("Money"),
1250                    "unexpected message: {message:?}",
1251                );
1252            }
1253            _ => panic!("expected PredicateViolation, was {error:?}"),
1254        }
1255    }
1256
1257    #[rstest]
1258    fn test_from_mantissa_exponent_exact_precision() {
1259        let money = Money::from_mantissa_exponent(12345, -2, Currency::USD());
1260        assert_eq!(money.as_f64(), 123.45);
1261    }
1262
1263    #[rstest]
1264    fn test_from_mantissa_exponent_excess_rounds_down() {
1265        // 12.345 rounds to 12.34 (4 is even, banker's rounding)
1266        let money = Money::from_mantissa_exponent(12345, -3, Currency::USD());
1267        assert_eq!(money.as_f64(), 12.34);
1268    }
1269
1270    #[rstest]
1271    fn test_from_mantissa_exponent_excess_rounds_up() {
1272        // 12.355 rounds to 12.36 (5 is odd, banker's rounding)
1273        let money = Money::from_mantissa_exponent(12355, -3, Currency::USD());
1274        assert_eq!(money.as_f64(), 12.36);
1275    }
1276
1277    #[rstest]
1278    fn test_from_mantissa_exponent_positive_exponent() {
1279        let money = Money::from_mantissa_exponent(5, 2, Currency::USD());
1280        assert_eq!(money.as_f64(), 500.0);
1281    }
1282
1283    #[rstest]
1284    #[should_panic(expected = "Overflow")]
1285    fn test_from_mantissa_exponent_overflow_panics() {
1286        let _ = Money::from_mantissa_exponent(i64::MAX, 9, Currency::USD());
1287    }
1288
1289    #[rstest]
1290    #[should_panic(expected = "exceeds i128 range")]
1291    fn test_from_mantissa_exponent_large_exponent_panics() {
1292        let _ = Money::from_mantissa_exponent(1, 119, Currency::USD());
1293    }
1294
1295    #[rstest]
1296    fn test_from_mantissa_exponent_zero_with_large_exponent() {
1297        let money = Money::from_mantissa_exponent(0, 119, Currency::USD());
1298        assert_eq!(money.as_f64(), 0.0);
1299    }
1300
1301    #[rstest]
1302    fn test_from_mantissa_exponent_very_negative_exponent_rounds_to_zero() {
1303        // exponent=-120, frac_digits=120, excess=118 for USD (precision 2)
1304        let money = Money::from_mantissa_exponent(12345, -120, Currency::USD());
1305        assert_eq!(money.as_f64(), 0.0);
1306    }
1307
1308    #[rstest]
1309    #[case(42.0, true, "positive value")]
1310    #[case(0.0, false, "zero value")]
1311    #[case( -13.5,  false, "negative value")]
1312    #[allow(clippy::used_underscore_binding)]
1313    fn test_check_positive_money(
1314        #[case] amount: f64,
1315        #[case] should_succeed: bool,
1316        #[case] _case_name: &str,
1317    ) {
1318        let money = Money::new(amount, Currency::USD());
1319
1320        let res = check_positive_money(money, "money");
1321
1322        if should_succeed {
1323            assert!(res.is_ok(), "expected Ok(..) for {amount}");
1324        } else {
1325            assert!(res.is_err(), "expected Err(..) for {amount}");
1326            let msg = res.unwrap_err().to_string();
1327            assert!(
1328                msg.contains("not positive"),
1329                "error message should mention positivity; got: {msg:?}"
1330            );
1331        }
1332    }
1333
1334    #[rstest]
1335    fn test_check_positive_money_returns_typed_error_with_stable_display() {
1336        let error = check_positive_money(Money::new(0.0, Currency::USD()), "money").unwrap_err();
1337
1338        assert_eq!(
1339            error,
1340            CorrectnessError::NotPositive {
1341                param: "money".to_string(),
1342                value: "0.00 USD".to_string(),
1343                type_name: "`Money`",
1344            }
1345        );
1346        assert_eq!(
1347            error.to_string(),
1348            "invalid `Money` for 'money' not positive, was 0.00 USD"
1349        );
1350    }
1351}
1352
1353#[cfg(test)]
1354mod property_tests {
1355    use proptest::prelude::*;
1356    use rstest::rstest;
1357
1358    use super::*;
1359
1360    fn currency_strategy() -> impl Strategy<Value = Currency> {
1361        prop_oneof![
1362            Just(Currency::USD()),
1363            Just(Currency::EUR()),
1364            Just(Currency::GBP()),
1365            Just(Currency::JPY()),
1366            Just(Currency::AUD()),
1367            Just(Currency::CAD()),
1368            Just(Currency::CHF()),
1369            Just(Currency::BTC()),
1370            Just(Currency::ETH()),
1371            Just(Currency::USDT()),
1372        ]
1373    }
1374
1375    fn money_amount_strategy() -> impl Strategy<Value = f64> {
1376        prop_oneof![
1377            -1000.0..1000.0,
1378            -100_000.0..100_000.0,
1379            -1_000_000.0..1_000_000.0,
1380            Just(0.0),
1381            Just(MONEY_MIN / 2.0),
1382            Just(MONEY_MAX / 2.0),
1383            Just(MONEY_MIN + 1.0),
1384            Just(MONEY_MAX - 1.0),
1385            Just(MONEY_MIN),
1386            Just(MONEY_MAX),
1387        ]
1388    }
1389
1390    fn money_strategy() -> impl Strategy<Value = Money> {
1391        (money_amount_strategy(), currency_strategy())
1392            .prop_filter_map("constructible money", |(amount, currency)| {
1393                Money::new_checked(amount, currency).ok()
1394            })
1395    }
1396
1397    proptest! {
1398        #[rstest]
1399        fn prop_money_construction_roundtrip(
1400            amount in money_amount_strategy(),
1401            currency in currency_strategy()
1402        ) {
1403            if let Ok(money) = Money::new_checked(amount, currency) {
1404                let roundtrip = money.as_f64();
1405                let precision_epsilon = if currency.precision == 0 {
1406                    1.0
1407                } else {
1408                    let currency_epsilon = 10.0_f64.powi(-i32::from(currency.precision));
1409                    let magnitude_epsilon = amount.abs() * 1e-10;
1410                    currency_epsilon.max(magnitude_epsilon)
1411                };
1412                prop_assert!((roundtrip - amount).abs() <= precision_epsilon,
1413                    "Roundtrip failed: {} -> {} -> {} (precision: {}, epsilon: {})",
1414                    amount, money.raw, roundtrip, currency.precision, precision_epsilon);
1415                prop_assert_eq!(money.currency, currency);
1416            }
1417        }
1418
1419        #[rstest]
1420        fn prop_money_addition_commutative(
1421            money1 in money_strategy(),
1422            money2 in money_strategy(),
1423        ) {
1424            if money1.currency == money2.currency
1425                && let (Some(_), Some(_)) = (
1426                    money1.raw.checked_add(money2.raw),
1427                    money2.raw.checked_add(money1.raw)
1428                )
1429            {
1430                let sum1 = money1 + money2;
1431                let sum2 = money2 + money1;
1432                prop_assert_eq!(sum1, sum2, "Addition should be commutative");
1433                prop_assert_eq!(sum1.currency, money1.currency);
1434            }
1435        }
1436
1437        #[rstest]
1438        fn prop_money_addition_associative(
1439            money1 in money_strategy(),
1440            money2 in money_strategy(),
1441            money3 in money_strategy(),
1442        ) {
1443            if money1.currency == money2.currency
1444                && money2.currency == money3.currency
1445                && let (Some(sum1), Some(sum2)) = (
1446                    money1.raw.checked_add(money2.raw),
1447                    money2.raw.checked_add(money3.raw)
1448                )
1449                && let (Some(left), Some(right)) = (
1450                    sum1.checked_add(money3.raw),
1451                    money1.raw.checked_add(sum2)
1452                )
1453                && (MONEY_RAW_MIN..=MONEY_RAW_MAX).contains(&left)
1454                && (MONEY_RAW_MIN..=MONEY_RAW_MAX).contains(&right)
1455            {
1456                let left_result = Money::from_raw(left, money1.currency);
1457                let right_result = Money::from_raw(right, money1.currency);
1458                prop_assert_eq!(left_result, right_result, "Addition should be associative");
1459            }
1460        }
1461
1462        #[rstest]
1463        fn prop_money_subtraction_inverse(
1464            money1 in money_strategy(),
1465            money2 in money_strategy(),
1466        ) {
1467            if money1.currency == money2.currency
1468                && let Some(sum_raw) = money1.raw.checked_add(money2.raw)
1469                && (MONEY_RAW_MIN..=MONEY_RAW_MAX).contains(&sum_raw)
1470            {
1471                let sum = Money::from_raw(sum_raw, money1.currency);
1472                let diff = sum - money2;
1473                prop_assert_eq!(diff, money1, "Subtraction should be inverse of addition");
1474            }
1475        }
1476
1477        /// Property: checked_add agrees with raw checked_add when result is in bounds and
1478        /// currencies match; returns None when out of bounds.
1479        #[rstest]
1480        fn prop_money_checked_add_matches_spec(
1481            raw1 in MONEY_RAW_MIN..=MONEY_RAW_MAX,
1482            raw2 in MONEY_RAW_MIN..=MONEY_RAW_MAX,
1483            currency in currency_strategy(),
1484        ) {
1485            let m1 = Money::from_raw(raw1, currency);
1486            let m2 = Money::from_raw(raw2, currency);
1487            let expected = m1.raw
1488                .checked_add(m2.raw)
1489                .filter(|r| (MONEY_RAW_MIN..=MONEY_RAW_MAX).contains(r))
1490                .map(|raw| Money { raw, currency });
1491            prop_assert_eq!(m1.checked_add(m2), expected);
1492        }
1493
1494        /// Property: checked_sub agrees with raw checked_sub when result is in bounds and
1495        /// currencies match; returns None when out of bounds.
1496        #[rstest]
1497        fn prop_money_checked_sub_matches_spec(
1498            raw1 in MONEY_RAW_MIN..=MONEY_RAW_MAX,
1499            raw2 in MONEY_RAW_MIN..=MONEY_RAW_MAX,
1500            currency in currency_strategy(),
1501        ) {
1502            let m1 = Money::from_raw(raw1, currency);
1503            let m2 = Money::from_raw(raw2, currency);
1504            let expected = m1.raw
1505                .checked_sub(m2.raw)
1506                .filter(|r| (MONEY_RAW_MIN..=MONEY_RAW_MAX).contains(r))
1507                .map(|raw| Money { raw, currency });
1508            prop_assert_eq!(m1.checked_sub(m2), expected);
1509        }
1510
1511        #[rstest]
1512        fn prop_money_zero_identity(money in money_strategy()) {
1513            let zero = Money::zero(money.currency);
1514            prop_assert_eq!(money + zero, money, "Zero should be additive identity");
1515            prop_assert_eq!(zero + money, money, "Zero should be additive identity (commutative)");
1516            prop_assert!(zero.is_zero(), "Zero should be recognized as zero");
1517        }
1518
1519        #[rstest]
1520        fn prop_money_negation_inverse(money in money_strategy()) {
1521            let negated = -money;
1522            let double_neg = -negated;
1523            prop_assert_eq!(money, double_neg, "Double negation should equal original");
1524            prop_assert_eq!(negated.currency, money.currency, "Negation preserves currency");
1525
1526            if let Some(sum_raw) = money.raw.checked_add(negated.raw)
1527                && (MONEY_RAW_MIN..=MONEY_RAW_MAX).contains(&sum_raw) {
1528                    let sum = Money::from_raw(sum_raw, money.currency);
1529                    prop_assert!(sum.is_zero(), "Money + (-Money) should equal zero");
1530                }
1531        }
1532
1533        #[rstest]
1534        fn prop_money_comparison_consistency(
1535            money1 in money_strategy(),
1536            money2 in money_strategy(),
1537        ) {
1538            if money1.currency == money2.currency {
1539                let eq = money1 == money2;
1540                let lt = money1 < money2;
1541                let gt = money1 > money2;
1542                let le = money1 <= money2;
1543                let ge = money1 >= money2;
1544
1545                let exclusive_count = [eq, lt, gt].iter().filter(|&&x| x).count();
1546                prop_assert_eq!(exclusive_count, 1, "Exactly one of ==, <, > should be true");
1547
1548                prop_assert_eq!(le, eq || lt, "<= should equal == || <");
1549                prop_assert_eq!(ge, eq || gt, ">= should equal == || >");
1550                prop_assert_eq!(lt, money2 > money1, "< should be symmetric with >");
1551                prop_assert_eq!(le, money2 >= money1, "<= should be symmetric with >=");
1552            }
1553        }
1554
1555        #[rstest]
1556        fn prop_money_decimal_conversion(money in money_strategy()) {
1557            let decimal = money.as_decimal();
1558
1559            // Scale must always match currency precision
1560            prop_assert_eq!(decimal.scale(), u32::from(money.currency.precision));
1561
1562            #[cfg(feature = "defi")]
1563            {
1564                let decimal_f64: f64 = decimal.try_into().unwrap_or(0.0);
1565                prop_assert!(decimal_f64.is_finite(), "Decimal should convert to finite f64");
1566            }
1567            #[cfg(not(feature = "defi"))]
1568            {
1569                let decimal_f64: f64 = decimal.try_into().unwrap_or(0.0);
1570                let original_f64 = money.as_f64();
1571
1572                let base_epsilon = 10.0_f64.powi(-(money.currency.precision as i32));
1573                let precision_epsilon = if cfg!(feature = "high-precision") {
1574                    base_epsilon.max(1e-10)
1575                } else {
1576                    base_epsilon
1577                };
1578                let diff = (decimal_f64 - original_f64).abs();
1579                prop_assert!(diff <= precision_epsilon,
1580                    "Decimal conversion should preserve value within currency precision: {} vs {} (diff: {}, epsilon: {})",
1581                    original_f64, decimal_f64, diff, precision_epsilon);
1582            }
1583        }
1584
1585        #[rstest]
1586        fn prop_money_arithmetic_with_f64(
1587            money in money_strategy(),
1588            factor in -1000.0..1000.0_f64,
1589        ) {
1590            if factor != 0.0 {
1591                let original_f64 = money.as_f64();
1592
1593                let mul_result = money * factor;
1594                let expected_mul = original_f64 * factor;
1595                prop_assert!((mul_result - expected_mul).abs() < 0.01,
1596                    "Multiplication with f64 should be accurate");
1597
1598                let div_result = money / factor;
1599                let expected_div = original_f64 / factor;
1600                if expected_div.is_finite() {
1601                    prop_assert!((div_result - expected_div).abs() < 0.01,
1602                        "Division with f64 should be accurate");
1603                }
1604
1605                let add_result = money + factor;
1606                let expected_add = original_f64 + factor;
1607                prop_assert!((add_result - expected_add).abs() < 0.01,
1608                    "Addition with f64 should be accurate");
1609
1610                let sub_result = money - factor;
1611                let expected_sub = original_f64 - factor;
1612                prop_assert!((sub_result - expected_sub).abs() < 0.01,
1613                    "Subtraction with f64 should be accurate");
1614            }
1615        }
1616    }
1617}