wickra_core/indicators/
burke_ratio.rs1use std::collections::VecDeque;
4
5use crate::error::{Error, Result};
6use crate::traits::Indicator;
7
8#[derive(Debug, Clone)]
44pub struct BurkeRatio {
45 period: usize,
46 window: VecDeque<f64>,
47}
48
49impl BurkeRatio {
50 pub fn new(period: usize) -> Result<Self> {
56 if period < 2 {
57 return Err(Error::InvalidPeriod {
58 message: "burke 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 for ret in &self.window {
87 sum_return += *ret;
88 equity *= 1.0 + *ret;
89 if equity >= peak {
90 if underwater {
91 let depth = (peak - trough) / peak;
93 depths += depth * depth;
94 underwater = false;
95 }
96 peak = equity;
97 } else if underwater {
98 trough = trough.min(equity);
99 } else {
100 underwater = true;
101 trough = equity;
102 }
103 }
104 if underwater {
105 let depth = (peak - trough) / peak;
107 depths += depth * depth;
108 }
109 let denom = depths.sqrt();
110 if denom > 0.0 {
111 (sum_return / length) / denom
112 } else {
113 0.0
114 }
115 }
116}
117
118impl Indicator for BurkeRatio {
119 type Input = f64;
120 type Output = f64;
121
122 #[inline]
123 fn update(&mut self, ret: f64) -> Option<f64> {
124 if !ret.is_finite() {
125 return None;
126 }
127 if self.window.len() == self.period {
128 self.window.pop_front();
129 }
130 self.window.push_back(ret);
131 if self.window.len() < self.period {
132 return None;
133 }
134 Some(self.compute())
135 }
136
137 fn reset(&mut self) {
138 self.window.clear();
139 }
140
141 #[inline]
142 fn warmup_period(&self) -> usize {
143 self.period
144 }
145
146 #[inline]
147 fn is_ready(&self) -> bool {
148 self.window.len() == self.period
149 }
150
151 #[inline]
152 fn name(&self) -> &'static str {
153 "BurkeRatio"
154 }
155}
156
157#[cfg(test)]
158mod tests {
159 use super::*;
160 use crate::traits::BatchExt;
161 use approx::assert_relative_eq;
162
163 #[test]
164 fn rejects_period_less_than_two() {
165 assert!(matches!(
166 BurkeRatio::new(1),
167 Err(Error::InvalidPeriod { .. })
168 ));
169 }
170
171 #[test]
172 fn accessors_and_metadata() {
173 let br = BurkeRatio::new(12).unwrap();
174 assert_eq!(br.period(), 12);
175 assert_eq!(br.warmup_period(), 12);
176 assert_eq!(br.name(), "BurkeRatio");
177 assert!(!br.is_ready());
178 }
179
180 #[test]
181 fn reference_value() {
182 let mut br = BurkeRatio::new(3).unwrap();
186 let out = br.batch(&[0.1, -0.1, 0.1]);
187 let expected = (0.1_f64 / 3.0) / 0.1;
188 assert_relative_eq!(out[2].unwrap(), expected, epsilon = 1e-9);
189 }
190
191 #[test]
192 fn no_drawdown_is_zero() {
193 let mut br = BurkeRatio::new(3).unwrap();
194 let last = br
195 .batch(&[0.01, 0.02, 0.03])
196 .into_iter()
197 .flatten()
198 .last()
199 .unwrap();
200 assert_relative_eq!(last, 0.0, epsilon = 1e-12);
201 }
202
203 #[test]
204 fn losing_window_is_negative() {
205 let mut br = BurkeRatio::new(3).unwrap();
206 let last = br
207 .batch(&[-0.05, -0.02, -0.03])
208 .into_iter()
209 .flatten()
210 .last()
211 .unwrap();
212 assert!(last < 0.0);
213 }
214
215 #[test]
216 fn ignores_non_finite_input() {
217 let mut br = BurkeRatio::new(3).unwrap();
218 assert_eq!(br.update(0.1), None);
219 assert_eq!(br.update(f64::NAN), None);
220 assert_eq!(br.update(-0.1), None);
221 assert!(br.update(0.1).is_some());
222 }
223
224 #[test]
225 fn reset_clears_state() {
226 let mut br = BurkeRatio::new(3).unwrap();
227 br.batch(&[0.1, -0.1, 0.1]);
228 assert!(br.is_ready());
229 br.reset();
230 assert!(!br.is_ready());
231 assert_eq!(br.update(0.1), None);
232 }
233
234 #[test]
235 fn batch_equals_streaming() {
236 let rets: Vec<f64> = (0..60)
237 .map(|i| (f64::from(i) * 0.25).sin() * 0.05)
238 .collect();
239 let batch = BurkeRatio::new(12).unwrap().batch(&rets);
240 let mut streamer = BurkeRatio::new(12).unwrap();
241 let streamed: Vec<_> = rets.iter().map(|r| streamer.update(*r)).collect();
242 assert_eq!(batch, streamed);
243 }
244
245 #[test]
246 fn rejects_zero_and_oversized_period() {
247 assert!(matches!(
248 BurkeRatio::new(0),
249 Err(Error::InvalidPeriod { .. })
250 ));
251 let too_long = crate::error::MAX_PERIOD + 1;
252 assert!(matches!(
253 BurkeRatio::new(too_long),
254 Err(Error::InvalidPeriod { .. })
255 ));
256 assert!(BurkeRatio::new(2).is_ok());
257 }
258
259 fn wavy(len: i32) -> Vec<f64> {
260 (0..len)
261 .map(|i| (f64::from(i) * 0.6).sin() * 0.04 + (f64::from(i) * 1.7).cos() * 0.02)
262 .collect()
263 }
264
265 #[test]
266 fn first_value_lands_exactly_at_warmup_index() {
267 let rets = wavy(30);
268 let mut ratio = BurkeRatio::new(8).unwrap();
269 let warmup = ratio.warmup_period();
270 let out = ratio.batch(&rets);
271 assert!(out.iter().take(warmup - 1).all(Option::is_none));
272 assert!(out.iter().skip(warmup - 1).all(Option::is_some));
273 }
274
275 #[test]
276 fn reset_replays_identically_to_fresh_instance() {
277 let rets = wavy(50);
278 let mut used = BurkeRatio::new(10).unwrap();
279 used.batch(&rets);
280 used.reset();
281 let replay = used.batch(&rets);
282 assert_eq!(replay, BurkeRatio::new(10).unwrap().batch(&rets));
283 }
284
285 #[test]
286 fn batch_nan_into_matches_streaming_bits() {
287 let rets = wavy(70);
288 let mut nan_out = vec![0.0; rets.len()];
289 BurkeRatio::new(9)
290 .unwrap()
291 .batch_nan_into(&rets, &mut nan_out);
292 let mut streamer = BurkeRatio::new(9).unwrap();
293 let identical = rets
294 .iter()
295 .zip(&nan_out)
296 .all(|(r, v)| streamer.update(*r).unwrap_or(f64::NAN).to_bits() == v.to_bits());
297 assert!(identical);
298 }
299
300 #[test]
303 fn nan_depth_from_overflowing_equity_reports_zero() {
304 let mut ratio = BurkeRatio::new(3).unwrap();
305 let out = ratio.batch(&[1e300, 1e300, -1.0]);
306 assert_eq!(out[2], Some(0.0));
307 }
308
309 #[test]
315 fn reference_two_closed_episodes() {
316 let mut br = BurkeRatio::new(4).unwrap();
317 let out = br.batch(&[-0.5, 1.0, -0.5, 1.0]);
318 assert_relative_eq!(out[3].unwrap(), 0.25 / 0.5_f64.sqrt(), epsilon = 1e-12);
319 }
320
321 #[test]
331 fn reference_multi_trough_and_open_episode() {
332 let mut br = BurkeRatio::new(7).unwrap();
333 let out = br.batch(&[0.25, -0.2, 0.5, -0.5, 0.5, -0.5, 0.6]);
334 let expected = (0.65 / 7.0) / 0.430_625_f64.sqrt();
335 assert_relative_eq!(out[6].unwrap(), expected, epsilon = 1e-12);
336 }
337
338 #[test]
339 fn bar_count_of_an_episode_does_not_change_its_weight() {
340 let short = BurkeRatio::new(4).unwrap().batch(&[-0.5, 1.0, 0.0, 0.0]);
343 let long = BurkeRatio::new(4).unwrap().batch(&[-0.5, 0.0, 0.0, 1.0]);
344 assert_relative_eq!(short[3].unwrap(), long[3].unwrap(), epsilon = 1e-12);
345 assert_relative_eq!(short[3].unwrap(), 0.125 / 0.5, epsilon = 1e-12);
346 }
347}