nautilus_indicators/momentum/
dm.rs1use 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#[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 #[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 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 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 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 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}