Skip to main content

wickra_core/indicators/
dynamic_momentum_index.rs

1//! Dynamic Momentum Index (Chande's volatility-adaptive RSI).
2
3use 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
10// Chande's definitional constants.
11const STD_PERIOD: usize = 5; // volatility window
12const STD_AVG_PERIOD: usize = 10; // smoothing of the volatility
13const MIN_PERIOD: usize = 5; // fastest RSI lookback
14const MAX_RSI_LOOKBACK: usize = 30; // slowest RSI lookback
15
16/// Dynamic Momentum Index — Tushar Chande's RSI whose lookback shrinks in
17/// volatile markets and lengthens in calm ones.
18///
19/// A standard RSI uses a fixed period; the DMI varies it from the recent
20/// volatility so the oscillator stays responsive when the market is fast and
21/// smooth when it is quiet:
22///
23/// ```text
24/// vol     = StdDev(close, 5)
25/// vol_avg = SMA(vol, 10)
26/// Vi      = vol / vol_avg                       (volatility index)
27/// td      = clamp(round(period / Vi), 5, 30)    (dynamic lookback)
28/// avg_gain, avg_loss = simple means of the last `td` price changes
29/// DMI     = 100 * avg_gain / (avg_gain + avg_loss)
30/// ```
31///
32/// High volatility (`Vi > 1`) shortens `td` toward `5` (faster); low volatility
33/// lengthens it toward `30` (slower). The averages of gains and losses are
34/// simple means over the last `td` changes (not Wilder-smoothed), recomputed as
35/// the window length flexes. Output is bounded in `[0, 100]`; a flat market
36/// returns the neutral `50`.
37///
38/// The first value lands after `MAX_RSI_LOOKBACK + 1 = 31` inputs, so the change
39/// buffer always holds enough history for any dynamic lookback up to `30`.
40///
41/// # Example
42///
43/// ```
44/// use wickra_core::{DynamicMomentumIndex, Indicator};
45///
46/// let mut dmi = DynamicMomentumIndex::new(14).unwrap();
47/// let mut last = None;
48/// for i in 0..80 {
49///     last = dmi.update(100.0 + (f64::from(i) * 0.2).sin() * 5.0);
50/// }
51/// assert!(last.is_some());
52/// ```
53#[derive(Debug, Clone)]
54pub struct DynamicMomentumIndex {
55    period: usize,
56    vol: StdDev,
57    vol_avg: Sma,
58    prev_close: Option<f64>,
59    /// The last `MAX_RSI_LOOKBACK` price changes, oldest at the front.
60    changes: VecDeque<f64>,
61    last_vol_avg: Option<f64>,
62    last_value: Option<f64>,
63}
64
65impl DynamicMomentumIndex {
66    /// Construct a DMI with the given base RSI period (Chande uses 14).
67    ///
68    /// # Errors
69    ///
70    /// Returns [`Error::PeriodZero`] if `period == 0`.
71    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    /// Configured base period.
92    pub const fn period(&self) -> usize {
93        self.period
94    }
95
96    /// Current value if available.
97    pub const fn value(&self) -> Option<f64> {
98        self.last_value
99    }
100
101    /// Dynamic lookback for the current volatility, clamped to `[5, 30]`.
102    fn dynamic_period(&self, vol: f64, vol_avg: f64) -> usize {
103        if vol_avg <= 0.0 || vol <= 0.0 {
104            // No measurable volatility -> slowest (calmest) lookback.
105            return MAX_RSI_LOOKBACK;
106        }
107        let vi = vol / vol_avg;
108        let td = (self.period as f64 / vi).round();
109        // td is finite and positive here; clamp into the valid band.
110        (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        // Track the smoothed volatility on every close.
123        if let Some(v) = self.vol.update(input) {
124            self.last_vol_avg = self.vol_avg.update(v);
125        }
126
127        // Record the price change.
128        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        // Average gains and losses over the last `td` changes.
145        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            // Ratio first, then scale, so `100 * g / g` cannot round above 100.
159            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        // The change buffer (MAX_RSI_LOOKBACK changes => MAX_RSI_LOOKBACK + 1 inputs) is the
177        // binding constraint; the volatility chain (5 + 10 - 1 = 14) is shorter.
178        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    /// Cover the const accessors `period` + `value` and the Indicator-impl
207    /// `warmup_period` + `name`.
208    #[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        // Every change positive -> avg_loss 0 -> 100, regardless of dynamic period.
233        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        // Constant prices: no volatility (dynamic period -> max) and no changes
242        // -> neutral 50.
243        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        // Vi = 2 (vol twice its average) -> td = round(14 / 2) = 7.
263        assert_eq!(dmi.dynamic_period(2.0, 1.0), 7);
264        // Vi = 0.5 (calm) -> td = round(14 / 0.5) = 28.
265        assert_eq!(dmi.dynamic_period(0.5, 1.0), 28);
266        // Extreme calm clamps to MAX_RSI_LOOKBACK; extreme volatility clamps to MIN.
267        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        // Zero volatility -> slowest lookback.
270        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}