Skip to main content

wickra_core/indicators/
burke_ratio.rs

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