Skip to main content

nautilus_model/types/
price.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 a price in a market with a specified precision.
17//!
18//! [`Price`] is an immutable value type for representing market prices, bid/ask quotes,
19//! and price levels. Unlike [`Quantity`](super::Quantity), prices can be negative (useful for spreads,
20//! basis trades, or certain derivative instruments).
21//!
22//! # Arithmetic behavior
23//!
24//! | Operation         | Result    | Notes                              |
25//! |-------------------|-----------|------------------------------------|
26//! | `Price + Price`   | `Price`   | Precision is max of both operands. |
27//! | `Price - Price`   | `Price`   | Precision is max of both operands. |
28//! | `Price + Decimal` | `Decimal` |                                    |
29//! | `Price - Decimal` | `Decimal` |                                    |
30//! | `Price * Decimal` | `Decimal` |                                    |
31//! | `Price / Decimal` | `Decimal` |                                    |
32//! | `Price + f64`     | `f64`     |                                    |
33//! | `Price - f64`     | `f64`     |                                    |
34//! | `Price * f64`     | `f64`     |                                    |
35//! | `Price / f64`     | `f64`     |                                    |
36//! | `-Price`          | `Price`   |                                    |
37//!
38//! # Immutability
39//!
40//! `Price` is immutable. All arithmetic operations return new instances.
41
42use std::{
43    cmp::Ordering,
44    fmt::{Debug, Display},
45    hash::{Hash, Hasher},
46    ops::{Add, Deref, Div, Mul, Neg, Sub},
47    str::FromStr,
48};
49
50use nautilus_core::{
51    correctness::{
52        CorrectnessError, CorrectnessResult, CorrectnessResultExt, FAILED,
53        check_in_range_inclusive_f64,
54    },
55    string::formatting::Separable,
56};
57use rust_decimal::Decimal;
58use serde::{Deserialize, Deserializer, Serialize};
59
60use super::fixed::{
61    FIXED_PRECISION, FIXED_SCALAR, check_fixed_precision, mantissa_exponent_to_fixed_i128,
62    mantissa_exponent_to_raw_checked, raw_scales_match, scaled_raw_to_decimal,
63};
64#[cfg(feature = "high-precision")]
65use super::fixed::{PRECISION_DIFF_SCALAR, f64_to_fixed_i128, fixed_i128_to_f64};
66#[cfg(not(feature = "high-precision"))]
67use super::fixed::{f64_to_fixed_i64, fixed_i64_to_f64};
68#[cfg(feature = "defi")]
69use crate::types::fixed::MAX_FLOAT_PRECISION;
70
71// -----------------------------------------------------------------------------
72// PriceRaw
73// -----------------------------------------------------------------------------
74
75// Use 128-bit integers when either `high-precision` or `defi` features are enabled. This is
76// required for the extended 18-decimal wei precision used in DeFi contexts.
77
78#[cfg(feature = "high-precision")]
79pub type PriceRaw = i128;
80
81#[cfg(not(feature = "high-precision"))]
82pub type PriceRaw = i64;
83
84// -----------------------------------------------------------------------------
85
86/// The maximum raw price integer value.
87///
88/// # Safety
89///
90/// `PRICE_MAX` and `FIXED_SCALAR` are cast to `PriceRaw` before multiplying, so the
91/// scaling uses exact integer arithmetic rather than a lossy `f64` product. The result
92/// fits within `PriceRaw`'s range in both high-precision (i128) and standard-precision
93/// (i64) modes, so the multiplication cannot overflow.
94#[unsafe(no_mangle)]
95#[allow(unsafe_code)]
96pub static PRICE_RAW_MAX: PriceRaw = (PRICE_MAX as PriceRaw) * (FIXED_SCALAR as PriceRaw);
97
98/// The minimum raw price integer value.
99///
100/// # Safety
101///
102/// `PRICE_MIN` and `FIXED_SCALAR` are cast to `PriceRaw` before multiplying, so the
103/// scaling uses exact integer arithmetic rather than a lossy `f64` product. The result
104/// fits within `PriceRaw`'s range in both high-precision (i128) and standard-precision
105/// (i64) modes, so the multiplication cannot overflow.
106#[unsafe(no_mangle)]
107#[allow(unsafe_code)]
108pub static PRICE_RAW_MIN: PriceRaw = (PRICE_MIN as PriceRaw) * (FIXED_SCALAR as PriceRaw);
109
110/// The sentinel value for an unset or null price.
111pub const PRICE_UNDEF: PriceRaw = PriceRaw::MAX;
112
113/// The sentinel value for an error or invalid price.
114pub const PRICE_ERROR: PriceRaw = PriceRaw::MIN;
115
116// -----------------------------------------------------------------------------
117// PRICE_MAX
118// -----------------------------------------------------------------------------
119
120/// The maximum valid price value that can be represented.
121#[cfg(feature = "high-precision")]
122pub const PRICE_MAX: f64 = 17_014_118_346_046.0;
123
124#[cfg(not(feature = "high-precision"))]
125/// The maximum valid price value that can be represented.
126pub const PRICE_MAX: f64 = 9_223_372_036.0;
127
128// -----------------------------------------------------------------------------
129// PRICE_MIN
130// -----------------------------------------------------------------------------
131
132#[cfg(feature = "high-precision")]
133/// The minimum valid price value that can be represented.
134pub const PRICE_MIN: f64 = -17_014_118_346_046.0;
135
136#[cfg(not(feature = "high-precision"))]
137/// The minimum valid price value that can be represented.
138pub const PRICE_MIN: f64 = -9_223_372_036.0;
139
140// -----------------------------------------------------------------------------
141
142/// The sentinel `Price` representing an error, returned by C FFI functions that
143/// cannot signal errors through `Option` or `Result`.
144pub const ERROR_PRICE: Price = Price {
145    raw: 0,
146    precision: 255,
147};
148
149/// Represents a price in a market with a specified precision.
150///
151/// The number of decimal places may vary. For certain asset classes, prices may
152/// have negative values. For example, prices for options instruments can be
153/// negative under certain conditions.
154///
155/// Handles up to [`FIXED_PRECISION`] decimals of precision.
156///
157/// - [`PRICE_MAX`] - Maximum representable price value.
158/// - [`PRICE_MIN`] - Minimum representable price value.
159#[repr(C)]
160#[derive(Clone, Copy, Default, Eq)]
161#[cfg_attr(
162    feature = "python",
163    pyo3::pyclass(module = "nautilus_trader.model", frozen, from_py_object)
164)]
165#[cfg_attr(
166    feature = "python",
167    pyo3_stub_gen::derive::gen_stub_pyclass(module = "nautilus_trader.model")
168)]
169pub struct Price {
170    /// Represents the raw fixed-point value, with `precision` defining the number of decimal places.
171    pub raw: PriceRaw,
172    /// The number of decimal places, with a maximum of [`FIXED_PRECISION`].
173    pub precision: u8,
174}
175
176impl Price {
177    /// Creates a new [`Price`] instance with correctness checking.
178    ///
179    /// # Errors
180    ///
181    /// Returns an error if:
182    /// - `value` is invalid outside the representable range [`PRICE_MIN`, `PRICE_MAX`].
183    /// - `precision` is invalid outside the representable range [0, `FIXED_PRECISION`].
184    ///
185    /// # Notes
186    ///
187    /// PyO3 requires a `Result` type for proper error handling and stacktrace printing in Python.
188    pub fn new_checked(value: f64, precision: u8) -> CorrectnessResult<Self> {
189        check_in_range_inclusive_f64(value, PRICE_MIN, PRICE_MAX, "value")?;
190
191        #[cfg(feature = "defi")]
192        if precision > MAX_FLOAT_PRECISION {
193            // Floats are only reliable up to ~16 decimal digits of precision regardless of feature flags
194            return Err(CorrectnessError::PredicateViolation {
195                message: format!(
196                    "`precision` exceeded maximum float precision ({MAX_FLOAT_PRECISION}), use `Price::from_wei()` for wei values instead"
197                ),
198            });
199        }
200
201        check_fixed_precision(precision)?;
202
203        #[cfg(feature = "high-precision")]
204        let raw = f64_to_fixed_i128(value, precision);
205
206        #[cfg(not(feature = "high-precision"))]
207        let raw = f64_to_fixed_i64(value, precision);
208
209        Ok(Self { raw, precision })
210    }
211
212    /// Creates a new [`Price`] instance.
213    ///
214    /// # Panics
215    ///
216    /// Panics if a correctness check fails. See [`Price::new_checked`] for more details.
217    #[must_use]
218    pub fn new(value: f64, precision: u8) -> Self {
219        Self::new_checked(value, precision).expect_display(FAILED)
220    }
221
222    /// Creates a new [`Price`] instance from the given `raw` fixed-point value and `precision`.
223    ///
224    /// # Panics
225    ///
226    /// Panics if `raw` is outside the valid range and is not a sentinel value.
227    /// Panics if `precision` exceeds [`FIXED_PRECISION`].
228    #[must_use]
229    pub fn from_raw(raw: PriceRaw, precision: u8) -> Self {
230        assert!(
231            raw == PRICE_ERROR
232                || raw == PRICE_UNDEF
233                || (raw >= PRICE_RAW_MIN && raw <= PRICE_RAW_MAX),
234            "`raw` value {raw} outside valid range [{PRICE_RAW_MIN}, {PRICE_RAW_MAX}] for Price"
235        );
236
237        if raw == PRICE_UNDEF {
238            assert!(
239                precision == 0,
240                "`precision` must be 0 when `raw` is PRICE_UNDEF"
241            );
242        }
243        check_fixed_precision(precision).expect_display(FAILED);
244
245        // TODO: Enforce spurious bits validation in v2
246        // if !matches!(raw, PRICE_UNDEF | PRICE_ERROR) && raw != 0 {
247        //     #[cfg(feature = "high-precision")]
248        //     super::fixed::check_fixed_raw_i128(raw, precision).expect(FAILED);
249        //     #[cfg(not(feature = "high-precision"))]
250        //     super::fixed::check_fixed_raw_i64(raw, precision).expect(FAILED);
251        // }
252
253        Self { raw, precision }
254    }
255
256    /// Creates a new [`Price`] instance from the given `raw` fixed-point value and `precision`
257    /// with correctness checking.
258    ///
259    /// # Errors
260    ///
261    /// Returns an error if:
262    /// - `precision` exceeds the maximum fixed precision.
263    /// - `precision` is not 0 when `raw` is `PRICE_UNDEF`.
264    /// - `raw` is outside the valid range `[PRICE_RAW_MIN, PRICE_RAW_MAX]`
265    ///   and is not a sentinel value.
266    pub fn from_raw_checked(raw: PriceRaw, precision: u8) -> CorrectnessResult<Self> {
267        if raw == PRICE_UNDEF && precision != 0 {
268            return Err(CorrectnessError::PredicateViolation {
269                message: "`precision` must be 0 when `raw` is PRICE_UNDEF".to_string(),
270            });
271        }
272
273        if raw != PRICE_ERROR && raw != PRICE_UNDEF && (raw < PRICE_RAW_MIN || raw > PRICE_RAW_MAX)
274        {
275            return Err(CorrectnessError::PredicateViolation {
276                message: format!(
277                    "raw value {raw} outside valid range [{PRICE_RAW_MIN}, {PRICE_RAW_MAX}]"
278                ),
279            });
280        }
281
282        check_fixed_precision(precision)?;
283
284        Ok(Self { raw, precision })
285    }
286
287    /// Creates a new [`Price`] instance with a value of zero with the given `precision`.
288    ///
289    /// # Panics
290    ///
291    /// Panics if a correctness check fails. See [`Price::new_checked`] for more details.
292    #[must_use]
293    pub fn zero(precision: u8) -> Self {
294        check_fixed_precision(precision).expect_display(FAILED);
295        Self { raw: 0, precision }
296    }
297
298    /// Creates a new [`Price`] instance with the maximum representable value with the given `precision`.
299    ///
300    /// # Panics
301    ///
302    /// Panics if a correctness check fails. See [`Price::new_checked`] for more details.
303    #[must_use]
304    pub fn max(precision: u8) -> Self {
305        check_fixed_precision(precision).expect_display(FAILED);
306        Self {
307            raw: PRICE_RAW_MAX,
308            precision,
309        }
310    }
311
312    /// Creates a new [`Price`] instance with the minimum representable value with the given `precision`.
313    ///
314    /// # Panics
315    ///
316    /// Panics if a correctness check fails. See [`Price::new_checked`] for more details.
317    #[must_use]
318    pub fn min(precision: u8) -> Self {
319        check_fixed_precision(precision).expect_display(FAILED);
320        Self {
321            raw: PRICE_RAW_MIN,
322            precision,
323        }
324    }
325
326    /// Performs a checked addition, returning `None` on raw integer overflow, when the
327    /// result falls outside `[PRICE_RAW_MIN, PRICE_RAW_MAX]`, when either operand is a
328    /// sentinel (`PRICE_UNDEF`, `PRICE_ERROR`, or `ERROR_PRICE`), or when the operands
329    /// have mixed raw scales (one at `FIXED_PRECISION` scale, the other at a defi
330    /// `WEI_PRECISION` scale).
331    ///
332    /// Precision follows the `Add` implementation: uses the maximum precision of both operands.
333    #[must_use]
334    pub fn checked_add(self, rhs: Self) -> Option<Self> {
335        if self.is_sentinel() || rhs.is_sentinel() {
336            return None;
337        }
338
339        if !raw_scales_match(self.precision, rhs.precision) {
340            return None;
341        }
342        let raw = self.raw.checked_add(rhs.raw)?;
343        if raw < PRICE_RAW_MIN || raw > PRICE_RAW_MAX {
344            return None;
345        }
346        Some(Self {
347            raw,
348            precision: self.precision.max(rhs.precision),
349        })
350    }
351
352    /// Performs a checked subtraction, returning `None` on raw integer underflow, when
353    /// the result falls outside `[PRICE_RAW_MIN, PRICE_RAW_MAX]`, when either operand
354    /// is a sentinel (`PRICE_UNDEF`, `PRICE_ERROR`, or `ERROR_PRICE`), or when the
355    /// operands have mixed raw scales (one at `FIXED_PRECISION` scale, the other at a
356    /// defi `WEI_PRECISION` scale).
357    ///
358    /// Precision follows the `Sub` implementation: uses the maximum precision of both operands.
359    #[must_use]
360    pub fn checked_sub(self, rhs: Self) -> Option<Self> {
361        if self.is_sentinel() || rhs.is_sentinel() {
362            return None;
363        }
364
365        if !raw_scales_match(self.precision, rhs.precision) {
366            return None;
367        }
368        let raw = self.raw.checked_sub(rhs.raw)?;
369        if raw < PRICE_RAW_MIN || raw > PRICE_RAW_MAX {
370            return None;
371        }
372        Some(Self {
373            raw,
374            precision: self.precision.max(rhs.precision),
375        })
376    }
377
378    #[inline]
379    fn is_sentinel(self) -> bool {
380        // ERROR_PRICE uses precision == u8::MAX as its sentinel marker, distinct from
381        // valid high-precision values (e.g. defi `from_wei` uses precision 18 which is
382        // > FIXED_PRECISION but is not a sentinel).
383        self.raw == PRICE_UNDEF || self.raw == PRICE_ERROR || self.precision == u8::MAX
384    }
385
386    /// Returns `true` if the value of this instance is undefined.
387    #[must_use]
388    pub fn is_undefined(&self) -> bool {
389        self.raw == PRICE_UNDEF
390    }
391
392    /// Returns `true` if the value of this instance is zero.
393    #[must_use]
394    pub fn is_zero(&self) -> bool {
395        self.raw == 0
396    }
397
398    /// Returns `true` if the value of this instance is position (> 0).
399    #[must_use]
400    pub fn is_positive(&self) -> bool {
401        self.raw != PRICE_UNDEF && self.raw > 0
402    }
403
404    #[cfg(feature = "high-precision")]
405    /// Returns the value of this instance as an `f64`.
406    ///
407    /// # Panics
408    ///
409    /// With the `defi` feature, panics if precision exceeds `MAX_FLOAT_PRECISION` (16).
410    #[must_use]
411    pub fn as_f64(&self) -> f64 {
412        #[cfg(feature = "defi")]
413        assert!(
414            self.precision <= MAX_FLOAT_PRECISION,
415            "Invalid f64 conversion beyond `MAX_FLOAT_PRECISION` (16)"
416        );
417
418        fixed_i128_to_f64(self.raw)
419    }
420
421    #[cfg(not(feature = "high-precision"))]
422    /// Returns the value of this instance as an `f64`.
423    #[must_use]
424    pub fn as_f64(&self) -> f64 {
425        fixed_i64_to_f64(self.raw)
426    }
427
428    /// Returns the value of this instance as a `Decimal`.
429    #[must_use]
430    pub fn as_decimal(&self) -> Decimal {
431        // Scale down the raw value to match the precision
432        let precision_diff = FIXED_PRECISION.saturating_sub(self.precision);
433        let rescaled_raw = self.raw / PriceRaw::pow(10, u32::from(precision_diff));
434        #[allow(
435            clippy::unnecessary_cast,
436            clippy::cast_lossless,
437            reason = "cast is real when PriceRaw is i64, no-op when i128"
438        )]
439        scaled_raw_to_decimal(rescaled_raw as i128, self.precision)
440    }
441
442    /// Returns a formatted string representation of this instance.
443    #[must_use]
444    pub fn to_formatted_string(&self) -> String {
445        format!("{self}").separate_with_underscores()
446    }
447
448    /// Creates a new [`Price`] from a `Decimal` value with specified precision.
449    ///
450    /// Uses pure integer arithmetic on the Decimal's mantissa and scale for fast conversion.
451    /// The value is rounded to the specified precision using banker's rounding (round half to even).
452    ///
453    /// # Errors
454    ///
455    /// Returns an error if:
456    /// - `precision` exceeds [`FIXED_PRECISION`].
457    /// - The decimal value cannot be converted to the raw representation.
458    /// - Overflow occurs during scaling.
459    pub fn from_decimal_dp(decimal: Decimal, precision: u8) -> CorrectnessResult<Self> {
460        let exponent = -(decimal.scale() as i8);
461        let raw_i128 = mantissa_exponent_to_fixed_i128(decimal.mantissa(), exponent, precision)?;
462
463        #[allow(
464            clippy::useless_conversion,
465            reason = "i128 to PriceRaw is real when not high-precision"
466        )]
467        let raw: PriceRaw =
468            raw_i128
469                .try_into()
470                .map_err(|_| CorrectnessError::PredicateViolation {
471                    message: format!(
472                        "Decimal value exceeds PriceRaw range [{PRICE_RAW_MIN}, {PRICE_RAW_MAX}]"
473                    ),
474                })?;
475
476        if !(raw >= PRICE_RAW_MIN && raw <= PRICE_RAW_MAX) {
477            return Err(CorrectnessError::PredicateViolation {
478                message: format!(
479                    "Raw value {raw} outside valid range [{PRICE_RAW_MIN}, {PRICE_RAW_MAX}] for Price"
480                ),
481            });
482        }
483
484        Ok(Self { raw, precision })
485    }
486
487    /// Creates a new [`Price`] from a [`Decimal`] value with precision inferred from the decimal's scale.
488    ///
489    /// The precision is determined by the scale of the decimal (number of decimal places).
490    /// The value is rounded to the inferred precision using banker's rounding (round half to even).
491    ///
492    /// # Errors
493    ///
494    /// Returns an error if:
495    /// - The inferred precision exceeds [`FIXED_PRECISION`].
496    /// - The decimal value cannot be converted to the raw representation.
497    /// - Overflow occurs during scaling.
498    pub fn from_decimal(decimal: Decimal) -> CorrectnessResult<Self> {
499        let precision = decimal.scale() as u8;
500        Self::from_decimal_dp(decimal, precision)
501    }
502
503    /// Creates a new [`Price`] from a mantissa/exponent pair using pure integer arithmetic.
504    ///
505    /// The value is `mantissa * 10^exponent`. This avoids all floating-point and Decimal
506    /// operations, making it ideal for exchange data that arrives as mantissa/exponent pairs.
507    ///
508    /// # Panics
509    ///
510    /// Panics if the resulting raw value exceeds [`PRICE_RAW_MAX`] or [`PRICE_RAW_MIN`].
511    #[must_use]
512    pub fn from_mantissa_exponent(mantissa: i64, exponent: i8, precision: u8) -> Self {
513        check_fixed_precision(precision).expect_display(FAILED);
514
515        if mantissa == 0 {
516            return Self { raw: 0, precision };
517        }
518
519        let raw_i128 = mantissa_exponent_to_fixed_i128(i128::from(mantissa), exponent, precision)
520            .expect("Overflow in Price::from_mantissa_exponent");
521
522        #[allow(
523            clippy::useless_conversion,
524            reason = "i128 to PriceRaw is real when not high-precision"
525        )]
526        let raw: PriceRaw = raw_i128
527            .try_into()
528            .expect("Raw value exceeds PriceRaw range in Price::from_mantissa_exponent");
529        assert!(
530            raw >= PRICE_RAW_MIN && raw <= PRICE_RAW_MAX,
531            "`raw` value {raw} exceeded bounds [{PRICE_RAW_MIN}, {PRICE_RAW_MAX}] for Price"
532        );
533
534        Self { raw, precision }
535    }
536
537    /// Checked variant of [`Price::from_mantissa_exponent`].
538    ///
539    /// # Errors
540    ///
541    /// Returns an error if the precision is invalid or the resulting raw value
542    /// exceeds the `PriceRaw` bounds.
543    pub fn from_mantissa_exponent_checked(
544        mantissa: i64,
545        exponent: i8,
546        precision: u8,
547    ) -> CorrectnessResult<Self> {
548        let raw = mantissa_exponent_to_raw_checked::<PriceRaw>(
549            i128::from(mantissa),
550            exponent,
551            precision,
552            "Price::from_mantissa_exponent",
553            "PriceRaw",
554            "Price",
555        )?;
556
557        Self::from_raw_checked(raw, precision)
558    }
559}
560
561impl FromStr for Price {
562    type Err = String;
563
564    fn from_str(value: &str) -> Result<Self, Self::Err> {
565        let clean_value = value.replace('_', "");
566
567        let decimal = if clean_value.contains('e') || clean_value.contains('E') {
568            Decimal::from_scientific(&clean_value)
569                .map_err(|e| format!("Error parsing `input` string '{value}' as Decimal: {e}"))?
570        } else {
571            Decimal::from_str(&clean_value)
572                .map_err(|e| format!("Error parsing `input` string '{value}' as Decimal: {e}"))?
573        };
574
575        // Use decimal scale to preserve caller-specified precision (including trailing zeros)
576        let precision = decimal.scale() as u8;
577
578        Self::from_decimal_dp(decimal, precision).map_err(|e| e.to_string())
579    }
580}
581
582impl<T: AsRef<str>> From<T> for Price {
583    fn from(value: T) -> Self {
584        Self::from_str(value.as_ref()).expect(FAILED)
585    }
586}
587
588impl From<Price> for f64 {
589    fn from(price: Price) -> Self {
590        price.as_f64()
591    }
592}
593
594impl From<&Price> for f64 {
595    fn from(price: &Price) -> Self {
596        price.as_f64()
597    }
598}
599
600impl From<Price> for Decimal {
601    fn from(value: Price) -> Self {
602        value.as_decimal()
603    }
604}
605
606impl From<&Price> for Decimal {
607    fn from(value: &Price) -> Self {
608        value.as_decimal()
609    }
610}
611
612impl Hash for Price {
613    fn hash<H: Hasher>(&self, state: &mut H) {
614        self.raw.hash(state);
615    }
616}
617
618impl PartialEq for Price {
619    fn eq(&self, other: &Self) -> bool {
620        self.raw == other.raw
621    }
622}
623
624impl PartialOrd for Price {
625    fn partial_cmp(&self, other: &Self) -> Option<Ordering> {
626        Some(self.cmp(other))
627    }
628
629    fn lt(&self, other: &Self) -> bool {
630        self.raw.lt(&other.raw)
631    }
632
633    fn le(&self, other: &Self) -> bool {
634        self.raw.le(&other.raw)
635    }
636
637    fn gt(&self, other: &Self) -> bool {
638        self.raw.gt(&other.raw)
639    }
640
641    fn ge(&self, other: &Self) -> bool {
642        self.raw.ge(&other.raw)
643    }
644}
645
646impl Ord for Price {
647    fn cmp(&self, other: &Self) -> Ordering {
648        self.raw.cmp(&other.raw)
649    }
650}
651
652impl Deref for Price {
653    type Target = PriceRaw;
654
655    fn deref(&self) -> &Self::Target {
656        &self.raw
657    }
658}
659
660impl Neg for Price {
661    type Output = Self;
662    fn neg(self) -> Self::Output {
663        // Preserve sentinel values (negating PRICE_ERROR would also overflow)
664        if self.raw == PRICE_ERROR || self.raw == PRICE_UNDEF {
665            return self;
666        }
667        Self {
668            raw: -self.raw,
669            precision: self.precision,
670        }
671    }
672}
673
674impl Add for Price {
675    type Output = Self;
676    fn add(self, rhs: Self) -> Self::Output {
677        Self {
678            raw: self
679                .raw
680                .checked_add(rhs.raw)
681                .expect("Overflow occurred when adding `Price`"),
682            precision: self.precision.max(rhs.precision),
683        }
684    }
685}
686
687impl Sub for Price {
688    type Output = Self;
689    fn sub(self, rhs: Self) -> Self::Output {
690        Self {
691            raw: self
692                .raw
693                .checked_sub(rhs.raw)
694                .expect("Underflow occurred when subtracting `Price`"),
695            precision: self.precision.max(rhs.precision),
696        }
697    }
698}
699
700impl Add<Decimal> for Price {
701    type Output = Decimal;
702    fn add(self, rhs: Decimal) -> Self::Output {
703        self.as_decimal() + rhs
704    }
705}
706
707impl Sub<Decimal> for Price {
708    type Output = Decimal;
709    fn sub(self, rhs: Decimal) -> Self::Output {
710        self.as_decimal() - rhs
711    }
712}
713
714impl Mul<Decimal> for Price {
715    type Output = Decimal;
716    fn mul(self, rhs: Decimal) -> Self::Output {
717        self.as_decimal() * rhs
718    }
719}
720
721impl Div<Decimal> for Price {
722    type Output = Decimal;
723    fn div(self, rhs: Decimal) -> Self::Output {
724        self.as_decimal() / rhs
725    }
726}
727
728impl Add<f64> for Price {
729    type Output = f64;
730    fn add(self, rhs: f64) -> Self::Output {
731        self.as_f64() + rhs
732    }
733}
734
735impl Sub<f64> for Price {
736    type Output = f64;
737    fn sub(self, rhs: f64) -> Self::Output {
738        self.as_f64() - rhs
739    }
740}
741
742impl Mul<f64> for Price {
743    type Output = f64;
744    fn mul(self, rhs: f64) -> Self::Output {
745        self.as_f64() * rhs
746    }
747}
748
749impl Div<f64> for Price {
750    type Output = f64;
751    fn div(self, rhs: f64) -> Self::Output {
752        self.as_f64() / rhs
753    }
754}
755
756impl Debug for Price {
757    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
758        if self.precision > crate::types::fixed::MAX_FLOAT_PRECISION {
759            write!(f, "{}({})", stringify!(Price), self.raw)
760        } else {
761            write!(f, "{}({})", stringify!(Price), self.as_decimal())
762        }
763    }
764}
765
766impl Display for Price {
767    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
768        if self.precision > crate::types::fixed::MAX_FLOAT_PRECISION {
769            write!(f, "{}", self.raw)
770        } else {
771            write!(f, "{}", self.as_decimal())
772        }
773    }
774}
775
776impl Serialize for Price {
777    fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
778    where
779        S: serde::Serializer,
780    {
781        serializer.serialize_str(&self.to_string())
782    }
783}
784
785impl<'de> Deserialize<'de> for Price {
786    fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
787    where
788        D: Deserializer<'de>,
789    {
790        let price_str: std::borrow::Cow<'de, str> = Deserialize::deserialize(deserializer)?;
791        Self::from_str(price_str.as_ref()).map_err(serde::de::Error::custom)
792    }
793}
794
795/// Checks the price `value` is positive.
796///
797/// # Errors
798///
799/// Returns an error if `value` is `PRICE_UNDEF` or not positive.
800pub fn check_positive_price(value: Price, param: &str) -> CorrectnessResult<()> {
801    if value.raw == PRICE_UNDEF {
802        return Err(CorrectnessError::InvalidValue {
803            param: param.to_string(),
804            value: "PRICE_UNDEF".to_string(),
805            type_name: "`Price`",
806        });
807    }
808
809    if !value.is_positive() {
810        return Err(CorrectnessError::NotPositive {
811            param: param.to_string(),
812            value: value.to_string(),
813            type_name: "`Price`",
814        });
815    }
816    Ok(())
817}
818
819#[cfg(feature = "high-precision")]
820/// The raw i64 price has already been scaled by 10^9. Further scale it by the difference to
821/// `FIXED_PRECISION` to make it high/defi-precision raw price.
822#[must_use]
823pub fn decode_raw_price_i64(value: i64) -> PriceRaw {
824    PriceRaw::from(value) * PRECISION_DIFF_SCALAR as PriceRaw
825}
826
827#[cfg(not(feature = "high-precision"))]
828#[must_use]
829pub fn decode_raw_price_i64(value: i64) -> PriceRaw {
830    value
831}
832
833#[cfg(test)]
834mod tests {
835    use nautilus_core::{approx_eq, correctness::CorrectnessError};
836    use rstest::rstest;
837    use rust_decimal_macros::dec;
838
839    use super::*;
840
841    #[rstest]
842    fn test_extreme_prices_round_trip_through_raw() {
843        // Regression: a lossy `f64` scalar previously left `PRICE_RAW_MAX`/`PRICE_RAW_MIN`
844        // beyond the raw produced by `new` at the bounds, causing spurious panics and errors.
845        let max = Price::new(PRICE_MAX, 0);
846        let min = Price::new(PRICE_MIN, 0);
847
848        assert_eq!(max.raw, PRICE_RAW_MAX);
849        assert_eq!(min.raw, PRICE_RAW_MIN);
850        assert!(Price::from_raw_checked(max.raw, 0).is_ok());
851        assert!(Price::from_raw_checked(min.raw, 0).is_ok());
852    }
853
854    #[rstest]
855    #[cfg(all(not(feature = "defi"), not(feature = "high-precision")))]
856    #[should_panic(expected = "`precision` exceeded maximum `FIXED_PRECISION` (9), was 50")]
857    fn test_invalid_precision_new() {
858        // Precision exceeds float precision limit
859        let _ = Price::new(1.0, 50);
860    }
861
862    #[rstest]
863    #[cfg(all(not(feature = "defi"), feature = "high-precision"))]
864    #[should_panic(expected = "`precision` exceeded maximum `FIXED_PRECISION` (16), was 50")]
865    fn test_invalid_precision_new() {
866        // Precision exceeds float precision limit
867        let _ = Price::new(1.0, 50);
868    }
869
870    #[rstest]
871    #[cfg(not(feature = "defi"))]
872    #[should_panic(expected = "Condition failed: `precision` exceeded maximum `FIXED_PRECISION`")]
873    fn test_invalid_precision_from_raw() {
874        // Precision out of range for fixed
875        let _ = Price::from_raw(1, FIXED_PRECISION + 1);
876    }
877
878    #[rstest]
879    #[cfg(not(feature = "defi"))]
880    #[should_panic(expected = "Condition failed: `precision` exceeded maximum `FIXED_PRECISION`")]
881    fn test_invalid_precision_max() {
882        // Precision out of range for fixed
883        let _ = Price::max(FIXED_PRECISION + 1);
884    }
885
886    #[rstest]
887    #[cfg(not(feature = "defi"))]
888    #[should_panic(expected = "Condition failed: `precision` exceeded maximum `FIXED_PRECISION`")]
889    fn test_invalid_precision_min() {
890        // Precision out of range for fixed
891        let _ = Price::min(FIXED_PRECISION + 1);
892    }
893
894    #[rstest]
895    #[cfg(not(feature = "defi"))]
896    #[should_panic(expected = "Condition failed: `precision` exceeded maximum `FIXED_PRECISION`")]
897    fn test_invalid_precision_zero() {
898        // Precision out of range for fixed
899        let _ = Price::zero(FIXED_PRECISION + 1);
900    }
901
902    #[rstest]
903    #[should_panic(expected = "Condition failed: invalid f64 for 'value' not in range")]
904    fn test_max_value_exceeded() {
905        let _ = Price::new(PRICE_MAX + 0.1, FIXED_PRECISION);
906    }
907
908    #[rstest]
909    #[should_panic(expected = "Condition failed: invalid f64 for 'value' not in range")]
910    fn test_min_value_exceeded() {
911        let _ = Price::new(PRICE_MIN - 0.1, FIXED_PRECISION);
912    }
913
914    #[rstest]
915    fn test_is_positive_ok() {
916        // A normal, non-zero price should be positive.
917        let price = Price::new(42.0, 2);
918        assert!(price.is_positive());
919
920        // `check_positive_price` should accept it without error.
921        check_positive_price(price, "price").unwrap();
922    }
923
924    #[rstest]
925    fn test_is_positive_rejects_non_positive() {
926        // Zero is NOT positive.
927        let zero = Price::zero(2);
928        let error = check_positive_price(zero, "price").unwrap_err();
929
930        assert_eq!(
931            error,
932            CorrectnessError::NotPositive {
933                param: "price".to_string(),
934                value: "0.00".to_string(),
935                type_name: "`Price`",
936            }
937        );
938        assert_eq!(
939            error.to_string(),
940            "invalid `Price` for 'price' not positive, was 0.00"
941        );
942    }
943
944    #[rstest]
945    fn test_is_positive_rejects_undefined() {
946        // PRICE_UNDEF must also be rejected.
947        let undef = Price::from_raw(PRICE_UNDEF, 0);
948        let error = check_positive_price(undef, "price").unwrap_err();
949
950        assert_eq!(
951            error,
952            CorrectnessError::InvalidValue {
953                param: "price".to_string(),
954                value: "PRICE_UNDEF".to_string(),
955                type_name: "`Price`",
956            }
957        );
958        assert_eq!(
959            error.to_string(),
960            "invalid `Price` for 'price', was PRICE_UNDEF"
961        );
962    }
963
964    #[rstest]
965    fn test_construction() {
966        let price = Price::new_checked(1.23456, 4);
967        assert!(price.is_ok());
968        let price = price.unwrap();
969        assert_eq!(price.precision, 4);
970        assert!(approx_eq!(f64, price.as_f64(), 1.23456, epsilon = 0.0001));
971    }
972
973    #[rstest]
974    fn test_negative_price_in_range() {
975        // Use max fixed precision which varies based on feature flags
976        let neg_price = Price::new(PRICE_MIN / 2.0, FIXED_PRECISION);
977        assert!(neg_price.raw < 0);
978    }
979
980    #[rstest]
981    fn test_new_checked() {
982        // Use max fixed precision which varies based on feature flags
983        assert!(Price::new_checked(1.0, FIXED_PRECISION).is_ok());
984        assert!(Price::new_checked(f64::NAN, FIXED_PRECISION).is_err());
985        assert!(Price::new_checked(f64::INFINITY, FIXED_PRECISION).is_err());
986    }
987
988    #[rstest]
989    fn test_new_checked_returns_typed_error_with_stable_display() {
990        let error = Price::new_checked(PRICE_MAX + 1.0, FIXED_PRECISION).unwrap_err();
991
992        assert!(matches!(error, CorrectnessError::OutOfRange { .. }));
993        assert_eq!(
994            error.to_string(),
995            format!(
996                "invalid f64 for 'value' not in range [{PRICE_MIN}, {PRICE_MAX}], was {}",
997                PRICE_MAX + 1.0
998            )
999        );
1000    }
1001
1002    #[rstest]
1003    fn test_from_raw_checked_returns_typed_error_with_stable_display() {
1004        let error = Price::from_raw_checked(PRICE_UNDEF, 3).unwrap_err();
1005
1006        assert_eq!(
1007            error,
1008            CorrectnessError::PredicateViolation {
1009                message: "`precision` must be 0 when `raw` is PRICE_UNDEF".to_string(),
1010            }
1011        );
1012        assert_eq!(
1013            error.to_string(),
1014            "`precision` must be 0 when `raw` is PRICE_UNDEF"
1015        );
1016    }
1017
1018    #[rstest]
1019    #[case::below_minimum(PRICE_RAW_MIN - 1)]
1020    #[case::above_maximum(PRICE_RAW_MAX + 1)]
1021    fn test_from_raw_checked_rejects_out_of_range_value(#[case] raw: PriceRaw) {
1022        let error = Price::from_raw_checked(raw, 0).unwrap_err();
1023
1024        assert_eq!(
1025            error,
1026            CorrectnessError::PredicateViolation {
1027                message: format!(
1028                    "raw value {raw} outside valid range [{PRICE_RAW_MIN}, {PRICE_RAW_MAX}]"
1029                ),
1030            }
1031        );
1032    }
1033
1034    #[rstest]
1035    #[should_panic(expected = "outside valid range")]
1036    fn test_from_raw_out_of_range_panics() {
1037        let _ = Price::from_raw(PRICE_RAW_MAX + 1, 0);
1038    }
1039
1040    #[rstest]
1041    fn test_from_raw() {
1042        let raw = 100 * FIXED_SCALAR as PriceRaw;
1043        let price = Price::from_raw(raw, 2);
1044        assert_eq!(price.raw, raw);
1045        assert_eq!(price.precision, 2);
1046    }
1047
1048    #[rstest]
1049    fn test_zero_constructor() {
1050        let zero = Price::zero(3);
1051        assert!(zero.is_zero());
1052        assert_eq!(zero.precision, 3);
1053    }
1054
1055    #[rstest]
1056    fn test_max_constructor() {
1057        let max = Price::max(4);
1058        assert_eq!(max.raw, PRICE_RAW_MAX);
1059        assert_eq!(max.precision, 4);
1060    }
1061
1062    #[rstest]
1063    fn test_min_constructor() {
1064        let min = Price::min(4);
1065        assert_eq!(min.raw, PRICE_RAW_MIN);
1066        assert_eq!(min.precision, 4);
1067    }
1068
1069    #[rstest]
1070    fn test_nan_validation() {
1071        assert!(Price::new_checked(f64::NAN, FIXED_PRECISION).is_err());
1072    }
1073
1074    #[rstest]
1075    fn test_infinity_validation() {
1076        assert!(Price::new_checked(f64::INFINITY, FIXED_PRECISION).is_err());
1077        assert!(Price::new_checked(f64::NEG_INFINITY, FIXED_PRECISION).is_err());
1078    }
1079
1080    #[rstest]
1081    fn test_special_values() {
1082        let zero = Price::zero(5);
1083        assert!(zero.is_zero());
1084        assert_eq!(zero.to_string(), "0.00000");
1085
1086        let undef = Price::from_raw(PRICE_UNDEF, 0);
1087        assert!(undef.is_undefined());
1088
1089        let error = ERROR_PRICE;
1090        assert_eq!(error.precision, 255);
1091    }
1092
1093    #[rstest]
1094    fn test_string_parsing() {
1095        let price: Price = "123.456".into();
1096        assert_eq!(price.precision, 3);
1097        assert_eq!(price, Price::from("123.456"));
1098    }
1099
1100    #[rstest]
1101    fn test_negative_price_from_str() {
1102        let price: Price = "-123.45".parse().unwrap();
1103        assert_eq!(price.precision, 2);
1104        assert!(approx_eq!(f64, price.as_f64(), -123.45, epsilon = 1e-9));
1105    }
1106
1107    #[rstest]
1108    fn test_string_parsing_errors() {
1109        assert!(Price::from_str("invalid").is_err());
1110    }
1111
1112    #[rstest]
1113    #[case("1e7", 0, 10_000_000.0)]
1114    #[case("1.5e3", 0, 1_500.0)]
1115    #[case("1.234e-2", 5, 0.01234)]
1116    #[case("5E-3", 3, 0.005)]
1117    fn test_from_str_scientific_notation(
1118        #[case] input: &str,
1119        #[case] expected_precision: u8,
1120        #[case] expected_value: f64,
1121    ) {
1122        let price = Price::from_str(input).unwrap();
1123        assert_eq!(price.precision, expected_precision);
1124        assert!(approx_eq!(
1125            f64,
1126            price.as_f64(),
1127            expected_value,
1128            epsilon = 1e-10
1129        ));
1130    }
1131
1132    #[rstest]
1133    #[case("1_234.56", 2, 1234.56)]
1134    #[case("1000000", 0, 1_000_000.0)]
1135    #[case("99_999.999_99", 5, 99_999.999_99)]
1136    fn test_from_str_with_underscores(
1137        #[case] input: &str,
1138        #[case] expected_precision: u8,
1139        #[case] expected_value: f64,
1140    ) {
1141        let price = Price::from_str(input).unwrap();
1142        assert_eq!(price.precision, expected_precision);
1143        assert!(approx_eq!(
1144            f64,
1145            price.as_f64(),
1146            expected_value,
1147            epsilon = 1e-10
1148        ));
1149    }
1150
1151    #[rstest]
1152    fn test_from_decimal_dp_preservation() {
1153        // Test that decimal conversion preserves exact values
1154        let decimal = dec!(123.456789);
1155        let price = Price::from_decimal_dp(decimal, 6).unwrap();
1156        assert_eq!(price.precision, 6);
1157        assert!(approx_eq!(
1158            f64,
1159            price.as_f64(),
1160            123.456_789,
1161            epsilon = 1e-10
1162        ));
1163
1164        // Verify raw value is exact
1165        let expected_raw = 123_456_789 * 10_i64.pow(u32::from(FIXED_PRECISION - 6));
1166        assert_eq!(price.raw, PriceRaw::from(expected_raw));
1167    }
1168
1169    #[rstest]
1170    fn test_from_decimal_dp_rounding() {
1171        // Test banker's rounding (round half to even)
1172        let decimal = dec!(1.005);
1173        let price = Price::from_decimal_dp(decimal, 2).unwrap();
1174        assert_eq!(price.as_f64(), 1.0); // 1.005 rounds to 1.00 (even)
1175
1176        let decimal = dec!(1.015);
1177        let price = Price::from_decimal_dp(decimal, 2).unwrap();
1178        assert_eq!(price.as_f64(), 1.02); // 1.015 rounds to 1.02 (even)
1179    }
1180
1181    #[rstest]
1182    fn test_from_decimal_infers_precision() {
1183        // Test that precision is inferred from decimal's scale
1184        let decimal = dec!(123.456);
1185        let price = Price::from_decimal(decimal).unwrap();
1186        assert_eq!(price.precision, 3);
1187        assert!(approx_eq!(f64, price.as_f64(), 123.456, epsilon = 1e-10));
1188
1189        // Test with integer (precision 0)
1190        let decimal = dec!(100);
1191        let price = Price::from_decimal(decimal).unwrap();
1192        assert_eq!(price.precision, 0);
1193        assert_eq!(price.as_f64(), 100.0);
1194
1195        // Test with high precision
1196        let decimal = dec!(1.23456789);
1197        let price = Price::from_decimal(decimal).unwrap();
1198        assert_eq!(price.precision, 8);
1199        assert!(approx_eq!(
1200            f64,
1201            price.as_f64(),
1202            1.234_567_89,
1203            epsilon = 1e-10
1204        ));
1205    }
1206
1207    #[rstest]
1208    fn test_from_decimal_trailing_zeros() {
1209        // Decimal preserves trailing zeros in scale
1210        let decimal = dec!(1.230);
1211        assert_eq!(decimal.scale(), 3); // Has 3 decimal places
1212
1213        // from_decimal infers precision from scale (includes trailing zeros)
1214        let price = Price::from_decimal(decimal).unwrap();
1215        assert_eq!(price.precision, 3);
1216        assert!(approx_eq!(f64, price.as_f64(), 1.23, epsilon = 1e-10));
1217
1218        // Normalized removes trailing zeros
1219        let normalized = decimal.normalize();
1220        assert_eq!(normalized.scale(), 2);
1221        let price_normalized = Price::from_decimal(normalized).unwrap();
1222        assert_eq!(price_normalized.precision, 2);
1223    }
1224
1225    #[rstest]
1226    #[case("1.00", 2)]
1227    #[case("1.0", 1)]
1228    #[case("1.000", 3)]
1229    #[case("100.00", 2)]
1230    #[case("0.10", 2)]
1231    #[case("0.100", 3)]
1232    fn test_from_str_preserves_trailing_zeros(#[case] input: &str, #[case] expected_precision: u8) {
1233        let price = Price::from_str(input).unwrap();
1234        assert_eq!(price.precision, expected_precision);
1235    }
1236
1237    #[rstest]
1238    fn test_from_decimal_excessive_precision_inference() {
1239        // Create a decimal with more precision than FIXED_PRECISION
1240        // Decimal supports up to 28 decimal places
1241        let decimal = dec!(1.1234567890123456789012345678);
1242
1243        // If scale exceeds FIXED_PRECISION, from_decimal should error
1244        if decimal.scale() > u32::from(FIXED_PRECISION) {
1245            assert!(Price::from_decimal(decimal).is_err());
1246        }
1247    }
1248
1249    #[rstest]
1250    fn test_from_decimal_dp_out_of_range_returns_typed_error_with_stable_display() {
1251        let huge = Decimal::from_str("99999999999999999999.99").unwrap();
1252        let error = Price::from_decimal_dp(huge, 2).unwrap_err();
1253        match error {
1254            CorrectnessError::PredicateViolation { ref message } => {
1255                assert!(
1256                    message.contains("PriceRaw range") || message.contains("for Price"),
1257                    "unexpected message: {message:?}",
1258                );
1259            }
1260            _ => panic!("expected PredicateViolation, was {error:?}"),
1261        }
1262    }
1263
1264    #[rstest]
1265    fn test_from_decimal_negative_price() {
1266        // Negative prices are valid for Price
1267        let decimal = dec!(-123.45);
1268        let price = Price::from_decimal(decimal).unwrap();
1269        assert_eq!(price.precision, 2);
1270        assert!(approx_eq!(f64, price.as_f64(), -123.45, epsilon = 1e-10));
1271        assert!(price.raw < 0);
1272    }
1273
1274    #[rstest]
1275    fn test_string_formatting() {
1276        assert_eq!(format!("{}", Price::new(1234.5678, 4)), "1234.5678");
1277        assert_eq!(
1278            format!("{:?}", Price::new(1234.5678, 4)),
1279            "Price(1234.5678)"
1280        );
1281        assert_eq!(Price::new(1234.5678, 4).to_formatted_string(), "1_234.5678");
1282    }
1283
1284    #[rstest]
1285    #[case(1234.5678, 4, "Price(1234.5678)", "1234.5678")] // Normal precision
1286    #[case(123.456_789_012_345, 8, "Price(123.45678901)", "123.45678901")] // At max normal precision
1287    #[cfg_attr(
1288        feature = "defi",
1289        case(
1290            2_000_000_000_000_000_000.0,
1291            18,
1292            "Price(2000000000000000000)",
1293            "2000000000000000000"
1294        )
1295    )] // High precision
1296    fn test_string_formatting_precision_handling(
1297        #[case] value: f64,
1298        #[case] precision: u8,
1299        #[case] expected_debug: &str,
1300        #[case] expected_display: &str,
1301    ) {
1302        let price = if precision > crate::types::fixed::MAX_FLOAT_PRECISION {
1303            Price::from_raw(value as PriceRaw, precision)
1304        } else {
1305            Price::new(value, precision)
1306        };
1307
1308        assert_eq!(format!("{price:?}"), expected_debug);
1309        assert_eq!(format!("{price}"), expected_display);
1310        assert_eq!(
1311            price.to_formatted_string().replace('_', ""),
1312            expected_display
1313        );
1314    }
1315
1316    #[rstest]
1317    fn test_decimal_conversions() {
1318        let price = Price::new(123.456, 3);
1319        assert_eq!(price.as_decimal(), dec!(123.456));
1320
1321        let price = Price::new(0.000_001, 6);
1322        assert_eq!(price.as_decimal(), dec!(0.000001));
1323    }
1324
1325    #[rstest]
1326    fn test_basic_arithmetic() {
1327        let p1 = Price::new(10.5, 2);
1328        let p2 = Price::new(5.25, 2);
1329        assert_eq!(p1 + p2, Price::from("15.75"));
1330        assert_eq!(p1 - p2, Price::from("5.25"));
1331        assert_eq!(-p1, Price::from("-10.5"));
1332    }
1333
1334    #[rstest]
1335    #[case::error(PRICE_ERROR)]
1336    #[case::undefined(PRICE_UNDEF)]
1337    fn test_neg_preserves_sentinel(#[case] raw: PriceRaw) {
1338        let price = Price::from_raw(raw, 0);
1339
1340        assert_eq!(-price, price);
1341    }
1342
1343    #[rstest]
1344    fn test_price_checked_add_within_bounds() {
1345        let a = Price::new(10.0, 2);
1346        let b = Price::new(5.0, 2);
1347        assert_eq!(a.checked_add(b), Some(Price::new(15.0, 2)));
1348
1349        let neg = Price::new(-3.0, 2);
1350        assert_eq!(a.checked_add(neg), Some(Price::new(7.0, 2)));
1351    }
1352
1353    #[rstest]
1354    fn test_price_checked_add_above_max_returns_none() {
1355        let near_max = Price::from_raw(PRICE_RAW_MAX, 0);
1356        let one = Price::new(1.0, 0);
1357        assert_eq!(near_max.checked_add(one), None);
1358    }
1359
1360    #[rstest]
1361    fn test_price_checked_sub_within_bounds() {
1362        let a = Price::new(10.0, 2);
1363        let b = Price::new(3.0, 2);
1364        assert_eq!(a.checked_sub(b), Some(Price::new(7.0, 2)));
1365        assert_eq!(b.checked_sub(a), Some(Price::new(-7.0, 2)));
1366    }
1367
1368    #[rstest]
1369    fn test_price_checked_sub_below_min_returns_none() {
1370        let near_min = Price::from_raw(PRICE_RAW_MIN, 0);
1371        let one = Price::new(1.0, 0);
1372        assert_eq!(near_min.checked_sub(one), None);
1373    }
1374
1375    #[rstest]
1376    fn test_price_checked_arith_uses_max_precision() {
1377        let a = Price::new(10.5, 1);
1378        let b = Price::new(5.25, 2);
1379        let sum = a.checked_add(b).unwrap();
1380        assert_eq!(sum.precision, 2);
1381        assert_eq!(sum.as_f64(), 15.75);
1382    }
1383
1384    #[rstest]
1385    fn test_price_checked_add_rejects_sentinel_undef() {
1386        let undef = Price::from_raw(PRICE_UNDEF, 0);
1387        let one = Price::new(1.0, 0);
1388        assert_eq!(undef.checked_add(one), None);
1389        assert_eq!(one.checked_add(undef), None);
1390    }
1391
1392    #[rstest]
1393    fn test_price_checked_sub_rejects_sentinel_undef() {
1394        let undef = Price::from_raw(PRICE_UNDEF, 0);
1395        let neg_one = Price::new(-1.0, 0);
1396        assert_eq!(undef.checked_sub(neg_one), None);
1397    }
1398
1399    #[rstest]
1400    fn test_price_checked_arith_rejects_error_price() {
1401        let one = Price::new(1.0, 0);
1402        assert_eq!(ERROR_PRICE.checked_add(one), None);
1403        assert_eq!(one.checked_sub(ERROR_PRICE), None);
1404    }
1405
1406    #[rstest]
1407    fn test_price_checked_arith_rejects_raw_error() {
1408        let error = Price::from_raw(PRICE_ERROR, 0);
1409        let one = Price::new(1.0, 0);
1410        assert_eq!(error.checked_add(one), None);
1411        assert_eq!(one.checked_add(error), None);
1412        assert_eq!(error.checked_sub(one), None);
1413        assert_eq!(one.checked_sub(error), None);
1414    }
1415
1416    #[rstest]
1417    fn test_price_checked_add_at_exact_max_returns_some() {
1418        let near_max = Price::from_raw(PRICE_RAW_MAX - 1, 0);
1419        let one_unit = Price::from_raw(1, 0);
1420        assert_eq!(
1421            near_max.checked_add(one_unit),
1422            Some(Price::from_raw(PRICE_RAW_MAX, 0)),
1423        );
1424    }
1425
1426    #[rstest]
1427    fn test_price_checked_sub_at_exact_min_returns_some() {
1428        let near_min = Price::from_raw(PRICE_RAW_MIN + 1, 0);
1429        let one_unit = Price::from_raw(1, 0);
1430        assert_eq!(
1431            near_min.checked_sub(one_unit),
1432            Some(Price::from_raw(PRICE_RAW_MIN, 0)),
1433        );
1434    }
1435
1436    #[rstest]
1437    fn test_mixed_precision_add() {
1438        let p1 = Price::new(10.5, 1);
1439        let p2 = Price::new(5.25, 2);
1440        let result = p1 + p2;
1441        assert_eq!(result.precision, 2);
1442        assert_eq!(result.as_f64(), 15.75);
1443    }
1444
1445    #[rstest]
1446    fn test_mixed_precision_sub() {
1447        let p1 = Price::new(10.5, 1);
1448        let p2 = Price::new(5.25, 2);
1449        let result = p1 - p2;
1450        assert_eq!(result.precision, 2);
1451        assert_eq!(result.as_f64(), 5.25);
1452    }
1453
1454    #[rstest]
1455    fn test_f64_operations() {
1456        let p = Price::new(10.5, 2);
1457        assert_eq!(p + 1.0, 11.5);
1458        assert_eq!(p - 1.0, 9.5);
1459        assert_eq!(p * 2.0, 21.0);
1460        assert_eq!(p / 2.0, 5.25);
1461    }
1462
1463    #[rstest]
1464    fn test_equality_and_comparisons() {
1465        let p1 = Price::new(10.0, 1);
1466        let p2 = Price::new(20.0, 1);
1467        let p3 = Price::new(10.0, 1);
1468
1469        assert!(p1 < p2);
1470        assert!(p2 > p1);
1471        assert!(p1 <= p3);
1472        assert!(p1 >= p3);
1473        assert_eq!(p1, p3);
1474        assert_ne!(p1, p2);
1475
1476        assert_eq!(Price::from("1.0"), Price::from("1.0"));
1477        assert_ne!(Price::from("1.1"), Price::from("1.0"));
1478        assert!(Price::from("1.0") <= Price::from("1.0"));
1479        assert!(Price::from("1.1") > Price::from("1.0"));
1480        assert!(Price::from("1.0") >= Price::from("1.0"));
1481        assert!(Price::from("1.0") >= Price::from("1.0"));
1482        assert!(Price::from("1.0") >= Price::from("1.0"));
1483        assert!(Price::from("0.9") < Price::from("1.0"));
1484        assert!(Price::from("0.9") <= Price::from("1.0"));
1485        assert!(Price::from("0.9") <= Price::from("1.0"));
1486    }
1487
1488    #[rstest]
1489    fn test_deref() {
1490        let price = Price::new(10.0, 1);
1491        assert_eq!(*price, price.raw);
1492    }
1493
1494    #[rstest]
1495    fn test_decode_raw_price_i64() {
1496        let raw_scaled_by_1e9 = 42_000_000_000i64; // 42.0 * 10^9
1497        let decoded = decode_raw_price_i64(raw_scaled_by_1e9);
1498        let price = Price::from_raw(decoded, FIXED_PRECISION);
1499        assert!(
1500            approx_eq!(f64, price.as_f64(), 42.0, epsilon = 1e-9),
1501            "Expected 42.0 f64, was {} (precision = {})",
1502            price.as_f64(),
1503            price.precision
1504        );
1505    }
1506
1507    #[rstest]
1508    fn test_hash() {
1509        use std::{
1510            collections::hash_map::DefaultHasher,
1511            hash::{Hash, Hasher},
1512        };
1513
1514        let price1 = Price::new(1.0, 2);
1515        let price2 = Price::new(1.0, 2);
1516        let price3 = Price::new(1.1, 2);
1517
1518        let mut hasher1 = DefaultHasher::new();
1519        let mut hasher2 = DefaultHasher::new();
1520        let mut hasher3 = DefaultHasher::new();
1521
1522        price1.hash(&mut hasher1);
1523        price2.hash(&mut hasher2);
1524        price3.hash(&mut hasher3);
1525
1526        assert_eq!(hasher1.finish(), hasher2.finish());
1527        assert_ne!(hasher1.finish(), hasher3.finish());
1528    }
1529
1530    #[rstest]
1531    fn test_price_serde_json_round_trip() {
1532        let price = Price::new(1.0500, 4);
1533        let json = serde_json::to_string(&price).unwrap();
1534        let deserialized: Price = serde_json::from_str(&json).unwrap();
1535        assert_eq!(deserialized, price);
1536    }
1537
1538    #[rstest]
1539    fn test_price_serde_json_from_value_round_trip() {
1540        let price = Price::new(1.0500, 4);
1541        let value = serde_json::to_value(price).unwrap();
1542
1543        let deserialized: Price = serde_json::from_value(value).unwrap();
1544        assert_eq!(deserialized, price);
1545        assert_eq!(deserialized.precision, 4);
1546    }
1547
1548    #[rstest]
1549    fn test_price_deserialize_invalid_string_returns_error() {
1550        let result = serde_json::from_str::<Price>("\"not-a-price\"");
1551        let error = result.unwrap_err();
1552        assert!(
1553            error.to_string().contains("Error parsing"),
1554            "unexpected message: {error}"
1555        );
1556    }
1557
1558    #[rstest]
1559    fn test_price_deserialize_out_of_range_returns_error() {
1560        let result = serde_json::from_str::<Price>("\"99999999999999999999.99\"");
1561        assert!(result.is_err());
1562    }
1563
1564    #[rstest]
1565    fn test_from_mantissa_exponent_exact_precision() {
1566        let price = Price::from_mantissa_exponent(12345, -2, 2);
1567        assert_eq!(price.as_f64(), 123.45);
1568    }
1569
1570    #[rstest]
1571    fn test_from_mantissa_exponent_excess_rounds_down() {
1572        // 12.345 rounds to 12.34 (4 is even, banker's rounding)
1573        let price = Price::from_mantissa_exponent(12345, -3, 2);
1574        assert_eq!(price.as_f64(), 12.34);
1575    }
1576
1577    #[rstest]
1578    fn test_from_mantissa_exponent_excess_rounds_up() {
1579        // 12.355 rounds to 12.36 (5 is odd, banker's rounding)
1580        let price = Price::from_mantissa_exponent(12355, -3, 2);
1581        assert_eq!(price.as_f64(), 12.36);
1582    }
1583
1584    #[rstest]
1585    fn test_from_mantissa_exponent_positive_exponent() {
1586        let price = Price::from_mantissa_exponent(5, 2, 0);
1587        assert_eq!(price.as_f64(), 500.0);
1588    }
1589
1590    #[rstest]
1591    fn test_from_mantissa_exponent_negative_mantissa() {
1592        let price = Price::from_mantissa_exponent(-12345, -2, 2);
1593        assert_eq!(price.as_f64(), -123.45);
1594    }
1595
1596    #[rstest]
1597    fn test_from_mantissa_exponent_zero() {
1598        let price = Price::from_mantissa_exponent(0, 2, 2);
1599        assert_eq!(price.as_f64(), 0.0);
1600    }
1601
1602    #[cfg(feature = "high-precision")]
1603    #[rstest]
1604    #[case(PRICE_RAW_MAX, dec!(17014118346046))]
1605    #[case(PRICE_RAW_MIN, dec!(-17014118346046))]
1606    fn test_as_decimal_above_decimal_mantissa(#[case] raw: PriceRaw, #[case] expected: Decimal) {
1607        // Regression: a precision-16 price above roughly 7.92e12 rescales to a raw value beyond
1608        // `Decimal`'s 96-bit mantissa, which used to panic during conversion.
1609        let price = Price::from_raw(raw, 16);
1610
1611        assert_eq!(price.as_decimal(), expected);
1612    }
1613
1614    #[rstest]
1615    fn test_from_mantissa_exponent_checked_exact_precision() {
1616        let price = Price::from_mantissa_exponent_checked(12345, -2, 2).unwrap();
1617        assert_eq!(price.as_decimal(), dec!(123.45));
1618    }
1619
1620    #[rstest]
1621    fn test_from_mantissa_exponent_checked_zero_with_large_exponent() {
1622        let price = Price::from_mantissa_exponent_checked(0, 119, 2).unwrap();
1623        assert_eq!(price.as_decimal(), dec!(0.00));
1624    }
1625
1626    #[rstest]
1627    fn test_from_mantissa_exponent_checked_invalid_precision() {
1628        #[cfg(feature = "defi")]
1629        let invalid_precision = crate::defi::WEI_PRECISION + 1;
1630        #[cfg(not(feature = "defi"))]
1631        let invalid_precision = FIXED_PRECISION + 1;
1632
1633        let error = Price::from_mantissa_exponent_checked(1, 0, invalid_precision).unwrap_err();
1634        assert!(error.to_string().contains("`precision` exceeded maximum"));
1635    }
1636
1637    #[rstest]
1638    fn test_from_mantissa_exponent_checked_overflow_returns_error() {
1639        let error = Price::from_mantissa_exponent_checked(i64::MAX, 100, 0).unwrap_err();
1640        assert!(
1641            error
1642                .to_string()
1643                .contains("Overflow in Price::from_mantissa_exponent")
1644        );
1645    }
1646
1647    #[rstest]
1648    #[should_panic(expected = "Price::from_mantissa_exponent")]
1649    fn test_from_mantissa_exponent_overflow_panics() {
1650        let _ = Price::from_mantissa_exponent(i64::MAX, 9, 0);
1651    }
1652
1653    #[rstest]
1654    #[should_panic(expected = "exceeds i128 range")]
1655    fn test_from_mantissa_exponent_large_exponent_panics() {
1656        let _ = Price::from_mantissa_exponent(1, 119, 0);
1657    }
1658
1659    #[rstest]
1660    fn test_from_mantissa_exponent_zero_with_large_exponent() {
1661        let price = Price::from_mantissa_exponent(0, 119, 0);
1662        assert_eq!(price.as_f64(), 0.0);
1663    }
1664
1665    #[rstest]
1666    fn test_from_mantissa_exponent_very_negative_exponent_rounds_to_zero() {
1667        let price = Price::from_mantissa_exponent(12345, -120, 2);
1668        assert_eq!(price.as_f64(), 0.0);
1669    }
1670
1671    #[rstest]
1672    fn test_decimal_arithmetic_operations() {
1673        let price = Price::new(100.0, 2);
1674        assert_eq!(price + dec!(50.25), dec!(150.25));
1675        assert_eq!(price - dec!(30.50), dec!(69.50));
1676        assert_eq!(price * dec!(1.5), dec!(150.00));
1677        assert_eq!(price / dec!(4), dec!(25.00));
1678    }
1679}
1680
1681#[cfg(test)]
1682mod property_tests {
1683    use proptest::prelude::*;
1684    use rstest::rstest;
1685
1686    use super::*;
1687
1688    /// Strategy to generate valid price values within the allowed range.
1689    fn price_value_strategy() -> impl Strategy<Value = f64> {
1690        // Use a reasonable range that's well within PRICE_MIN/PRICE_MAX
1691        // but still tests edge cases with various scales
1692        prop_oneof![
1693            // Small positive values
1694            0.00001..1.0,
1695            // Normal trading range
1696            1.0..100_000.0,
1697            // Large values (but safe)
1698            100_000.0..1_000_000.0,
1699            // Small negative values (for spreads, etc.)
1700            -1_000.0..0.0,
1701            // Boundary values close to the extremes
1702            Just(PRICE_MIN / 2.0),
1703            Just(PRICE_MAX / 2.0),
1704        ]
1705    }
1706
1707    fn float_precision_upper_bound() -> u8 {
1708        FIXED_PRECISION.min(crate::types::fixed::MAX_FLOAT_PRECISION)
1709    }
1710
1711    /// Strategy to exercise both typical and extreme precision values.
1712    fn precision_strategy() -> impl Strategy<Value = u8> {
1713        let upper = float_precision_upper_bound();
1714        prop_oneof![Just(0u8), 0u8..=upper, Just(FIXED_PRECISION),]
1715    }
1716
1717    fn precision_strategy_non_zero() -> impl Strategy<Value = u8> {
1718        let upper = float_precision_upper_bound().max(1);
1719        prop_oneof![Just(upper), Just(FIXED_PRECISION.max(1)), 1u8..=upper,]
1720    }
1721
1722    /// Strategy to generate a valid (precision, raw) pair where raw is properly scaled.
1723    ///
1724    /// Raw values must be multiples of `10^(FIXED_PRECISION` - precision) to pass validation.
1725    fn valid_precision_raw_strategy() -> impl Strategy<Value = (u8, PriceRaw)> {
1726        precision_strategy().prop_flat_map(|precision| {
1727            let scale: PriceRaw = if precision >= FIXED_PRECISION {
1728                1
1729            } else {
1730                (10 as PriceRaw).pow(u32::from(FIXED_PRECISION - precision))
1731            };
1732            // Generate a base value, then multiply by scale to ensure valid raw
1733            let max_base = PRICE_RAW_MAX / scale;
1734            let min_base = PRICE_RAW_MIN / scale;
1735            (min_base..=max_base).prop_map(move |base| (precision, base * scale))
1736        })
1737    }
1738
1739    /// Strategy to generate valid precision values for float-based constructors.
1740    fn float_precision_strategy() -> impl Strategy<Value = u8> {
1741        precision_strategy()
1742    }
1743
1744    const DECIMAL_MAX_MANTISSA: i128 = 79_228_162_514_264_337_593_543_950_335;
1745
1746    #[allow(
1747        clippy::useless_conversion,
1748        reason = "PriceRaw is i64 or i128 depending on feature; the conversion is only useless in high-precision builds"
1749    )]
1750    fn decimal_compatible(raw: PriceRaw, precision: u8) -> bool {
1751        if precision > crate::types::fixed::MAX_FLOAT_PRECISION {
1752            return false;
1753        }
1754        let precision_diff = u32::from(FIXED_PRECISION.saturating_sub(precision));
1755        let divisor = (10 as PriceRaw).pow(precision_diff);
1756        let rescaled_raw = raw / divisor;
1757        i128::from(rescaled_raw.abs()) <= DECIMAL_MAX_MANTISSA
1758    }
1759
1760    proptest! {
1761        /// Property: Price string serialization round-trip should preserve value and precision
1762        #[rstest]
1763        fn prop_price_serde_round_trip(
1764            value in price_value_strategy().prop_filter("Reasonable values", |&x| x.abs() < 1e6),
1765            precision in precision_strategy()
1766        ) {
1767            let original = Price::new(value, precision);
1768
1769            // String round-trip (this should be exact and is the most important)
1770            let string_repr = original.to_string();
1771            let from_string: Price = string_repr.parse().unwrap();
1772            prop_assert_eq!(from_string.raw, original.raw);
1773            prop_assert_eq!(from_string.precision, original.precision);
1774
1775            // JSON uses the same canonical decimal string as Display, so it must be exact.
1776            let json = serde_json::to_string(&original).unwrap();
1777            let from_json: Price = serde_json::from_str(&json).unwrap();
1778            prop_assert_eq!(from_json.precision, original.precision);
1779            prop_assert_eq!(from_json.raw, original.raw);
1780        }
1781
1782        /// Property: Price arithmetic should be associative for same precision
1783        #[rstest]
1784        fn prop_price_arithmetic_associative(
1785            a in price_value_strategy().prop_filter("Reasonable values", |&x| x.abs() > 1e-3 && x.abs() < 1e6),
1786            b in price_value_strategy().prop_filter("Reasonable values", |&x| x.abs() > 1e-3 && x.abs() < 1e6),
1787            c in price_value_strategy().prop_filter("Reasonable values", |&x| x.abs() > 1e-3 && x.abs() < 1e6),
1788            precision in precision_strategy()
1789        ) {
1790            let p_a = Price::new(a, precision);
1791            let p_b = Price::new(b, precision);
1792            let p_c = Price::new(c, precision);
1793
1794            let expected = p_a
1795                .raw
1796                .checked_add(p_b.raw)
1797                .and_then(|sum| sum.checked_add(p_c.raw))
1798                .filter(|sum| (PRICE_RAW_MIN..=PRICE_RAW_MAX).contains(sum));
1799
1800            if let Some(expected) = expected {
1801                let left = (p_a + p_b) + p_c;
1802                let right = p_a + (p_b + p_c);
1803                prop_assert_eq!(left.raw, expected);
1804                prop_assert_eq!(right.raw, expected);
1805            }
1806        }
1807
1808        /// Property: Price addition/subtraction should be inverse operations
1809        #[rstest]
1810        fn prop_price_addition_subtraction_inverse(
1811            base in price_value_strategy().prop_filter("Reasonable values", |&x| x.abs() < 1e6),
1812            delta in price_value_strategy().prop_filter("Reasonable values", |&x| x.abs() > 1e-3 && x.abs() < 1e6),
1813            precision in precision_strategy()
1814        ) {
1815            let p_base = Price::new(base, precision);
1816            let p_delta = Price::new(delta, precision);
1817
1818            if p_base
1819                .raw
1820                .checked_add(p_delta.raw)
1821                .is_some_and(|sum| (PRICE_RAW_MIN..=PRICE_RAW_MAX).contains(&sum))
1822            {
1823                prop_assert_eq!((p_base + p_delta) - p_delta, p_base);
1824            }
1825        }
1826
1827        /// Property: Price ordering should be transitive
1828        #[rstest]
1829        fn prop_price_ordering_transitive(
1830            a in price_value_strategy(),
1831            b in price_value_strategy(),
1832            c in price_value_strategy(),
1833            precision in float_precision_strategy()
1834        ) {
1835            let p_a = Price::new(a, precision);
1836            let p_b = Price::new(b, precision);
1837            let p_c = Price::new(c, precision);
1838
1839            // If a <= b and b <= c, then a <= c
1840            if p_a <= p_b && p_b <= p_c {
1841                prop_assert!(p_a <= p_c, "Transitivity failed: {} <= {} <= {} but {} > {}",
1842                    p_a.as_f64(), p_b.as_f64(), p_c.as_f64(), p_a.as_f64(), p_c.as_f64());
1843            }
1844        }
1845
1846        /// Property: String parsing should be consistent with precision inference
1847        #[rstest]
1848        fn prop_price_string_parsing_precision(
1849            integral in 0u32..1_000_000,
1850            fractional in 0u32..1_000_000,
1851            precision in precision_strategy_non_zero()
1852        ) {
1853            // Create a decimal string with exactly 'precision' decimal places
1854            let pow = 10u128.pow(u32::from(precision));
1855            let fractional_mod = u128::from(fractional) % pow;
1856            let fractional_str = format!("{:0width$}", fractional_mod, width = precision as usize);
1857            let price_str = format!("{integral}.{fractional_str}");
1858
1859            let parsed: Price = price_str.parse().unwrap();
1860            prop_assert_eq!(parsed.precision, precision);
1861
1862            // Round-trip should preserve the original string (after normalization)
1863            let round_trip = parsed.to_string();
1864            let expected_value = format!("{integral}.{fractional_str}");
1865            prop_assert_eq!(round_trip, expected_value);
1866        }
1867
1868        /// Property: Price arithmetic should never produce invalid values
1869        #[rstest]
1870        fn prop_price_arithmetic_bounds(
1871            a in price_value_strategy(),
1872            b in price_value_strategy(),
1873            precision in float_precision_strategy()
1874        ) {
1875            let p_a = Price::new(a, precision);
1876            let p_b = Price::new(b, precision);
1877
1878            // Addition should either succeed or fail predictably
1879            let sum_f64 = p_a.as_f64() + p_b.as_f64();
1880            if sum_f64.is_finite() && (PRICE_MIN..=PRICE_MAX).contains(&sum_f64) {
1881                let sum = p_a + p_b;
1882                prop_assert!(sum.as_f64().is_finite());
1883                prop_assert!(!sum.is_undefined());
1884            }
1885
1886            // Subtraction should either succeed or fail predictably
1887            let diff_f64 = p_a.as_f64() - p_b.as_f64();
1888            if diff_f64.is_finite() && (PRICE_MIN..=PRICE_MAX).contains(&diff_f64) {
1889                let diff = p_a - p_b;
1890                prop_assert!(diff.as_f64().is_finite());
1891                prop_assert!(!diff.is_undefined());
1892            }
1893        }
1894
1895        /// Property: checked_add agrees with Add when bounds and sentinel guards hold,
1896        /// and returns None otherwise.
1897        #[rstest]
1898        fn prop_price_checked_add_matches_spec(
1899            a in price_value_strategy(),
1900            b in price_value_strategy(),
1901            precision in float_precision_strategy()
1902        ) {
1903            let p_a = Price::new(a, precision);
1904            let p_b = Price::new(b, precision);
1905            let expected = p_a.raw
1906                .checked_add(p_b.raw)
1907                .filter(|r| (PRICE_RAW_MIN..=PRICE_RAW_MAX).contains(r))
1908                .filter(|_| !p_a.is_sentinel() && !p_b.is_sentinel())
1909                .map(|raw| Price { raw, precision: p_a.precision.max(p_b.precision) });
1910            prop_assert_eq!(p_a.checked_add(p_b), expected);
1911        }
1912
1913        /// Property: checked_sub agrees with Sub when bounds and sentinel guards hold,
1914        /// and returns None otherwise.
1915        #[rstest]
1916        fn prop_price_checked_sub_matches_spec(
1917            a in price_value_strategy(),
1918            b in price_value_strategy(),
1919            precision in float_precision_strategy()
1920        ) {
1921            let p_a = Price::new(a, precision);
1922            let p_b = Price::new(b, precision);
1923            let expected = p_a.raw
1924                .checked_sub(p_b.raw)
1925                .filter(|r| (PRICE_RAW_MIN..=PRICE_RAW_MAX).contains(r))
1926                .filter(|_| !p_a.is_sentinel() && !p_b.is_sentinel())
1927                .map(|raw| Price { raw, precision: p_a.precision.max(p_b.precision) });
1928            prop_assert_eq!(p_a.checked_sub(p_b), expected);
1929        }
1930    }
1931
1932    proptest! {
1933        /// Property: as_decimal scale always matches precision
1934        #[rstest]
1935        fn prop_price_as_decimal_preserves_precision(
1936            (precision, raw) in valid_precision_raw_strategy()
1937        ) {
1938            prop_assume!(decimal_compatible(raw, precision));
1939            let price = Price::from_raw(raw, precision);
1940            let decimal = price.as_decimal();
1941            prop_assert_eq!(decimal.scale(), u32::from(precision));
1942        }
1943
1944        /// Property: as_decimal and Display produce the same string
1945        #[rstest]
1946        fn prop_price_as_decimal_matches_display(
1947            value in price_value_strategy().prop_filter("Reasonable values", |&x| x.abs() < 1e6),
1948            precision in float_precision_strategy()
1949        ) {
1950            let price = Price::new(value, precision);
1951            prop_assume!(decimal_compatible(price.raw, precision));
1952            let display_str = format!("{price}");
1953            let decimal_str = price.as_decimal().to_string();
1954            prop_assert_eq!(display_str, decimal_str);
1955        }
1956
1957        /// Property: from_decimal roundtrip preserves exact value
1958        #[rstest]
1959        fn prop_price_from_decimal_roundtrip(
1960            (precision, raw) in valid_precision_raw_strategy()
1961        ) {
1962            prop_assume!(decimal_compatible(raw, precision));
1963            let original = Price::from_raw(raw, precision);
1964            let decimal = original.as_decimal();
1965            let reconstructed = Price::from_decimal(decimal).unwrap();
1966            prop_assert_eq!(original.raw, reconstructed.raw);
1967            prop_assert_eq!(original.precision, reconstructed.precision);
1968        }
1969
1970        /// Property: constructing from valid raw values preserves raw/precision fields
1971        #[rstest]
1972        fn prop_price_from_raw_round_trip(
1973            (precision, raw) in valid_precision_raw_strategy()
1974        ) {
1975            let price = Price::from_raw(raw, precision);
1976            prop_assert_eq!(price.raw, raw);
1977            prop_assert_eq!(price.precision, precision);
1978        }
1979    }
1980}