wickra_core/indicators/
dynamic_momentum_index.rs1use std::collections::VecDeque;
4
5use crate::error::{Error, Result};
6use crate::indicators::sma::Sma;
7use crate::indicators::std_dev::StdDev;
8use crate::traits::Indicator;
9
10const STD_PERIOD: usize = 5; const STD_AVG_PERIOD: usize = 10; const MIN_PERIOD: usize = 5; const MAX_PERIOD: usize = 30; #[derive(Debug, Clone)]
54pub struct DynamicMomentumIndex {
55 period: usize,
56 vol: StdDev,
57 vol_avg: Sma,
58 prev_close: Option<f64>,
59 changes: VecDeque<f64>,
61 last_vol_avg: Option<f64>,
62 last_value: Option<f64>,
63}
64
65impl DynamicMomentumIndex {
66 pub fn new(period: usize) -> Result<Self> {
72 if period == 0 {
73 return Err(Error::PeriodZero);
74 }
75 Ok(Self {
76 period,
77 vol: StdDev::new(STD_PERIOD)?,
78 vol_avg: Sma::new(STD_AVG_PERIOD)?,
79 prev_close: None,
80 changes: VecDeque::with_capacity(MAX_PERIOD),
81 last_vol_avg: None,
82 last_value: None,
83 })
84 }
85
86 pub const fn period(&self) -> usize {
88 self.period
89 }
90
91 pub const fn value(&self) -> Option<f64> {
93 self.last_value
94 }
95
96 fn dynamic_period(&self, vol: f64, vol_avg: f64) -> usize {
98 if vol_avg <= 0.0 || vol <= 0.0 {
99 return MAX_PERIOD;
101 }
102 let vi = vol / vol_avg;
103 let td = (self.period as f64 / vi).round();
104 (td as usize).clamp(MIN_PERIOD, MAX_PERIOD)
106 }
107}
108
109impl Indicator for DynamicMomentumIndex {
110 type Input = f64;
111 type Output = f64;
112
113 fn update(&mut self, input: f64) -> Option<f64> {
114 if !input.is_finite() {
115 return self.last_value;
116 }
117 if let Some(v) = self.vol.update(input) {
119 self.last_vol_avg = self.vol_avg.update(v);
120 }
121
122 if let Some(prev) = self.prev_close {
124 let change = input - prev;
125 if self.changes.len() == MAX_PERIOD {
126 self.changes.pop_front();
127 }
128 self.changes.push_back(change);
129 }
130 self.prev_close = Some(input);
131
132 let vol = self.vol.value()?;
133 let vol_avg = self.last_vol_avg?;
134 if self.changes.len() < MAX_PERIOD {
135 return None;
136 }
137
138 let td = self.dynamic_period(vol, vol_avg);
139 let mut sum_gain = 0.0;
141 let mut sum_loss = 0.0;
142 for &c in self.changes.iter().skip(MAX_PERIOD - td) {
143 if c > 0.0 {
144 sum_gain += c;
145 } else if c < 0.0 {
146 sum_loss -= c;
147 }
148 }
149 let denom = sum_gain + sum_loss;
150 let v = if denom == 0.0 {
151 50.0
152 } else {
153 100.0 * (sum_gain / denom)
155 };
156 self.last_value = Some(v);
157 Some(v)
158 }
159
160 fn reset(&mut self) {
161 self.vol.reset();
162 self.vol_avg.reset();
163 self.prev_close = None;
164 self.changes.clear();
165 self.last_vol_avg = None;
166 self.last_value = None;
167 }
168
169 fn warmup_period(&self) -> usize {
170 MAX_PERIOD + 1
173 }
174
175 fn is_ready(&self) -> bool {
176 self.last_value.is_some()
177 }
178
179 fn name(&self) -> &'static str {
180 "DynamicMomentumIndex"
181 }
182}
183
184#[cfg(test)]
185mod tests {
186 use super::*;
187 use crate::traits::BatchExt;
188 use approx::assert_relative_eq;
189
190 #[test]
191 fn rejects_zero_period() {
192 assert!(matches!(
193 DynamicMomentumIndex::new(0),
194 Err(Error::PeriodZero)
195 ));
196 }
197
198 #[test]
201 fn accessors_and_metadata() {
202 let dmi = DynamicMomentumIndex::new(14).unwrap();
203 assert_eq!(dmi.period(), 14);
204 assert_eq!(dmi.value(), None);
205 assert_eq!(dmi.warmup_period(), 31);
206 assert_eq!(dmi.name(), "DynamicMomentumIndex");
207 }
208
209 #[test]
210 fn first_emission_matches_warmup_period() {
211 let prices: Vec<f64> = (0..50)
212 .map(|i| 100.0 + (f64::from(i) * 0.4).sin() * 6.0)
213 .collect();
214 let mut dmi = DynamicMomentumIndex::new(14).unwrap();
215 let out = dmi.batch(&prices);
216 for (i, v) in out.iter().enumerate().take(30) {
217 assert!(v.is_none(), "index {i} must be None during warmup");
218 }
219 assert!(out[30].is_some(), "first value at warmup_period - 1 = 30");
220 }
221
222 #[test]
223 fn pure_uptrend_is_one_hundred() {
224 let prices: Vec<f64> = (1..=60).map(f64::from).collect();
226 let mut dmi = DynamicMomentumIndex::new(14).unwrap();
227 let last = dmi.batch(&prices).into_iter().flatten().last().unwrap();
228 assert_relative_eq!(last, 100.0, epsilon = 1e-9);
229 }
230
231 #[test]
232 fn flat_market_is_neutral() {
233 let mut dmi = DynamicMomentumIndex::new(14).unwrap();
236 let last = dmi.batch(&[42.0; 50]).into_iter().flatten().last().unwrap();
237 assert_relative_eq!(last, 50.0, epsilon = 1e-12);
238 }
239
240 #[test]
241 fn output_stays_in_range() {
242 let prices: Vec<f64> = (0..120)
243 .map(|i| 100.0 + (f64::from(i) * 0.3).sin() * 10.0 + (f64::from(i) * 0.07).cos() * 4.0)
244 .collect();
245 let mut dmi = DynamicMomentumIndex::new(14).unwrap();
246 for v in dmi.batch(&prices).into_iter().flatten() {
247 assert!((0.0..=100.0).contains(&v), "DMI {v} left [0, 100]");
248 }
249 }
250
251 #[test]
252 fn high_volatility_shortens_period() {
253 let dmi = DynamicMomentumIndex::new(14).unwrap();
254 assert_eq!(dmi.dynamic_period(2.0, 1.0), 7);
256 assert_eq!(dmi.dynamic_period(0.5, 1.0), 28);
258 assert_eq!(dmi.dynamic_period(0.1, 1.0), MAX_PERIOD);
260 assert_eq!(dmi.dynamic_period(100.0, 1.0), MIN_PERIOD);
261 assert_eq!(dmi.dynamic_period(0.0, 1.0), MAX_PERIOD);
263 assert_eq!(dmi.dynamic_period(1.0, 0.0), MAX_PERIOD);
264 }
265
266 #[test]
267 fn ignores_non_finite_input() {
268 let mut dmi = DynamicMomentumIndex::new(14).unwrap();
269 let ready = dmi
270 .batch(&(0..40).map(|i| 100.0 + f64::from(i)).collect::<Vec<_>>())
271 .into_iter()
272 .flatten()
273 .last()
274 .unwrap();
275 assert_eq!(dmi.update(f64::NAN), Some(ready));
276 assert_eq!(dmi.update(f64::INFINITY), Some(ready));
277 }
278
279 #[test]
280 fn reset_clears_state() {
281 let mut dmi = DynamicMomentumIndex::new(14).unwrap();
282 dmi.batch(&(0..40).map(|i| 100.0 + f64::from(i)).collect::<Vec<_>>());
283 assert!(dmi.is_ready());
284 dmi.reset();
285 assert!(!dmi.is_ready());
286 assert_eq!(dmi.update(1.0), None);
287 }
288
289 #[test]
290 fn batch_equals_streaming() {
291 let prices: Vec<f64> = (0..80)
292 .map(|i| 50.0 + (f64::from(i) * 0.5).sin() * 10.0)
293 .collect();
294 let mut a = DynamicMomentumIndex::new(14).unwrap();
295 let mut b = DynamicMomentumIndex::new(14).unwrap();
296 assert_eq!(
297 a.batch(&prices),
298 prices.iter().map(|p| b.update(*p)).collect::<Vec<_>>()
299 );
300 }
301}