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