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