Skip to main content

wickra_core/indicators/
sterling_ratio.rs

1//! Sterling Ratio — mean return over the average drawdown episode of the equity curve.
2
3use std::collections::VecDeque;
4
5use crate::error::{Error, Result};
6use crate::traits::Indicator;
7
8/// Sterling Ratio over a trailing window of `period` returns.
9///
10/// ```text
11/// equity_t  = Π_{i<=t} (1 + return_i)          (compounded curve)
12/// episode   = a stretch below the running peak, closed by a full recovery
13/// D_j       = (peak_j − trough_j) / peak_j      (depth of episode j)
14/// Sterling  = mean(returns) / mean(D_j)
15/// ```
16///
17/// The Sterling Ratio rewards return per unit of *typical* drawdown: it divides
18/// the average per-period return by the **average depth of the drawdown
19/// episodes** in the window (Bacon's form of Deane Sterling Jones' ratio, with
20/// every episode averaged). An episode still open at the window's right edge is
21/// booked at its current trough. Averaging episode depths — not every bar under
22/// water, which would be the [`PainIndex`](crate::PainIndex) — means one deep
23/// crater does not dominate the way it does in the
24/// [`BurkeRatio`](crate::BurkeRatio) (root of the summed squared episode depths).
25/// A window that never draws down reports `0.0`.
26///
27/// The first value lands after `period` returns; each `update` rebuilds the equity
28/// curve over the window (O(period)), which is O(1) in the length of the overall
29/// series.
30///
31/// # Example
32///
33/// ```
34/// use wickra_core::{Indicator, SterlingRatio};
35///
36/// let mut indicator = SterlingRatio::new(12).unwrap();
37/// let mut last = None;
38/// for i in 0..24 {
39///     last = indicator.update((f64::from(i) * 0.5).sin() * 0.05);
40/// }
41/// assert!(last.is_some());
42/// ```
43#[derive(Debug, Clone)]
44pub struct SterlingRatio {
45    period: usize,
46    window: VecDeque<f64>,
47}
48
49impl SterlingRatio {
50    /// Construct a Sterling Ratio over `period` returns.
51    ///
52    /// # Errors
53    ///
54    /// Returns [`Error::InvalidPeriod`] if `period < 2`.
55    pub fn new(period: usize) -> Result<Self> {
56        if period < 2 {
57            return Err(Error::InvalidPeriod {
58                message: "sterling ratio needs period >= 2",
59            });
60        }
61        if period > crate::error::MAX_PERIOD {
62            return Err(Error::InvalidPeriod {
63                message: crate::error::PERIOD_ABOVE_MAX,
64            });
65        }
66        Ok(Self {
67            period,
68            window: VecDeque::with_capacity(period),
69        })
70    }
71
72    /// Configured window of returns.
73    pub const fn period(&self) -> usize {
74        self.period
75    }
76
77    fn compute(&self) -> f64 {
78        #[allow(clippy::cast_precision_loss)]
79        let length = self.window.len() as f64;
80        let mut sum_return = 0.0;
81        let mut equity = 1.0;
82        let mut peak: f64 = 1.0;
83        let mut trough = f64::INFINITY;
84        let mut underwater = false;
85        let mut depths = 0.0;
86        let mut episodes = 0usize;
87        for ret in &self.window {
88            sum_return += *ret;
89            equity *= 1.0 + *ret;
90            if equity >= peak {
91                if underwater {
92                    // Full recovery closes the episode at its trough depth.
93                    let depth = (peak - trough) / peak;
94                    depths += depth;
95                    episodes += 1;
96                    underwater = false;
97                }
98                peak = equity;
99            } else if underwater {
100                trough = trough.min(equity);
101            } else {
102                underwater = true;
103                trough = equity;
104            }
105        }
106        if underwater {
107            // An episode still open at the window's edge is booked at its trough.
108            let depth = (peak - trough) / peak;
109            depths += depth;
110            episodes += 1;
111        }
112        if episodes == 0 {
113            return 0.0;
114        }
115        #[allow(clippy::cast_precision_loss)]
116        let avg_drawdown = depths / episodes as f64;
117        if avg_drawdown > 0.0 {
118            (sum_return / length) / avg_drawdown
119        } else {
120            0.0
121        }
122    }
123}
124
125impl Indicator for SterlingRatio {
126    type Input = f64;
127    type Output = f64;
128
129    #[inline]
130    fn update(&mut self, ret: f64) -> Option<f64> {
131        if !ret.is_finite() {
132            return None;
133        }
134        if self.window.len() == self.period {
135            self.window.pop_front();
136        }
137        self.window.push_back(ret);
138        if self.window.len() < self.period {
139            return None;
140        }
141        Some(self.compute())
142    }
143
144    fn reset(&mut self) {
145        self.window.clear();
146    }
147
148    #[inline]
149    fn warmup_period(&self) -> usize {
150        self.period
151    }
152
153    #[inline]
154    fn is_ready(&self) -> bool {
155        self.window.len() == self.period
156    }
157
158    #[inline]
159    fn name(&self) -> &'static str {
160        "SterlingRatio"
161    }
162}
163
164#[cfg(test)]
165mod tests {
166    use super::*;
167    use crate::traits::BatchExt;
168    use approx::assert_relative_eq;
169
170    #[test]
171    fn rejects_period_less_than_two() {
172        assert!(matches!(
173            SterlingRatio::new(1),
174            Err(Error::InvalidPeriod { .. })
175        ));
176    }
177
178    #[test]
179    fn accessors_and_metadata() {
180        let sr = SterlingRatio::new(12).unwrap();
181        assert_eq!(sr.period(), 12);
182        assert_eq!(sr.warmup_period(), 12);
183        assert_eq!(sr.name(), "SterlingRatio");
184        assert!(!sr.is_ready());
185    }
186
187    #[test]
188    fn reference_value() {
189        // returns [0.1, -0.1, 0.1]:
190        //   equity 1.1, 0.99, 1.089; peak stays 1.1.
191        //   One open episode, depth (1.1 − 0.99) / 1.1 = 0.1; mean_return = 0.1/3.
192        //   Sterling = (0.1/3) / 0.1.
193        let mut sr = SterlingRatio::new(3).unwrap();
194        let out = sr.batch(&[0.1, -0.1, 0.1]);
195        assert_relative_eq!(out[2].unwrap(), (0.1_f64 / 3.0) / 0.1, epsilon = 1e-9);
196    }
197
198    #[test]
199    fn no_drawdown_is_zero() {
200        // Monotonically rising equity never draws down.
201        let mut sr = SterlingRatio::new(3).unwrap();
202        let last = sr
203            .batch(&[0.01, 0.02, 0.03])
204            .into_iter()
205            .flatten()
206            .last()
207            .unwrap();
208        assert_relative_eq!(last, 0.0, epsilon = 1e-12);
209    }
210
211    #[test]
212    fn losing_window_is_negative() {
213        let mut sr = SterlingRatio::new(3).unwrap();
214        let last = sr
215            .batch(&[-0.05, -0.02, -0.03])
216            .into_iter()
217            .flatten()
218            .last()
219            .unwrap();
220        assert!(last < 0.0);
221    }
222
223    #[test]
224    fn ignores_non_finite_input() {
225        let mut sr = SterlingRatio::new(3).unwrap();
226        assert_eq!(sr.update(0.1), None);
227        assert_eq!(sr.update(f64::NAN), None);
228        assert_eq!(sr.update(-0.1), None);
229        assert!(sr.update(0.1).is_some());
230    }
231
232    #[test]
233    fn reset_clears_state() {
234        let mut sr = SterlingRatio::new(3).unwrap();
235        sr.batch(&[0.1, -0.1, 0.1]);
236        assert!(sr.is_ready());
237        sr.reset();
238        assert!(!sr.is_ready());
239        assert_eq!(sr.update(0.1), None);
240    }
241
242    #[test]
243    fn batch_equals_streaming() {
244        let rets: Vec<f64> = (0..60)
245            .map(|i| (f64::from(i) * 0.25).sin() * 0.05)
246            .collect();
247        let batch = SterlingRatio::new(12).unwrap().batch(&rets);
248        let mut streamer = SterlingRatio::new(12).unwrap();
249        let streamed: Vec<_> = rets.iter().map(|r| streamer.update(*r)).collect();
250        assert_eq!(batch, streamed);
251    }
252
253    #[test]
254    fn rejects_zero_and_oversized_period() {
255        assert!(matches!(
256            SterlingRatio::new(0),
257            Err(Error::InvalidPeriod { .. })
258        ));
259        let too_long = crate::error::MAX_PERIOD + 1;
260        assert!(matches!(
261            SterlingRatio::new(too_long),
262            Err(Error::InvalidPeriod { .. })
263        ));
264        assert!(SterlingRatio::new(2).is_ok());
265    }
266
267    fn wavy(len: i32) -> Vec<f64> {
268        (0..len)
269            .map(|i| (f64::from(i) * 0.6).sin() * 0.04 + (f64::from(i) * 1.7).cos() * 0.02)
270            .collect()
271    }
272
273    #[test]
274    fn first_value_lands_exactly_at_warmup_index() {
275        let rets = wavy(30);
276        let mut ratio = SterlingRatio::new(8).unwrap();
277        let warmup = ratio.warmup_period();
278        let out = ratio.batch(&rets);
279        assert!(out.iter().take(warmup - 1).all(Option::is_none));
280        assert!(out.iter().skip(warmup - 1).all(Option::is_some));
281    }
282
283    #[test]
284    fn reset_replays_identically_to_fresh_instance() {
285        let rets = wavy(50);
286        let mut used = SterlingRatio::new(10).unwrap();
287        used.batch(&rets);
288        used.reset();
289        let replay = used.batch(&rets);
290        assert_eq!(replay, SterlingRatio::new(10).unwrap().batch(&rets));
291    }
292
293    #[test]
294    fn batch_nan_into_matches_streaming_bits() {
295        let rets = wavy(70);
296        let mut nan_out = vec![0.0; rets.len()];
297        SterlingRatio::new(9)
298            .unwrap()
299            .batch_nan_into(&rets, &mut nan_out);
300        let mut streamer = SterlingRatio::new(9).unwrap();
301        let identical = rets
302            .iter()
303            .zip(&nan_out)
304            .all(|(r, v)| streamer.update(*r).unwrap_or(f64::NAN).to_bits() == v.to_bits());
305        assert!(identical);
306    }
307
308    /// An overflowing equity curve (`inf · 0 = NaN`) poisons the episode depth;
309    /// the non-positive / NaN denominator guard must report `0.0`, not NaN.
310    #[test]
311    fn nan_depth_from_overflowing_equity_reports_zero() {
312        let mut ratio = SterlingRatio::new(3).unwrap();
313        let out = ratio.batch(&[1e300, 1e300, -1.0]);
314        assert_eq!(out[2], Some(0.0));
315    }
316
317    /// Two closed episodes. Returns `[-0.5, 1.0, -0.5, 1.0]`:
318    /// equity `0.5, 1.0, 0.5, 1.0`; each dip from the `1.0` peak to `0.5` is fully
319    /// recovered (equity == peak closes it): `D_1 = D_2 = 0.5`, mean depth `0.5`.
320    /// `mean return = 0.25`, `Sterling = 0.25 / 0.5 = 0.5`.
321    #[test]
322    fn reference_two_closed_episodes() {
323        let mut sr = SterlingRatio::new(4).unwrap();
324        let out = sr.batch(&[-0.5, 1.0, -0.5, 1.0]);
325        assert_relative_eq!(out[3].unwrap(), 0.5, epsilon = 1e-12);
326    }
327
328    /// Several troughs inside one episode plus an episode open at the window edge.
329    /// Returns `[0.25, −0.2, 0.5, −0.5, 0.5, −0.5, 0.6]`:
330    /// equity `1.25` (peak), `1.0` (episode 1 opens, trough 1.0),
331    /// `1.5` (recovers: `D_1 = (1.25 − 1.0) / 1.25 = 0.2`, new peak 1.5),
332    /// `0.75` (episode 2 opens), `1.125` (still under water, trough stays 0.75),
333    /// `0.5625` (deeper trough), `0.9` (still under water at the edge):
334    /// `D_2 = (1.5 − 0.5625) / 1.5 = 0.625`.
335    /// `mean depth = (0.2 + 0.625) / 2 = 0.4125`, `mean return = 0.65 / 7`,
336    /// `Sterling = (0.65 / 7) / 0.4125`.
337    #[test]
338    fn reference_multi_trough_and_open_episode() {
339        let mut sr = SterlingRatio::new(7).unwrap();
340        let out = sr.batch(&[0.25, -0.2, 0.5, -0.5, 0.5, -0.5, 0.6]);
341        assert_relative_eq!(out[6].unwrap(), (0.65 / 7.0) / 0.4125, epsilon = 1e-12);
342    }
343
344    #[test]
345    fn rolling_window_drops_episodes_that_slide_out() {
346        // Period 4 over `[-0.5, 1.0, 0.1, 0.1, 0.1]`: the first window holds the
347        // closed 0.5-deep episode; once `-0.5` leaves, the window only rises and
348        // reports `0.0` (no episodes).
349        let out = SterlingRatio::new(4)
350            .unwrap()
351            .batch(&[-0.5, 1.0, 0.1, 0.1, 0.1]);
352        assert_relative_eq!(out[3].unwrap(), (0.7 / 4.0) / 0.5, epsilon = 1e-12);
353        assert_eq!(out[4], Some(0.0));
354    }
355}