wickra_core/indicators/
sterling_ratio.rs1use std::collections::VecDeque;
4
5use crate::error::{Error, Result};
6use crate::traits::Indicator;
7
8#[derive(Debug, Clone)]
44pub struct SterlingRatio {
45 period: usize,
46 window: VecDeque<f64>,
47}
48
49impl SterlingRatio {
50 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 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 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 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 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 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 #[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 #[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 #[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 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}