wickra_core/indicators/
martin_ratio.rs1use std::collections::VecDeque;
4
5use crate::error::{Error, Result};
6use crate::traits::Indicator;
7
8#[derive(Debug, Clone)]
47pub struct MartinRatio {
48 period: usize,
49 window: VecDeque<f64>,
50}
51
52impl MartinRatio {
53 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 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 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}