Skip to main content

wickra_core/indicators/
pain_index.rs

1//! Rolling Pain Index — mean depth of drawdowns.
2
3use std::collections::VecDeque;
4
5use crate::error::{Error, Result};
6use crate::traits::Indicator;
7
8/// Rolling Pain Index — Thomas Becker's continuous-pain risk measure.
9///
10/// Input is treated as an equity-curve sample. The Pain Index is the **mean**
11/// drawdown depth over the trailing window of `period` bars, expressed as a
12/// non-negative fraction:
13///
14/// ```text
15/// peak_t   = running max over window up to t
16/// dd_t     = (peak_t − equity_t) / peak_t          (0 if no drawdown)
17/// PainIdx  = mean(dd_t over window)
18/// ```
19///
20/// Where Ulcer Index uses an RMS aggregation that punishes deep drawdowns
21/// disproportionately, the Pain Index uses a plain arithmetic mean. The two
22/// are normally similar; the Pain Index reads slightly lower on stresses with
23/// a few large drawdowns and similar elsewhere.
24///
25/// Each `update` is O(period).
26#[derive(Debug, Clone)]
27pub struct PainIndex {
28    period: usize,
29    window: VecDeque<f64>,
30}
31
32impl PainIndex {
33    /// Construct a new rolling Pain Index.
34    ///
35    /// # Errors
36    /// Returns [`Error::PeriodZero`] if `period == 0`.
37    pub fn new(period: usize) -> Result<Self> {
38        if period == 0 {
39            return Err(Error::PeriodZero);
40        }
41        if period > crate::error::MAX_PERIOD {
42            return Err(Error::InvalidPeriod {
43                message: crate::error::PERIOD_ABOVE_MAX,
44            });
45        }
46        Ok(Self {
47            period,
48            window: VecDeque::with_capacity(period),
49        })
50    }
51
52    /// Configured window length.
53    pub const fn period(&self) -> usize {
54        self.period
55    }
56}
57
58impl Indicator for PainIndex {
59    type Input = f64;
60    type Output = f64;
61
62    #[inline]
63    fn update(&mut self, input: f64) -> Option<f64> {
64        if !input.is_finite() {
65            return None;
66        }
67        if self.window.len() == self.period {
68            self.window.pop_front();
69        }
70        self.window.push_back(input);
71        if self.window.len() < self.period {
72            return None;
73        }
74        let mut peak = f64::NEG_INFINITY;
75        let mut sum_dd = 0.0_f64;
76        for &v in &self.window {
77            if v > peak {
78                peak = v;
79            }
80            if peak > 0.0 {
81                sum_dd += (peak - v) / peak;
82            }
83        }
84        Some(sum_dd / self.period as f64)
85    }
86
87    fn reset(&mut self) {
88        self.window.clear();
89    }
90
91    #[inline]
92    fn warmup_period(&self) -> usize {
93        self.period
94    }
95
96    #[inline]
97    fn is_ready(&self) -> bool {
98        self.window.len() == self.period
99    }
100
101    #[inline]
102    fn name(&self) -> &'static str {
103        "PainIndex"
104    }
105}
106
107#[cfg(test)]
108mod tests {
109    use super::*;
110    use crate::traits::BatchExt;
111    use approx::assert_relative_eq;
112
113    #[test]
114    fn rejects_zero_period() {
115        assert!(matches!(PainIndex::new(0), Err(Error::PeriodZero)));
116    }
117
118    #[test]
119    fn accessors_and_metadata() {
120        let p = PainIndex::new(10).unwrap();
121        assert_eq!(p.period(), 10);
122        assert_eq!(p.name(), "PainIndex");
123        assert_eq!(p.warmup_period(), 10);
124    }
125
126    #[test]
127    fn pure_uptrend_yields_zero() {
128        let mut p = PainIndex::new(5).unwrap();
129        let out = p.batch(&(1..=20).map(f64::from).collect::<Vec<_>>());
130        for v in out.into_iter().flatten() {
131            assert_relative_eq!(v, 0.0, epsilon = 1e-12);
132        }
133    }
134
135    #[test]
136    fn reference_value() {
137        // window [100, 120, 90]: peaks 100,120,120; dd: 0, 0, 0.25.
138        // Pain = 0.25 / 3 ≈ 0.08333...
139        let mut p = PainIndex::new(3).unwrap();
140        let out = p.batch(&[100.0, 120.0, 90.0]);
141        assert_relative_eq!(out[2].unwrap(), 0.25 / 3.0, epsilon = 1e-12);
142    }
143
144    #[test]
145    fn ignores_non_finite_input() {
146        let mut p = PainIndex::new(3).unwrap();
147        assert_eq!(p.update(f64::NAN), None);
148        assert_eq!(p.update(f64::INFINITY), None);
149    }
150
151    #[test]
152    fn reset_clears_state() {
153        let mut p = PainIndex::new(3).unwrap();
154        p.batch(&[100.0, 90.0, 110.0]);
155        assert!(p.is_ready());
156        p.reset();
157        assert!(!p.is_ready());
158        assert_eq!(p.update(100.0), None);
159    }
160
161    #[test]
162    fn batch_equals_streaming() {
163        let prices: Vec<f64> = (0..40)
164            .map(|i| 100.0 + (f64::from(i) * 0.3).sin() * 8.0)
165            .collect();
166        let batch = PainIndex::new(10).unwrap().batch(&prices);
167        let mut s = PainIndex::new(10).unwrap();
168        let streamed: Vec<_> = prices.iter().map(|p| s.update(*p)).collect();
169        assert_eq!(batch, streamed);
170    }
171
172    #[test]
173    fn non_positive_peak_yields_zero() {
174        let mut p = PainIndex::new(3).unwrap();
175        let out = p.batch(&[0.0_f64; 6]);
176        for v in out.into_iter().flatten() {
177            assert_eq!(v, 0.0);
178        }
179    }
180}