quant-primitives 0.7.0

Pure trading primitives — candles, intervals, symbols, currencies, asset taxonomy
Documentation
//! Validated percentage value object.
//!
//! Replaces raw `Decimal` fields like `max_drawdown_pct`, `margin_pct`, etc.
//! with a type that guarantees the value is in [0, 100].

use std::fmt;

use rust_decimal::Decimal;
use serde::{Deserialize, Serialize};

use crate::Fraction;

/// A percentage value validated to the range [0, 100].
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)]
pub struct Percentage(Decimal);

/// Error for invalid percentage construction.
#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
pub enum PercentageError {
    /// Value is outside the valid [0, 100] range.
    #[error("percentage {0} out of range [0, 100]")]
    OutOfRange(Decimal),
}

impl Percentage {
    /// Create a new percentage, validating that the value is in [0, 100].
    pub fn new(value: Decimal) -> Result<Self, PercentageError> {
        if value < Decimal::ZERO || value > Decimal::from(100) {
            return Err(PercentageError::OutOfRange(value));
        }
        Ok(Self(value))
    }

    /// Create a percentage from a trusted `u32` value.
    ///
    /// # Panics
    ///
    /// Panics if `value > 100`.
    pub fn from_trusted(value: u32) -> Self {
        assert!(value <= 100, "from_trusted: {value} > 100");
        Self(Decimal::from(value))
    }

    /// The raw percentage value (0-100).
    pub fn value(&self) -> Decimal {
        self.0
    }

    /// Convert to a fraction in [0.0, 1.0] (raw Decimal).
    pub fn as_fraction(&self) -> Decimal {
        self.0 / Decimal::from(100)
    }

    /// Convert to a validated [`Fraction`] value.
    pub fn to_fraction(&self) -> Fraction {
        // Safety: Percentage is [0, 100], so / 100 is always [0, 1].
        Fraction(self.0 / Decimal::from(100))
    }

    /// Returns `true` if this value looks like it was passed as a fraction (0-1)
    /// instead of a percentage (0-100).
    ///
    /// Values < 1.0 are suspiciously small for most percentage use cases
    /// (stop loss, margin requirement, risk limit).
    ///
    /// # Example
    /// ```
    /// use rust_decimal_macros::dec;
    /// use quant_primitives::Percentage;
    ///
    /// let pct = Percentage::new(dec!(0.05)).unwrap();
    /// assert!(pct.is_likely_fraction()); // 0.05% is suspicious — caller probably meant 5%
    ///
    /// let normal = Percentage::new(dec!(5)).unwrap();
    /// assert!(!normal.is_likely_fraction()); // 5% is plausible
    /// ```
    pub fn is_likely_fraction(&self) -> bool {
        self.0 > Decimal::ZERO && self.0 < Decimal::ONE
    }

    /// The zero percentage.
    pub fn zero() -> Self {
        Self(Decimal::ZERO)
    }

    /// The 100% percentage.
    pub fn hundred() -> Self {
        Self(Decimal::from(100))
    }
}

impl fmt::Display for Percentage {
    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
        write!(f, "{}%", self.0.normalize())
    }
}

#[cfg(test)]
#[path = "percentage_tests.rs"]
mod tests;