Skip to main content

wickra_core/indicators/
sterling_ratio.rs

1//! Sterling Ratio — mean return over the average drawdown 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/// peak_t    = max_{s<=t} equity_s
13/// dd_t      = (peak_t − equity_t) / peak_t      (fractional drawdown, >= 0)
14/// Sterling  = mean(returns) / mean(dd_t)
15/// ```
16///
17/// The Sterling Ratio rewards return per unit of *typical* pain: it divides the
18/// average per-period return by the **average drawdown** experienced along the
19/// compounded equity curve. Of the three drawdown-based ratios Wickra ships it is
20/// the gentlest on outliers — averaging the drawdowns means one deep crater does
21/// not dominate the way it does in the [`BurkeRatio`](crate::BurkeRatio) (which
22/// sums squared drawdowns) or the [`MartinRatio`](crate::MartinRatio) (which uses
23/// the root-mean-square percentage drawdown). A window that never draws down has
24/// zero average drawdown and the indicator reports `0.0`.
25///
26/// The first value lands after `period` returns; each `update` rebuilds the equity
27/// curve over the window (O(period)), which is O(1) in the length of the overall
28/// series.
29///
30/// # Example
31///
32/// ```
33/// use wickra_core::{Indicator, SterlingRatio};
34///
35/// let mut indicator = SterlingRatio::new(12).unwrap();
36/// let mut last = None;
37/// for i in 0..24 {
38///     last = indicator.update((f64::from(i) * 0.5).sin() * 0.05);
39/// }
40/// assert!(last.is_some());
41/// ```
42#[derive(Debug, Clone)]
43pub struct SterlingRatio {
44    period: usize,
45    window: VecDeque<f64>,
46}
47
48impl SterlingRatio {
49    /// Construct a Sterling Ratio over `period` returns.
50    ///
51    /// # Errors
52    ///
53    /// Returns [`Error::InvalidPeriod`] if `period < 2`.
54    pub fn new(period: usize) -> Result<Self> {
55        if period < 2 {
56            return Err(Error::InvalidPeriod {
57                message: "sterling ratio needs period >= 2",
58            });
59        }
60        Ok(Self {
61            period,
62            window: VecDeque::with_capacity(period),
63        })
64    }
65
66    /// Configured window of returns.
67    pub const fn period(&self) -> usize {
68        self.period
69    }
70
71    fn compute(&self) -> f64 {
72        #[allow(clippy::cast_precision_loss)]
73        let length = self.window.len() as f64;
74        let mut sum_return = 0.0;
75        let mut sum_drawdown = 0.0;
76        let mut equity = 1.0;
77        let mut peak: f64 = 1.0;
78        for ret in &self.window {
79            sum_return += *ret;
80            equity *= 1.0 + *ret;
81            peak = peak.max(equity);
82            sum_drawdown += (peak - equity) / peak;
83        }
84        let avg_drawdown = sum_drawdown / length;
85        if avg_drawdown > 0.0 {
86            (sum_return / length) / avg_drawdown
87        } else {
88            0.0
89        }
90    }
91}
92
93impl Indicator for SterlingRatio {
94    type Input = f64;
95    type Output = f64;
96
97    fn update(&mut self, ret: f64) -> Option<f64> {
98        if !ret.is_finite() {
99            return None;
100        }
101        if self.window.len() == self.period {
102            self.window.pop_front();
103        }
104        self.window.push_back(ret);
105        if self.window.len() < self.period {
106            return None;
107        }
108        Some(self.compute())
109    }
110
111    fn reset(&mut self) {
112        self.window.clear();
113    }
114
115    fn warmup_period(&self) -> usize {
116        self.period
117    }
118
119    fn is_ready(&self) -> bool {
120        self.window.len() == self.period
121    }
122
123    fn name(&self) -> &'static str {
124        "SterlingRatio"
125    }
126}
127
128#[cfg(test)]
129mod tests {
130    use super::*;
131    use crate::traits::BatchExt;
132    use approx::assert_relative_eq;
133
134    #[test]
135    fn rejects_period_less_than_two() {
136        assert!(matches!(
137            SterlingRatio::new(1),
138            Err(Error::InvalidPeriod { .. })
139        ));
140    }
141
142    #[test]
143    fn accessors_and_metadata() {
144        let sr = SterlingRatio::new(12).unwrap();
145        assert_eq!(sr.period(), 12);
146        assert_eq!(sr.warmup_period(), 12);
147        assert_eq!(sr.name(), "SterlingRatio");
148        assert!(!sr.is_ready());
149    }
150
151    #[test]
152    fn reference_value() {
153        // returns [0.1, -0.1, 0.1]:
154        //   equity 1.1, 0.99, 1.089; peak stays 1.1.
155        //   dd = [0, 0.1, 0.01]; avg_dd = 0.11/3; mean_return = 0.1/3.
156        //   Sterling = (0.1/3) / (0.11/3) = 0.1/0.11.
157        let mut sr = SterlingRatio::new(3).unwrap();
158        let out = sr.batch(&[0.1, -0.1, 0.1]);
159        assert_relative_eq!(out[2].unwrap(), 0.1_f64 / 0.11, epsilon = 1e-9);
160    }
161
162    #[test]
163    fn no_drawdown_is_zero() {
164        // Monotonically rising equity never draws down.
165        let mut sr = SterlingRatio::new(3).unwrap();
166        let last = sr
167            .batch(&[0.01, 0.02, 0.03])
168            .into_iter()
169            .flatten()
170            .last()
171            .unwrap();
172        assert_relative_eq!(last, 0.0, epsilon = 1e-12);
173    }
174
175    #[test]
176    fn losing_window_is_negative() {
177        let mut sr = SterlingRatio::new(3).unwrap();
178        let last = sr
179            .batch(&[-0.05, -0.02, -0.03])
180            .into_iter()
181            .flatten()
182            .last()
183            .unwrap();
184        assert!(last < 0.0);
185    }
186
187    #[test]
188    fn ignores_non_finite_input() {
189        let mut sr = SterlingRatio::new(3).unwrap();
190        assert_eq!(sr.update(0.1), None);
191        assert_eq!(sr.update(f64::NAN), None);
192        assert_eq!(sr.update(-0.1), None);
193        assert!(sr.update(0.1).is_some());
194    }
195
196    #[test]
197    fn reset_clears_state() {
198        let mut sr = SterlingRatio::new(3).unwrap();
199        sr.batch(&[0.1, -0.1, 0.1]);
200        assert!(sr.is_ready());
201        sr.reset();
202        assert!(!sr.is_ready());
203        assert_eq!(sr.update(0.1), None);
204    }
205
206    #[test]
207    fn batch_equals_streaming() {
208        let rets: Vec<f64> = (0..60)
209            .map(|i| (f64::from(i) * 0.25).sin() * 0.05)
210            .collect();
211        let batch = SterlingRatio::new(12).unwrap().batch(&rets);
212        let mut streamer = SterlingRatio::new(12).unwrap();
213        let streamed: Vec<_> = rets.iter().map(|r| streamer.update(*r)).collect();
214        assert_eq!(batch, streamed);
215    }
216}