Skip to main content

quant_primitives/
percentage.rs

1//! Validated percentage value object.
2//!
3//! Replaces raw `Decimal` fields like `max_drawdown_pct`, `margin_pct`, etc.
4//! with a type that guarantees the value is in [0, 100].
5
6use std::fmt;
7
8use rust_decimal::Decimal;
9use serde::{Deserialize, Serialize};
10
11use crate::Fraction;
12
13/// A percentage value validated to the range [0, 100].
14#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)]
15pub struct Percentage(Decimal);
16
17/// Error for invalid percentage construction.
18#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
19pub enum PercentageError {
20    /// Value is outside the valid [0, 100] range.
21    #[error("percentage {0} out of range [0, 100]")]
22    OutOfRange(Decimal),
23}
24
25impl Percentage {
26    /// Create a new percentage, validating that the value is in [0, 100].
27    pub fn new(value: Decimal) -> Result<Self, PercentageError> {
28        if value < Decimal::ZERO || value > Decimal::from(100) {
29            return Err(PercentageError::OutOfRange(value));
30        }
31        Ok(Self(value))
32    }
33
34    /// Create a percentage from a trusted `u32` value.
35    ///
36    /// # Panics
37    ///
38    /// Panics if `value > 100`.
39    pub fn from_trusted(value: u32) -> Self {
40        assert!(value <= 100, "from_trusted: {value} > 100");
41        Self(Decimal::from(value))
42    }
43
44    /// The raw percentage value (0-100).
45    pub fn value(&self) -> Decimal {
46        self.0
47    }
48
49    /// Convert to a fraction in [0.0, 1.0] (raw Decimal).
50    pub fn as_fraction(&self) -> Decimal {
51        self.0 / Decimal::from(100)
52    }
53
54    /// Convert to a validated [`Fraction`] value.
55    pub fn to_fraction(&self) -> Fraction {
56        // Safety: Percentage is [0, 100], so / 100 is always [0, 1].
57        Fraction(self.0 / Decimal::from(100))
58    }
59
60    /// Returns `true` if this value looks like it was passed as a fraction (0-1)
61    /// instead of a percentage (0-100).
62    ///
63    /// Values < 1.0 are suspiciously small for most percentage use cases
64    /// (stop loss, margin requirement, risk limit).
65    ///
66    /// # Example
67    /// ```
68    /// use rust_decimal_macros::dec;
69    /// use quant_primitives::Percentage;
70    ///
71    /// let pct = Percentage::new(dec!(0.05)).unwrap();
72    /// assert!(pct.is_likely_fraction()); // 0.05% is suspicious — caller probably meant 5%
73    ///
74    /// let normal = Percentage::new(dec!(5)).unwrap();
75    /// assert!(!normal.is_likely_fraction()); // 5% is plausible
76    /// ```
77    pub fn is_likely_fraction(&self) -> bool {
78        self.0 > Decimal::ZERO && self.0 < Decimal::ONE
79    }
80
81    /// The zero percentage.
82    pub fn zero() -> Self {
83        Self(Decimal::ZERO)
84    }
85
86    /// The 100% percentage.
87    pub fn hundred() -> Self {
88        Self(Decimal::from(100))
89    }
90}
91
92impl fmt::Display for Percentage {
93    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
94        write!(f, "{}%", self.0.normalize())
95    }
96}
97
98#[cfg(test)]
99#[path = "percentage_tests.rs"]
100mod tests;