wickra_core/indicators/
calmar_ratio.rs1use std::collections::VecDeque;
4
5use crate::error::{Error, Result};
6use crate::indicators::rolling_moments::RollingSum;
7use crate::traits::Indicator;
8
9#[derive(Debug, Clone)]
42pub struct CalmarRatio {
43 period: usize,
44 window: VecDeque<f64>,
45 sum: RollingSum,
46}
47
48impl CalmarRatio {
49 pub fn new(period: usize) -> Result<Self> {
54 if period < 2 {
55 return Err(Error::InvalidPeriod {
56 message: "calmar ratio needs period >= 2",
57 });
58 }
59 if period > crate::error::MAX_PERIOD {
60 return Err(Error::InvalidPeriod {
61 message: crate::error::PERIOD_ABOVE_MAX,
62 });
63 }
64 Ok(Self {
65 period,
66 window: VecDeque::with_capacity(period),
67 sum: RollingSum::new(),
68 })
69 }
70
71 pub const fn period(&self) -> usize {
73 self.period
74 }
75}
76
77impl Indicator for CalmarRatio {
78 type Input = f64;
79 type Output = f64;
80
81 #[inline]
82 fn update(&mut self, input: f64) -> Option<f64> {
83 if !input.is_finite() {
84 return None;
85 }
86 if self.window.len() == self.period {
87 let old = self.window.pop_front().expect("non-empty");
88 self.sum.evict(old);
89 }
90 self.window.push_back(input);
91 self.sum.push(input);
92 if self.sum.needs_reseed(self.period) {
93 self.sum.reseed(self.window.iter().copied());
94 }
95 if self.window.len() < self.period {
96 return None;
97 }
98 let n = self.period as f64;
99 let mean = self.sum.value() / n;
100 let mut equity = 1.0_f64;
102 let mut peak = 1.0_f64;
103 let mut mdd = 0.0_f64;
104 for &r in &self.window {
105 equity *= 1.0 + r;
106 if equity > peak {
107 peak = equity;
108 }
109 let dd = (peak - equity) / peak;
111 if dd > mdd {
112 mdd = dd;
113 }
114 }
115 if mdd == 0.0 {
116 return Some(0.0);
117 }
118 Some(mean / mdd)
119 }
120
121 fn reset(&mut self) {
122 self.window.clear();
123 self.sum.reset();
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 "CalmarRatio"
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 CalmarRatio::new(1),
152 Err(Error::InvalidPeriod { .. })
153 ));
154 }
155
156 #[test]
157 fn accessors_and_metadata() {
158 let c = CalmarRatio::new(10).unwrap();
159 assert_eq!(c.period(), 10);
160 assert_eq!(c.name(), "CalmarRatio");
161 assert_eq!(c.warmup_period(), 10);
162 }
163
164 #[test]
165 fn pure_uptrend_yields_zero() {
166 let mut c = CalmarRatio::new(5).unwrap();
168 let out = c.batch(&[0.01; 10]);
169 for v in out.into_iter().flatten() {
170 assert_eq!(v, 0.0);
171 }
172 }
173
174 #[test]
175 fn reference_value() {
176 let mut c = CalmarRatio::new(3).unwrap();
182 let out = c.batch(&[0.10, -0.20, 0.05]);
183 let mean = (0.10 - 0.20 + 0.05) / 3.0;
184 let expected = mean / 0.20;
185 assert_relative_eq!(out[2].unwrap(), expected, epsilon = 1e-9);
186 }
187
188 #[test]
189 fn ignores_non_finite_input() {
190 let mut c = CalmarRatio::new(3).unwrap();
191 assert_eq!(c.update(f64::NAN), None);
192 assert_eq!(c.update(f64::INFINITY), None);
193 }
194
195 #[test]
196 fn reset_clears_state() {
197 let mut c = CalmarRatio::new(3).unwrap();
198 c.batch(&[0.10, -0.20, 0.05]);
199 assert!(c.is_ready());
200 c.reset();
201 assert!(!c.is_ready());
202 assert_eq!(c.update(0.01), None);
203 }
204
205 #[test]
206 fn batch_equals_streaming() {
207 let returns: Vec<f64> = (0..50)
208 .map(|i| 0.001 + (f64::from(i) * 0.25).sin() * 0.02)
209 .collect();
210 let batch = CalmarRatio::new(10).unwrap().batch(&returns);
211 let mut s = CalmarRatio::new(10).unwrap();
212 let streamed: Vec<_> = returns.iter().map(|r| s.update(*r)).collect();
213 assert_eq!(batch, streamed);
214 }
215}