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    fn update(&mut self, input: f64) -> Option<f64> {
77        if !input.is_finite() {
78            return self.value();
79        }
80        if self.seen {
81            if input > self.peak {
82                self.peak = input;
83            }
84            if self.peak > 0.0 {
85                let dd = (self.peak - input) / self.peak;
86                if dd > self.max_dd {
87                    self.max_dd = dd;
88                }
89            }
90        } else {
91            self.first = input;
92            self.peak = input;
93            self.seen = true;
94        }
95        self.last = input;
96        self.value()
97    }
98
99    fn reset(&mut self) {
100        self.first = 0.0;
101        self.last = 0.0;
102        self.peak = f64::NEG_INFINITY;
103        self.max_dd = 0.0;
104        self.seen = false;
105    }
106
107    fn warmup_period(&self) -> usize {
108        1
109    }
110
111    fn is_ready(&self) -> bool {
112        self.seen && self.first != 0.0
113    }
114
115    fn name(&self) -> &'static str {
116        "RecoveryFactor"
117    }
118}
119
120#[cfg(test)]
121mod tests {
122    use super::*;
123    use crate::traits::BatchExt;
124    use approx::assert_relative_eq;
125
126    #[test]
127    fn accessors_and_metadata() {
128        let r = RecoveryFactor::new();
129        assert_eq!(r.name(), "RecoveryFactor");
130        assert_eq!(r.warmup_period(), 1);
131        assert_eq!(r.value(), None);
132    }
133
134    #[test]
135    fn pure_uptrend_yields_zero() {
136        let mut r = RecoveryFactor::new();
137        for v in 1..=10 {
138            r.update(f64::from(v));
139        }
140        // max_dd == 0 -> 0 by convention.
141        assert_eq!(r.value(), Some(0.0));
142    }
143
144    #[test]
145    fn reference_value() {
146        // Start 100, peak 110, trough 88 -> max_dd = 0.2.
147        // End 130 -> net_return = 0.3 -> Recovery = 1.5.
148        let mut r = RecoveryFactor::new();
149        let out = r.batch(&[100.0, 110.0, 105.0, 95.0, 88.0, 100.0, 120.0, 130.0]);
150        let last = out.last().copied().unwrap().unwrap();
151        assert_relative_eq!(last, 0.30 / 0.20, epsilon = 1e-9);
152    }
153
154    #[test]
155    fn ignores_non_finite_input() {
156        let mut r = RecoveryFactor::new();
157        r.update(100.0);
158        r.update(90.0);
159        let v = r.value();
160        assert_eq!(r.update(f64::NAN), v);
161        assert_eq!(r.update(f64::INFINITY), v);
162    }
163
164    #[test]
165    fn first_value_alone_yields_zero() {
166        // First update: max_dd is still 0 -> 0 by convention; value defined.
167        let mut r = RecoveryFactor::new();
168        assert_eq!(r.update(100.0), Some(0.0));
169    }
170
171    #[test]
172    fn first_zero_equity_keeps_value_none() {
173        // first == 0 means net-return division would be 0/0; indicator stays
174        // not-ready until a non-zero baseline is reset in.
175        let mut r = RecoveryFactor::new();
176        assert_eq!(r.update(0.0), None);
177        assert!(!r.is_ready());
178    }
179
180    #[test]
181    fn reset_clears_state() {
182        let mut r = RecoveryFactor::new();
183        r.batch(&[100.0, 90.0, 80.0]);
184        assert!(r.is_ready());
185        r.reset();
186        assert!(!r.is_ready());
187        assert_eq!(r.update(100.0), Some(0.0));
188    }
189
190    #[test]
191    fn batch_equals_streaming() {
192        let prices: Vec<f64> = (0..40)
193            .map(|i| 100.0 + (f64::from(i) * 0.3).sin() * 8.0)
194            .collect();
195        let batch = RecoveryFactor::new().batch(&prices);
196        let mut s = RecoveryFactor::new();
197        let streamed: Vec<_> = prices.iter().map(|p| s.update(*p)).collect();
198        assert_eq!(batch, streamed);
199    }
200
201    #[test]
202    fn non_positive_peak_skips_drawdown_calc() {
203        // All inputs <= 0 keep `peak` non-positive, so the guarded drawdown
204        // computation is skipped on every step. Exercises the `else` branch
205        // of `if self.peak > 0.0`.
206        let mut r = RecoveryFactor::new();
207        assert_eq!(r.update(-1.0), Some(0.0));
208        assert_eq!(r.update(-2.0), Some(0.0));
209        assert_eq!(r.update(-0.5), Some(0.0));
210        assert!(r.is_ready());
211    }
212}