Skip to main content

wickra_core/indicators/
recovery_factor.rs

1//! Recovery Factor — cumulative net return over max drawdown.
2
3use crate::traits::Indicator;
4
5/// Recovery Factor.
6///
7/// Input is treated as an equity-curve sample (e.g. total account equity).
8/// The indicator tracks the running all-time peak and the deepest drawdown
9/// seen so far, plus the cumulative net return relative to the *first*
10/// observation:
11///
12/// ```text
13/// peak       = max(equity since start)
14/// trough_dd  = max((peak − equity) / peak)
15/// net_return = (equity_last / equity_first) − 1
16/// Recovery   = net_return / trough_dd
17/// ```
18///
19/// `Recovery > 1` means the strategy has earned more than it ever lost on
20/// the way. A pure up-trend has no drawdown and the indicator reports `0.0`
21/// (the ratio is undefined; zero by convention).
22///
23/// Cumulative-from-start rather than rolling-windowed: the user resets to
24/// re-start the count. Each `update` is O(1).
25///
26/// # Example
27///
28/// ```
29/// use wickra_core::{Indicator, RecoveryFactor};
30///
31/// let mut r = RecoveryFactor::new();
32/// // Equity climbs, drops 20%, recovers and exceeds original peak.
33/// for v in [100.0, 110.0, 105.0, 95.0, 88.0, 100.0, 120.0, 130.0] {
34///     r.update(v);
35/// }
36/// assert!(r.value().unwrap() > 0.0);
37/// ```
38#[derive(Debug, Clone, Default)]
39pub struct RecoveryFactor {
40    first: f64,
41    last: f64,
42    peak: f64,
43    max_dd: f64,
44    seen: bool,
45}
46
47impl RecoveryFactor {
48    /// Construct a new Recovery Factor tracker.
49    pub const fn new() -> Self {
50        Self {
51            first: 0.0,
52            last: 0.0,
53            peak: f64::NEG_INFINITY,
54            max_dd: 0.0,
55            seen: false,
56        }
57    }
58
59    /// Current value if available.
60    pub fn value(&self) -> Option<f64> {
61        if !self.seen || self.first == 0.0 {
62            return None;
63        }
64        if self.max_dd == 0.0 {
65            return Some(0.0);
66        }
67        let net_return = (self.last / self.first) - 1.0;
68        Some(net_return / self.max_dd)
69    }
70}
71
72impl Indicator for RecoveryFactor {
73    type Input = f64;
74    type Output = f64;
75
76    #[inline]
77    fn update(&mut self, input: f64) -> Option<f64> {
78        if !input.is_finite() {
79            return None;
80        }
81        if self.seen {
82            if input > self.peak {
83                self.peak = input;
84            }
85            if self.peak > 0.0 {
86                let dd = (self.peak - input) / self.peak;
87                if dd > self.max_dd {
88                    self.max_dd = dd;
89                }
90            }
91        } else {
92            self.first = input;
93            self.peak = input;
94            self.seen = true;
95        }
96        self.last = input;
97        self.value()
98    }
99
100    fn reset(&mut self) {
101        self.first = 0.0;
102        self.last = 0.0;
103        self.peak = f64::NEG_INFINITY;
104        self.max_dd = 0.0;
105        self.seen = false;
106    }
107
108    #[inline]
109    fn warmup_period(&self) -> usize {
110        1
111    }
112
113    #[inline]
114    fn is_ready(&self) -> bool {
115        self.seen && self.first != 0.0
116    }
117
118    #[inline]
119    fn name(&self) -> &'static str {
120        "RecoveryFactor"
121    }
122}
123
124#[cfg(test)]
125mod tests {
126    use super::*;
127    use crate::traits::BatchExt;
128    use approx::assert_relative_eq;
129
130    #[test]
131    fn accessors_and_metadata() {
132        let r = RecoveryFactor::new();
133        assert_eq!(r.name(), "RecoveryFactor");
134        assert_eq!(r.warmup_period(), 1);
135        assert_eq!(r.value(), None);
136    }
137
138    #[test]
139    fn pure_uptrend_yields_zero() {
140        let mut r = RecoveryFactor::new();
141        for v in 1..=10 {
142            r.update(f64::from(v));
143        }
144        // max_dd == 0 -> 0 by convention.
145        assert_eq!(r.value(), Some(0.0));
146    }
147
148    #[test]
149    fn reference_value() {
150        // Start 100, peak 110, trough 88 -> max_dd = 0.2.
151        // End 130 -> net_return = 0.3 -> Recovery = 1.5.
152        let mut r = RecoveryFactor::new();
153        let out = r.batch(&[100.0, 110.0, 105.0, 95.0, 88.0, 100.0, 120.0, 130.0]);
154        let last = out.last().copied().unwrap().unwrap();
155        assert_relative_eq!(last, 0.30 / 0.20, epsilon = 1e-9);
156    }
157
158    #[test]
159    fn ignores_non_finite_input() {
160        let mut r = RecoveryFactor::new();
161        r.update(100.0);
162        r.update(90.0);
163        let v = r.value();
164        assert_eq!(r.update(f64::NAN), None);
165        assert_eq!(r.update(f64::INFINITY), None);
166        // The rejected input must not have disturbed the state.
167        assert_eq!(r.value(), v);
168    }
169
170    #[test]
171    fn first_value_alone_yields_zero() {
172        // First update: max_dd is still 0 -> 0 by convention; value defined.
173        let mut r = RecoveryFactor::new();
174        assert_eq!(r.update(100.0), Some(0.0));
175    }
176
177    #[test]
178    fn first_zero_equity_keeps_value_none() {
179        // first == 0 means net-return division would be 0/0; indicator stays
180        // not-ready until a non-zero baseline is reset in.
181        let mut r = RecoveryFactor::new();
182        assert_eq!(r.update(0.0), None);
183        assert!(!r.is_ready());
184    }
185
186    #[test]
187    fn reset_clears_state() {
188        let mut r = RecoveryFactor::new();
189        r.batch(&[100.0, 90.0, 80.0]);
190        assert!(r.is_ready());
191        r.reset();
192        assert!(!r.is_ready());
193        assert_eq!(r.update(100.0), Some(0.0));
194    }
195
196    #[test]
197    fn batch_equals_streaming() {
198        let prices: Vec<f64> = (0..40)
199            .map(|i| 100.0 + (f64::from(i) * 0.3).sin() * 8.0)
200            .collect();
201        let batch = RecoveryFactor::new().batch(&prices);
202        let mut s = RecoveryFactor::new();
203        let streamed: Vec<_> = prices.iter().map(|p| s.update(*p)).collect();
204        assert_eq!(batch, streamed);
205    }
206
207    #[test]
208    fn non_positive_peak_skips_drawdown_calc() {
209        // All inputs <= 0 keep `peak` non-positive, so the guarded drawdown
210        // computation is skipped on every step. Exercises the `else` branch
211        // of `if self.peak > 0.0`.
212        let mut r = RecoveryFactor::new();
213        assert_eq!(r.update(-1.0), Some(0.0));
214        assert_eq!(r.update(-2.0), Some(0.0));
215        assert_eq!(r.update(-0.5), Some(0.0));
216        assert!(r.is_ready());
217    }
218}