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