Skip to main content

wickra_core/indicators/
burke_ratio.rs

1//! Burke Ratio — mean return over the root of the summed squared drawdown episodes.
2
3use std::collections::VecDeque;
4
5use crate::error::{Error, Result};
6use crate::traits::Indicator;
7
8/// Burke 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/// Burke    = mean(returns) / sqrt( Σ_j D_j² )
15/// ```
16///
17/// The Burke Ratio (Gibbons Burke, 1994) divides the average per-period return by
18/// the **Euclidean norm of the drawdown episodes** — the square root of the sum
19/// of each episode's squared depth. Squaring penalises deep drawdowns far more
20/// than shallow ones, and summing means the denominator grows with both the depth
21/// and the *number* of drawdowns, but not with how many bars an episode lasts.
22/// An episode still open at the window's right edge is booked at its current
23/// trough. Where the [`SterlingRatio`](crate::SterlingRatio) averages the episode
24/// depths and shrugs off a single crater, Burke makes that crater dominate. A
25/// window that never draws down has a zero denominator and 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, BurkeRatio};
35///
36/// let mut indicator = BurkeRatio::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 BurkeRatio {
45    period: usize,
46    window: VecDeque<f64>,
47}
48
49impl BurkeRatio {
50    /// Construct a Burke 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: "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    /// 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        for ret in &self.window {
87            sum_return += *ret;
88            equity *= 1.0 + *ret;
89            if equity >= peak {
90                if underwater {
91                    // Full recovery closes the episode at its trough depth.
92                    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            // An episode still open at the window's edge is booked at its trough.
106            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        // returns [0.1, -0.1, 0.1]: equity 1.1, 0.99, 1.089 — one episode from
183        // the 1.1 peak down to 0.99, still open: depth 0.1.
184        // Burke = (0.1/3) / sqrt(0.1²) = (0.1/3) / 0.1.
185        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    /// An overflowing equity curve (`inf · 0 = NaN`) poisons the episode depth;
301    /// the non-positive / NaN denominator guard must report `0.0`, not NaN.
302    #[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    /// Two closed episodes. Returns `[-0.5, 1.0, -0.5, 1.0]`:
310    /// equity `0.5, 1.0, 0.5, 1.0`. Each dip from the `1.0` peak to `0.5` is fully
311    /// recovered (equity == peak closes it): `D_1 = D_2 = 0.5`.
312    /// `mean = (−0.5 + 1 − 0.5 + 1) / 4 = 0.25`, `sqrt(0.25 + 0.25) = sqrt(0.5)`,
313    /// `Burke = 0.25 / sqrt(0.5)`.
314    #[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    /// Several troughs inside one episode plus an episode open at the window edge.
322    /// Returns `[0.25, −0.2, 0.5, −0.5, 0.5, −0.5, 0.6]`:
323    /// equity `1.25` (peak), `1.0` (episode 1 opens, trough 1.0),
324    /// `1.5` (recovers: `D_1 = (1.25 − 1.0) / 1.25 = 0.2`, new peak 1.5),
325    /// `0.75` (episode 2 opens), `1.125` (still under water, trough stays 0.75),
326    /// `0.5625` (deeper trough), `0.9` (still under water at the edge):
327    /// `D_2 = (1.5 − 0.5625) / 1.5 = 0.625`.
328    /// `mean = 0.65 / 7`; `Burke = (0.65 / 7) / sqrt(0.2² + 0.625²)
329    /// = (0.65 / 7) / sqrt(0.430625)`.
330    #[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        // Same single 0.5-deep episode, held for one bar versus three bars
341        // (mean return kept equal): the denominator is the same.
342        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}