Skip to main content

wickra_core/indicators/
martin_ratio.rs

1//! Martin Ratio (Ulcer Performance Index) — mean return over the Ulcer Index.
2
3use std::collections::VecDeque;
4
5use crate::error::{Error, Result};
6use crate::traits::Indicator;
7
8/// Martin Ratio — also called the Ulcer Performance Index (UPI) — over a trailing
9/// window of `period` returns.
10///
11/// ```text
12/// equity_t = Π_{i<=t} (1 + return_i)               (compounded curve)
13/// peak_t   = max_{s<=t} equity_s
14/// dd_t%    = 100 · (peak_t − equity_t) / peak_t      (percentage drawdown)
15/// UlcerIdx = sqrt( mean( dd_t%² ) )
16/// Martin   = mean(returns) / UlcerIdx
17/// ```
18///
19/// The Martin Ratio divides the average per-period return by the **Ulcer Index** —
20/// the root-mean-square of the *percentage* drawdowns. The Ulcer Index, by
21/// construction, measures the depth *and* duration of the time spent under water:
22/// a long shallow slump and a short deep one can score the same. Compared to
23/// Wickra's other drawdown ratios, Martin uses the RMS (not the average as in the
24/// [`SterlingRatio`](crate::SterlingRatio), nor the un-normalised sum-norm as in the
25/// [`BurkeRatio`](crate::BurkeRatio)) and expresses drawdowns in **percent**, so its
26/// denominator is on a `0..100` scale and its output is numerically smaller than
27/// the fractional-drawdown ratios. A window that never draws down has an Ulcer Index
28/// of zero and the indicator reports `0.0`.
29///
30/// The first value lands after `period` returns; each `update` rebuilds the equity
31/// curve over the window (O(period)), which is O(1) in the length of the overall
32/// series.
33///
34/// # Example
35///
36/// ```
37/// use wickra_core::{Indicator, MartinRatio};
38///
39/// let mut indicator = MartinRatio::new(14).unwrap();
40/// let mut last = None;
41/// for i in 0..28 {
42///     last = indicator.update((f64::from(i) * 0.5).sin() * 0.05);
43/// }
44/// assert!(last.is_some());
45/// ```
46#[derive(Debug, Clone)]
47pub struct MartinRatio {
48    period: usize,
49    window: VecDeque<f64>,
50}
51
52impl MartinRatio {
53    /// Construct a Martin Ratio over `period` returns.
54    ///
55    /// # Errors
56    ///
57    /// Returns [`Error::InvalidPeriod`] if `period < 2`.
58    pub fn new(period: usize) -> Result<Self> {
59        if period < 2 {
60            return Err(Error::InvalidPeriod {
61                message: "martin ratio needs period >= 2",
62            });
63        }
64        if period > crate::error::MAX_PERIOD {
65            return Err(Error::InvalidPeriod {
66                message: crate::error::PERIOD_ABOVE_MAX,
67            });
68        }
69        Ok(Self {
70            period,
71            window: VecDeque::with_capacity(period),
72        })
73    }
74
75    /// Configured window of returns.
76    pub const fn period(&self) -> usize {
77        self.period
78    }
79
80    fn compute(&self) -> f64 {
81        #[allow(clippy::cast_precision_loss)]
82        let length = self.window.len() as f64;
83        let mut sum_return = 0.0;
84        let mut sum_drawdown_pct_sq = 0.0;
85        let mut equity = 1.0;
86        let mut peak: f64 = 1.0;
87        for ret in &self.window {
88            sum_return += *ret;
89            equity *= 1.0 + *ret;
90            peak = peak.max(equity);
91            let drawdown_pct = 100.0 * (peak - equity) / peak;
92            sum_drawdown_pct_sq += drawdown_pct * drawdown_pct;
93        }
94        let ulcer_index = (sum_drawdown_pct_sq / length).sqrt();
95        if ulcer_index > 0.0 {
96            (sum_return / length) / ulcer_index
97        } else {
98            0.0
99        }
100    }
101}
102
103impl Indicator for MartinRatio {
104    type Input = f64;
105    type Output = f64;
106
107    #[inline]
108    fn update(&mut self, ret: f64) -> Option<f64> {
109        if !ret.is_finite() {
110            return None;
111        }
112        if self.window.len() == self.period {
113            self.window.pop_front();
114        }
115        self.window.push_back(ret);
116        if self.window.len() < self.period {
117            return None;
118        }
119        Some(self.compute())
120    }
121
122    fn reset(&mut self) {
123        self.window.clear();
124    }
125
126    #[inline]
127    fn warmup_period(&self) -> usize {
128        self.period
129    }
130
131    #[inline]
132    fn is_ready(&self) -> bool {
133        self.window.len() == self.period
134    }
135
136    #[inline]
137    fn name(&self) -> &'static str {
138        "MartinRatio"
139    }
140}
141
142#[cfg(test)]
143mod tests {
144    use super::*;
145    use crate::traits::BatchExt;
146    use approx::assert_relative_eq;
147
148    #[test]
149    fn rejects_period_less_than_two() {
150        assert!(matches!(
151            MartinRatio::new(1),
152            Err(Error::InvalidPeriod { .. })
153        ));
154    }
155
156    #[test]
157    fn accessors_and_metadata() {
158        let mr = MartinRatio::new(14).unwrap();
159        assert_eq!(mr.period(), 14);
160        assert_eq!(mr.warmup_period(), 14);
161        assert_eq!(mr.name(), "MartinRatio");
162        assert!(!mr.is_ready());
163    }
164
165    #[test]
166    fn reference_value() {
167        // returns [0.1, -0.1, 0.1]: drawdowns% = [0, 10, 1].
168        // Ulcer Index = sqrt((0 + 100 + 1)/3) = sqrt(101/3).
169        // Martin = (0.1/3) / sqrt(101/3).
170        let mut mr = MartinRatio::new(3).unwrap();
171        let out = mr.batch(&[0.1, -0.1, 0.1]);
172        let expected = (0.1_f64 / 3.0) / (101.0_f64 / 3.0).sqrt();
173        assert_relative_eq!(out[2].unwrap(), expected, epsilon = 1e-9);
174    }
175
176    #[test]
177    fn no_drawdown_is_zero() {
178        let mut mr = MartinRatio::new(3).unwrap();
179        let last = mr
180            .batch(&[0.01, 0.02, 0.03])
181            .into_iter()
182            .flatten()
183            .last()
184            .unwrap();
185        assert_relative_eq!(last, 0.0, epsilon = 1e-12);
186    }
187
188    #[test]
189    fn losing_window_is_negative() {
190        let mut mr = MartinRatio::new(3).unwrap();
191        let last = mr
192            .batch(&[-0.05, -0.02, -0.03])
193            .into_iter()
194            .flatten()
195            .last()
196            .unwrap();
197        assert!(last < 0.0);
198    }
199
200    #[test]
201    fn ignores_non_finite_input() {
202        let mut mr = MartinRatio::new(3).unwrap();
203        assert_eq!(mr.update(0.1), None);
204        assert_eq!(mr.update(f64::NAN), None);
205        assert_eq!(mr.update(-0.1), None);
206        assert!(mr.update(0.1).is_some());
207    }
208
209    #[test]
210    fn reset_clears_state() {
211        let mut mr = MartinRatio::new(3).unwrap();
212        mr.batch(&[0.1, -0.1, 0.1]);
213        assert!(mr.is_ready());
214        mr.reset();
215        assert!(!mr.is_ready());
216        assert_eq!(mr.update(0.1), None);
217    }
218
219    #[test]
220    fn batch_equals_streaming() {
221        let rets: Vec<f64> = (0..60)
222            .map(|i| (f64::from(i) * 0.25).sin() * 0.05)
223            .collect();
224        let batch = MartinRatio::new(14).unwrap().batch(&rets);
225        let mut streamer = MartinRatio::new(14).unwrap();
226        let streamed: Vec<_> = rets.iter().map(|r| streamer.update(*r)).collect();
227        assert_eq!(batch, streamed);
228    }
229}