nautilus_model/types/
quantity.rs

1// -------------------------------------------------------------------------------------------------
2//  Copyright (C) 2015-2025 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 quantity with a non-negative value.
17
18use std::{
19    cmp::Ordering,
20    fmt::{Debug, Display},
21    hash::{Hash, Hasher},
22    ops::{Add, AddAssign, Deref, Mul, MulAssign, Sub, SubAssign},
23    str::FromStr,
24};
25
26use nautilus_core::{
27    correctness::{FAILED, check_in_range_inclusive_f64, check_predicate_true},
28    parsing::precision_from_str,
29};
30use rust_decimal::Decimal;
31use serde::{Deserialize, Deserializer, Serialize};
32use thousands::Separable;
33
34use super::fixed::{FIXED_PRECISION, FIXED_SCALAR, check_fixed_precision};
35#[cfg(not(feature = "high-precision"))]
36use super::fixed::{f64_to_fixed_u64, fixed_u64_to_f64};
37#[cfg(feature = "high-precision")]
38use super::fixed::{f64_to_fixed_u128, fixed_u128_to_f64};
39
40#[cfg(feature = "high-precision")]
41pub type QuantityRaw = u128;
42#[cfg(not(feature = "high-precision"))]
43pub type QuantityRaw = u64;
44
45/// The maximum raw quantity integer value.
46#[unsafe(no_mangle)]
47#[allow(unsafe_code)]
48pub static QUANTITY_RAW_MAX: QuantityRaw = (QUANTITY_MAX * FIXED_SCALAR) as QuantityRaw;
49
50/// The sentinel value for an unset or null quantity.
51pub const QUANTITY_UNDEF: QuantityRaw = QuantityRaw::MAX;
52
53/// The maximum valid quantity value which can be represented.
54#[cfg(feature = "high-precision")]
55pub const QUANTITY_MAX: f64 = 34_028_236_692_093.0;
56#[cfg(not(feature = "high-precision"))]
57pub const QUANTITY_MAX: f64 = 18_446_744_073.0;
58
59/// The minimum valid quantity value which can be represented.
60pub const QUANTITY_MIN: f64 = 0.0;
61
62/// Represents a quantity with a non-negative value.
63///
64/// Capable of storing either a whole number (no decimal places) of 'contracts'
65/// or 'shares' (instruments denominated in whole units) or a decimal value
66/// containing decimal places for instruments denominated in fractional units.
67///
68/// Handles up to {FIXED_PRECISION} decimals of precision.
69///
70/// - `QUANTITY_MAX` = {QUANTITY_MAX}
71/// - `QUANTITY_MIN` = 0
72#[repr(C)]
73#[derive(Clone, Copy, Default, Eq)]
74#[cfg_attr(
75    feature = "python",
76    pyo3::pyclass(module = "nautilus_trader.core.nautilus_pyo3.model")
77)]
78pub struct Quantity {
79    /// Represents the raw fixed-point value, with `precision` defining the number of decimal places.
80    pub raw: QuantityRaw,
81    /// The number of decimal places, with a maximum of {FIXED_PRECISION}.
82    pub precision: u8,
83}
84
85impl Quantity {
86    /// Creates a new [`Quantity`] instance with correctness checking.
87    ///
88    /// # Errors
89    ///
90    /// Returns an error if:
91    /// - `value` is invalid outside the representable range [0, {QUANTITY_MAX}].
92    /// - `precision` is invalid outside the representable range [0, {FIXED_PRECISION}].
93    ///
94    /// # Notes
95    ///
96    /// PyO3 requires a `Result` type for proper error handling and stacktrace printing in Python.
97    pub fn new_checked(value: f64, precision: u8) -> anyhow::Result<Self> {
98        check_in_range_inclusive_f64(value, QUANTITY_MIN, QUANTITY_MAX, "value")?;
99        check_fixed_precision(precision)?;
100
101        #[cfg(feature = "high-precision")]
102        let raw = f64_to_fixed_u128(value, precision);
103        #[cfg(not(feature = "high-precision"))]
104        let raw = f64_to_fixed_u64(value, precision);
105
106        Ok(Self { raw, precision })
107    }
108
109    /// Creates a new [`Quantity`] instance with a guaranteed non zero value.
110    ///
111    /// # Errors
112    ///
113    /// Returns an error if:
114    /// - `value` is zero.
115    /// - `value` becomes zero after rounding to `precision`.
116    /// - `value` is invalid outside the representable range [0, {QUANTITY_MAX}].
117    /// - `precision` is invalid outside the representable range [0, {FIXED_PRECISION}].
118    ///
119    /// # Notes
120    ///
121    /// PyO3 requires a `Result` type for proper error handling and stacktrace printing in Python.
122    pub fn non_zero_checked(value: f64, precision: u8) -> anyhow::Result<Self> {
123        check_predicate_true(value != 0.0, "value was zero")?;
124        check_fixed_precision(precision)?;
125        let rounded_value =
126            (value * 10.0_f64.powi(precision as i32)).round() / 10.0_f64.powi(precision as i32);
127        check_predicate_true(
128            rounded_value != 0.0,
129            &format!("value {value} was zero after rounding to precision {precision}"),
130        )?;
131
132        Self::new_checked(value, precision)
133    }
134
135    /// Creates a new [`Quantity`] instance.
136    ///
137    /// # Panics
138    ///
139    /// Panics if a correctness check fails. See [`Quantity::new_checked`] for more details.
140    pub fn new(value: f64, precision: u8) -> Self {
141        Self::new_checked(value, precision).expect(FAILED)
142    }
143
144    /// Creates a new [`Quantity`] instance with a guaranteed non zero value.
145    ///
146    /// # Panics
147    ///
148    /// Panics if a correctness check fails. See [`Quantity::non_zero_checked`] for more details.
149    pub fn non_zero(value: f64, precision: u8) -> Self {
150        Self::non_zero_checked(value, precision).expect(FAILED)
151    }
152
153    /// Creates a new [`Quantity`] instance from the given `raw` fixed-point value and `precision`.
154    ///
155    /// # Panics
156    ///
157    /// Panics if a correctness check fails. See [`Quantity::new_checked`] for more details.
158    pub fn from_raw(raw: QuantityRaw, precision: u8) -> Self {
159        if raw == QUANTITY_UNDEF {
160            check_predicate_true(
161                precision == 0,
162                "`precision` must be 0 when `raw` is QUANTITY_UNDEF",
163            )
164            .expect(FAILED);
165        }
166        check_predicate_true(
167            raw == QUANTITY_UNDEF || raw <= QUANTITY_RAW_MAX,
168            &format!("raw outside valid range, was {raw}"),
169        )
170        .expect(FAILED);
171        check_fixed_precision(precision).expect(FAILED);
172        Self { raw, precision }
173    }
174
175    /// Creates a new [`Quantity`] instance with a value of zero with the given `precision`.
176    ///
177    /// # Panics
178    ///
179    /// Panics if a correctness check fails. See [`Quantity::new_checked`] for more details.
180    #[must_use]
181    pub fn zero(precision: u8) -> Self {
182        check_fixed_precision(precision).expect(FAILED);
183        Self::new(0.0, precision)
184    }
185
186    /// Returns `true` if the value of this instance is undefined.
187    #[must_use]
188    pub fn is_undefined(&self) -> bool {
189        self.raw == QUANTITY_UNDEF
190    }
191
192    /// Returns `true` if the value of this instance is zero.
193    #[must_use]
194    pub fn is_zero(&self) -> bool {
195        self.raw == 0
196    }
197
198    /// Returns `true` if the value of this instance is position (> 0).
199    #[must_use]
200    pub fn is_positive(&self) -> bool {
201        self.raw != QUANTITY_UNDEF && self.raw > 0
202    }
203
204    /// Returns the value of this instance as an `f64`.
205    #[must_use]
206    #[cfg(feature = "high-precision")]
207    pub fn as_f64(&self) -> f64 {
208        fixed_u128_to_f64(self.raw)
209    }
210
211    #[cfg(not(feature = "high-precision"))]
212    pub fn as_f64(&self) -> f64 {
213        fixed_u64_to_f64(self.raw)
214    }
215
216    /// Returns the value of this instance as a `Decimal`.
217    #[must_use]
218    pub fn as_decimal(&self) -> Decimal {
219        // Scale down the raw value to match the precision
220        let rescaled_raw =
221            self.raw / QuantityRaw::pow(10, u32::from(FIXED_PRECISION - self.precision));
222        // SAFETY: The raw value is guaranteed to be within i128 range after scaling
223        // because our quantity constraints ensure the maximum raw value times the scaling
224        // factor cannot exceed i128::MAX (high-precision) or i64::MAX (standard-precision).
225        #[allow(clippy::useless_conversion)] // Required for precision modes
226        Decimal::from_i128_with_scale(rescaled_raw as i128, u32::from(self.precision))
227    }
228
229    /// Returns a formatted string representation of this instance.
230    #[must_use]
231    pub fn to_formatted_string(&self) -> String {
232        format!("{self}").separate_with_underscores()
233    }
234}
235
236impl From<Quantity> for f64 {
237    fn from(qty: Quantity) -> Self {
238        qty.as_f64()
239    }
240}
241
242impl From<&Quantity> for f64 {
243    fn from(qty: &Quantity) -> Self {
244        qty.as_f64()
245    }
246}
247
248impl From<i32> for Quantity {
249    fn from(value: i32) -> Self {
250        Self::new(value as f64, 0)
251    }
252}
253
254impl From<i64> for Quantity {
255    fn from(value: i64) -> Self {
256        Self::new(value as f64, 0)
257    }
258}
259
260impl From<u32> for Quantity {
261    fn from(value: u32) -> Self {
262        Self::new(value as f64, 0)
263    }
264}
265
266impl From<u64> for Quantity {
267    fn from(value: u64) -> Self {
268        Self::new(value as f64, 0)
269    }
270}
271
272impl Hash for Quantity {
273    fn hash<H: Hasher>(&self, state: &mut H) {
274        self.raw.hash(state);
275    }
276}
277
278impl PartialEq for Quantity {
279    fn eq(&self, other: &Self) -> bool {
280        self.raw == other.raw
281    }
282}
283
284impl PartialOrd for Quantity {
285    fn partial_cmp(&self, other: &Self) -> Option<Ordering> {
286        Some(self.cmp(other))
287    }
288
289    fn lt(&self, other: &Self) -> bool {
290        self.raw.lt(&other.raw)
291    }
292
293    fn le(&self, other: &Self) -> bool {
294        self.raw.le(&other.raw)
295    }
296
297    fn gt(&self, other: &Self) -> bool {
298        self.raw.gt(&other.raw)
299    }
300
301    fn ge(&self, other: &Self) -> bool {
302        self.raw.ge(&other.raw)
303    }
304}
305
306impl Ord for Quantity {
307    fn cmp(&self, other: &Self) -> Ordering {
308        self.raw.cmp(&other.raw)
309    }
310}
311
312impl Deref for Quantity {
313    type Target = QuantityRaw;
314
315    fn deref(&self) -> &Self::Target {
316        &self.raw
317    }
318}
319
320impl Add for Quantity {
321    type Output = Self;
322    fn add(self, rhs: Self) -> Self::Output {
323        let precision = match self.precision {
324            0 => rhs.precision,
325            _ => self.precision,
326        };
327        assert!(
328            self.precision >= rhs.precision,
329            "Precision mismatch: cannot add precision {} to precision {} (precision loss)",
330            rhs.precision,
331            self.precision,
332        );
333        Self {
334            raw: self
335                .raw
336                .checked_add(rhs.raw)
337                .expect("Overflow occurred when adding `Quantity`"),
338            precision,
339        }
340    }
341}
342
343impl Sub for Quantity {
344    type Output = Self;
345    fn sub(self, rhs: Self) -> Self::Output {
346        let precision = match self.precision {
347            0 => rhs.precision,
348            _ => self.precision,
349        };
350        assert!(
351            self.precision >= rhs.precision,
352            "Precision mismatch: cannot subtract precision {} from precision {} (precision loss)",
353            rhs.precision,
354            self.precision,
355        );
356        Self {
357            raw: self
358                .raw
359                .checked_sub(rhs.raw)
360                .expect("Underflow occurred when subtracting `Quantity`"),
361            precision,
362        }
363    }
364}
365
366#[allow(clippy::suspicious_arithmetic_impl)] // Can use division to scale back
367impl Mul for Quantity {
368    type Output = Self;
369    fn mul(self, rhs: Self) -> Self::Output {
370        let precision = match self.precision {
371            0 => rhs.precision,
372            _ => self.precision,
373        };
374        assert!(
375            self.precision >= rhs.precision,
376            "Precision mismatch: cannot multiply precision {} with precision {} (precision loss)",
377            rhs.precision,
378            self.precision,
379        );
380
381        let result_raw = self
382            .raw
383            .checked_mul(rhs.raw)
384            .expect("Overflow occurred when multiplying `Quantity`");
385
386        Self {
387            raw: result_raw / (FIXED_SCALAR as QuantityRaw),
388            precision,
389        }
390    }
391}
392
393impl Mul<f64> for Quantity {
394    type Output = f64;
395    fn mul(self, rhs: f64) -> Self::Output {
396        self.as_f64() * rhs
397    }
398}
399
400impl From<Quantity> for QuantityRaw {
401    fn from(value: Quantity) -> Self {
402        value.raw
403    }
404}
405
406impl From<&Quantity> for QuantityRaw {
407    fn from(value: &Quantity) -> Self {
408        value.raw
409    }
410}
411
412impl FromStr for Quantity {
413    type Err = String;
414
415    fn from_str(value: &str) -> Result<Self, Self::Err> {
416        let float_from_input = value
417            .replace('_', "")
418            .parse::<f64>()
419            .map_err(|e| format!("Error parsing `input` string '{value}' as f64: {e}"))?;
420
421        Self::new_checked(float_from_input, precision_from_str(value)).map_err(|e| e.to_string())
422    }
423}
424
425// Note: we can't implement `AsRef<str>` due overlapping traits (maybe there is a way)
426impl From<&str> for Quantity {
427    fn from(value: &str) -> Self {
428        Self::from_str(value).expect("Valid string input for `Quantity`")
429    }
430}
431
432impl From<String> for Quantity {
433    fn from(value: String) -> Self {
434        Self::from_str(&value).expect("Valid string input for `Quantity`")
435    }
436}
437
438impl From<&String> for Quantity {
439    fn from(value: &String) -> Self {
440        Self::from_str(value).expect("Valid string input for `Quantity`")
441    }
442}
443
444impl<T: Into<QuantityRaw>> AddAssign<T> for Quantity {
445    fn add_assign(&mut self, other: T) {
446        self.raw = self
447            .raw
448            .checked_add(other.into())
449            .expect("Overflow occurred when adding `Quantity`");
450    }
451}
452
453impl<T: Into<QuantityRaw>> SubAssign<T> for Quantity {
454    fn sub_assign(&mut self, other: T) {
455        self.raw = self
456            .raw
457            .checked_sub(other.into())
458            .expect("Underflow occurred when subtracting `Quantity`");
459    }
460}
461
462impl<T: Into<QuantityRaw>> MulAssign<T> for Quantity {
463    fn mul_assign(&mut self, other: T) {
464        self.raw = self
465            .raw
466            .checked_mul(other.into())
467            .expect("Overflow occurred when multiplying `Quantity`");
468    }
469}
470
471impl Debug for Quantity {
472    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
473        write!(
474            f,
475            "{}({:.*})",
476            stringify!(Quantity),
477            self.precision as usize,
478            self.as_f64(),
479        )
480    }
481}
482
483impl Display for Quantity {
484    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
485        write!(f, "{:.*}", self.precision as usize, self.as_f64())
486    }
487}
488
489impl Serialize for Quantity {
490    fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
491    where
492        S: serde::Serializer,
493    {
494        serializer.serialize_str(&self.to_string())
495    }
496}
497
498impl<'de> Deserialize<'de> for Quantity {
499    fn deserialize<D>(_deserializer: D) -> Result<Self, D::Error>
500    where
501        D: Deserializer<'de>,
502    {
503        let qty_str: &str = Deserialize::deserialize(_deserializer)?;
504        let qty: Self = qty_str.into();
505        Ok(qty)
506    }
507}
508
509/// Checks if the given quantity is positive.
510///
511/// # Errors
512///
513/// Returns an error if the quantity is not positive.
514pub fn check_positive_quantity(value: Quantity, param: &str) -> anyhow::Result<()> {
515    if !value.is_positive() {
516        anyhow::bail!("invalid `Quantity` for '{param}' not positive, was {value}")
517    }
518    Ok(())
519}
520
521////////////////////////////////////////////////////////////////////////////////
522// Tests
523////////////////////////////////////////////////////////////////////////////////
524#[cfg(test)]
525mod tests {
526    use std::str::FromStr;
527
528    use float_cmp::approx_eq;
529    use rstest::rstest;
530    use rust_decimal_macros::dec;
531
532    use super::*;
533
534    #[rstest]
535    #[should_panic(expected = "invalid `Quantity` for 'qty' not positive, was 0")]
536    fn test_check_quantity_positive() {
537        let qty = Quantity::new(0.0, 0);
538        check_positive_quantity(qty, "qty").unwrap();
539    }
540
541    #[rstest]
542    #[should_panic(expected = "Condition failed: `precision` exceeded maximum `FIXED_PRECISION`")]
543    fn test_invalid_precision_new() {
544        // Precision out of range for fixed
545        let _ = Quantity::new(1.0, FIXED_PRECISION + 1);
546    }
547
548    #[rstest]
549    #[should_panic(expected = "Condition failed: `precision` exceeded maximum `FIXED_PRECISION`")]
550    fn test_invalid_precision_from_raw() {
551        // Precision out of range for fixed
552        let _ = Quantity::from_raw(1, FIXED_PRECISION + 1);
553    }
554
555    #[rstest]
556    #[should_panic(expected = "Condition failed: `precision` exceeded maximum `FIXED_PRECISION`")]
557    fn test_invalid_precision_zero() {
558        // Precision out of range for fixed
559        let _ = Quantity::zero(FIXED_PRECISION + 1);
560    }
561
562    #[rstest]
563    #[should_panic(
564        expected = "Precision mismatch: cannot add precision 2 to precision 1 (precision loss)"
565    )]
566    fn test_precision_mismatch_add() {
567        let q1 = Quantity::new(1.0, 1);
568        let q2 = Quantity::new(1.0, 2);
569        let _ = q1 + q2;
570    }
571
572    #[rstest]
573    #[should_panic(
574        expected = "Precision mismatch: cannot subtract precision 2 from precision 1 (precision loss)"
575    )]
576    fn test_precision_mismatch_sub() {
577        let q1 = Quantity::new(1.0, 1);
578        let q2 = Quantity::new(1.0, 2);
579        let _ = q1 - q2;
580    }
581
582    #[rstest]
583    #[should_panic(
584        expected = "Precision mismatch: cannot multiply precision 2 with precision 1 (precision loss)"
585    )]
586    fn test_precision_mismatch_mul() {
587        let q1 = Quantity::new(2.0, 1);
588        let q2 = Quantity::new(3.0, 2);
589        let _ = q1 * q2;
590    }
591
592    #[rstest]
593    fn test_new_non_zero_ok() {
594        let qty = Quantity::non_zero_checked(123.456, 3).unwrap();
595        assert_eq!(qty.raw, Quantity::new(123.456, 3).raw);
596        assert!(qty.is_positive());
597    }
598
599    #[rstest]
600    fn test_new_non_zero_zero_input() {
601        assert!(Quantity::non_zero_checked(0.0, 0).is_err());
602    }
603
604    #[rstest]
605    fn test_new_non_zero_rounds_to_zero() {
606        // 0.0004 rounded to 3 dp ⇒ 0.000
607        assert!(Quantity::non_zero_checked(0.0004, 3).is_err());
608    }
609
610    #[rstest]
611    fn test_new_non_zero_negative() {
612        assert!(Quantity::non_zero_checked(-1.0, 0).is_err());
613    }
614
615    #[rstest]
616    fn test_new_non_zero_exceeds_max() {
617        assert!(Quantity::non_zero_checked(QUANTITY_MAX * 10.0, 0).is_err());
618    }
619
620    #[rstest]
621    fn test_new_non_zero_invalid_precision() {
622        assert!(Quantity::non_zero_checked(1.0, FIXED_PRECISION + 1).is_err());
623    }
624
625    #[rstest]
626    fn test_new() {
627        let value = 0.00812;
628        let qty = Quantity::new(value, 8);
629        assert_eq!(qty, qty);
630        assert_eq!(qty.raw, Quantity::from(&format!("{value}")).raw);
631        assert_eq!(qty.precision, 8);
632        assert_eq!(qty.as_f64(), 0.00812);
633        assert_eq!(qty.as_decimal(), dec!(0.00812000));
634        assert_eq!(qty.to_string(), "0.00812000");
635        assert!(!qty.is_zero());
636        assert!(qty.is_positive());
637        assert!(approx_eq!(f64, qty.as_f64(), 0.00812, epsilon = 0.000_001));
638    }
639
640    #[rstest]
641    fn test_check_quantity_positive_ok() {
642        let qty = Quantity::new(10.0, 0);
643        check_positive_quantity(qty, "qty").unwrap();
644    }
645
646    #[rstest]
647    fn test_negative_quantity_validation() {
648        assert!(Quantity::new_checked(-1.0, FIXED_PRECISION).is_err());
649    }
650
651    #[rstest]
652    fn test_undefined() {
653        let qty = Quantity::from_raw(QUANTITY_UNDEF, 0);
654        assert_eq!(qty.raw, QUANTITY_UNDEF);
655        assert!(qty.is_undefined());
656    }
657
658    #[rstest]
659    fn test_zero() {
660        let qty = Quantity::zero(8);
661        assert_eq!(qty.raw, 0);
662        assert_eq!(qty.precision, 8);
663        assert!(qty.is_zero());
664        assert!(!qty.is_positive());
665    }
666
667    #[rstest]
668    fn test_from_i32() {
669        let value = 100_000i32;
670        let qty = Quantity::from(value);
671        assert_eq!(qty, qty);
672        assert_eq!(qty.raw, Quantity::from(&format!("{value}")).raw);
673        assert_eq!(qty.precision, 0);
674    }
675
676    #[rstest]
677    fn test_from_u32() {
678        let value: u32 = 5000;
679        let qty = Quantity::from(value);
680        assert_eq!(qty.raw, Quantity::from(format!("{value}")).raw);
681        assert_eq!(qty.precision, 0);
682    }
683
684    #[rstest]
685    fn test_from_i64() {
686        let value = 100_000i64;
687        let qty = Quantity::from(value);
688        assert_eq!(qty, qty);
689        assert_eq!(qty.raw, Quantity::from(&format!("{value}")).raw);
690        assert_eq!(qty.precision, 0);
691    }
692
693    #[rstest]
694    fn test_from_u64() {
695        let value = 100_000u64;
696        let qty = Quantity::from(value);
697        assert_eq!(qty, qty);
698        assert_eq!(qty.raw, Quantity::from(&format!("{value}")).raw);
699        assert_eq!(qty.precision, 0);
700    }
701
702    #[rstest] // Test does not panic rather than exact value
703    fn test_with_maximum_value() {
704        let qty = Quantity::new_checked(QUANTITY_MAX, 0);
705        assert!(qty.is_ok());
706    }
707
708    #[rstest]
709    fn test_with_minimum_positive_value() {
710        let value = 0.000_000_001;
711        let qty = Quantity::new(value, 9);
712        assert_eq!(qty.raw, Quantity::from("0.000000001").raw);
713        assert_eq!(qty.as_decimal(), dec!(0.000000001));
714        assert_eq!(qty.to_string(), "0.000000001");
715    }
716
717    #[rstest]
718    fn test_with_minimum_value() {
719        let qty = Quantity::new(QUANTITY_MIN, 9);
720        assert_eq!(qty.raw, 0);
721        assert_eq!(qty.as_decimal(), dec!(0));
722        assert_eq!(qty.to_string(), "0.000000000");
723    }
724
725    #[rstest]
726    fn test_is_zero() {
727        let qty = Quantity::zero(8);
728        assert_eq!(qty, qty);
729        assert_eq!(qty.raw, 0);
730        assert_eq!(qty.precision, 8);
731        assert_eq!(qty.as_f64(), 0.0);
732        assert_eq!(qty.as_decimal(), dec!(0));
733        assert_eq!(qty.to_string(), "0.00000000");
734        assert!(qty.is_zero());
735    }
736
737    #[rstest]
738    fn test_precision() {
739        let value = 1.001;
740        let qty = Quantity::new(value, 2);
741        assert_eq!(qty.to_string(), "1.00");
742    }
743
744    #[rstest]
745    fn test_new_from_str() {
746        let qty = Quantity::new(0.00812000, 8);
747        assert_eq!(qty, qty);
748        assert_eq!(qty.precision, 8);
749        assert_eq!(qty.as_f64(), 0.00812);
750        assert_eq!(qty.to_string(), "0.00812000");
751    }
752
753    #[rstest]
754    #[case("0", 0)]
755    #[case("1.1", 1)]
756    #[case("1.123456789", 9)]
757    fn test_from_str_valid_input(#[case] input: &str, #[case] expected_prec: u8) {
758        let qty = Quantity::from(input);
759        assert_eq!(qty.precision, expected_prec);
760        assert_eq!(qty.as_decimal(), Decimal::from_str(input).unwrap());
761    }
762
763    #[rstest]
764    #[should_panic]
765    fn test_from_str_invalid_input() {
766        let input = "invalid";
767        Quantity::new(f64::from_str(input).unwrap(), 8);
768    }
769
770    #[rstest]
771    fn test_add() {
772        let a = 1.0;
773        let b = 2.0;
774        let quantity1 = Quantity::new(1.0, 0);
775        let quantity2 = Quantity::new(2.0, 0);
776        let quantity3 = quantity1 + quantity2;
777        assert_eq!(quantity3.raw, Quantity::new(a + b, 0).raw);
778    }
779
780    #[rstest]
781    fn test_sub() {
782        let a = 3.0;
783        let b = 2.0;
784        let quantity1 = Quantity::new(a, 0);
785        let quantity2 = Quantity::new(b, 0);
786        let quantity3 = quantity1 - quantity2;
787        assert_eq!(quantity3.raw, Quantity::new(a - b, 0).raw);
788    }
789
790    #[rstest]
791    fn test_add_assign() {
792        let a = 1.0;
793        let b = 2.0;
794        let mut quantity1 = Quantity::new(a, 0);
795        let quantity2 = Quantity::new(b, 0);
796        quantity1 += quantity2;
797        assert_eq!(quantity1.raw, Quantity::new(a + b, 0).raw);
798    }
799
800    #[rstest]
801    fn test_sub_assign() {
802        let a = 3.0;
803        let b = 2.0;
804        let mut quantity1 = Quantity::new(a, 0);
805        let quantity2 = Quantity::new(b, 0);
806        quantity1 -= quantity2;
807        assert_eq!(quantity1.raw, Quantity::new(a - b, 0).raw);
808    }
809
810    #[rstest]
811    fn test_mul() {
812        let value = 2.0;
813        let quantity1 = Quantity::new(value, 1);
814        let quantity2 = Quantity::new(value, 1);
815        let quantity3 = quantity1 * quantity2;
816        assert_eq!(quantity3.raw, Quantity::new(value * value, 0).raw);
817    }
818
819    #[rstest]
820    fn test_mul_assign() {
821        let mut quantity = Quantity::new(2.0, 0);
822        quantity *= 3u64; // calls MulAssign<T: Into<QuantityRaw>>
823        assert_eq!(quantity.raw, Quantity::new(6.0, 0).raw);
824
825        let mut fraction = Quantity::new(1.5, 2);
826        fraction *= 2u64; // => 1.5 * 2 = 3.0 => raw=300, precision=2
827        assert_eq!(fraction.raw, Quantity::new(3.0, 2).raw);
828    }
829
830    #[rstest]
831    fn test_comparisons() {
832        assert_eq!(Quantity::new(1.0, 1), Quantity::new(1.0, 1));
833        assert_eq!(Quantity::new(1.0, 1), Quantity::new(1.0, 2));
834        assert_ne!(Quantity::new(1.1, 1), Quantity::new(1.0, 1));
835        assert!(Quantity::new(1.0, 1) <= Quantity::new(1.0, 2));
836        assert!(Quantity::new(1.1, 1) > Quantity::new(1.0, 1));
837        assert!(Quantity::new(1.0, 1) >= Quantity::new(1.0, 1));
838        assert!(Quantity::new(1.0, 1) >= Quantity::new(1.0, 2));
839        assert!(Quantity::new(1.0, 1) >= Quantity::new(1.0, 2));
840        assert!(Quantity::new(0.9, 1) < Quantity::new(1.0, 1));
841        assert!(Quantity::new(0.9, 1) <= Quantity::new(1.0, 2));
842        assert!(Quantity::new(0.9, 1) <= Quantity::new(1.0, 1));
843    }
844
845    #[rstest]
846    fn test_debug() {
847        let quantity = Quantity::from_str("44.12").unwrap();
848        let result = format!("{quantity:?}");
849        assert_eq!(result, "Quantity(44.12)");
850    }
851
852    #[rstest]
853    fn test_display() {
854        let quantity = Quantity::from_str("44.12").unwrap();
855        let result = format!("{quantity}");
856        assert_eq!(result, "44.12");
857    }
858
859    #[rstest]
860    fn test_to_formatted_string() {
861        let qty = Quantity::new(1234.5678, 4);
862        let formatted = qty.to_formatted_string();
863        assert_eq!(formatted, "1_234.5678");
864        assert_eq!(qty.to_string(), "1234.5678");
865    }
866
867    #[rstest]
868    fn test_hash() {
869        use std::{
870            collections::hash_map::DefaultHasher,
871            hash::{Hash, Hasher},
872        };
873
874        let q1 = Quantity::new(100.0, 1);
875        let q2 = Quantity::new(100.0, 1);
876        let q3 = Quantity::new(200.0, 1);
877
878        let mut s1 = DefaultHasher::new();
879        let mut s2 = DefaultHasher::new();
880        let mut s3 = DefaultHasher::new();
881
882        q1.hash(&mut s1);
883        q2.hash(&mut s2);
884        q3.hash(&mut s3);
885
886        assert_eq!(
887            s1.finish(),
888            s2.finish(),
889            "Equal quantities must hash equally"
890        );
891        assert_ne!(
892            s1.finish(),
893            s3.finish(),
894            "Different quantities must hash differently"
895        );
896    }
897
898    #[rstest]
899    fn test_quantity_serde_json_round_trip() {
900        let original = Quantity::new(123.456, 3);
901        let json_str = serde_json::to_string(&original).unwrap();
902        assert_eq!(json_str, "\"123.456\"");
903
904        let deserialized: Quantity = serde_json::from_str(&json_str).unwrap();
905        assert_eq!(deserialized, original);
906        assert_eq!(deserialized.precision, 3);
907    }
908}