Skip to main content

use_drawdown/
lib.rs

1#![forbid(unsafe_code)]
2#![doc = include_str!("../README.md")]
3
4use core::fmt;
5use std::error::Error;
6
7/// Common drawdown primitives.
8pub mod prelude {
9    pub use crate::{Drawdown, DrawdownError, DrawdownPoint, DrawdownWindow};
10}
11
12/// A finite drawdown value using the convention `current / peak - 1.0`, capped at `0.0`.
13#[derive(Clone, Copy, Debug, PartialEq, PartialOrd)]
14pub struct Drawdown {
15    value: f64,
16}
17
18impl Drawdown {
19    /// Creates a drawdown value.
20    ///
21    /// # Errors
22    ///
23    /// Returns [`DrawdownError::NonFinite`] for non-finite values and
24    /// [`DrawdownError::Positive`] for positive values.
25    pub fn new(value: f64) -> Result<Self, DrawdownError> {
26        if !value.is_finite() {
27            return Err(DrawdownError::NonFinite);
28        }
29
30        if value > 0.0 {
31            return Err(DrawdownError::Positive);
32        }
33
34        Ok(Self { value })
35    }
36
37    /// Computes drawdown from a positive peak and non-negative current value.
38    ///
39    /// # Errors
40    ///
41    /// Returns [`DrawdownError`] when inputs are non-finite, the peak is not positive, or the
42    /// current value is negative.
43    pub fn from_peak_current(peak: f64, current: f64) -> Result<Self, DrawdownError> {
44        validate_peak(peak)?;
45        validate_current(current)?;
46
47        if current >= peak {
48            Self::new(0.0)
49        } else {
50            Self::new((current / peak) - 1.0)
51        }
52    }
53
54    /// Computes maximum drawdown over a simple price or equity series.
55    ///
56    /// # Errors
57    ///
58    /// Returns [`DrawdownError`] for empty series, invalid first peak, or invalid values.
59    pub fn maximum_from_values(values: &[f64]) -> Result<Self, DrawdownError> {
60        let Some((&first, rest)) = values.split_first() else {
61            return Err(DrawdownError::EmptySeries);
62        };
63
64        validate_peak(first)?;
65        let mut peak = first;
66        let mut maximum = Self::new(0.0)?;
67
68        for &value in rest {
69            validate_current(value)?;
70            if value > peak {
71                peak = value;
72            }
73
74            let drawdown = Self::from_peak_current(peak, value)?;
75            if drawdown.value() < maximum.value() {
76                maximum = drawdown;
77            }
78        }
79
80        Ok(maximum)
81    }
82
83    /// Returns the drawdown value.
84    #[must_use]
85    pub const fn value(self) -> f64 {
86        self.value
87    }
88}
89
90impl fmt::Display for Drawdown {
91    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
92        self.value.fmt(formatter)
93    }
94}
95
96/// A drawdown point with an optional label.
97#[derive(Clone, Debug, PartialEq)]
98pub struct DrawdownPoint {
99    label: Option<String>,
100    drawdown: Drawdown,
101}
102
103impl DrawdownPoint {
104    /// Creates an unlabeled drawdown point.
105    #[must_use]
106    pub const fn new(drawdown: Drawdown) -> Self {
107        Self {
108            label: None,
109            drawdown,
110        }
111    }
112
113    /// Attaches a non-empty label.
114    ///
115    /// # Errors
116    ///
117    /// Returns [`DrawdownError::EmptyLabel`] when the trimmed label is empty.
118    pub fn with_label(mut self, label: impl AsRef<str>) -> Result<Self, DrawdownError> {
119        let trimmed = label.as_ref().trim();
120        if trimmed.is_empty() {
121            return Err(DrawdownError::EmptyLabel);
122        }
123
124        self.label = Some(trimmed.to_string());
125        Ok(self)
126    }
127
128    /// Returns the optional label.
129    #[must_use]
130    pub fn label(&self) -> Option<&str> {
131        self.label.as_deref()
132    }
133
134    /// Returns the drawdown.
135    #[must_use]
136    pub const fn drawdown(&self) -> Drawdown {
137        self.drawdown
138    }
139}
140
141/// A simple observation-count drawdown window.
142#[derive(Clone, Copy, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)]
143pub struct DrawdownWindow {
144    length: usize,
145}
146
147impl DrawdownWindow {
148    /// Creates a non-zero drawdown window length.
149    ///
150    /// # Errors
151    ///
152    /// Returns [`DrawdownError::ZeroWindow`] when `length` is zero.
153    pub const fn new(length: usize) -> Result<Self, DrawdownError> {
154        if length == 0 {
155            Err(DrawdownError::ZeroWindow)
156        } else {
157            Ok(Self { length })
158        }
159    }
160
161    /// Returns the window length.
162    #[must_use]
163    pub const fn length(self) -> usize {
164        self.length
165    }
166}
167
168/// Errors returned by drawdown helpers.
169#[derive(Clone, Copy, Debug, Eq, PartialEq)]
170pub enum DrawdownError {
171    /// Drawdown and series values must be finite.
172    NonFinite,
173    /// Drawdowns use zero-or-negative convention.
174    Positive,
175    /// Peak values must be finite and strictly positive.
176    InvalidPeak,
177    /// Current values must not be negative.
178    NegativeValue,
179    /// Maximum drawdown requires at least one value.
180    EmptySeries,
181    /// Window lengths must be non-zero.
182    ZeroWindow,
183    /// Labels must be non-empty after trimming whitespace.
184    EmptyLabel,
185}
186
187impl fmt::Display for DrawdownError {
188    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
189        match self {
190            Self::NonFinite => formatter.write_str("drawdown values must be finite"),
191            Self::Positive => {
192                formatter.write_str("drawdown cannot be positive with this convention")
193            },
194            Self::InvalidPeak => formatter.write_str("drawdown peak must be finite and positive"),
195            Self::NegativeValue => formatter.write_str("drawdown current value cannot be negative"),
196            Self::EmptySeries => {
197                formatter.write_str("maximum drawdown requires at least one value")
198            },
199            Self::ZeroWindow => formatter.write_str("drawdown window length must be non-zero"),
200            Self::EmptyLabel => formatter.write_str("drawdown point label cannot be empty"),
201        }
202    }
203}
204
205impl Error for DrawdownError {}
206
207fn validate_peak(value: f64) -> Result<(), DrawdownError> {
208    if !value.is_finite() || value <= 0.0 {
209        Err(DrawdownError::InvalidPeak)
210    } else {
211        Ok(())
212    }
213}
214
215fn validate_current(value: f64) -> Result<(), DrawdownError> {
216    if !value.is_finite() {
217        return Err(DrawdownError::NonFinite);
218    }
219
220    if value < 0.0 {
221        return Err(DrawdownError::NegativeValue);
222    }
223
224    Ok(())
225}
226
227#[cfg(test)]
228mod tests {
229    use super::{Drawdown, DrawdownError};
230
231    #[test]
232    fn computes_drawdown_from_peak_current() {
233        let drawdown = Drawdown::from_peak_current(120.0, 90.0).expect("drawdown should compute");
234
235        assert!((drawdown.value() + 0.25).abs() < f64::EPSILON);
236    }
237
238    #[test]
239    fn returns_zero_drawdown_at_new_high() {
240        let drawdown = Drawdown::from_peak_current(120.0, 130.0).expect("drawdown should compute");
241
242        assert!(drawdown.value().abs() < f64::EPSILON);
243    }
244
245    #[test]
246    fn rejects_invalid_peak() {
247        assert_eq!(
248            Drawdown::from_peak_current(0.0, 90.0),
249            Err(DrawdownError::InvalidPeak)
250        );
251    }
252
253    #[test]
254    fn computes_maximum_drawdown() {
255        let drawdown = Drawdown::maximum_from_values(&[100.0, 120.0, 90.0, 80.0, 130.0])
256            .expect("drawdown should compute");
257
258        assert!((drawdown.value() - (-1.0 / 3.0)).abs() < 1.0e-12);
259    }
260}