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        if period > crate::error::MAX_PERIOD {
61            return Err(Error::InvalidPeriod {
62                message: crate::error::PERIOD_ABOVE_MAX,
63            });
64        }
65        Ok(Self {
66            period,
67            window: VecDeque::with_capacity(period),
68        })
69    }
70
71    /// Configured window of returns.
72    pub const fn period(&self) -> usize {
73        self.period
74    }
75
76    fn compute(&self) -> f64 {
77        #[allow(clippy::cast_precision_loss)]
78        let length = self.window.len() as f64;
79        let mut sum_return = 0.0;
80        let mut sum_drawdown = 0.0;
81        let mut equity = 1.0;
82        let mut peak: f64 = 1.0;
83        for ret in &self.window {
84            sum_return += *ret;
85            equity *= 1.0 + *ret;
86            peak = peak.max(equity);
87            sum_drawdown += (peak - equity) / peak;
88        }
89        let avg_drawdown = sum_drawdown / length;
90        if avg_drawdown > 0.0 {
91            (sum_return / length) / avg_drawdown
92        } else {
93            0.0
94        }
95    }
96}
97
98impl Indicator for SterlingRatio {
99    type Input = f64;
100    type Output = f64;
101
102    #[inline]
103    fn update(&mut self, ret: f64) -> Option<f64> {
104        if !ret.is_finite() {
105            return None;
106        }
107        if self.window.len() == self.period {
108            self.window.pop_front();
109        }
110        self.window.push_back(ret);
111        if self.window.len() < self.period {
112            return None;
113        }
114        Some(self.compute())
115    }
116
117    fn reset(&mut self) {
118        self.window.clear();
119    }
120
121    #[inline]
122    fn warmup_period(&self) -> usize {
123        self.period
124    }
125
126    #[inline]
127    fn is_ready(&self) -> bool {
128        self.window.len() == self.period
129    }
130
131    #[inline]
132    fn name(&self) -> &'static str {
133        "SterlingRatio"
134    }
135}
136
137#[cfg(test)]
138mod tests {
139    use super::*;
140    use crate::traits::BatchExt;
141    use approx::assert_relative_eq;
142
143    #[test]
144    fn rejects_period_less_than_two() {
145        assert!(matches!(
146            SterlingRatio::new(1),
147            Err(Error::InvalidPeriod { .. })
148        ));
149    }
150
151    #[test]
152    fn accessors_and_metadata() {
153        let sr = SterlingRatio::new(12).unwrap();
154        assert_eq!(sr.period(), 12);
155        assert_eq!(sr.warmup_period(), 12);
156        assert_eq!(sr.name(), "SterlingRatio");
157        assert!(!sr.is_ready());
158    }
159
160    #[test]
161    fn reference_value() {
162        // returns [0.1, -0.1, 0.1]:
163        //   equity 1.1, 0.99, 1.089; peak stays 1.1.
164        //   dd = [0, 0.1, 0.01]; avg_dd = 0.11/3; mean_return = 0.1/3.
165        //   Sterling = (0.1/3) / (0.11/3) = 0.1/0.11.
166        let mut sr = SterlingRatio::new(3).unwrap();
167        let out = sr.batch(&[0.1, -0.1, 0.1]);
168        assert_relative_eq!(out[2].unwrap(), 0.1_f64 / 0.11, epsilon = 1e-9);
169    }
170
171    #[test]
172    fn no_drawdown_is_zero() {
173        // Monotonically rising equity never draws down.
174        let mut sr = SterlingRatio::new(3).unwrap();
175        let last = sr
176            .batch(&[0.01, 0.02, 0.03])
177            .into_iter()
178            .flatten()
179            .last()
180            .unwrap();
181        assert_relative_eq!(last, 0.0, epsilon = 1e-12);
182    }
183
184    #[test]
185    fn losing_window_is_negative() {
186        let mut sr = SterlingRatio::new(3).unwrap();
187        let last = sr
188            .batch(&[-0.05, -0.02, -0.03])
189            .into_iter()
190            .flatten()
191            .last()
192            .unwrap();
193        assert!(last < 0.0);
194    }
195
196    #[test]
197    fn ignores_non_finite_input() {
198        let mut sr = SterlingRatio::new(3).unwrap();
199        assert_eq!(sr.update(0.1), None);
200        assert_eq!(sr.update(f64::NAN), None);
201        assert_eq!(sr.update(-0.1), None);
202        assert!(sr.update(0.1).is_some());
203    }
204
205    #[test]
206    fn reset_clears_state() {
207        let mut sr = SterlingRatio::new(3).unwrap();
208        sr.batch(&[0.1, -0.1, 0.1]);
209        assert!(sr.is_ready());
210        sr.reset();
211        assert!(!sr.is_ready());
212        assert_eq!(sr.update(0.1), None);
213    }
214
215    #[test]
216    fn batch_equals_streaming() {
217        let rets: Vec<f64> = (0..60)
218            .map(|i| (f64::from(i) * 0.25).sin() * 0.05)
219            .collect();
220        let batch = SterlingRatio::new(12).unwrap().batch(&rets);
221        let mut streamer = SterlingRatio::new(12).unwrap();
222        let streamed: Vec<_> = rets.iter().map(|r| streamer.update(*r)).collect();
223        assert_eq!(batch, streamed);
224    }
225}