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