Skip to main content

nautilus_indicators/momentum/
cmo.rs

1// -------------------------------------------------------------------------------------------------
2//  Copyright (C) 2015-2026 Nautech Systems Pty Ltd. All rights reserved.
3//  https://nautechsystems.io
4//
5//  Licensed under the GNU Lesser General Public License Version 3.0 (the "License");
6//  You may not use this file except in compliance with the License.
7//  You may obtain a copy of the License at https://www.gnu.org/licenses/lgpl-3.0.en.html
8//
9//  Unless required by applicable law or agreed to in writing, software
10//  distributed under the License is distributed on an "AS IS" BASIS,
11//  WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12//  See the License for the specific language governing permissions and
13//  limitations under the License.
14// -------------------------------------------------------------------------------------------------
15
16use 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/// Chande momentum oscillator.
28#[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    /// Creates a new [`ChandeMomentumOscillator`] instance.
92    ///
93    /// # Panics
94    ///
95    /// Panics if `period` is not positive (> 0).
96    #[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            // Divide before scaling, so a zero gain average gives exactly -100
151            // rather than a value just outside the oscillator's range
152            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}