nautilus_indicators/momentum/
cmo.rs1use std::fmt::Display;
17
18use nautilus_core::correctness::FAILED;
19use nautilus_model::data::{Bar, QuoteTick, TradeTick};
20
21use crate::{
22 average::{MovingAverageFactory, MovingAverageType},
23 indicator::{Indicator, MovingAverage},
24 support::MAX_PERIOD,
25};
26
27#[repr(C)]
29#[derive(Debug)]
30#[cfg_attr(
31 feature = "python",
32 pyo3::pyclass(module = "nautilus_trader.indicators", unsendable)
33)]
34#[cfg_attr(
35 feature = "python",
36 pyo3_stub_gen::derive::gen_stub_pyclass(module = "nautilus_trader.indicators")
37)]
38pub struct ChandeMomentumOscillator {
39 pub period: usize,
40 pub ma_type: MovingAverageType,
41 pub value: f64,
42 pub count: usize,
43 pub initialized: bool,
44 previous_close: f64,
45 average_gain: Box<dyn MovingAverage + Send + 'static>,
46 average_loss: Box<dyn MovingAverage + Send + 'static>,
47 has_inputs: bool,
48}
49
50impl Display for ChandeMomentumOscillator {
51 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
52 write!(f, "{}({})", self.name(), self.period)
53 }
54}
55
56impl Indicator for ChandeMomentumOscillator {
57 fn name(&self) -> String {
58 stringify!(ChandeMomentumOscillator).to_string()
59 }
60
61 fn has_inputs(&self) -> bool {
62 self.has_inputs
63 }
64
65 fn initialized(&self) -> bool {
66 self.initialized
67 }
68
69 fn handle_quote(&mut self, _quote: &QuoteTick) -> anyhow::Result<()> {
70 Ok(())
71 }
72
73 fn handle_trade(&mut self, _trade: &TradeTick) {}
74
75 fn handle_bar(&mut self, bar: &Bar) {
76 self.update_raw((&bar.close).into());
77 }
78
79 fn reset(&mut self) {
80 self.value = 0.0;
81 self.count = 0;
82 self.has_inputs = false;
83 self.initialized = false;
84 self.previous_close = 0.0;
85 self.average_gain.reset();
86 self.average_loss.reset();
87 }
88}
89
90impl ChandeMomentumOscillator {
91 #[must_use]
97 pub fn new(period: usize, ma_type: Option<MovingAverageType>) -> Self {
98 Self::new_checked(period, ma_type).expect(FAILED)
99 }
100
101 pub(crate) fn new_checked(
102 period: usize,
103 ma_type: Option<MovingAverageType>,
104 ) -> anyhow::Result<Self> {
105 anyhow::ensure!(
106 (1..=MAX_PERIOD).contains(&period),
107 "period must be in 1..={MAX_PERIOD}"
108 );
109 let ma_type = ma_type.unwrap_or(MovingAverageType::Simple);
110 Ok(Self {
111 period,
112 ma_type,
113 average_gain: MovingAverageFactory::create(ma_type, period),
114 average_loss: MovingAverageFactory::create(ma_type, period),
115 previous_close: 0.0,
116 value: 0.0,
117 count: 0,
118 initialized: false,
119 has_inputs: false,
120 })
121 }
122
123 pub fn update_raw(&mut self, close: f64) {
124 if !close.is_finite() {
125 return;
126 }
127
128 self.count += 1;
129
130 if !self.has_inputs {
131 self.previous_close = close;
132 self.has_inputs = true;
133 return;
134 }
135
136 let change = close - self.previous_close;
137 self.previous_close = close;
138 self.average_gain.update_raw(change.max(0.0));
139 self.average_loss.update_raw((-change).max(0.0));
140 if !self.average_gain.initialized() || !self.average_loss.initialized() {
141 return;
142 }
143
144 let gain = self.average_gain.value();
145 let loss = self.average_loss.value();
146 let total = gain + loss;
147 self.value = if total == 0.0 {
148 0.0
149 } else {
150 100.0 * ((gain - loss) / total)
153 };
154 self.initialized = true;
155 }
156}
157
158#[cfg(test)]
159mod tests {
160 use nautilus_model::data::{Bar, QuoteTick};
161 use rstest::rstest;
162
163 use crate::{
164 average::MovingAverageType, indicator::Indicator, momentum::cmo::ChandeMomentumOscillator,
165 stubs::*,
166 };
167
168 #[rstest]
169 fn test_cmo_initialized(cmo_10: ChandeMomentumOscillator) {
170 let display_str = format!("{cmo_10}");
171 assert_eq!(display_str, "ChandeMomentumOscillator(10)");
172 assert_eq!(cmo_10.period, 10);
173 assert!(!cmo_10.initialized);
174 }
175
176 #[rstest]
177 fn test_value_stays_within_bounds_when_gain_average_is_zero() {
178 let mut cmo = ChandeMomentumOscillator::new(3, None);
179 for value in [4.0, 3.0, 2.0, 1.0] {
180 cmo.update_raw(value);
181 }
182
183 assert!(cmo.initialized());
184 assert_eq!(cmo.value, -100.0);
185 }
186
187 #[rstest]
188 fn test_initialized_with_required_inputs_returns_true(mut cmo_10: ChandeMomentumOscillator) {
189 for i in 0..12 {
190 cmo_10.update_raw(f64::from(i));
191 }
192 assert!(cmo_10.initialized);
193 }
194
195 #[rstest]
196 fn test_value_all_higher_inputs_returns_expected_value() {
197 let mut cmo_10 = ChandeMomentumOscillator::new(10, None);
198 for value in 1..=11 {
199 cmo_10.update_raw(f64::from(value));
200 }
201
202 assert_eq!(cmo_10.value, 100.0);
203 assert!(cmo_10.initialized());
204 }
205
206 #[rstest]
207 fn test_value_with_one_input_returns_expected_value(mut cmo_10: ChandeMomentumOscillator) {
208 cmo_10.update_raw(1.00000);
209 assert_eq!(cmo_10.value, 0.0);
210 }
211
212 #[rstest]
213 fn test_reset(mut cmo_10: ChandeMomentumOscillator) {
214 cmo_10.update_raw(1.00020);
215 cmo_10.update_raw(1.00030);
216 cmo_10.update_raw(1.00050);
217 cmo_10.reset();
218 assert!(!cmo_10.initialized());
219 assert_eq!(cmo_10.count, 0);
220 assert_eq!(cmo_10.value, 0.0);
221 assert_eq!(cmo_10.previous_close, 0.0);
222 }
223
224 #[rstest]
225 fn test_handle_quote_tick(mut cmo_10: ChandeMomentumOscillator, stub_quote: QuoteTick) {
226 cmo_10.handle_quote(&stub_quote).unwrap();
227 assert_eq!(cmo_10.count, 0);
228 assert_eq!(cmo_10.value, 0.0);
229 }
230
231 #[rstest]
232 fn test_handle_bar(mut cmo_10: ChandeMomentumOscillator, bar_ethusdt_binance_minute_bid: Bar) {
233 cmo_10.handle_bar(&bar_ethusdt_binance_minute_bid);
234 assert_eq!(cmo_10.count, 1);
235 assert_eq!(cmo_10.value, 0.0);
236 }
237
238 #[rstest]
239 fn test_ma_type_affects_value() {
240 let mut cmo_sma = ChandeMomentumOscillator::new(3, Some(MovingAverageType::Simple));
241 let mut cmo_wilder = ChandeMomentumOscillator::new(3, Some(MovingAverageType::Wilder));
242 let prices = [1.0, 2.0, 3.0, 2.5, 3.5];
243 for price in prices {
244 cmo_sma.update_raw(price);
245 cmo_wilder.update_raw(price);
246 }
247 assert_ne!(cmo_sma.value, cmo_wilder.value);
248 }
249
250 #[rstest]
251 fn test_count_increments(mut cmo_10: ChandeMomentumOscillator) {
252 for i in 0..5 {
253 cmo_10.update_raw(f64::from(i));
254 }
255 assert_eq!(cmo_10.count, 5);
256 }
257
258 #[rstest]
259 fn test_reset_resets_inner_mas() {
260 let mut cmo = ChandeMomentumOscillator::new(3, None);
261 for value in [1.0, 2.0, 3.0, 4.0] {
262 cmo.update_raw(value);
263 }
264 assert!(cmo.average_gain.initialized());
265 assert!(cmo.average_loss.initialized());
266
267 cmo.reset();
268
269 assert!(!cmo.average_gain.initialized());
270 assert!(!cmo.average_loss.initialized());
271 assert_eq!(cmo.average_gain.count(), 0);
272 assert_eq!(cmo.average_loss.count(), 0);
273 }
274
275 #[rstest]
276 #[should_panic]
277 fn test_invalid_period_panics() {
278 let _ = ChandeMomentumOscillator::new(0, None);
279 }
280
281 #[rstest]
282 fn test_ma_type_propagation() {
283 let cmo = ChandeMomentumOscillator::new(5, Some(MovingAverageType::Simple));
284 assert_eq!(cmo.ma_type, MovingAverageType::Simple);
285 }
286
287 #[rstest]
288 fn test_zero_divisor_returns_zero() {
289 let mut cmo = ChandeMomentumOscillator::new(3, None);
290 for _ in 0..5 {
291 cmo.update_raw(100.0);
292 }
293 assert!(cmo.initialized);
294 assert_eq!(cmo.value, 0.0);
295 }
296
297 #[rstest]
298 fn test_random_walk_values_within_bounds() {
299 let prices = [
300 100.0, 100.5, 99.8, 100.3, 101.0, 100.7, 101.5, 101.2, 100.6, 101.1, 100.9, 101.4,
301 100.8, 101.2, 100.6, 100.9, 101.3, 101.0, 100.5, 101.1, 100.7, 101.4, 100.9, 100.8,
302 101.2, 100.6, 100.9, 101.3, 101.0, 100.5,
303 ];
304 let mut cmo = ChandeMomentumOscillator::new(10, None);
305 for price in prices {
306 cmo.update_raw(price);
307 }
308 assert!(cmo.initialized);
309 assert!(cmo.value <= 100.0 && cmo.value >= -100.0);
310 }
311
312 #[rstest]
313 #[case(0.0, 0.123, 100.0)]
314 #[case(0.123, 0.0, -100.0)]
315 fn test_one_sided_window_is_exact(
316 #[case] first: f64,
317 #[case] second: f64,
318 #[case] expected: f64,
319 ) {
320 let mut cmo = ChandeMomentumOscillator::new(1, Some(MovingAverageType::Simple));
321 cmo.update_raw(first);
322 cmo.update_raw(second);
323
324 assert_eq!(cmo.value, expected);
325 }
326}