Skip to main content

nautilus_indicators/momentum/
roc.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::{collections::VecDeque, fmt::Display};
17
18use nautilus_core::correctness::FAILED;
19use nautilus_model::data::{Bar, QuoteTick, TradeTick};
20
21use crate::{indicator::Indicator, support::MAX_PERIOD};
22
23/// Output convention for [`RateOfChange`].
24#[repr(C)]
25#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Hash)]
26#[cfg_attr(
27    feature = "python",
28    pyo3::pyclass(
29        frozen,
30        eq,
31        eq_int,
32        module = "nautilus_trader.indicators",
33        from_py_object
34    )
35)]
36#[cfg_attr(
37    feature = "python",
38    pyo3_stub_gen::derive::gen_stub_pyclass_enum(module = "nautilus_trader.indicators")
39)]
40pub enum RateOfChangeMode {
41    /// Percentage change: `100 * (current - previous) / previous`.
42    #[default]
43    Percentage,
44    /// Fractional change: `(current - previous) / previous`.
45    Fraction,
46    /// Price ratio: `current / previous`.
47    Ratio,
48    /// Price ratio scaled by 100.
49    RatioPercent,
50    /// Natural logarithm of the price ratio.
51    Log,
52}
53
54/// Rate of change with configurable output units.
55#[repr(C)]
56#[derive(Debug)]
57#[cfg_attr(
58    feature = "python",
59    pyo3::pyclass(module = "nautilus_trader.indicators")
60)]
61#[cfg_attr(
62    feature = "python",
63    pyo3_stub_gen::derive::gen_stub_pyclass(module = "nautilus_trader.indicators")
64)]
65pub struct RateOfChange {
66    pub period: usize,
67    pub use_log: bool,
68    pub mode: RateOfChangeMode,
69    pub value: f64,
70    pub initialized: bool,
71    has_inputs: bool,
72    prices: VecDeque<f64>,
73}
74
75impl Display for RateOfChange {
76    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
77        write!(f, "{}({})", self.name(), self.period)
78    }
79}
80
81impl Indicator for RateOfChange {
82    fn name(&self) -> String {
83        stringify!(RateOfChange).to_string()
84    }
85
86    fn has_inputs(&self) -> bool {
87        self.has_inputs
88    }
89
90    fn initialized(&self) -> bool {
91        self.initialized
92    }
93
94    fn handle_quote(&mut self, _quote: &QuoteTick) -> anyhow::Result<()> {
95        Ok(())
96    }
97
98    fn handle_trade(&mut self, _trade: &TradeTick) {}
99
100    fn handle_bar(&mut self, bar: &Bar) {
101        self.update_raw((&bar.close).into());
102    }
103
104    fn reset(&mut self) {
105        self.prices.clear();
106        self.value = 0.0;
107        self.has_inputs = false;
108        self.initialized = false;
109    }
110}
111
112impl RateOfChange {
113    /// Creates a new [`RateOfChange`] instance.
114    ///
115    /// # Panics
116    ///
117    /// Panics if `period` is outside `1..=MAX_PERIOD`.
118    #[must_use]
119    pub fn new(period: usize, use_log: Option<bool>) -> Self {
120        let mode = if use_log.unwrap_or(false) {
121            RateOfChangeMode::Log
122        } else {
123            RateOfChangeMode::Percentage
124        };
125        Self::new_checked(period, mode).expect(FAILED)
126    }
127
128    pub(crate) fn new_checked(period: usize, mode: RateOfChangeMode) -> anyhow::Result<Self> {
129        anyhow::ensure!(
130            (1..=MAX_PERIOD).contains(&period),
131            "period must be in 1..={MAX_PERIOD}"
132        );
133        Ok(Self {
134            period,
135            use_log: mode == RateOfChangeMode::Log,
136            mode,
137            value: 0.0,
138            prices: VecDeque::with_capacity(period + 1),
139            has_inputs: false,
140            initialized: false,
141        })
142    }
143
144    /// Creates a new [`RateOfChange`] with an explicit output convention.
145    ///
146    /// # Panics
147    ///
148    /// Panics if `period` is zero or exceeds 16,777,216.
149    #[must_use]
150    pub fn new_with_mode(period: usize, mode: RateOfChangeMode) -> Self {
151        Self::new_checked(period, mode).expect(FAILED)
152    }
153
154    pub fn update_raw(&mut self, price: f64) {
155        if !price.is_finite() || (self.mode == RateOfChangeMode::Log && price <= 0.0) {
156            return;
157        }
158        // The window holds `period + 1` prices so the front is the price exactly
159        // `period` updates ago (the standard ROC lookback).
160        if self.prices.len() == self.period + 1 {
161            let _ = self.prices.pop_front();
162        }
163        self.prices.push_back(price);
164
165        if !self.initialized {
166            self.has_inputs = true;
167
168            if self.prices.len() > self.period {
169                self.initialized = true;
170            }
171        }
172
173        if !self.initialized {
174            return;
175        }
176
177        if let Some(first) = self.prices.front() {
178            if *first == 0.0 {
179                self.value = 0.0;
180                return;
181            }
182            let ratio = price / first;
183            self.value = match self.mode {
184                RateOfChangeMode::Percentage => 100.0 * (ratio - 1.0),
185                RateOfChangeMode::Fraction => ratio - 1.0,
186                RateOfChangeMode::Ratio => ratio,
187                RateOfChangeMode::RatioPercent => 100.0 * ratio,
188                RateOfChangeMode::Log => (price / first).ln(),
189            };
190        }
191    }
192}
193
194#[cfg(test)]
195mod tests {
196    use rstest::rstest;
197
198    use super::*;
199    use crate::{stubs::roc_10, testing::assert_approx_equal};
200
201    #[rstest]
202    fn test_name_returns_expected_string(roc_10: RateOfChange) {
203        assert_eq!(roc_10.name(), "RateOfChange");
204    }
205
206    #[rstest]
207    fn test_str_repr_returns_expected_string(roc_10: RateOfChange) {
208        assert_eq!(format!("{roc_10}"), "RateOfChange(10)");
209    }
210
211    #[rstest]
212    fn test_period_returns_expected_value(roc_10: RateOfChange) {
213        assert_eq!(roc_10.period, 10);
214        assert!(roc_10.use_log);
215    }
216
217    #[rstest]
218    fn test_initialized_without_inputs_returns_false(roc_10: RateOfChange) {
219        assert!(!roc_10.initialized());
220    }
221
222    #[rstest]
223    fn test_value_with_all_higher_inputs_returns_expected_value(mut roc_10: RateOfChange) {
224        let close_values = [
225            0.95, 1.95, 2.95, 3.95, 4.95, 5.95, 6.95, 7.95, 8.95, 9.95, 10.05, 10.15, 10.25, 11.05,
226            11.45,
227        ];
228
229        for close in &close_values {
230            roc_10.update_raw(*close);
231        }
232
233        assert!(roc_10.initialized());
234        // ln(11.45 / 4.95): the price exactly 10 updates back is 4.95
235        assert_approx_equal(roc_10.value, 0.838_602_153_419_649_5);
236    }
237
238    #[rstest]
239    fn test_reset_successfully_returns_indicator_to_fresh_state(mut roc_10: RateOfChange) {
240        roc_10.update_raw(1.00020);
241        roc_10.update_raw(1.00030);
242        roc_10.update_raw(1.00070);
243
244        roc_10.reset();
245
246        assert!(!roc_10.initialized());
247        assert!(!roc_10.has_inputs);
248        assert_eq!(roc_10.value, 0.0);
249    }
250
251    #[rstest]
252    fn test_value_respects_period_window() {
253        let mut roc = RateOfChange::new(3, Some(false));
254
255        roc.update_raw(100.0);
256        roc.update_raw(1.0);
257        roc.update_raw(2.0);
258        roc.update_raw(3.0);
259        roc.update_raw(4.0);
260
261        // Lookback is exactly `period` = 3 updates: 4.0 against 1.0
262        assert_eq!(roc.value, 300.0);
263    }
264}