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