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;