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//! Adding or subtracting two `Money` values requires matching effective fixed-point scales.
25//! These operations panic on a scale mismatch.
26//! Comparisons and hashes account for scale differences without rounding.
27//!
28//! | Operation         | Result    | Notes                             |
29//! |-------------------|-----------|-----------------------------------|
30//! | `Money + Money`   | `Money`   | Panics if currencies don't match. |
31//! | `Money - Money`   | `Money`   | Panics if currencies don't match. |
32//! | `Money + Decimal` | `Decimal` |                                   |
33//! | `Money - Decimal` | `Decimal` |                                   |
34//! | `Money * Decimal` | `Decimal` |                                   |
35//! | `Money / Decimal` | `Decimal` |                                   |
36//! | `Money + f64`     | `f64`     |                                   |
37//! | `Money - f64`     | `f64`     |                                   |
38//! | `Money * f64`     | `f64`     |                                   |
39//! | `Money / f64`     | `f64`     |                                   |
40//! | `-Money`          | `Money`   |                                   |
41//!
42//! # Ordering behavior
43//!
44//! Rust ordering compares currency codes lexicographically, then scale-adjusted amounts.
45//! This order supports sorting and ordered collections across currencies, but it does not convert
46//! amounts to a common currency. Values with the same currency code compare by numeric value.
47//! Python comparisons reject values with different currency codes.
48//!
49//! # Currency constraints
50//!
51//! When performing arithmetic between two `Money` values, both must have the same currency.
52//! Attempting to add or subtract money with different currencies raises an error.
53//!
54//! # Immutability
55//!
56//! `Money` is immutable. All arithmetic operations return new instances.
57
58use std::{
59    cmp::Ordering,
60    fmt::{Debug, Display},
61    hash::{Hash, Hasher},
62    ops::{Add, Div, Mul, Neg, Sub},
63    str::FromStr,
64};
65
66use nautilus_core::{
67    correctness::{
68        CorrectnessError, CorrectnessResult, CorrectnessResultExt, FAILED,
69        check_in_range_inclusive_f64, check_predicate_false, check_predicate_true,
70    },
71    string::formatting::Separable,
72};
73use rust_decimal::Decimal;
74use serde::{Deserialize, Deserializer, Serialize};
75
76#[cfg(not(any(feature = "defi", feature = "high-precision")))]
77use super::fixed::{f64_to_fixed_i64, fixed_i64_to_f64};
78#[cfg(any(feature = "defi", feature = "high-precision"))]
79use super::fixed::{f64_to_fixed_i128, fixed_i128_to_f64};
80#[cfg(feature = "defi")]
81use crate::types::fixed::MAX_FLOAT_PRECISION;
82use crate::types::{
83    Currency, Quantity,
84    fixed::{
85        FIXED_PRECISION, FIXED_SCALAR, canonical_raw, check_fixed_precision, check_fixed_raw_i128,
86        check_fixed_raw_u128, compare_raw_signed, format_scaled_i128,
87        mantissa_exponent_to_fixed_i128, mantissa_exponent_to_raw_checked, parse_decimal_mantissa,
88        raw_scale, raw_scales_match, scaled_raw_to_decimal,
89    },
90};
91
92// -----------------------------------------------------------------------------
93// MoneyRaw
94// -----------------------------------------------------------------------------
95
96#[cfg(feature = "high-precision")]
97pub type MoneyRaw = i128;
98
99#[cfg(not(feature = "high-precision"))]
100pub type MoneyRaw = i64;
101
102// -----------------------------------------------------------------------------
103
104/// The maximum raw money integer value.
105///
106/// # Safety
107///
108/// `MONEY_MAX` and `FIXED_SCALAR` are cast to `MoneyRaw` before multiplying, so the
109/// scaling uses exact integer arithmetic rather than a lossy `f64` product. The result
110/// fits within `MoneyRaw`'s range in both high-precision (i128) and standard-precision
111/// (i64) modes, so the multiplication cannot overflow.
112#[unsafe(no_mangle)]
113#[allow(unsafe_code)]
114pub static MONEY_RAW_MAX: MoneyRaw = (MONEY_MAX as MoneyRaw) * (FIXED_SCALAR as MoneyRaw);
115
116/// The minimum raw money integer value.
117///
118/// # Safety
119///
120/// `MONEY_MIN` and `FIXED_SCALAR` are cast to `MoneyRaw` before multiplying, so the
121/// scaling uses exact integer arithmetic rather than a lossy `f64` product. The result
122/// fits within `MoneyRaw`'s range in both high-precision (i128) and standard-precision
123/// (i64) modes, so the multiplication cannot overflow.
124#[unsafe(no_mangle)]
125#[allow(unsafe_code)]
126pub static MONEY_RAW_MIN: MoneyRaw = (MONEY_MIN as MoneyRaw) * (FIXED_SCALAR as MoneyRaw);
127
128// -----------------------------------------------------------------------------
129// MONEY_MAX
130// -----------------------------------------------------------------------------
131
132#[cfg(feature = "high-precision")]
133/// The maximum valid money amount that can be represented.
134pub const MONEY_MAX: f64 = 17_014_118_346_046.0;
135
136#[cfg(not(feature = "high-precision"))]
137/// The maximum valid money amount that can be represented.
138pub const MONEY_MAX: f64 = 9_223_372_036.0;
139
140// -----------------------------------------------------------------------------
141// MONEY_MIN
142// -----------------------------------------------------------------------------
143
144#[cfg(feature = "high-precision")]
145/// The minimum valid money amount that can be represented.
146pub const MONEY_MIN: f64 = -17_014_118_346_046.0;
147
148#[cfg(not(feature = "high-precision"))]
149/// The minimum valid money amount that can be represented.
150pub const MONEY_MIN: f64 = -9_223_372_036.0;
151
152// -----------------------------------------------------------------------------
153
154/// Represents an amount of money in a specified currency denomination.
155///
156/// - [`MONEY_MAX`] - Maximum representable money amount
157/// - [`MONEY_MIN`] - Minimum representable money amount
158#[repr(C)]
159#[derive(Clone, Copy, Eq)]
160#[cfg_attr(
161    feature = "python",
162    pyo3::pyclass(module = "nautilus_trader.model", frozen, from_py_object)
163)]
164#[cfg_attr(
165    feature = "python",
166    pyo3_stub_gen::derive::gen_stub_pyclass(module = "nautilus_trader.model")
167)]
168pub struct Money {
169    pub(crate) raw: MoneyRaw,
170    /// The currency denomination associated with the monetary amount.
171    pub currency: Currency,
172}
173
174impl Money {
175    /// Creates a new [`Money`] instance with correctness checking.
176    ///
177    /// # Errors
178    ///
179    /// Returns an error if:
180    /// - `amount` is invalid outside the representable range [`MONEY_MIN`, `MONEY_MAX`].
181    /// - `currency.precision` exceeds the maximum fixed precision.
182    ///
183    /// # Notes
184    ///
185    /// PyO3 requires a `Result` type for proper error handling and stacktrace printing in Python.
186    pub fn new_checked(amount: f64, currency: Currency) -> CorrectnessResult<Self> {
187        // check_in_range_inclusive_f64 already validates that amount is finite
188        // (not NaN or infinite) as part of its range validation logic, so no additional
189        // infinity checks are needed here.
190        check_in_range_inclusive_f64(amount, MONEY_MIN, MONEY_MAX, "amount")?;
191
192        #[cfg(feature = "defi")]
193        if currency.precision > MAX_FLOAT_PRECISION {
194            // Floats are only reliable up to ~16 decimal digits of precision regardless of feature flags
195            return Err(CorrectnessError::PredicateViolation {
196                message: format!(
197                    "`currency.precision` exceeded maximum float precision ({MAX_FLOAT_PRECISION}), use `Money::from_wei()` for wei values instead"
198                ),
199            });
200        }
201
202        check_fixed_precision(currency.precision)?;
203
204        #[cfg(feature = "high-precision")]
205        let raw = f64_to_fixed_i128(amount, currency.precision);
206
207        #[cfg(not(feature = "high-precision"))]
208        let raw = f64_to_fixed_i64(amount, currency.precision);
209
210        Ok(Self { raw, currency })
211    }
212
213    /// Creates a new [`Money`] instance.
214    ///
215    /// # Panics
216    ///
217    /// Panics if a correctness check fails. See [`Money::new_checked`] for more details.
218    #[must_use]
219    pub fn new(amount: f64, currency: Currency) -> Self {
220        Self::new_checked(amount, currency).expect_display(FAILED)
221    }
222
223    /// Creates a new [`Money`] instance from the given `raw` fixed-point value and the specified `currency`.
224    ///
225    /// # Panics
226    ///
227    /// Panics if a correctness check fails. See [`Money::from_raw_checked`] for more details.
228    #[must_use]
229    pub fn from_raw(raw: MoneyRaw, currency: Currency) -> Self {
230        Self::from_raw_checked(raw, currency).expect_display(FAILED)
231    }
232
233    /// Creates a new [`Money`] instance from the given `raw` fixed-point value and the specified
234    /// `currency` with correctness checking.
235    ///
236    /// # Errors
237    ///
238    /// Returns an error if:
239    /// - `raw` is outside the representable range [`MONEY_RAW_MIN`, `MONEY_RAW_MAX`].
240    /// - `currency.precision` exceeds the maximum fixed precision.
241    pub fn from_raw_checked(raw: MoneyRaw, currency: Currency) -> CorrectnessResult<Self> {
242        if raw < MONEY_RAW_MIN || raw > MONEY_RAW_MAX {
243            return Err(CorrectnessError::PredicateViolation {
244                message: format!(
245                    "`raw` value {raw} exceeded bounds [{MONEY_RAW_MIN}, {MONEY_RAW_MAX}] for Money"
246                ),
247            });
248        }
249
250        check_fixed_precision(currency.precision)?;
251
252        // TODO: Enforce spurious bits validation in v2
253        // Validate raw value has no spurious bits beyond the precision scale
254        // if raw != 0 {
255        //     #[cfg(feature = "high-precision")]
256        //     super::fixed::check_fixed_raw_i128(raw, currency.precision)?;
257        //     #[cfg(not(feature = "high-precision"))]
258        //     super::fixed::check_fixed_raw_i64(raw, currency.precision)?;
259        // }
260
261        Ok(Self { raw, currency })
262    }
263
264    /// Creates a new [`Money`] from a mantissa/exponent pair using pure integer arithmetic.
265    ///
266    /// The value is `mantissa * 10^exponent`. This avoids all floating-point and Decimal
267    /// operations, making it ideal for exchange data that arrives as mantissa/exponent pairs.
268    ///
269    /// # Panics
270    ///
271    /// Panics if the resulting raw value exceeds [`MONEY_RAW_MAX`] or [`MONEY_RAW_MIN`].
272    #[must_use]
273    pub fn from_mantissa_exponent(mantissa: i64, exponent: i8, currency: Currency) -> Self {
274        check_fixed_precision(currency.precision).expect_display(FAILED);
275
276        if mantissa == 0 {
277            return Self { raw: 0, currency };
278        }
279
280        let raw_i128 =
281            mantissa_exponent_to_fixed_i128(i128::from(mantissa), exponent, currency.precision)
282                .expect("Overflow in Money::from_mantissa_exponent");
283
284        #[allow(
285            clippy::useless_conversion,
286            reason = "i128 to MoneyRaw is real when not high-precision"
287        )]
288        let raw: MoneyRaw = raw_i128
289            .try_into()
290            .expect("Raw value exceeds MoneyRaw range in Money::from_mantissa_exponent");
291        assert!(
292            raw >= MONEY_RAW_MIN && raw <= MONEY_RAW_MAX,
293            "`raw` value {raw} exceeded bounds [{MONEY_RAW_MIN}, {MONEY_RAW_MAX}] for Money"
294        );
295
296        Self { raw, currency }
297    }
298
299    /// Creates a new [`Money`] instance with a value of zero with the given [`Currency`].
300    ///
301    /// # Panics
302    ///
303    /// Panics if `currency.precision` exceeds the maximum allowed by `check_fixed_precision`.
304    #[must_use]
305    pub fn zero(currency: Currency) -> Self {
306        check_fixed_precision(currency.precision).expect_display(FAILED);
307        Self { raw: 0, currency }
308    }
309
310    /// Returns a copy with raw value rounded to currency precision,
311    /// stripping any sub-scale bits.
312    #[must_use]
313    pub fn normalized(&self) -> Self {
314        #[cfg(feature = "high-precision")]
315        let raw = super::fixed::correct_raw_i128(self.raw, self.currency.precision);
316
317        #[cfg(not(feature = "high-precision"))]
318        let raw = super::fixed::correct_raw_i64(self.raw, self.currency.precision);
319
320        Self {
321            raw,
322            currency: self.currency,
323        }
324    }
325
326    /// Returns the stored fixed-point integer without rescaling.
327    ///
328    /// Use this for serialization and explicit fixed-point conversions. Prefer domain
329    /// operations for calculations; the storage scale can differ from display precision.
330    ///
331    /// Direct field access is restricted to this crate:
332    ///
333    /// ```compile_fail
334    /// use nautilus_model::types::Money;
335    /// let value = Money::from("1 USD");
336    /// let raw = value.raw;
337    /// ```
338    #[must_use]
339    #[inline]
340    pub const fn raw(&self) -> MoneyRaw {
341        self.raw
342    }
343
344    /// Returns `true` if the value of this instance is zero.
345    #[must_use]
346    pub fn is_zero(&self) -> bool {
347        self.raw == 0
348    }
349
350    /// Returns `true` if the value of this instance is positive (> 0).
351    #[must_use]
352    pub fn is_positive(&self) -> bool {
353        self.raw > 0
354    }
355
356    /// Returns `true` if the value of this instance is negative (< 0).
357    #[must_use]
358    pub fn is_negative(&self) -> bool {
359        self.raw < 0
360    }
361
362    /// Returns the absolute amount in the same currency.
363    #[must_use]
364    pub fn abs(self) -> Self {
365        if self.is_negative() { -self } else { self }
366    }
367
368    /// Performs a checked addition, returning `None` on raw integer overflow, when
369    /// the result falls outside `[MONEY_RAW_MIN, MONEY_RAW_MAX]`, or when the operands
370    /// have mixed raw scales (e.g. a wei-scaled `Money` and a `FIXED_SCALAR`-scaled
371    /// `Money`, even if their currency codes match).
372    ///
373    /// # Panics
374    ///
375    /// Panics if `self.currency` and `rhs.currency` differ by code (currency mismatch
376    /// is a type-system invariant violation, not a recoverable arithmetic condition).
377    #[must_use]
378    pub fn checked_add(self, rhs: Self) -> Option<Self> {
379        assert_eq!(
380            self.currency, rhs.currency,
381            "Currency mismatch: cannot add {} to {}",
382            rhs.currency.code, self.currency.code
383        );
384
385        if !raw_scales_match(self.currency.precision, rhs.currency.precision) {
386            return None;
387        }
388
389        let raw = self.raw.checked_add(rhs.raw)?;
390        if raw < MONEY_RAW_MIN || raw > MONEY_RAW_MAX {
391            return None;
392        }
393
394        Some(Self {
395            raw,
396            currency: self.currency,
397        })
398    }
399
400    /// Performs a checked subtraction, returning `None` on raw integer underflow, when
401    /// the result falls outside `[MONEY_RAW_MIN, MONEY_RAW_MAX]`, or when the operands
402    /// have mixed raw scales (e.g. a wei-scaled `Money` and a `FIXED_SCALAR`-scaled
403    /// `Money`, even if their currency codes match).
404    ///
405    /// # Panics
406    ///
407    /// Panics if `self.currency` and `rhs.currency` differ by code (currency mismatch
408    /// is a type-system invariant violation, not a recoverable arithmetic condition).
409    #[must_use]
410    pub fn checked_sub(self, rhs: Self) -> Option<Self> {
411        assert_eq!(
412            self.currency, rhs.currency,
413            "Currency mismatch: cannot subtract {} from {}",
414            rhs.currency.code, self.currency.code
415        );
416
417        if !raw_scales_match(self.currency.precision, rhs.currency.precision) {
418            return None;
419        }
420
421        let raw = self.raw.checked_sub(rhs.raw)?;
422        if raw < MONEY_RAW_MIN || raw > MONEY_RAW_MAX {
423            return None;
424        }
425
426        Some(Self {
427            raw,
428            currency: self.currency,
429        })
430    }
431
432    #[cfg(feature = "high-precision")]
433    /// Returns the value of this instance as an `f64`.
434    ///
435    /// # Panics
436    ///
437    /// With the `defi` feature, panics if precision exceeds `MAX_FLOAT_PRECISION` (16).
438    #[must_use]
439    pub fn as_f64(&self) -> f64 {
440        #[cfg(feature = "defi")]
441        assert!(
442            self.currency.precision <= MAX_FLOAT_PRECISION,
443            "Invalid f64 conversion beyond `MAX_FLOAT_PRECISION` (16)"
444        );
445
446        fixed_i128_to_f64(self.raw)
447    }
448
449    #[cfg(not(feature = "high-precision"))]
450    /// Returns the value of this instance as an `f64`.
451    #[must_use]
452    pub fn as_f64(&self) -> f64 {
453        fixed_i64_to_f64(self.raw)
454    }
455
456    /// Returns the value of this instance as a `Decimal`.
457    #[must_use]
458    pub fn as_decimal(&self) -> Decimal {
459        // Scale down the raw value to match the precision
460        let precision = self.currency.precision;
461        let precision_diff = FIXED_PRECISION.saturating_sub(precision);
462
463        // Money's raw value is stored at fixed precision scale, but needs to be adjusted
464        // to the currency's actual precision for decimal conversion.
465        let rescaled_raw = self.raw / MoneyRaw::pow(10, u32::from(precision_diff));
466
467        #[allow(
468            clippy::useless_conversion,
469            reason = "i128::from is real when MoneyRaw is i64"
470        )]
471        scaled_raw_to_decimal(i128::from(rescaled_raw), precision)
472    }
473
474    /// Returns a formatted string representation of this instance.
475    #[must_use]
476    pub fn to_formatted_string(&self) -> String {
477        let amount_str = format_scaled_i128(self.raw_at_precision(), self.currency.precision);
478        format!(
479            "{} {}",
480            amount_str.separate_with_underscores(),
481            self.currency.code
482        )
483    }
484
485    /// Converts a quantity denominated in `currency` to money without rounding.
486    ///
487    /// # Errors
488    ///
489    /// Returns an error if the quantity is invalid, the amount is not exactly representable
490    /// at the currency precision, or scaling exceeds the money bounds.
491    #[allow(
492        clippy::useless_conversion,
493        reason = "the raw width differs when high-precision is disabled"
494    )]
495    pub fn from_quantity(quantity: Quantity, currency: Currency) -> CorrectnessResult<Self> {
496        check_predicate_false(quantity.is_undefined(), "quantity was undefined")?;
497        Quantity::from_raw_checked(quantity.raw, quantity.precision)?;
498        check_fixed_raw_u128(u128::from(quantity.raw), quantity.precision).map_err(|e| {
499            CorrectnessError::PredicateViolation {
500                message: e.to_string(),
501            }
502        })?;
503
504        let raw = i128::try_from(u128::from(quantity.raw)).map_err(|_| {
505            CorrectnessError::PredicateViolation {
506                message: format!("quantity for {currency} exceeds signed raw bounds"),
507            }
508        })?;
509
510        Self::from_rescaled_raw(raw, quantity.precision, currency, "quantity")
511    }
512
513    fn raw_at_precision(&self) -> i128 {
514        let precision_diff = FIXED_PRECISION.saturating_sub(self.currency.precision);
515        let rescaled_raw = self.raw / MoneyRaw::pow(10, u32::from(precision_diff));
516        Self::raw_as_i128(rescaled_raw)
517    }
518
519    fn raw_as_i128(raw: MoneyRaw) -> i128 {
520        #[allow(
521            clippy::useless_conversion,
522            reason = "i128::from is a widening conversion when MoneyRaw is i64"
523        )]
524        i128::from(raw)
525    }
526
527    /// Creates a new [`Money`] from a `Decimal` value with specified currency.
528    ///
529    /// This method provides more reliable parsing by using Decimal arithmetic
530    /// to avoid floating-point precision issues during conversion.
531    ///
532    /// # Errors
533    ///
534    /// Returns an error if:
535    /// - The decimal value cannot be converted to the raw representation.
536    /// - Overflow occurs during scaling.
537    pub fn from_decimal(decimal: Decimal, currency: Currency) -> CorrectnessResult<Self> {
538        let exponent = -(decimal.scale() as i8);
539        let raw_i128 =
540            mantissa_exponent_to_fixed_i128(decimal.mantissa(), exponent, currency.precision)?;
541
542        #[allow(
543            clippy::useless_conversion,
544            reason = "i128 to MoneyRaw is real when not high-precision"
545        )]
546        let raw: MoneyRaw =
547            raw_i128
548                .try_into()
549                .map_err(|_| CorrectnessError::PredicateViolation {
550                    message: format!(
551                        "Decimal value exceeds MoneyRaw range [{MONEY_RAW_MIN}, {MONEY_RAW_MAX}]"
552                    ),
553                })?;
554
555        if !(raw >= MONEY_RAW_MIN && raw <= MONEY_RAW_MAX) {
556            return Err(CorrectnessError::PredicateViolation {
557                message: format!(
558                    "Raw value {raw} exceeded bounds [{MONEY_RAW_MIN}, {MONEY_RAW_MAX}] for Money"
559                ),
560            });
561        }
562
563        Ok(Self { raw, currency })
564    }
565
566    #[allow(
567        clippy::useless_conversion,
568        reason = "the raw width differs when high-precision is disabled"
569    )]
570    pub(crate) fn from_rescaled_raw(
571        raw: i128,
572        source_precision: u8,
573        currency: Currency,
574        subject: &str,
575    ) -> CorrectnessResult<Self> {
576        check_fixed_precision(source_precision)?;
577        check_fixed_precision(currency.precision)?;
578        let source_precision = source_precision.max(FIXED_PRECISION);
579        let target_precision = currency.precision.max(FIXED_PRECISION);
580
581        let raw = match source_precision.cmp(&target_precision) {
582            Ordering::Less => {
583                let scale = 10_i128.pow(u32::from(target_precision - source_precision));
584                raw.checked_mul(scale)
585                    .ok_or_else(|| CorrectnessError::PredicateViolation {
586                        message: format!(
587                            "{subject} for {currency} overflowed while increasing raw scale"
588                        ),
589                    })?
590            }
591            Ordering::Greater => {
592                let scale = 10_i128.pow(u32::from(source_precision - target_precision));
593                check_predicate_true(
594                    raw % scale == 0,
595                    &format!("{subject} for {currency} loses precision when decreasing raw scale"),
596                )?;
597                raw / scale
598            }
599            Ordering::Equal => raw,
600        };
601
602        check_fixed_raw_i128(raw, currency.precision).map_err(|e| {
603            CorrectnessError::PredicateViolation {
604                message: e.to_string(),
605            }
606        })?;
607
608        let raw: MoneyRaw = raw
609            .try_into()
610            .map_err(|_| CorrectnessError::PredicateViolation {
611                message: format!("{subject} for {currency} exceeds Money raw bounds"),
612            })?;
613
614        Self::from_raw_checked(raw, currency)
615    }
616}
617
618impl FromStr for Money {
619    type Err = String;
620
621    fn from_str(value: &str) -> Result<Self, Self::Err> {
622        let parts: Vec<&str> = value.split_whitespace().collect();
623
624        // Ensure we have both the amount and currency
625        if parts.len() != 2 {
626            return Err(format!(
627                "Error invalid input format '{value}'. Expected '<amount> <currency>'"
628            ));
629        }
630
631        let clean_amount = parts[0].replace('_', "");
632
633        let currency = Currency::from_str(parts[1]).map_err(|e| e.to_string())?;
634        if clean_amount.contains('e') || clean_amount.contains('E') {
635            let decimal = Decimal::from_scientific(&clean_amount)
636                .map_err(|e| format!("Error parsing amount '{}' as Decimal: {e}", parts[0]))?;
637            return Self::from_decimal(decimal, currency).map_err(|e| e.to_string());
638        }
639
640        let (mantissa, amount_precision) = parse_decimal_mantissa(&clean_amount)?;
641        let exponent = -i8::try_from(amount_precision).map_err(|e| e.to_string())?;
642        let raw = mantissa_exponent_to_raw_checked::<MoneyRaw>(
643            mantissa,
644            exponent,
645            currency.precision,
646            "Money::from_str",
647            "MoneyRaw",
648            "Money",
649        )
650        .map_err(|e| e.to_string())?;
651        Self::from_raw_checked(raw, currency).map_err(|e| e.to_string())
652    }
653}
654
655impl<T: AsRef<str>> From<T> for Money {
656    fn from(value: T) -> Self {
657        Self::from_str(value.as_ref()).expect(FAILED)
658    }
659}
660
661impl From<Money> for f64 {
662    fn from(money: Money) -> Self {
663        money.as_f64()
664    }
665}
666
667impl From<&Money> for f64 {
668    fn from(money: &Money) -> Self {
669        money.as_f64()
670    }
671}
672
673impl Hash for Money {
674    fn hash<H: Hasher>(&self, state: &mut H) {
675        self.raw.signum().hash(state);
676        canonical_raw(self.raw.unsigned_abs(), self.currency.precision).hash(state);
677        self.currency.hash(state);
678    }
679}
680
681impl PartialEq for Money {
682    fn eq(&self, other: &Self) -> bool {
683        self.cmp(other) == Ordering::Equal
684    }
685}
686
687impl PartialOrd for Money {
688    fn partial_cmp(&self, other: &Self) -> Option<Ordering> {
689        Some(self.cmp(other))
690    }
691}
692
693impl Ord for Money {
694    fn cmp(&self, other: &Self) -> Ordering {
695        self.currency.code.cmp(&other.currency.code).then_with(|| {
696            compare_raw_signed(
697                self.raw,
698                self.currency.precision,
699                other.raw,
700                other.currency.precision,
701            )
702        })
703    }
704}
705
706impl Neg for Money {
707    type Output = Self;
708    fn neg(self) -> Self::Output {
709        Self {
710            raw: -self.raw,
711            currency: self.currency,
712        }
713    }
714}
715
716impl Add for Money {
717    type Output = Self;
718    fn add(self, rhs: Self) -> Self::Output {
719        assert_eq!(
720            self.currency, rhs.currency,
721            "Currency mismatch: cannot add {} to {}",
722            rhs.currency.code, self.currency.code
723        );
724        assert!(
725            raw_scales_match(self.currency.precision, rhs.currency.precision),
726            "Cannot add `Money` values with mismatched decimal scales"
727        );
728        Self {
729            raw: self
730                .raw
731                .checked_add(rhs.raw)
732                .expect("Overflow occurred when adding `Money`"),
733            currency: self.currency,
734        }
735    }
736}
737
738impl Sub for Money {
739    type Output = Self;
740    fn sub(self, rhs: Self) -> Self::Output {
741        assert_eq!(
742            self.currency, rhs.currency,
743            "Currency mismatch: cannot subtract {} from {}",
744            rhs.currency.code, self.currency.code
745        );
746        assert!(
747            raw_scales_match(self.currency.precision, rhs.currency.precision),
748            "Cannot subtract `Money` values with mismatched decimal scales"
749        );
750        Self {
751            raw: self
752                .raw
753                .checked_sub(rhs.raw)
754                .expect("Underflow occurred when subtracting `Money`"),
755            currency: self.currency,
756        }
757    }
758}
759
760impl Add<Decimal> for Money {
761    type Output = Decimal;
762    fn add(self, rhs: Decimal) -> Self::Output {
763        self.as_decimal() + rhs
764    }
765}
766
767impl Sub<Decimal> for Money {
768    type Output = Decimal;
769    fn sub(self, rhs: Decimal) -> Self::Output {
770        self.as_decimal() - rhs
771    }
772}
773
774impl Mul<Decimal> for Money {
775    type Output = Decimal;
776    fn mul(self, rhs: Decimal) -> Self::Output {
777        self.as_decimal() * rhs
778    }
779}
780
781impl Div<Decimal> for Money {
782    type Output = Decimal;
783    fn div(self, rhs: Decimal) -> Self::Output {
784        self.as_decimal() / rhs
785    }
786}
787
788impl Add<f64> for Money {
789    type Output = f64;
790    fn add(self, rhs: f64) -> Self::Output {
791        self.as_f64() + rhs
792    }
793}
794
795impl Sub<f64> for Money {
796    type Output = f64;
797    fn sub(self, rhs: f64) -> Self::Output {
798        self.as_f64() - rhs
799    }
800}
801
802impl Mul<f64> for Money {
803    type Output = f64;
804    fn mul(self, rhs: f64) -> Self::Output {
805        self.as_f64() * rhs
806    }
807}
808
809impl Div<f64> for Money {
810    type Output = f64;
811    fn div(self, rhs: f64) -> Self::Output {
812        self.as_f64() / rhs
813    }
814}
815
816impl Debug for Money {
817    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
818        if self.currency.precision > crate::types::fixed::MAX_FLOAT_PRECISION {
819            write!(
820                f,
821                "{}({}, {})",
822                stringify!(Money),
823                format_scaled_i128(Self::raw_as_i128(self.raw), self.currency.precision),
824                self.currency,
825            )
826        } else {
827            let precision = self.currency.precision;
828            let scale = MoneyRaw::try_from(raw_scale(precision))
829                .expect("effective raw scale should fit in MoneyRaw");
830            let currency_scale = MoneyRaw::pow(10, u32::from(precision));
831            let amount = self.raw / (scale / currency_scale);
832
833            if precision == 0 {
834                write!(f, "{}({}, {})", stringify!(Money), amount, self.currency)
835            } else {
836                let sign = if amount < 0 { "-" } else { "" };
837                let amount_abs = amount.unsigned_abs();
838                let currency_scale = currency_scale.unsigned_abs();
839                let whole = amount_abs / currency_scale;
840                let fraction = amount_abs % currency_scale;
841                write!(
842                    f,
843                    "{}({sign}{whole}.{fraction:0>width$}, {})",
844                    stringify!(Money),
845                    self.currency,
846                    width = usize::from(precision),
847                )
848            }
849        }
850    }
851}
852
853impl Display for Money {
854    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
855        write!(
856            f,
857            "{} {}",
858            format_scaled_i128(self.raw_at_precision(), self.currency.precision),
859            self.currency,
860        )
861    }
862}
863
864impl Serialize for Money {
865    fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
866    where
867        S: serde::Serializer,
868    {
869        serializer.serialize_str(&self.to_string())
870    }
871}
872
873impl<'de> Deserialize<'de> for Money {
874    fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
875    where
876        D: Deserializer<'de>,
877    {
878        let money_str: std::borrow::Cow<'de, str> = Deserialize::deserialize(deserializer)?;
879        Self::from_str(money_str.as_ref()).map_err(serde::de::Error::custom)
880    }
881}
882
883/// Checks if the money `value` is positive.
884///
885/// # Errors
886///
887/// Returns an error if `value` is not positive.
888#[inline(always)]
889pub fn check_positive_money(value: Money, param: &str) -> CorrectnessResult<()> {
890    if value.raw <= 0 {
891        return Err(CorrectnessError::NotPositive {
892            param: param.to_string(),
893            value: value.to_string(),
894            type_name: "`Money`",
895        });
896    }
897    Ok(())
898}
899
900#[cfg(test)]
901mod tests {
902    use nautilus_core::{approx_eq, correctness::CorrectnessError};
903    use rstest::rstest;
904    use rust_decimal_macros::dec;
905
906    use super::*;
907    use crate::enums::CurrencyType;
908
909    #[cfg(feature = "high-precision")]
910    #[rstest]
911    fn test_as_decimal_above_decimal_mantissa() {
912        // Regression: a precision-16 currency amount above roughly 7.92e12 rescales to a raw
913        // value beyond `Decimal`'s 96-bit mantissa, which used to panic during conversion.
914        let currency = Currency::new("XYZ", 16, 0, "XYZ", crate::enums::CurrencyType::Crypto);
915        let money = Money::from_raw(MONEY_RAW_MAX, currency);
916
917        assert_eq!(money.as_decimal(), dec!(17014118346046));
918    }
919
920    #[rstest]
921    fn test_money_ordering_is_structural_across_currencies() {
922        use std::collections::BTreeSet;
923
924        let aud_low = Money::new(-1.0, Currency::AUD());
925        let aud_high = Money::new(100.0, Currency::AUD());
926        let usd_low = Money::new(-100.0, Currency::USD());
927        let usd_high = Money::new(1.0, Currency::USD());
928
929        assert_eq!(aud_high.cmp(&usd_low), Ordering::Less);
930        assert_eq!(aud_high.partial_cmp(&usd_low), Some(Ordering::Less));
931        assert_eq!(
932            (
933                aud_high < usd_low,
934                aud_high <= usd_low,
935                aud_high > usd_low,
936                aud_high >= usd_low,
937                usd_low < aud_high,
938                usd_low <= aud_high,
939                usd_low > aud_high,
940                usd_low >= aud_high,
941            ),
942            (true, true, false, false, false, false, true, true),
943        );
944
945        let expected = vec![aud_low, aud_high, usd_low, usd_high];
946        let mut sorted = vec![usd_high, aud_high, usd_low, aud_low];
947        sorted.sort();
948        let ordered = BTreeSet::from([usd_high, aud_high, usd_low, aud_low]);
949
950        assert_eq!(sorted, expected);
951        assert_eq!(ordered.into_iter().collect::<Vec<_>>(), expected);
952    }
953
954    #[rstest]
955    fn test_money_cmp_equal_matches_equality() {
956        use crate::enums::CurrencyType;
957
958        let currency = Currency::new("TST", 2, 1, "Test fiat", CurrencyType::Fiat);
959        let same_code = Currency::new("TST", 8, 2, "Test crypto", CurrencyType::Crypto);
960        let other_code = Currency::new("TSU", 2, 1, "Other fiat", CurrencyType::Fiat);
961        let money = Money::from_raw(1, currency);
962        let cases = [
963            (Money::from_raw(1, same_code), true),
964            (Money::from_raw(2, same_code), false),
965            (Money::from_raw(1, other_code), false),
966        ];
967
968        for (other, expected_equal) in cases {
969            assert_eq!(money == other, expected_equal);
970            assert_eq!(money.cmp(&other) == Ordering::Equal, expected_equal);
971        }
972    }
973
974    #[rstest]
975    fn test_from_quantity_rejects_noncanonical_raw() {
976        let precision = FIXED_PRECISION - 1;
977        let quantity = Quantity::from_raw(1, precision);
978        let currency = Currency::new("TOKEN", FIXED_PRECISION, 0, "Token", CurrencyType::Crypto);
979        let result = Money::from_quantity(quantity, currency);
980
981        assert_eq!(
982            result,
983            Err(CorrectnessError::PredicateViolation {
984                message: format!(
985                    "Invalid fixed-point raw value 1 for precision {precision}: \
986                             remainder 1 when divided by scale 10. Raw value should be a multiple of 10. \
987                             This indicates data corruption or incorrect precision/scaling upstream"
988                ),
989            })
990        );
991    }
992
993    #[rstest]
994    fn test_extreme_money_round_trips_through_raw() {
995        // Regression: a lossy `f64` scalar previously left `MONEY_RAW_MAX`/`MONEY_RAW_MIN`
996        // beyond the raw produced by `new` at the bounds, causing spurious panics and errors.
997        let max = Money::new(MONEY_MAX, Currency::USD());
998        let min = Money::new(MONEY_MIN, Currency::USD());
999
1000        assert_eq!(max.raw, MONEY_RAW_MAX);
1001        assert_eq!(min.raw, MONEY_RAW_MIN);
1002        assert!(Money::from_raw_checked(max.raw, Currency::USD()).is_ok());
1003        assert!(Money::from_raw_checked(min.raw, Currency::USD()).is_ok());
1004    }
1005
1006    #[cfg(all(feature = "defi", feature = "high-precision"))]
1007    #[rstest]
1008    fn test_wei_above_decimal_mantissa_formats_exactly() {
1009        let raw = 80_000_000_000_000_000_250_000_000_000_i128;
1010        let currency = Currency::new("ETH", 18, 0, "Ether", crate::enums::CurrencyType::Crypto);
1011        let money = Money::from_raw(raw, currency);
1012
1013        assert_eq!(money.to_string(), "80000000000.000000250000000000 ETH");
1014        assert_eq!(
1015            money.to_formatted_string(),
1016            "80_000_000_000.000000250000000000 ETH"
1017        );
1018    }
1019
1020    #[cfg(all(feature = "defi", feature = "high-precision"))]
1021    #[rstest]
1022    fn test_wei_money_serde_json_round_trip() {
1023        let currency = Currency::new(
1024            "WXW",
1025            18,
1026            0,
1027            "Wei exact",
1028            crate::enums::CurrencyType::Crypto,
1029        );
1030        Currency::register(currency, true).unwrap();
1031        let money = Money::from_raw(80_000_000_000_000_000_250_000_000_000_i128, currency);
1032
1033        let json = serde_json::to_string(&money).unwrap();
1034        let deserialized = serde_json::from_str::<Money>(&json).unwrap();
1035
1036        assert_eq!(json, "\"80000000000.000000250000000000 WXW\"");
1037        assert_eq!(deserialized, money);
1038    }
1039
1040    #[rstest]
1041    fn test_debug() {
1042        let money = Money::new(1010.12, Currency::USD());
1043        let result = format!("{money:?}");
1044        let expected = "Money(1010.12, USD)";
1045        assert_eq!(result, expected);
1046    }
1047
1048    #[rstest]
1049    #[case(dec!(9007199253.999999999), "Money(9007199253.999999999, TST9)")]
1050    #[case(dec!(-9007199253.999999999), "Money(-9007199253.999999999, TST9)")]
1051    fn test_debug_preserves_exact_amount(#[case] amount: Decimal, #[case] expected: &str) {
1052        use crate::enums::CurrencyType;
1053
1054        let currency = Currency::new("TST9", 9, 0, "Test 9dp", CurrencyType::Crypto);
1055        let money = Money::from_decimal(amount, currency).unwrap();
1056
1057        assert_eq!(format!("{money:?}"), expected);
1058    }
1059
1060    #[rstest]
1061    fn test_debug_preserves_domain_maximum() {
1062        use crate::enums::CurrencyType;
1063
1064        let currency = Currency::new(
1065            "TST",
1066            FIXED_PRECISION,
1067            0,
1068            "Test fixed precision",
1069            CurrencyType::Crypto,
1070        );
1071        let money = Money::from_raw(MONEY_RAW_MAX, currency);
1072        let expected = if cfg!(feature = "high-precision") {
1073            "Money(17014118346046.0000000000000000, TST)"
1074        } else {
1075            "Money(9223372036.000000000, TST)"
1076        };
1077
1078        assert_eq!(format!("{money:?}"), expected);
1079    }
1080
1081    #[rstest]
1082    fn test_display() {
1083        let money = Money::new(1010.12, Currency::USD());
1084        let result = format!("{money}");
1085        let expected = "1010.12 USD";
1086        assert_eq!(result, expected);
1087    }
1088
1089    #[rstest]
1090    #[case(42.0, 0, "JPY", "Money(42, JPY)", "42 JPY")]
1091    #[case(0.0, 2, "USD", "Money(0.00, USD)", "0.00 USD")]
1092    #[case(-1010.12, 2, "USD", "Money(-1010.12, USD)", "-1010.12 USD")]
1093    #[case(1010.12, 2, "USD", "Money(1010.12, USD)", "1010.12 USD")] // Normal precision
1094    #[case(123.456_789, 8, "BTC", "Money(123.45678900, BTC)", "123.45678900 BTC")] // At max normal precision
1095    fn test_formatting_normal_precision(
1096        #[case] value: f64,
1097        #[case] precision: u8,
1098        #[case] currency_code: &str,
1099        #[case] expected_debug: &str,
1100        #[case] expected_display: &str,
1101    ) {
1102        use crate::enums::CurrencyType;
1103        let currency = Currency::new(
1104            currency_code,
1105            precision,
1106            0,
1107            currency_code,
1108            CurrencyType::Fiat,
1109        );
1110        let money = Money::new(value, currency);
1111
1112        assert_eq!(format!("{money:?}"), expected_debug);
1113        assert_eq!(format!("{money}"), expected_display);
1114    }
1115
1116    #[rstest]
1117    #[cfg(feature = "defi")]
1118    #[case(
1119        1_000_000_000_000_000_000_i128,
1120        18,
1121        "wei",
1122        "Money(1.000000000000000000, wei)",
1123        "1.000000000000000000 wei"
1124    )] // High precision
1125    #[case(
1126        2_500_000_000_000_000_000_i128,
1127        18,
1128        "ETH",
1129        "Money(2.500000000000000000, ETH)",
1130        "2.500000000000000000 ETH"
1131    )] // High precision
1132    fn test_formatting_high_precision(
1133        #[case] raw_value: i128,
1134        #[case] precision: u8,
1135        #[case] currency_code: &str,
1136        #[case] expected_debug: &str,
1137        #[case] expected_display: &str,
1138    ) {
1139        use crate::enums::CurrencyType;
1140        let currency = Currency::new(
1141            currency_code,
1142            precision,
1143            0,
1144            currency_code,
1145            CurrencyType::Crypto,
1146        );
1147        let money = Money::from_raw(raw_value, currency);
1148
1149        assert_eq!(format!("{money:?}"), expected_debug);
1150        assert_eq!(format!("{money}"), expected_display);
1151        assert_eq!(
1152            serde_json::to_value(money).unwrap(),
1153            serde_json::Value::String(expected_display.to_string()),
1154        );
1155    }
1156
1157    #[rstest]
1158    fn test_zero_constructor() {
1159        let usd = Currency::USD();
1160        let money = Money::zero(usd);
1161        assert_eq!(money.raw, 0);
1162        assert_eq!(money.currency, usd);
1163    }
1164
1165    #[rstest]
1166    #[should_panic(expected = "Currency mismatch")]
1167    fn test_money_different_currency_addition() {
1168        let usd = Money::new(1000.0, Currency::USD());
1169        let btc = Money::new(1.0, Currency::BTC());
1170        let _ = usd + btc; // This should panic since currencies are different
1171    }
1172
1173    #[rstest] // Test does not panic rather than exact value
1174    fn test_with_maximum_value() {
1175        let money = Money::new_checked(MONEY_MAX, Currency::USD());
1176        assert!(money.is_ok());
1177    }
1178
1179    #[rstest] // Test does not panic rather than exact value
1180    fn test_with_minimum_value() {
1181        let money = Money::new_checked(MONEY_MIN, Currency::USD());
1182        assert!(money.is_ok());
1183    }
1184
1185    #[rstest]
1186    fn test_new_checked_returns_typed_error_with_stable_display() {
1187        let error = Money::new_checked(MONEY_MAX + 1.0, Currency::USD()).unwrap_err();
1188
1189        assert!(matches!(error, CorrectnessError::OutOfRange { .. }));
1190        assert_eq!(
1191            error.to_string(),
1192            format!(
1193                "invalid f64 for 'amount' not in range [{MONEY_MIN}, {MONEY_MAX}], was {}",
1194                MONEY_MAX + 1.0
1195            )
1196        );
1197    }
1198
1199    #[cfg(not(feature = "defi"))]
1200    #[rstest]
1201    fn test_new_checked_invalid_currency_precision_returns_error() {
1202        let mut currency = Currency::USD();
1203        currency.precision = FIXED_PRECISION + 1;
1204
1205        let error = Money::new_checked(1.0, currency).unwrap_err();
1206        assert!(
1207            error
1208                .to_string()
1209                .contains("`precision` exceeded maximum `FIXED_PRECISION`"),
1210            "unexpected message: {error}"
1211        );
1212    }
1213
1214    #[cfg(feature = "defi")]
1215    #[rstest]
1216    fn test_new_checked_rejects_float_for_wei_currency() {
1217        use crate::enums::CurrencyType;
1218
1219        let currency = Currency::new("TST18", 18, 0, "Test token", CurrencyType::Crypto);
1220        let error = Money::new_checked(1.0, currency).unwrap_err();
1221        let message = "`currency.precision` exceeded maximum float precision (16), use \
1222                       `Money::from_wei()` for wei values instead";
1223
1224        assert_eq!(
1225            error,
1226            CorrectnessError::PredicateViolation {
1227                message: message.to_string(),
1228            }
1229        );
1230        assert_eq!(error.to_string(), message);
1231    }
1232
1233    #[rstest]
1234    fn test_money_is_zero() {
1235        let zero_usd = Money::new(0.0, Currency::USD());
1236        assert!(zero_usd.is_zero());
1237        assert_eq!(zero_usd, Money::from("0.0 USD"));
1238
1239        let non_zero_usd = Money::new(100.0, Currency::USD());
1240        assert!(!non_zero_usd.is_zero());
1241    }
1242
1243    #[rstest]
1244    fn test_money_is_positive() {
1245        let usd = Currency::USD();
1246        assert!(Money::new(100.0, usd).is_positive());
1247        assert!(!Money::new(0.0, usd).is_positive());
1248        assert!(!Money::new(-100.0, usd).is_positive());
1249    }
1250
1251    #[rstest]
1252    fn test_money_comparisons() {
1253        let usd = Currency::USD();
1254        let m1 = Money::new(100.0, usd);
1255        let m2 = Money::new(200.0, usd);
1256
1257        assert!(m1 < m2);
1258        assert!(m2 > m1);
1259        assert!(m1 <= m2);
1260        assert!(m2 >= m1);
1261        assert_eq!(m1.cmp(&m2), Ordering::Less);
1262        assert_eq!(m2.cmp(&m1), Ordering::Greater);
1263
1264        // Equality
1265        let m3 = Money::new(100.0, usd);
1266        assert_eq!(m1, m3);
1267    }
1268
1269    #[rstest]
1270    fn test_add() {
1271        let a = 1000.0;
1272        let b = 500.0;
1273        let money1 = Money::new(a, Currency::USD());
1274        let money2 = Money::new(b, Currency::USD());
1275        let money3 = money1 + money2;
1276        assert_eq!(money3.raw, Money::new(a + b, Currency::USD()).raw);
1277    }
1278
1279    #[rstest]
1280    fn test_sub() {
1281        let usd = Currency::USD();
1282        let money1 = Money::new(1000.0, usd);
1283        let money2 = Money::new(250.0, usd);
1284        let result = money1 - money2;
1285        assert!(approx_eq!(f64, result.as_f64(), 750.0, epsilon = 1e-9));
1286        assert_eq!(result.currency, usd);
1287    }
1288
1289    #[rstest]
1290    fn test_money_checked_add_within_bounds() {
1291        let usd = Currency::USD();
1292        let a = Money::new(100.0, usd);
1293        let b = Money::new(50.0, usd);
1294        assert_eq!(a.checked_add(b), Some(Money::new(150.0, usd)));
1295    }
1296
1297    #[rstest]
1298    fn test_money_checked_add_above_max_returns_none() {
1299        let usd = Currency::USD();
1300        let near_max = Money::from_raw(MONEY_RAW_MAX, usd);
1301        let one = Money::new(1.0, usd);
1302        assert_eq!(near_max.checked_add(one), None);
1303    }
1304
1305    #[rstest]
1306    fn test_money_checked_sub_within_bounds() {
1307        let usd = Currency::USD();
1308        let a = Money::new(100.0, usd);
1309        let b = Money::new(40.0, usd);
1310        assert_eq!(a.checked_sub(b), Some(Money::new(60.0, usd)));
1311    }
1312
1313    #[rstest]
1314    fn test_money_checked_sub_below_min_returns_none() {
1315        let usd = Currency::USD();
1316        let near_min = Money::from_raw(MONEY_RAW_MIN, usd);
1317        let one = Money::new(1.0, usd);
1318        assert_eq!(near_min.checked_sub(one), None);
1319    }
1320
1321    #[rstest]
1322    fn test_money_checked_add_below_min_returns_none() {
1323        let usd = Currency::USD();
1324        let minus_one_unit = Money::from_raw(-1, usd);
1325
1326        assert_eq!(
1327            Money::from_raw(MONEY_RAW_MIN, usd).checked_add(minus_one_unit),
1328            None
1329        );
1330        assert_eq!(
1331            Money::from_raw(MONEY_RAW_MIN + 1, usd).checked_add(minus_one_unit),
1332            Some(Money::from_raw(MONEY_RAW_MIN, usd))
1333        );
1334    }
1335
1336    #[rstest]
1337    fn test_money_checked_sub_above_max_returns_none() {
1338        let usd = Currency::USD();
1339        let minus_one_unit = Money::from_raw(-1, usd);
1340
1341        assert_eq!(
1342            Money::from_raw(MONEY_RAW_MAX, usd).checked_sub(minus_one_unit),
1343            None
1344        );
1345        assert_eq!(
1346            Money::from_raw(MONEY_RAW_MAX - 1, usd).checked_sub(minus_one_unit),
1347            Some(Money::from_raw(MONEY_RAW_MAX, usd))
1348        );
1349    }
1350
1351    #[rstest]
1352    #[should_panic(expected = "Currency mismatch")]
1353    fn test_money_checked_add_currency_mismatch_panics() {
1354        let usd = Money::new(100.0, Currency::USD());
1355        let aud = Money::new(50.0, Currency::AUD());
1356        let _ = usd.checked_add(aud);
1357    }
1358
1359    #[rstest]
1360    #[should_panic(expected = "Currency mismatch")]
1361    fn test_money_checked_sub_currency_mismatch_panics() {
1362        let usd = Money::new(100.0, Currency::USD());
1363        let aud = Money::new(50.0, Currency::AUD());
1364        let _ = usd.checked_sub(aud);
1365    }
1366
1367    #[rstest]
1368    fn test_money_checked_add_at_exact_max_returns_some() {
1369        let usd = Currency::USD();
1370        let near_max = Money::from_raw(MONEY_RAW_MAX - 1, usd);
1371        let one_unit = Money::from_raw(1, usd);
1372        assert_eq!(
1373            near_max.checked_add(one_unit),
1374            Some(Money::from_raw(MONEY_RAW_MAX, usd)),
1375        );
1376    }
1377
1378    #[rstest]
1379    fn test_money_checked_sub_at_exact_min_returns_some() {
1380        let usd = Currency::USD();
1381        let near_min = Money::from_raw(MONEY_RAW_MIN + 1, usd);
1382        let one_unit = Money::from_raw(1, usd);
1383        assert_eq!(
1384            near_min.checked_sub(one_unit),
1385            Some(Money::from_raw(MONEY_RAW_MIN, usd)),
1386        );
1387    }
1388
1389    #[rstest]
1390    fn test_money_negation() {
1391        let money = Money::new(100.0, Currency::USD());
1392        let result = -money;
1393        assert_eq!(result, Money::from("-100.0 USD"));
1394        assert_eq!(result.currency, Currency::USD().clone());
1395    }
1396
1397    #[rstest]
1398    fn test_money_addition_decimal() {
1399        let money = Money::new(100.0, Currency::USD());
1400        let result = money + dec!(50.25);
1401        assert_eq!(result, dec!(150.25));
1402    }
1403
1404    #[rstest]
1405    fn test_money_subtraction_decimal() {
1406        let money = Money::new(100.0, Currency::USD());
1407        let result = money - dec!(30.50);
1408        assert_eq!(result, dec!(69.50));
1409    }
1410
1411    #[rstest]
1412    fn test_money_multiplication_decimal() {
1413        let money = Money::new(100.0, Currency::USD());
1414        let result = money * dec!(1.5);
1415        assert_eq!(result, dec!(150.00));
1416    }
1417
1418    #[rstest]
1419    fn test_money_division_decimal() {
1420        let money = Money::new(100.0, Currency::USD());
1421        let result = money / dec!(4);
1422        assert_eq!(result, dec!(25.00));
1423    }
1424
1425    #[rstest]
1426    fn test_money_addition_f64() {
1427        let money = Money::new(100.0, Currency::USD());
1428        let result = money + 50.25;
1429        assert!(approx_eq!(f64, result, 150.25, epsilon = 1e-9));
1430    }
1431
1432    #[rstest]
1433    fn test_money_subtraction_f64() {
1434        let money = Money::new(100.0, Currency::USD());
1435        let result = money - 30.50;
1436        assert!(approx_eq!(f64, result, 69.50, epsilon = 1e-9));
1437    }
1438
1439    #[rstest]
1440    fn test_money_multiplication_f64() {
1441        let money = Money::new(100.0, Currency::USD());
1442        let result = money * 1.5;
1443        assert!(approx_eq!(f64, result, 150.0, epsilon = 1e-9));
1444    }
1445
1446    #[rstest]
1447    fn test_money_division_f64() {
1448        let money = Money::new(100.0, Currency::USD());
1449        let result = money / 4.0;
1450        assert!(approx_eq!(f64, result, 25.0, epsilon = 1e-9));
1451    }
1452
1453    #[rstest]
1454    fn test_money_new_usd() {
1455        let money = Money::new(1000.0, Currency::USD());
1456        assert_eq!(money.currency.code.as_str(), "USD");
1457        assert_eq!(money.currency.precision, 2);
1458        assert_eq!(money.to_string(), "1000.00 USD");
1459        assert_eq!(money.to_formatted_string(), "1_000.00 USD");
1460        assert_eq!(money.as_decimal(), dec!(1000.00));
1461        assert!(approx_eq!(f64, money.as_f64(), 1000.0, epsilon = 0.001));
1462    }
1463
1464    #[rstest]
1465    fn test_money_new_btc() {
1466        let money = Money::new(10.3, Currency::BTC());
1467        assert_eq!(money.currency.code.as_str(), "BTC");
1468        assert_eq!(money.currency.precision, 8);
1469        assert_eq!(money.to_string(), "10.30000000 BTC");
1470        assert_eq!(money.to_formatted_string(), "10.30000000 BTC");
1471    }
1472
1473    #[rstest]
1474    fn test_to_formatted_string_preserves_digits_beyond_f64_precision() {
1475        use crate::enums::CurrencyType;
1476
1477        // 19 significant digits exceed f64's ~16-digit resolution; the previous
1478        // f64-based formatting printed the nearest float instead.
1479        let currency = Currency::new("TST9", 9, 0, "Test 9dp", CurrencyType::Crypto);
1480        let money = Money::from_decimal(dec!(1234567890.123456789), currency).unwrap();
1481
1482        assert_eq!(money.to_formatted_string(), "1_234_567_890.123456789 TST9");
1483    }
1484
1485    #[rstest]
1486    #[case("0USD")] // <-- No whitespace separator
1487    #[case("0x00 USD")] // <-- Invalid float
1488    #[case("0 US")] // <-- Invalid currency
1489    #[case("0 USD USD")] // <-- Too many parts
1490    #[should_panic(expected = "Condition failed")]
1491    fn test_from_str_invalid_input(#[case] input: &str) {
1492        let _ = Money::from(input);
1493    }
1494
1495    #[rstest]
1496    #[case("0 USD", Currency::USD(), dec!(0.00))]
1497    #[case("1.1 AUD", Currency::AUD(), dec!(1.10))]
1498    #[case("1.12345678 BTC", Currency::BTC(), dec!(1.12345678))]
1499    #[case("10_000.10 USD", Currency::USD(), dec!(10000.10))]
1500    fn test_from_str_valid_input(
1501        #[case] input: &str,
1502        #[case] expected_currency: Currency,
1503        #[case] expected_dec: Decimal,
1504    ) {
1505        let money = Money::from(input);
1506        assert_eq!(money.currency, expected_currency);
1507        assert_eq!(money.as_decimal(), expected_dec);
1508    }
1509
1510    #[rstest]
1511    #[case("1.005 USD", dec!(1.00))]
1512    #[case("1.015 USD", dec!(1.02))]
1513    #[case("-1.005 USD", dec!(-1.00))]
1514    #[case("-1.015 USD", dec!(-1.02))]
1515    fn test_from_str_rounds_half_to_even(#[case] input: &str, #[case] expected: Decimal) {
1516        let money = input.parse::<Money>().unwrap();
1517
1518        assert_eq!(
1519            money,
1520            Money::from_decimal(expected, Currency::USD()).unwrap()
1521        );
1522        assert_eq!(money.currency, Currency::USD());
1523        assert_eq!(money.as_decimal(), expected);
1524    }
1525
1526    #[rstest]
1527    fn test_money_from_str_negative() {
1528        let money = Money::from("-123.45 USD");
1529        assert!(approx_eq!(f64, money.as_f64(), -123.45, epsilon = 1e-9));
1530        assert_eq!(money.currency, Currency::USD());
1531    }
1532
1533    #[rstest]
1534    #[case("1e7 USD", 10_000_000.0)]
1535    #[case("2.5e3 EUR", 2_500.0)]
1536    #[case("1.234e-2 GBP", 0.01)] // GBP has 2 decimal places, so 0.01234 becomes 0.01
1537    #[case("5E-3 JPY", 0.0)] // JPY has 0 decimal places, so 0.005 becomes 0
1538    fn test_from_str_scientific_notation(#[case] input: &str, #[case] expected_value: f64) {
1539        let money = Money::from_str(input).unwrap();
1540        assert!(approx_eq!(
1541            f64,
1542            money.as_f64(),
1543            expected_value,
1544            epsilon = 1e-10
1545        ));
1546    }
1547
1548    #[rstest]
1549    #[case("1_234.56 USD", 1234.56)]
1550    #[case("1_000_000 EUR", 1_000_000.0)]
1551    #[case("99_999.99 GBP", 99_999.99)]
1552    fn test_from_str_with_underscores(#[case] input: &str, #[case] expected_value: f64) {
1553        let money = Money::from_str(input).unwrap();
1554        assert!(approx_eq!(
1555            f64,
1556            money.as_f64(),
1557            expected_value,
1558            epsilon = 1e-10
1559        ));
1560    }
1561
1562    #[rstest]
1563    fn test_from_decimal_precision_preservation() {
1564        use rust_decimal::Decimal;
1565
1566        let decimal = Decimal::from_str("123.45").unwrap();
1567        let money = Money::from_decimal(decimal, Currency::USD()).unwrap();
1568        assert_eq!(money.currency.precision, 2);
1569        assert!(approx_eq!(f64, money.as_f64(), 123.45, epsilon = 1e-10));
1570
1571        // Verify raw value is exact for USD (2 decimal places)
1572        let expected_raw = 12345 * 10_i64.pow(u32::from(FIXED_PRECISION - 2));
1573        assert_eq!(money.raw, MoneyRaw::from(expected_raw));
1574    }
1575
1576    #[rstest]
1577    fn test_from_decimal_rounding() {
1578        use rust_decimal::Decimal;
1579
1580        // Test banker's rounding with USD (2 decimal places)
1581        let decimal = Decimal::from_str("1.005").unwrap();
1582        let money = Money::from_decimal(decimal, Currency::USD()).unwrap();
1583        assert_eq!(money.as_f64(), 1.0); // 1.005 rounds to 1.00 (even)
1584
1585        let decimal = Decimal::from_str("1.015").unwrap();
1586        let money = Money::from_decimal(decimal, Currency::USD()).unwrap();
1587        assert_eq!(money.as_f64(), 1.02); // 1.015 rounds to 1.02 (even)
1588    }
1589
1590    #[rstest]
1591    fn test_money_hash() {
1592        use std::{
1593            collections::hash_map::DefaultHasher,
1594            hash::{Hash, Hasher},
1595        };
1596
1597        let m1 = Money::new(100.0, Currency::USD());
1598        let m2 = Money::new(100.0, Currency::USD());
1599        let m3 = Money::new(100.0, Currency::AUD());
1600
1601        let mut s1 = DefaultHasher::new();
1602        let mut s2 = DefaultHasher::new();
1603        let mut s3 = DefaultHasher::new();
1604
1605        m1.hash(&mut s1);
1606        m2.hash(&mut s2);
1607        m3.hash(&mut s3);
1608
1609        assert_eq!(
1610            s1.finish(),
1611            s2.finish(),
1612            "Same amount + same currency => same hash"
1613        );
1614        assert_ne!(
1615            s1.finish(),
1616            s3.finish(),
1617            "Same amount + different currency => different hash"
1618        );
1619    }
1620
1621    #[rstest]
1622    fn test_money_serialization_deserialization() {
1623        let money = Money::new(123.45, Currency::USD());
1624        let serialized = serde_json::to_string(&money);
1625        let deserialized: Money = serde_json::from_str(&serialized.unwrap()).unwrap();
1626        assert_eq!(money, deserialized);
1627    }
1628
1629    #[rstest]
1630    fn test_money_deserialize_from_owned_value() {
1631        let money = Money::new(123.45, Currency::USD());
1632        let value = serde_json::to_value(money).unwrap();
1633
1634        let deserialized: Money = serde_json::from_value(value).unwrap();
1635        assert_eq!(money, deserialized);
1636    }
1637
1638    #[rstest]
1639    fn test_money_deserialize_invalid_format_returns_error() {
1640        let result = serde_json::from_str::<Money>("\"100.00\"");
1641        let error = result.unwrap_err();
1642        assert!(
1643            error.to_string().contains("Expected '<amount> <currency>'"),
1644            "unexpected message: {error}"
1645        );
1646    }
1647
1648    #[rstest]
1649    fn test_money_deserialize_unknown_currency_returns_error() {
1650        let result = serde_json::from_str::<Money>("\"100.00 ZZZZ\"");
1651        let error = result.unwrap_err();
1652        assert!(
1653            error.to_string().contains("Unknown currency"),
1654            "unexpected message: {error}"
1655        );
1656    }
1657
1658    #[rstest]
1659    #[should_panic(expected = "`raw` value")]
1660    fn test_money_from_raw_out_of_range_panics() {
1661        let usd = Currency::USD();
1662        let raw = MONEY_RAW_MAX.saturating_add(1);
1663        let _ = Money::from_raw(raw, usd);
1664    }
1665
1666    #[rstest]
1667    fn test_money_from_raw_checked_valid() {
1668        let usd = Currency::USD();
1669        let money = Money::from_raw_checked(123_450_000_000, usd).unwrap();
1670        assert_eq!(money.currency, usd);
1671    }
1672
1673    #[rstest]
1674    fn test_money_from_raw_checked_above_max_returns_error() {
1675        let usd = Currency::USD();
1676        let raw = MONEY_RAW_MAX.saturating_add(1);
1677        let error = Money::from_raw_checked(raw, usd).unwrap_err();
1678        assert!(matches!(error, CorrectnessError::PredicateViolation { .. }));
1679    }
1680
1681    #[rstest]
1682    fn test_money_from_raw_checked_below_min_returns_error() {
1683        let usd = Currency::USD();
1684        let raw = MONEY_RAW_MIN.saturating_sub(1);
1685        let error = Money::from_raw_checked(raw, usd).unwrap_err();
1686        assert!(matches!(error, CorrectnessError::PredicateViolation { .. }));
1687    }
1688
1689    #[rstest]
1690    fn test_from_decimal_rejects_out_of_range() {
1691        let huge = Decimal::from_str("99999999999999999999.99").unwrap();
1692        let result = Money::from_decimal(huge, Currency::USD());
1693        assert!(result.is_err());
1694    }
1695
1696    #[rstest]
1697    fn test_from_decimal_rejects_raw_between_money_and_raw_bounds() {
1698        let usd = Currency::USD();
1699        let at_max = Decimal::try_from(MONEY_MAX).unwrap();
1700        let above_max = at_max + dec!(0.5);
1701        let expected_raw = MONEY_RAW_MAX + 5 * MoneyRaw::pow(10, u32::from(FIXED_PRECISION - 1));
1702
1703        let error = Money::from_decimal(above_max, usd).unwrap_err();
1704
1705        assert_eq!(
1706            error.to_string(),
1707            format!(
1708                "Raw value {expected_raw} exceeded bounds [{MONEY_RAW_MIN}, {MONEY_RAW_MAX}] for Money"
1709            )
1710        );
1711        assert_eq!(Money::from_decimal(at_max, usd).unwrap().raw, MONEY_RAW_MAX);
1712    }
1713
1714    #[rstest]
1715    fn test_from_decimal_out_of_range_returns_typed_error_with_stable_display() {
1716        let huge = Decimal::from_str("99999999999999999999.99").unwrap();
1717        let error = Money::from_decimal(huge, Currency::USD()).unwrap_err();
1718        match error {
1719            CorrectnessError::PredicateViolation { ref message } => {
1720                assert!(
1721                    message.contains("MoneyRaw range") || message.contains("Money"),
1722                    "unexpected message: {message:?}",
1723                );
1724            }
1725            _ => panic!("expected PredicateViolation, was {error:?}"),
1726        }
1727    }
1728
1729    #[rstest]
1730    fn test_from_mantissa_exponent_exact_precision() {
1731        let money = Money::from_mantissa_exponent(12345, -2, Currency::USD());
1732        assert_eq!(money.as_f64(), 123.45);
1733    }
1734
1735    #[rstest]
1736    fn test_from_mantissa_exponent_excess_rounds_down() {
1737        // 12.345 rounds to 12.34 (4 is even, banker's rounding)
1738        let money = Money::from_mantissa_exponent(12345, -3, Currency::USD());
1739        assert_eq!(money.as_f64(), 12.34);
1740    }
1741
1742    #[rstest]
1743    fn test_from_mantissa_exponent_excess_rounds_up() {
1744        // 12.355 rounds to 12.36 (5 is odd, banker's rounding)
1745        let money = Money::from_mantissa_exponent(12355, -3, Currency::USD());
1746        assert_eq!(money.as_f64(), 12.36);
1747    }
1748
1749    #[rstest]
1750    fn test_from_mantissa_exponent_positive_exponent() {
1751        let money = Money::from_mantissa_exponent(5, 2, Currency::USD());
1752        assert_eq!(money.as_f64(), 500.0);
1753    }
1754
1755    #[rstest]
1756    #[should_panic(expected = "Money::from_mantissa_exponent")]
1757    fn test_from_mantissa_exponent_overflow_panics() {
1758        let _ = Money::from_mantissa_exponent(i64::MAX, 9, Currency::USD());
1759    }
1760
1761    #[rstest]
1762    #[should_panic(expected = "exceeds i128 range")]
1763    fn test_from_mantissa_exponent_large_exponent_panics() {
1764        let _ = Money::from_mantissa_exponent(1, 119, Currency::USD());
1765    }
1766
1767    #[rstest]
1768    fn test_from_mantissa_exponent_zero_with_large_exponent() {
1769        let money = Money::from_mantissa_exponent(0, 119, Currency::USD());
1770        assert_eq!(money.as_f64(), 0.0);
1771    }
1772
1773    #[rstest]
1774    fn test_from_mantissa_exponent_very_negative_exponent_rounds_to_zero() {
1775        // exponent=-120, frac_digits=120, excess=118 for USD (precision 2)
1776        let money = Money::from_mantissa_exponent(12345, -120, Currency::USD());
1777        assert_eq!(money.as_f64(), 0.0);
1778    }
1779
1780    #[rstest]
1781    #[case(42.0, true, "positive value")]
1782    #[case(0.0, false, "zero value")]
1783    #[case( -13.5,  false, "negative value")]
1784    #[expect(
1785        clippy::used_underscore_binding,
1786        reason = "rstest case name documents the parameterized input"
1787    )]
1788    fn test_check_positive_money(
1789        #[case] amount: f64,
1790        #[case] should_succeed: bool,
1791        #[case] _case_name: &str,
1792    ) {
1793        let money = Money::new(amount, Currency::USD());
1794
1795        let res = check_positive_money(money, "money");
1796
1797        if should_succeed {
1798            assert!(res.is_ok(), "expected Ok(..) for {amount}");
1799        } else {
1800            assert!(res.is_err(), "expected Err(..) for {amount}");
1801            let msg = res.unwrap_err().to_string();
1802            assert!(
1803                msg.contains("not positive"),
1804                "error message should mention positivity; got: {msg:?}"
1805            );
1806        }
1807    }
1808
1809    #[rstest]
1810    fn test_check_positive_money_returns_typed_error_with_stable_display() {
1811        let error = check_positive_money(Money::new(0.0, Currency::USD()), "money").unwrap_err();
1812
1813        assert_eq!(
1814            error,
1815            CorrectnessError::NotPositive {
1816                param: "money".to_string(),
1817                value: "0.00 USD".to_string(),
1818                type_name: "`Money`",
1819            }
1820        );
1821        assert_eq!(
1822            error.to_string(),
1823            "invalid `Money` for 'money' not positive, was 0.00 USD"
1824        );
1825    }
1826}
1827
1828#[cfg(test)]
1829mod property_tests {
1830    use proptest::prelude::*;
1831    use rstest::rstest;
1832
1833    use super::*;
1834
1835    fn currency_strategy() -> impl Strategy<Value = Currency> {
1836        prop_oneof![
1837            Just(Currency::USD()),
1838            Just(Currency::EUR()),
1839            Just(Currency::GBP()),
1840            Just(Currency::JPY()),
1841            Just(Currency::AUD()),
1842            Just(Currency::CAD()),
1843            Just(Currency::CHF()),
1844            Just(Currency::BTC()),
1845            Just(Currency::ETH()),
1846            Just(Currency::USDT()),
1847        ]
1848    }
1849
1850    fn money_amount_strategy() -> impl Strategy<Value = f64> {
1851        prop_oneof![
1852            -1000.0..1000.0,
1853            -100_000.0..100_000.0,
1854            -1_000_000.0..1_000_000.0,
1855            Just(0.0),
1856            Just(MONEY_MIN / 2.0),
1857            Just(MONEY_MAX / 2.0),
1858            Just(MONEY_MIN + 1.0),
1859            Just(MONEY_MAX - 1.0),
1860            Just(MONEY_MIN),
1861            Just(MONEY_MAX),
1862        ]
1863    }
1864
1865    fn money_strategy() -> impl Strategy<Value = Money> {
1866        (money_amount_strategy(), currency_strategy())
1867            .prop_filter_map("constructible money", |(amount, currency)| {
1868                Money::new_checked(amount, currency).ok()
1869            })
1870    }
1871
1872    proptest! {
1873        #[rstest]
1874        fn prop_money_construction_roundtrip(
1875            amount in money_amount_strategy(),
1876            currency in currency_strategy()
1877        ) {
1878            if let Ok(money) = Money::new_checked(amount, currency) {
1879                let roundtrip = money.as_f64();
1880                let precision_epsilon = if currency.precision == 0 {
1881                    1.0
1882                } else {
1883                    let currency_epsilon = 10.0_f64.powi(-i32::from(currency.precision));
1884                    let magnitude_epsilon = amount.abs() * 1e-10;
1885                    currency_epsilon.max(magnitude_epsilon)
1886                };
1887                prop_assert!((roundtrip - amount).abs() <= precision_epsilon,
1888                    "Roundtrip failed: {} -> {} -> {} (precision: {}, epsilon: {})",
1889                    amount, money.raw, roundtrip, currency.precision, precision_epsilon);
1890                prop_assert_eq!(money.currency, currency);
1891            }
1892        }
1893
1894        #[rstest]
1895        fn prop_money_addition_commutative(
1896            money1 in money_strategy(),
1897            money2 in money_strategy(),
1898        ) {
1899            if money1.currency == money2.currency
1900                && let (Some(_), Some(_)) = (
1901                    money1.raw.checked_add(money2.raw),
1902                    money2.raw.checked_add(money1.raw)
1903                )
1904            {
1905                let sum1 = money1 + money2;
1906                let sum2 = money2 + money1;
1907                prop_assert_eq!(sum1, sum2, "Addition should be commutative");
1908                prop_assert_eq!(sum1.currency, money1.currency);
1909            }
1910        }
1911
1912        #[rstest]
1913        fn prop_money_addition_associative(
1914            money1 in money_strategy(),
1915            money2 in money_strategy(),
1916            money3 in money_strategy(),
1917        ) {
1918            if money1.currency == money2.currency
1919                && money2.currency == money3.currency
1920                && let (Some(sum1), Some(sum2)) = (
1921                    money1.raw.checked_add(money2.raw),
1922                    money2.raw.checked_add(money3.raw)
1923                )
1924                && let (Some(left), Some(right)) = (
1925                    sum1.checked_add(money3.raw),
1926                    money1.raw.checked_add(sum2)
1927                )
1928                && (MONEY_RAW_MIN..=MONEY_RAW_MAX).contains(&left)
1929                && (MONEY_RAW_MIN..=MONEY_RAW_MAX).contains(&right)
1930            {
1931                let left_result = Money::from_raw(left, money1.currency);
1932                let right_result = Money::from_raw(right, money1.currency);
1933                prop_assert_eq!(left_result, right_result, "Addition should be associative");
1934            }
1935        }
1936
1937        #[rstest]
1938        fn prop_money_subtraction_inverse(
1939            money1 in money_strategy(),
1940            money2 in money_strategy(),
1941        ) {
1942            if money1.currency == money2.currency
1943                && let Some(sum_raw) = money1.raw.checked_add(money2.raw)
1944                && (MONEY_RAW_MIN..=MONEY_RAW_MAX).contains(&sum_raw)
1945            {
1946                let sum = Money::from_raw(sum_raw, money1.currency);
1947                let diff = sum - money2;
1948                prop_assert_eq!(diff, money1, "Subtraction should be inverse of addition");
1949            }
1950        }
1951
1952        /// Property: checked_add agrees with raw checked_add when result is in bounds and
1953        /// currencies match; returns None when out of bounds.
1954        #[rstest]
1955        fn prop_money_checked_add_matches_spec(
1956            raw1 in MONEY_RAW_MIN..=MONEY_RAW_MAX,
1957            raw2 in MONEY_RAW_MIN..=MONEY_RAW_MAX,
1958            currency in currency_strategy(),
1959        ) {
1960            let m1 = Money::from_raw(raw1, currency);
1961            let m2 = Money::from_raw(raw2, currency);
1962            let expected = m1.raw
1963                .checked_add(m2.raw)
1964                .filter(|r| (MONEY_RAW_MIN..=MONEY_RAW_MAX).contains(r))
1965                .map(|raw| Money { raw, currency });
1966            prop_assert_eq!(m1.checked_add(m2), expected);
1967        }
1968
1969        /// Property: checked_sub agrees with raw checked_sub when result is in bounds and
1970        /// currencies match; returns None when out of bounds.
1971        #[rstest]
1972        fn prop_money_checked_sub_matches_spec(
1973            raw1 in MONEY_RAW_MIN..=MONEY_RAW_MAX,
1974            raw2 in MONEY_RAW_MIN..=MONEY_RAW_MAX,
1975            currency in currency_strategy(),
1976        ) {
1977            let m1 = Money::from_raw(raw1, currency);
1978            let m2 = Money::from_raw(raw2, currency);
1979            let expected = m1.raw
1980                .checked_sub(m2.raw)
1981                .filter(|r| (MONEY_RAW_MIN..=MONEY_RAW_MAX).contains(r))
1982                .map(|raw| Money { raw, currency });
1983            prop_assert_eq!(m1.checked_sub(m2), expected);
1984        }
1985
1986        #[rstest]
1987        fn prop_money_zero_identity(money in money_strategy()) {
1988            let zero = Money::zero(money.currency);
1989            prop_assert_eq!(money + zero, money, "Zero should be additive identity");
1990            prop_assert_eq!(zero + money, money, "Zero should be additive identity (commutative)");
1991            prop_assert!(zero.is_zero(), "Zero should be recognized as zero");
1992        }
1993
1994        #[rstest]
1995        fn prop_money_negation_inverse(money in money_strategy()) {
1996            let negated = -money;
1997            let double_neg = -negated;
1998            prop_assert_eq!(money, double_neg, "Double negation should equal original");
1999            prop_assert_eq!(negated.currency, money.currency, "Negation preserves currency");
2000
2001            if let Some(sum_raw) = money.raw.checked_add(negated.raw)
2002                && (MONEY_RAW_MIN..=MONEY_RAW_MAX).contains(&sum_raw) {
2003                    let sum = Money::from_raw(sum_raw, money.currency);
2004                    prop_assert!(sum.is_zero(), "Money + (-Money) should equal zero");
2005                }
2006        }
2007
2008        #[rstest]
2009        fn prop_money_comparison_consistency(
2010            money1 in money_strategy(),
2011            money2 in money_strategy(),
2012        ) {
2013            let eq = money1 == money2;
2014            let lt = money1 < money2;
2015            let gt = money1 > money2;
2016            let le = money1 <= money2;
2017            let ge = money1 >= money2;
2018
2019            let exclusive_count = [eq, lt, gt].iter().filter(|&&x| x).count();
2020            prop_assert_eq!(exclusive_count, 1, "Exactly one of ==, <, > should be true");
2021
2022            prop_assert_eq!(le, eq || lt, "<= should equal == || <");
2023            prop_assert_eq!(ge, eq || gt, ">= should equal == || >");
2024            prop_assert_eq!(lt, money2 > money1, "< should be symmetric with >");
2025            prop_assert_eq!(le, money2 >= money1, "<= should be symmetric with >=");
2026        }
2027
2028        #[rstest]
2029        fn prop_money_decimal_conversion(money in money_strategy()) {
2030            let decimal = money.as_decimal();
2031
2032            // Scale must always match currency precision
2033            prop_assert_eq!(decimal.scale(), u32::from(money.currency.precision));
2034
2035            #[cfg(feature = "defi")]
2036            {
2037                let decimal_f64: f64 = decimal.try_into().unwrap_or(0.0);
2038                prop_assert!(decimal_f64.is_finite(), "Decimal should convert to finite f64");
2039            }
2040            #[cfg(not(feature = "defi"))]
2041            {
2042                let decimal_f64: f64 = decimal.try_into().unwrap_or(0.0);
2043                let original_f64 = money.as_f64();
2044
2045                let base_epsilon = 10.0_f64.powi(-i32::from(money.currency.precision));
2046                let precision_epsilon = if cfg!(feature = "high-precision") {
2047                    base_epsilon.max(1e-10)
2048                } else {
2049                    base_epsilon
2050                };
2051                let diff = (decimal_f64 - original_f64).abs();
2052                prop_assert!(diff <= precision_epsilon,
2053                    "Decimal conversion should preserve value within currency precision: {} vs {} (diff: {}, epsilon: {})",
2054                    original_f64, decimal_f64, diff, precision_epsilon);
2055            }
2056        }
2057
2058        #[rstest]
2059        fn prop_money_arithmetic_with_f64(
2060            money in money_strategy(),
2061            factor in -1000.0..1000.0_f64,
2062        ) {
2063            if factor != 0.0 {
2064                let original_f64 = money.as_f64();
2065
2066                let mul_result = money * factor;
2067                let expected_mul = original_f64 * factor;
2068                prop_assert!((mul_result - expected_mul).abs() < 0.01,
2069                    "Multiplication with f64 should be accurate");
2070
2071                let div_result = money / factor;
2072                let expected_div = original_f64 / factor;
2073                if expected_div.is_finite() {
2074                    prop_assert!((div_result - expected_div).abs() < 0.01,
2075                        "Division with f64 should be accurate");
2076                }
2077
2078                let add_result = money + factor;
2079                let expected_add = original_f64 + factor;
2080                prop_assert!((add_result - expected_add).abs() < 0.01,
2081                    "Addition with f64 should be accurate");
2082
2083                let sub_result = money - factor;
2084                let expected_sub = original_f64 - factor;
2085                prop_assert!((sub_result - expected_sub).abs() < 0.01,
2086                    "Subtraction with f64 should be accurate");
2087            }
2088        }
2089    }
2090}