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