wickra_core/indicators/
cmo.rs1use std::collections::VecDeque;
4
5use crate::error::{Error, Result};
6use crate::traits::Indicator;
7
8#[derive(Debug, Clone)]
35pub struct Cmo {
36 period: usize,
37 prev_price: Option<f64>,
38 window: VecDeque<(f64, f64)>,
40 sum_gain: f64,
41 sum_loss: f64,
42 current: Option<f64>,
43}
44
45impl Cmo {
46 pub fn new(period: usize) -> Result<Self> {
52 if period == 0 {
53 return Err(Error::PeriodZero);
54 }
55 if period > crate::error::MAX_PERIOD {
56 return Err(Error::InvalidPeriod {
57 message: crate::error::PERIOD_ABOVE_MAX,
58 });
59 }
60 Ok(Self {
61 period,
62 prev_price: None,
63 window: VecDeque::with_capacity(period),
64 sum_gain: 0.0,
65 sum_loss: 0.0,
66 current: None,
67 })
68 }
69
70 pub const fn period(&self) -> usize {
72 self.period
73 }
74
75 pub const fn value(&self) -> Option<f64> {
77 self.current
78 }
79}
80
81impl Indicator for Cmo {
82 type Input = f64;
83 type Output = f64;
84
85 #[inline]
86 fn update(&mut self, input: f64) -> Option<f64> {
87 if !input.is_finite() {
88 return None;
90 }
91 let Some(prev) = self.prev_price else {
92 self.prev_price = Some(input);
93 return None;
94 };
95 self.prev_price = Some(input);
96
97 let change = input - prev;
98 let gain = change.max(0.0);
99 let loss = (-change).max(0.0);
100
101 if self.window.len() == self.period {
102 let (old_gain, old_loss) = self.window.pop_front().expect("window is non-empty");
103 self.sum_gain -= old_gain;
104 self.sum_loss -= old_loss;
105 }
106 self.window.push_back((gain, loss));
107 self.sum_gain += gain;
108 self.sum_loss += loss;
109
110 if self.window.len() < self.period {
111 return None;
112 }
113 let denom = self.sum_gain + self.sum_loss;
114 let cmo = if denom == 0.0 {
115 0.0
117 } else {
118 100.0 * (self.sum_gain - self.sum_loss) / denom
119 };
120 self.current = Some(cmo);
121 Some(cmo)
122 }
123
124 fn reset(&mut self) {
125 self.prev_price = None;
126 self.window.clear();
127 self.sum_gain = 0.0;
128 self.sum_loss = 0.0;
129 self.current = None;
130 }
131
132 #[inline]
133 fn warmup_period(&self) -> usize {
134 self.period + 1
135 }
136
137 #[inline]
138 fn is_ready(&self) -> bool {
139 self.current.is_some()
140 }
141
142 #[inline]
143 fn name(&self) -> &'static str {
144 "CMO"
145 }
146}
147
148#[cfg(test)]
149mod tests {
150 use super::*;
151 use crate::traits::BatchExt;
152 use approx::assert_relative_eq;
153
154 #[test]
155 fn new_rejects_zero_period() {
156 assert!(matches!(Cmo::new(0), Err(Error::PeriodZero)));
157 }
158
159 #[test]
163 fn accessors_and_metadata() {
164 let mut cmo = Cmo::new(14).unwrap();
165 assert_eq!(cmo.period(), 14);
166 assert_eq!(cmo.name(), "CMO");
167 assert_eq!(cmo.value(), None);
168 for i in 1..=15 {
169 cmo.update(f64::from(i));
170 }
171 assert!(cmo.value().is_some());
172 }
173
174 #[test]
175 fn reference_value() {
176 let mut cmo = Cmo::new(3).unwrap();
179 let out = cmo.batch(&[10.0, 11.0, 10.0, 12.0]);
180 assert_eq!(cmo.warmup_period(), 4);
181 assert_eq!(out[0], None);
182 assert_eq!(out[2], None);
183 assert_relative_eq!(out[3].unwrap(), 50.0, epsilon = 1e-12);
184 }
185
186 #[test]
187 fn pure_uptrend_saturates_at_plus_100() {
188 let mut cmo = Cmo::new(5).unwrap();
189 let out = cmo.batch(&(1..=20).map(f64::from).collect::<Vec<_>>());
190 for v in out.iter().skip(6).flatten() {
191 assert_relative_eq!(*v, 100.0, epsilon = 1e-12);
192 }
193 }
194
195 #[test]
196 fn pure_downtrend_saturates_at_minus_100() {
197 let mut cmo = Cmo::new(5).unwrap();
198 let out = cmo.batch(&(1..=20).rev().map(f64::from).collect::<Vec<_>>());
199 for v in out.iter().skip(6).flatten() {
200 assert_relative_eq!(*v, -100.0, epsilon = 1e-12);
201 }
202 }
203
204 #[test]
205 fn constant_series_yields_zero() {
206 let mut cmo = Cmo::new(5).unwrap();
207 let out = cmo.batch(&[42.0; 20]);
208 for v in out.iter().skip(6).flatten() {
209 assert_relative_eq!(*v, 0.0, epsilon = 1e-12);
210 }
211 }
212
213 #[test]
214 fn ignores_non_finite_input() {
215 let mut cmo = Cmo::new(3).unwrap();
216 let out = cmo.batch(&[10.0, 11.0, 10.0, 12.0]);
217 out[3].expect("CMO(3) ready after four inputs");
218 assert_eq!(cmo.update(f64::NAN), None);
219 assert_eq!(cmo.update(f64::INFINITY), None);
220 }
221
222 #[test]
223 fn reset_clears_state() {
224 let mut cmo = Cmo::new(3).unwrap();
225 cmo.batch(&[10.0, 11.0, 12.0, 13.0, 14.0]);
226 assert!(cmo.is_ready());
227 cmo.reset();
228 assert!(!cmo.is_ready());
229 assert_eq!(cmo.update(10.0), None);
230 }
231
232 #[test]
233 fn batch_equals_streaming() {
234 let prices: Vec<f64> = (1..=60)
235 .map(|i| 100.0 + (f64::from(i) * 0.4).sin() * 6.0)
236 .collect();
237 let batch = Cmo::new(9).unwrap().batch(&prices);
238 let mut b = Cmo::new(9).unwrap();
239 let streamed: Vec<_> = prices.iter().map(|p| b.update(*p)).collect();
240 assert_eq!(batch, streamed);
241 }
242}