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        Ok(Self {
42            period,
43            window: VecDeque::with_capacity(period),
44        })
45    }
46
47    /// Configured window length.
48    pub const fn period(&self) -> usize {
49        self.period
50    }
51}
52
53impl Indicator for PainIndex {
54    type Input = f64;
55    type Output = f64;
56
57    fn update(&mut self, input: f64) -> Option<f64> {
58        if !input.is_finite() {
59            return None;
60        }
61        if self.window.len() == self.period {
62            self.window.pop_front();
63        }
64        self.window.push_back(input);
65        if self.window.len() < self.period {
66            return None;
67        }
68        let mut peak = f64::NEG_INFINITY;
69        let mut sum_dd = 0.0_f64;
70        for &v in &self.window {
71            if v > peak {
72                peak = v;
73            }
74            if peak > 0.0 {
75                sum_dd += (peak - v) / peak;
76            }
77        }
78        Some(sum_dd / self.period as f64)
79    }
80
81    fn reset(&mut self) {
82        self.window.clear();
83    }
84
85    fn warmup_period(&self) -> usize {
86        self.period
87    }
88
89    fn is_ready(&self) -> bool {
90        self.window.len() == self.period
91    }
92
93    fn name(&self) -> &'static str {
94        "PainIndex"
95    }
96}
97
98#[cfg(test)]
99mod tests {
100    use super::*;
101    use crate::traits::BatchExt;
102    use approx::assert_relative_eq;
103
104    #[test]
105    fn rejects_zero_period() {
106        assert!(matches!(PainIndex::new(0), Err(Error::PeriodZero)));
107    }
108
109    #[test]
110    fn accessors_and_metadata() {
111        let p = PainIndex::new(10).unwrap();
112        assert_eq!(p.period(), 10);
113        assert_eq!(p.name(), "PainIndex");
114        assert_eq!(p.warmup_period(), 10);
115    }
116
117    #[test]
118    fn pure_uptrend_yields_zero() {
119        let mut p = PainIndex::new(5).unwrap();
120        let out = p.batch(&(1..=20).map(f64::from).collect::<Vec<_>>());
121        for v in out.into_iter().flatten() {
122            assert_relative_eq!(v, 0.0, epsilon = 1e-12);
123        }
124    }
125
126    #[test]
127    fn reference_value() {
128        // window [100, 120, 90]: peaks 100,120,120; dd: 0, 0, 0.25.
129        // Pain = 0.25 / 3 ≈ 0.08333...
130        let mut p = PainIndex::new(3).unwrap();
131        let out = p.batch(&[100.0, 120.0, 90.0]);
132        assert_relative_eq!(out[2].unwrap(), 0.25 / 3.0, epsilon = 1e-12);
133    }
134
135    #[test]
136    fn ignores_non_finite_input() {
137        let mut p = PainIndex::new(3).unwrap();
138        assert_eq!(p.update(f64::NAN), None);
139        assert_eq!(p.update(f64::INFINITY), None);
140    }
141
142    #[test]
143    fn reset_clears_state() {
144        let mut p = PainIndex::new(3).unwrap();
145        p.batch(&[100.0, 90.0, 110.0]);
146        assert!(p.is_ready());
147        p.reset();
148        assert!(!p.is_ready());
149        assert_eq!(p.update(100.0), None);
150    }
151
152    #[test]
153    fn batch_equals_streaming() {
154        let prices: Vec<f64> = (0..40)
155            .map(|i| 100.0 + (f64::from(i) * 0.3).sin() * 8.0)
156            .collect();
157        let batch = PainIndex::new(10).unwrap().batch(&prices);
158        let mut s = PainIndex::new(10).unwrap();
159        let streamed: Vec<_> = prices.iter().map(|p| s.update(*p)).collect();
160        assert_eq!(batch, streamed);
161    }
162
163    #[test]
164    fn non_positive_peak_yields_zero() {
165        let mut p = PainIndex::new(3).unwrap();
166        let out = p.batch(&[0.0_f64; 6]);
167        for v in out.into_iter().flatten() {
168            assert_eq!(v, 0.0);
169        }
170    }
171}