Skip to main content

nautilus_indicators/momentum/
dm.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::{Debug, Display};
17
18use nautilus_core::correctness::FAILED;
19use nautilus_model::data::Bar;
20
21use crate::{
22    indicator::Indicator,
23    support::{MAX_PERIOD, is_valid_high_low},
24};
25
26/// Wilder's directional movement, smoothed as a running sum.
27///
28/// The up-move is `high - previous_high` and the down-move is
29/// `previous_low - low`; only the larger of the two contributes, and only when it
30/// is positive. The first `period` movements, which start with the second bar,
31/// are summed to seed `pos` and `neg`. Later bars update them with
32/// `smoothed = smoothed - smoothed / period + movement`, so both values are
33/// on the scale of a `period`-bar sum of movements. The first complete output
34/// arrives after `period + 1` bars.
35#[repr(C)]
36#[derive(Debug)]
37#[cfg_attr(
38    feature = "python",
39    pyo3::pyclass(module = "nautilus_trader.indicators")
40)]
41#[cfg_attr(
42    feature = "python",
43    pyo3_stub_gen::derive::gen_stub_pyclass(module = "nautilus_trader.indicators")
44)]
45pub struct DirectionalMovement {
46    pub period: usize,
47    pub pos: f64,
48    pub neg: f64,
49    pub initialized: bool,
50    has_inputs: bool,
51    previous: Option<(f64, f64)>,
52    seed_count: usize,
53}
54
55impl Display for DirectionalMovement {
56    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
57        write!(f, "{}({})", self.name(), self.period)
58    }
59}
60
61impl Indicator for DirectionalMovement {
62    fn name(&self) -> String {
63        stringify!(DirectionalMovement).to_string()
64    }
65
66    fn has_inputs(&self) -> bool {
67        self.has_inputs
68    }
69
70    fn initialized(&self) -> bool {
71        self.initialized
72    }
73
74    fn handle_bar(&mut self, bar: &Bar) {
75        self.update_raw((&bar.high).into(), (&bar.low).into());
76    }
77
78    fn reset(&mut self) {
79        self.previous = None;
80        self.seed_count = 0;
81        self.pos = 0.0;
82        self.neg = 0.0;
83        self.has_inputs = false;
84        self.initialized = false;
85    }
86}
87
88impl DirectionalMovement {
89    /// Creates a new [`DirectionalMovement`] instance.
90    ///
91    /// # Panics
92    ///
93    /// Panics if `period` is zero or exceeds 16,777,216.
94    #[must_use]
95    pub fn new(period: usize) -> Self {
96        Self::new_checked(period).expect(FAILED)
97    }
98
99    pub(crate) fn new_checked(period: usize) -> anyhow::Result<Self> {
100        anyhow::ensure!(
101            (1..=MAX_PERIOD).contains(&period),
102            "DirectionalMovement: period must be in 1..={MAX_PERIOD} (received {period})"
103        );
104
105        Ok(Self {
106            period,
107            pos: 0.0,
108            neg: 0.0,
109            initialized: false,
110            has_inputs: false,
111            previous: None,
112            seed_count: 0,
113        })
114    }
115
116    pub fn update_raw(&mut self, high: f64, low: f64) {
117        if !is_valid_high_low(high, low) {
118            return;
119        }
120        self.has_inputs = true;
121
122        let Some((previous_high, previous_low)) = self.previous.replace((high, low)) else {
123            return;
124        };
125
126        let up = high - previous_high;
127        let down = previous_low - low;
128        let pos = if up > down && up > 0.0 { up } else { 0.0 };
129        let neg = if down > up && down > 0.0 { down } else { 0.0 };
130
131        if self.initialized {
132            let period = self.period as f64;
133            self.pos = self.pos - self.pos / period + pos;
134            self.neg = self.neg - self.neg / period + neg;
135            return;
136        }
137
138        self.pos += pos;
139        self.neg += neg;
140        self.seed_count += 1;
141        self.initialized = self.seed_count == self.period;
142    }
143}
144
145#[cfg(test)]
146mod tests {
147    use rstest::rstest;
148
149    use super::*;
150    use crate::stubs::dm_10;
151
152    #[rstest]
153    fn test_name_returns_expected_string(dm_10: DirectionalMovement) {
154        assert_eq!(dm_10.name(), "DirectionalMovement");
155    }
156
157    #[rstest]
158    fn test_str_repr_returns_expected_string(dm_10: DirectionalMovement) {
159        assert_eq!(format!("{dm_10}"), "DirectionalMovement(10)");
160    }
161
162    #[rstest]
163    fn test_period_returns_expected_value(dm_10: DirectionalMovement) {
164        assert_eq!(dm_10.period, 10);
165    }
166
167    #[rstest]
168    #[case(0)]
169    #[case(MAX_PERIOD + 1)]
170    fn test_new_checked_rejects_invalid_period(#[case] period: usize) {
171        assert!(DirectionalMovement::new_checked(period).is_err());
172    }
173
174    #[rstest]
175    fn test_initialized_without_inputs_returns_false(dm_10: DirectionalMovement) {
176        assert!(!dm_10.initialized());
177    }
178
179    #[rstest]
180    fn test_has_inputs_returns_true_after_first_update(mut dm_10: DirectionalMovement) {
181        assert!(!dm_10.has_inputs());
182        dm_10.update_raw(1.0, 0.9);
183        assert!(dm_10.has_inputs());
184        assert!(!dm_10.initialized());
185    }
186
187    #[rstest]
188    fn test_first_output_arrives_after_period_plus_one_bars() {
189        let mut dm = DirectionalMovement::new(3);
190        for i in 0..3 {
191            dm.update_raw(10.0 + f64::from(i), 9.0 + f64::from(i));
192            assert!(!dm.initialized());
193        }
194
195        dm.update_raw(13.0, 12.0);
196
197        assert!(dm.initialized());
198        assert_eq!(dm.pos, 3.0);
199        assert_eq!(dm.neg, 0.0);
200    }
201
202    #[rstest]
203    fn test_seed_is_sum_of_first_period_movements() {
204        let mut dm = DirectionalMovement::new(3);
205        let bars = [(10.0, 9.0), (12.0, 9.5), (11.0, 8.0), (11.5, 8.5)];
206        for (high, low) in bars {
207            dm.update_raw(high, low);
208        }
209
210        // Movements after the first bar: (+2, 0), (0, 1.5), (0.5, 0)
211        assert!(dm.initialized());
212        assert_eq!(dm.pos, 2.5);
213        assert_eq!(dm.neg, 1.5);
214    }
215
216    #[rstest]
217    fn test_smoothing_after_seed_decays_by_one_period() {
218        let mut dm = DirectionalMovement::new(3);
219        for (high, low) in [(10.0, 9.0), (12.0, 9.5), (11.0, 8.0), (11.5, 8.5)] {
220            dm.update_raw(high, low);
221        }
222        dm.update_raw(11.0, 8.6);
223
224        // Movement (0, 0): pos = 2.5 - 2.5 / 3, neg = 1.5 - 1.5 / 3
225        assert_eq!(dm.pos, 2.5 - 2.5 / 3.0);
226        assert_eq!(dm.neg, 1.5 - 1.5 / 3.0);
227    }
228
229    #[rstest]
230    fn test_value_with_all_higher_inputs_returns_expected_value(mut dm_10: DirectionalMovement) {
231        for i in 0..15 {
232            dm_10.update_raw(f64::from(i) + 1.0, f64::from(i) + 0.5);
233        }
234
235        assert!(dm_10.initialized());
236        assert_eq!(dm_10.pos, 10.0);
237        assert_eq!(dm_10.neg, 0.0);
238    }
239
240    #[rstest]
241    fn test_value_with_all_lower_inputs_returns_expected_value(mut dm_10: DirectionalMovement) {
242        for i in 0..15 {
243            dm_10.update_raw(15.0 - f64::from(i), 14.5 - f64::from(i));
244        }
245
246        assert!(dm_10.initialized());
247        assert_eq!(dm_10.pos, 0.0);
248        assert_eq!(dm_10.neg, 10.0);
249    }
250
251    #[rstest]
252    #[case(f64::NAN, 1.0)]
253    #[case(2.0, f64::INFINITY)]
254    #[case(1.0, 2.0)]
255    fn test_rejected_input_leaves_state_unchanged(#[case] high: f64, #[case] low: f64) {
256        let mut dm = DirectionalMovement::new(2);
257        dm.update_raw(10.0, 9.0);
258        dm.update_raw(12.0, 9.5);
259        dm.update_raw(high, low);
260        dm.update_raw(11.0, 8.0);
261
262        // Movements after the first bar: (+2, 0), (0, 1.5)
263        assert!(dm.initialized());
264        assert_eq!(dm.pos, 2.0);
265        assert_eq!(dm.neg, 1.5);
266    }
267
268    #[rstest]
269    fn test_reset_successfully_returns_indicator_to_fresh_state(mut dm_10: DirectionalMovement) {
270        dm_10.update_raw(12.0, 10.0);
271        dm_10.update_raw(13.0, 11.0);
272        dm_10.update_raw(15.0, 12.0);
273        // Up moves of 1 and 2 against down moves of -1
274        assert!(dm_10.has_inputs());
275        assert_eq!(dm_10.pos, 3.0);
276        assert_eq!(dm_10.neg, 0.0);
277        assert_eq!(dm_10.previous, Some((15.0, 12.0)));
278        assert_eq!(dm_10.seed_count, 2);
279
280        dm_10.reset();
281
282        assert!(!dm_10.initialized());
283        assert!(!dm_10.has_inputs());
284        assert_eq!(dm_10.pos, 0.0);
285        assert_eq!(dm_10.neg, 0.0);
286        assert_eq!(dm_10.previous, None);
287        assert_eq!(dm_10.seed_count, 0);
288    }
289}