Skip to main content

nautilus_model/python/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
16use std::{
17    collections::hash_map::DefaultHasher,
18    hash::{Hash, Hasher},
19    str::FromStr,
20};
21
22use nautilus_core::python::{
23    correctness_error_to_pyvalue_err, get_pytype_name, to_pytype_err, to_pyvalue_err,
24};
25use pyo3::{IntoPyObjectExt, basic::CompareOp, prelude::*, types::PyFloat};
26use rust_decimal::{Decimal, RoundingStrategy};
27
28use super::fixed::{
29    ArithmeticError, ArithmeticOperation, FloatArithmetic, check_raw_scales,
30    extract_arithmetic_decimal,
31};
32use crate::types::{Currency, Money, fixed::raw_scale, money::MoneyRaw};
33
34#[pymethods]
35#[pyo3_stub_gen::derive::gen_stub_pymethods]
36impl Money {
37    /// Represents an amount of money in a specified currency denomination.
38    ///
39    /// The currency precision supplies default rounding and minimum display precision.
40    /// Exact constructors and raw construction retain finer native fixed-point amounts.
41    ///
42    /// - `MONEY_MAX` - Maximum representable money amount
43    /// - `MONEY_MIN` - Minimum representable money amount
44    #[new]
45    fn py_new(amount: f64, currency: Currency) -> PyResult<Self> {
46        Self::new_checked(amount, currency).map_err(to_pyvalue_err)
47    }
48
49    fn __reduce__(&self, py: Python) -> PyResult<Py<PyAny>> {
50        let from_raw = py.get_type::<Self>().getattr("from_raw")?;
51        let args = (self.raw(), self.currency).into_py_any(py)?;
52        (from_raw, args).into_py_any(py)
53    }
54
55    fn __richcmp__(
56        &self,
57        other: &Bound<'_, PyAny>,
58        op: CompareOp,
59        py: Python<'_>,
60    ) -> PyResult<Py<PyAny>> {
61        if let Ok(other_money) = other.extract::<Self>() {
62            if self.currency != other_money.currency {
63                return Err(to_pyvalue_err(format!(
64                    "Cannot compare Money with different currencies: {} vs {}",
65                    self.currency.code, other_money.currency.code
66                )));
67            }
68            let result = match op {
69                CompareOp::Eq => self.eq(&other_money),
70                CompareOp::Ne => self.ne(&other_money),
71                CompareOp::Ge => self.ge(&other_money),
72                CompareOp::Gt => self.gt(&other_money),
73                CompareOp::Le => self.le(&other_money),
74                CompareOp::Lt => self.lt(&other_money),
75            };
76            result.into_py_any(py)
77        } else {
78            Ok(py.NotImplemented())
79        }
80    }
81
82    fn __hash__(&self) -> isize {
83        let mut h = DefaultHasher::new();
84        self.hash(&mut h);
85        h.finish() as isize
86    }
87
88    fn __add__(&self, other: &Bound<'_, PyAny>, py: Python) -> PyResult<Py<PyAny>> {
89        if other.is_instance_of::<PyFloat>() {
90            let other_float: f64 = other.extract::<f64>()?;
91            ArithmeticOperation::Add
92                .checked_f64(self.as_f64_checked()?, other_float)?
93                .into_py_any(py)
94        } else if let Ok(other_money) = other.extract::<Self>() {
95            check_money_currency(self, &other_money)?;
96            check_raw_scales(self.currency.precision, other_money.currency.precision)?;
97            (*self)
98                .checked_add(other_money)
99                .ok_or(ArithmeticError::Overflow)?
100                .into_py_any(py)
101        } else if let Some(other_dec) = extract_arithmetic_decimal(other) {
102            ArithmeticOperation::Add
103                .checked_decimal(self.as_decimal(), other_dec)?
104                .into_py_any(py)
105        } else {
106            let pytype_name = get_pytype_name(other)?;
107            Err(to_pytype_err(format!(
108                "Unsupported type for __add__, was `{pytype_name}`"
109            )))
110        }
111    }
112
113    fn __radd__(&self, other: &Bound<'_, PyAny>, py: Python) -> PyResult<Py<PyAny>> {
114        if other.is_instance_of::<PyFloat>() {
115            let other_float: f64 = other.extract()?;
116            ArithmeticOperation::Add
117                .checked_f64(other_float, self.as_f64_checked()?)?
118                .into_py_any(py)
119        } else if let Ok(other_money) = other.extract::<Self>() {
120            check_money_currency(&other_money, self)?;
121            check_raw_scales(other_money.currency.precision, self.currency.precision)?;
122            other_money
123                .checked_add(*self)
124                .ok_or(ArithmeticError::Overflow)?
125                .into_py_any(py)
126        } else if let Some(other_dec) = extract_arithmetic_decimal(other) {
127            ArithmeticOperation::Add
128                .checked_decimal(other_dec, self.as_decimal())?
129                .into_py_any(py)
130        } else {
131            let pytype_name = get_pytype_name(other)?;
132            Err(to_pytype_err(format!(
133                "Unsupported type for __radd__, was `{pytype_name}`"
134            )))
135        }
136    }
137
138    fn __sub__(&self, other: &Bound<'_, PyAny>, py: Python) -> PyResult<Py<PyAny>> {
139        if other.is_instance_of::<PyFloat>() {
140            let other_float: f64 = other.extract()?;
141            ArithmeticOperation::Sub
142                .checked_f64(self.as_f64_checked()?, other_float)?
143                .into_py_any(py)
144        } else if let Ok(other_money) = other.extract::<Self>() {
145            check_money_currency(self, &other_money)?;
146            check_raw_scales(self.currency.precision, other_money.currency.precision)?;
147            (*self)
148                .checked_sub(other_money)
149                .ok_or(ArithmeticError::Overflow)?
150                .into_py_any(py)
151        } else if let Some(other_dec) = extract_arithmetic_decimal(other) {
152            ArithmeticOperation::Sub
153                .checked_decimal(self.as_decimal(), other_dec)?
154                .into_py_any(py)
155        } else {
156            let pytype_name = get_pytype_name(other)?;
157            Err(to_pytype_err(format!(
158                "Unsupported type for __sub__, was `{pytype_name}`"
159            )))
160        }
161    }
162
163    fn __rsub__(&self, other: &Bound<'_, PyAny>, py: Python) -> PyResult<Py<PyAny>> {
164        if other.is_instance_of::<PyFloat>() {
165            let other_float: f64 = other.extract()?;
166            ArithmeticOperation::Sub
167                .checked_f64(other_float, self.as_f64_checked()?)?
168                .into_py_any(py)
169        } else if let Ok(other_money) = other.extract::<Self>() {
170            check_money_currency(&other_money, self)?;
171            check_raw_scales(other_money.currency.precision, self.currency.precision)?;
172            other_money
173                .checked_sub(*self)
174                .ok_or(ArithmeticError::Overflow)?
175                .into_py_any(py)
176        } else if let Some(other_dec) = extract_arithmetic_decimal(other) {
177            ArithmeticOperation::Sub
178                .checked_decimal(other_dec, self.as_decimal())?
179                .into_py_any(py)
180        } else {
181            let pytype_name = get_pytype_name(other)?;
182            Err(to_pytype_err(format!(
183                "Unsupported type for __rsub__, was `{pytype_name}`"
184            )))
185        }
186    }
187
188    fn __mul__(&self, other: &Bound<'_, PyAny>, py: Python) -> PyResult<Py<PyAny>> {
189        if other.is_instance_of::<PyFloat>() {
190            let other_float: f64 = other.extract()?;
191            ArithmeticOperation::Mul
192                .checked_f64(self.as_f64_checked()?, other_float)?
193                .into_py_any(py)
194        } else if let Ok(other_money) = other.extract::<Self>() {
195            check_money_currency(self, &other_money)?;
196            ArithmeticOperation::Mul
197                .checked_decimal(self.as_decimal(), other_money.as_decimal())?
198                .into_py_any(py)
199        } else if let Some(other_dec) = extract_arithmetic_decimal(other) {
200            ArithmeticOperation::Mul
201                .checked_decimal(self.as_decimal(), other_dec)?
202                .into_py_any(py)
203        } else {
204            let pytype_name = get_pytype_name(other)?;
205            Err(to_pytype_err(format!(
206                "Unsupported type for __mul__, was `{pytype_name}`"
207            )))
208        }
209    }
210
211    fn __rmul__(&self, other: &Bound<'_, PyAny>, py: Python) -> PyResult<Py<PyAny>> {
212        if other.is_instance_of::<PyFloat>() {
213            let other_float: f64 = other.extract()?;
214            ArithmeticOperation::Mul
215                .checked_f64(other_float, self.as_f64_checked()?)?
216                .into_py_any(py)
217        } else if let Ok(other_money) = other.extract::<Self>() {
218            check_money_currency(&other_money, self)?;
219            ArithmeticOperation::Mul
220                .checked_decimal(other_money.as_decimal(), self.as_decimal())?
221                .into_py_any(py)
222        } else if let Some(other_dec) = extract_arithmetic_decimal(other) {
223            ArithmeticOperation::Mul
224                .checked_decimal(other_dec, self.as_decimal())?
225                .into_py_any(py)
226        } else {
227            let pytype_name = get_pytype_name(other)?;
228            Err(to_pytype_err(format!(
229                "Unsupported type for __rmul__, was `{pytype_name}`"
230            )))
231        }
232    }
233
234    fn __truediv__(&self, other: &Bound<'_, PyAny>, py: Python) -> PyResult<Py<PyAny>> {
235        if other.is_instance_of::<PyFloat>() {
236            let other_float: f64 = other.extract()?;
237            ArithmeticOperation::Div
238                .checked_f64(self.as_f64_checked()?, other_float)?
239                .into_py_any(py)
240        } else if let Ok(other_money) = other.extract::<Self>() {
241            check_money_currency(self, &other_money)?;
242            ArithmeticOperation::Div
243                .checked_decimal(self.as_decimal(), other_money.as_decimal())?
244                .into_py_any(py)
245        } else if let Some(other_dec) = extract_arithmetic_decimal(other) {
246            ArithmeticOperation::Div
247                .checked_decimal(self.as_decimal(), other_dec)?
248                .into_py_any(py)
249        } else {
250            let pytype_name = get_pytype_name(other)?;
251            Err(to_pytype_err(format!(
252                "Unsupported type for __truediv__, was `{pytype_name}`"
253            )))
254        }
255    }
256
257    fn __rtruediv__(&self, other: &Bound<'_, PyAny>, py: Python) -> PyResult<Py<PyAny>> {
258        if other.is_instance_of::<PyFloat>() {
259            let other_float: f64 = other.extract()?;
260            ArithmeticOperation::Div
261                .checked_f64(other_float, self.as_f64_checked()?)?
262                .into_py_any(py)
263        } else if let Ok(other_money) = other.extract::<Self>() {
264            check_money_currency(&other_money, self)?;
265            ArithmeticOperation::Div
266                .checked_decimal(other_money.as_decimal(), self.as_decimal())?
267                .into_py_any(py)
268        } else if let Some(other_dec) = extract_arithmetic_decimal(other) {
269            ArithmeticOperation::Div
270                .checked_decimal(other_dec, self.as_decimal())?
271                .into_py_any(py)
272        } else {
273            let pytype_name = get_pytype_name(other)?;
274            Err(to_pytype_err(format!(
275                "Unsupported type for __rtruediv__, was `{pytype_name}`"
276            )))
277        }
278    }
279
280    fn __floordiv__(&self, other: &Bound<'_, PyAny>, py: Python) -> PyResult<Py<PyAny>> {
281        if other.is_instance_of::<PyFloat>() {
282            let other_float: f64 = other.extract()?;
283            ArithmeticOperation::Div
284                .checked_f64(self.as_f64_checked()?, other_float)?
285                .floor()
286                .into_py_any(py)
287        } else if let Ok(other_money) = other.extract::<Self>() {
288            check_money_currency(self, &other_money)?;
289            ArithmeticOperation::Div
290                .checked_decimal(self.as_decimal(), other_money.as_decimal())?
291                .floor()
292                .into_py_any(py)
293        } else if let Some(other_dec) = extract_arithmetic_decimal(other) {
294            ArithmeticOperation::Div
295                .checked_decimal(self.as_decimal(), other_dec)?
296                .floor()
297                .into_py_any(py)
298        } else {
299            let pytype_name = get_pytype_name(other)?;
300            Err(to_pytype_err(format!(
301                "Unsupported type for __floordiv__, was `{pytype_name}`"
302            )))
303        }
304    }
305
306    fn __rfloordiv__(&self, other: &Bound<'_, PyAny>, py: Python) -> PyResult<Py<PyAny>> {
307        if other.is_instance_of::<PyFloat>() {
308            let other_float: f64 = other.extract()?;
309            ArithmeticOperation::Div
310                .checked_f64(other_float, self.as_f64_checked()?)?
311                .floor()
312                .into_py_any(py)
313        } else if let Ok(other_money) = other.extract::<Self>() {
314            check_money_currency(&other_money, self)?;
315            ArithmeticOperation::Div
316                .checked_decimal(other_money.as_decimal(), self.as_decimal())?
317                .floor()
318                .into_py_any(py)
319        } else if let Some(other_dec) = extract_arithmetic_decimal(other) {
320            ArithmeticOperation::Div
321                .checked_decimal(other_dec, self.as_decimal())?
322                .floor()
323                .into_py_any(py)
324        } else {
325            let pytype_name = get_pytype_name(other)?;
326            Err(to_pytype_err(format!(
327                "Unsupported type for __rfloordiv__, was `{pytype_name}`"
328            )))
329        }
330    }
331
332    fn __mod__(&self, other: &Bound<'_, PyAny>, py: Python) -> PyResult<Py<PyAny>> {
333        if other.is_instance_of::<PyFloat>() {
334            let other_float: f64 = other.extract()?;
335            ArithmeticOperation::Rem
336                .checked_f64(self.as_f64_checked()?, other_float)?
337                .into_py_any(py)
338        } else if let Ok(other_money) = other.extract::<Self>() {
339            check_money_currency(self, &other_money)?;
340            ArithmeticOperation::Rem
341                .checked_decimal(self.as_decimal(), other_money.as_decimal())?
342                .into_py_any(py)
343        } else if let Some(other_dec) = extract_arithmetic_decimal(other) {
344            ArithmeticOperation::Rem
345                .checked_decimal(self.as_decimal(), other_dec)?
346                .into_py_any(py)
347        } else {
348            let pytype_name = get_pytype_name(other)?;
349            Err(to_pytype_err(format!(
350                "Unsupported type for __mod__, was `{pytype_name}`"
351            )))
352        }
353    }
354
355    fn __rmod__(&self, other: &Bound<'_, PyAny>, py: Python) -> PyResult<Py<PyAny>> {
356        if other.is_instance_of::<PyFloat>() {
357            let other_float: f64 = other.extract()?;
358            ArithmeticOperation::Rem
359                .checked_f64(other_float, self.as_f64_checked()?)?
360                .into_py_any(py)
361        } else if let Ok(other_money) = other.extract::<Self>() {
362            check_money_currency(&other_money, self)?;
363            ArithmeticOperation::Rem
364                .checked_decimal(other_money.as_decimal(), self.as_decimal())?
365                .into_py_any(py)
366        } else if let Some(other_dec) = extract_arithmetic_decimal(other) {
367            ArithmeticOperation::Rem
368                .checked_decimal(other_dec, self.as_decimal())?
369                .into_py_any(py)
370        } else {
371            let pytype_name = get_pytype_name(other)?;
372            Err(to_pytype_err(format!(
373                "Unsupported type for __rmod__, was `{pytype_name}`"
374            )))
375        }
376    }
377
378    fn __neg__(&self) -> Self {
379        -*self
380    }
381
382    fn __pos__(&self) -> Self {
383        *self
384    }
385
386    fn __abs__(&self) -> Self {
387        self.abs()
388    }
389
390    fn __int__(&self) -> MoneyRaw {
391        let scale = MoneyRaw::try_from(raw_scale(self.currency.precision))
392            .expect("effective raw scale should fit in MoneyRaw");
393        self.raw() / scale
394    }
395
396    fn __float__(&self) -> PyResult<f64> {
397        self.as_f64_checked()
398    }
399
400    #[pyo3(signature = (ndigits=None))]
401    fn __round__(&self, ndigits: Option<u32>) -> Decimal {
402        self.as_decimal()
403            .round_dp_with_strategy(ndigits.unwrap_or(0), RoundingStrategy::MidpointNearestEven)
404    }
405
406    fn __repr__(&self) -> String {
407        format!("{self:?}")
408    }
409
410    fn __str__(&self) -> String {
411        self.to_string()
412    }
413
414    /// Returns the stored fixed-point integer without rescaling.
415    ///
416    /// Use this for serialization and explicit fixed-point conversions. Prefer domain
417    /// operations for calculations; the storage scale can differ from display precision.
418    ///
419    /// Direct field access is restricted to this crate:
420    ///
421    /// ```compile_fail
422    /// use nautilus_model::types::Money;
423    /// let value = Money::from("1 USD");
424    /// let raw = value.raw;
425    /// ```
426    #[getter(raw)]
427    fn py_raw(&self) -> MoneyRaw {
428        self.raw()
429    }
430
431    #[getter]
432    fn currency(&self) -> Currency {
433        self.currency
434    }
435
436    /// Creates a new `Money` instance with a value of zero with the given `Currency`.
437    #[staticmethod]
438    #[pyo3(name = "zero")]
439    fn py_zero(currency: Currency) -> PyResult<Self> {
440        Self::from_raw_checked(0, currency).map_err(correctness_error_to_pyvalue_err)
441    }
442
443    /// Creates a new `Money` instance from the given `raw` fixed-point value and the specified `currency`.
444    #[staticmethod]
445    #[pyo3(name = "from_raw")]
446    fn py_from_raw(raw: MoneyRaw, currency: Currency) -> PyResult<Self> {
447        Self::from_raw_checked(raw, currency).map_err(correctness_error_to_pyvalue_err)
448    }
449
450    /// Creates a new `Money` from a `Decimal` value with specified currency.
451    ///
452    /// This method provides more reliable parsing by using Decimal arithmetic
453    /// to avoid floating-point precision issues during conversion.
454    ///
455    /// # Errors
456    ///
457    /// Returns an error if:
458    /// - The decimal value cannot be converted to the raw representation.
459    /// - Overflow occurs during scaling.
460    #[staticmethod]
461    #[pyo3(name = "from_decimal")]
462    fn py_from_decimal(value: Decimal, currency: Currency) -> PyResult<Self> {
463        Self::from_decimal(value, currency).map_err(to_pyvalue_err)
464    }
465
466    #[staticmethod]
467    #[pyo3(name = "from_str")]
468    fn py_from_str(value: &str) -> PyResult<Self> {
469        Self::from_str(value).map_err(to_pyvalue_err)
470    }
471
472    /// Creates money without rounding to the currency precision.
473    ///
474    /// The native fixed-point scale limits the amount precision. The currency
475    /// identity and its default precision remain unchanged.
476    ///
477    /// # Errors
478    ///
479    /// Returns an error if the currency precision is invalid, the amount loses precision at the
480    /// native scale, or the amount exceeds money bounds.
481    #[staticmethod]
482    #[pyo3(name = "from_decimal_exact")]
483    fn py_from_decimal_exact(
484        #[gen_stub(override_type(type_repr = "decimal.Decimal", imports = ("decimal",)))]
485        value: &Bound<'_, PyAny>,
486        currency: Currency,
487    ) -> PyResult<Self> {
488        let decimal_type = value.py().import("decimal")?.getattr("Decimal")?;
489        if !value.is_instance(&decimal_type)? {
490            return Err(to_pytype_err("value must be a Decimal"));
491        }
492
493        let amount = value.str()?;
494        Self::from_decimal_str_exact(&amount.to_cow()?, currency).map_err(to_pyvalue_err)
495    }
496
497    /// Parses an amount and currency without rounding to the currency precision.
498    ///
499    /// # Errors
500    ///
501    /// Returns an error if the input is invalid, its currency is unknown, or its amount
502    /// is not exactly representable at the native scale within money bounds.
503    #[staticmethod]
504    #[pyo3(name = "from_str_exact")]
505    fn py_from_str_exact(value: &str) -> PyResult<Self> {
506        Self::from_str_exact(value).map_err(to_pyvalue_err)
507    }
508
509    /// Returns `true` if the value of this instance is zero.
510    #[pyo3(name = "is_zero")]
511    fn py_is_zero(&self) -> bool {
512        self.is_zero()
513    }
514
515    /// Returns `true` if the value of this instance is positive (> 0).
516    #[pyo3(name = "is_positive")]
517    fn py_is_positive(&self) -> bool {
518        self.is_positive()
519    }
520
521    /// Returns the value of this instance as a `Decimal`.
522    ///
523    /// Preserves stored fractional digits beyond the currency precision. Values whose
524    /// significant digits exceed the 96-bit mantissa of Rust `Decimal` can lose scale during conversion.
525    #[pyo3(name = "as_decimal")]
526    fn py_as_decimal(&self) -> Decimal {
527        self.as_decimal()
528    }
529
530    #[pyo3(name = "as_double")]
531    fn py_as_double(&self) -> PyResult<f64> {
532        self.as_f64_checked()
533    }
534
535    #[pyo3(name = "to_formatted_str")]
536    fn py_to_formatted_str(&self) -> String {
537        self.to_formatted_string()
538    }
539
540    /// Performs a checked addition, returning `None` on raw integer overflow, when
541    /// the result falls outside `[MONEY_RAW_MIN, MONEY_RAW_MAX]`, or when the operands
542    /// have mixed raw scales (e.g. a wei-scaled `Money` and a `FIXED_SCALAR`-scaled
543    /// `Money`, even if their currency codes match).
544    #[pyo3(name = "checked_add")]
545    fn py_checked_add(&self, other: Self) -> PyResult<Option<Self>> {
546        if self.currency != other.currency {
547            return Err(to_pyvalue_err(format!(
548                "Currency mismatch: cannot add {} to {}",
549                other.currency.code, self.currency.code
550            )));
551        }
552        Ok(self.checked_add(other))
553    }
554
555    /// Performs a checked subtraction, returning `None` on raw integer underflow, when
556    /// the result falls outside `[MONEY_RAW_MIN, MONEY_RAW_MAX]`, or when the operands
557    /// have mixed raw scales (e.g. a wei-scaled `Money` and a `FIXED_SCALAR`-scaled
558    /// `Money`, even if their currency codes match).
559    #[pyo3(name = "checked_sub")]
560    fn py_checked_sub(&self, other: Self) -> PyResult<Option<Self>> {
561        if self.currency != other.currency {
562            return Err(to_pyvalue_err(format!(
563                "Currency mismatch: cannot subtract {} from {}",
564                other.currency.code, self.currency.code
565            )));
566        }
567        Ok(self.checked_sub(other))
568    }
569}
570
571fn check_money_currency(lhs: &Money, rhs: &Money) -> PyResult<()> {
572    if lhs.currency != rhs.currency {
573        return Err(to_pyvalue_err(format!(
574            "Currency mismatch: {} vs {}",
575            lhs.currency.code, rhs.currency.code
576        )));
577    }
578    Ok(())
579}
580
581#[cfg(test)]
582mod tests {
583    use pyo3::Python;
584    use rstest::rstest;
585    use rust_decimal::Decimal;
586
587    use super::*;
588    use crate::{
589        enums::CurrencyType,
590        types::{
591            fixed::FIXED_PRECISION,
592            money::{MONEY_RAW_MAX, MONEY_RAW_MIN},
593        },
594    };
595
596    #[rstest]
597    #[case("0", 0)]
598    #[case("0.000000001", 0)]
599    #[case("-0.000000001", 0)]
600    #[case("1.999999999", 1)]
601    #[case("-1.999999999", -1)]
602    #[case("50.25", 50)]
603    #[case("9007199253.999999999", 9_007_199_253)]
604    fn test_int_uses_exact_raw_value(#[case] value: &str, #[case] expected: i64) {
605        let currency = Currency::new("TST9", 9, 0, "Test 9dp", CurrencyType::Crypto);
606        let money = Money::from_decimal(Decimal::from_str(value).unwrap(), currency).unwrap();
607
608        assert_eq!(money.__int__(), MoneyRaw::from(expected));
609    }
610
611    #[rstest]
612    fn test_int_preserves_domain_boundaries() {
613        let currency = Currency::new(
614            "TST",
615            FIXED_PRECISION,
616            0,
617            "Test fixed precision",
618            CurrencyType::Crypto,
619        );
620        let expected_max: MoneyRaw = if cfg!(feature = "high-precision") {
621            17_014_118_346_046
622        } else {
623            9_223_372_036
624        };
625        let expected_min = -expected_max;
626
627        assert_eq!(
628            Money::from_raw(MONEY_RAW_MAX, currency).__int__(),
629            expected_max
630        );
631        assert_eq!(
632            Money::from_raw(MONEY_RAW_MIN, currency).__int__(),
633            expected_min
634        );
635    }
636
637    #[rstest]
638    #[cfg(feature = "defi")]
639    fn test_int_uses_defi_raw_scale() {
640        let currency = Currency::new(
641            "TST18",
642            crate::defi::WEI_PRECISION,
643            0,
644            "Test 18dp",
645            CurrencyType::Crypto,
646        );
647        let money = Money::from_raw(1_999_999_999_999_999_999, currency);
648
649        assert_eq!(money.__int__(), 1);
650    }
651
652    #[rstest]
653    fn test_py_from_raw_rejects_out_of_range_raw_value() {
654        Python::initialize();
655        Python::attach(|_| {
656            let raw = MONEY_RAW_MAX.saturating_add(1);
657            let error = Money::py_from_raw(raw, Currency::USD()).unwrap_err();
658
659            assert_eq!(
660                error.to_string(),
661                format!(
662                    "ValueError: `raw` value {raw} exceeded bounds [{MONEY_RAW_MIN}, {MONEY_RAW_MAX}] for Money"
663                )
664            );
665        });
666    }
667
668    #[rstest]
669    fn test_py_zero_handles_currency_precision() {
670        Python::initialize();
671        Python::attach(|_| {
672            #[cfg(feature = "defi")]
673            let max_precision = crate::defi::WEI_PRECISION;
674            #[cfg(not(feature = "defi"))]
675            let max_precision = FIXED_PRECISION;
676
677            let currency = Currency::new(
678                "TST",
679                max_precision,
680                0,
681                "Test currency",
682                CurrencyType::Crypto,
683            );
684            let money = Money::py_zero(currency).unwrap();
685            let invalid_precision = max_precision + 1;
686            let mut invalid_currency = currency;
687            invalid_currency.precision = invalid_precision;
688            let precision_error = Money::py_zero(invalid_currency).unwrap_err();
689            let precision_name = if cfg!(feature = "defi") {
690                "WEI_PRECISION"
691            } else {
692                "FIXED_PRECISION"
693            };
694
695            assert_eq!(money.raw(), 0);
696            assert_eq!(money.currency, currency);
697            assert_eq!(
698                precision_error.to_string(),
699                format!(
700                    "ValueError: `precision` exceeded maximum `{precision_name}` ({max_precision}), was {invalid_precision}"
701                )
702            );
703        });
704    }
705}