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