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_PERIOD: 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_PERIOD + 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_PERIOD` 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        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    /// Configured base period.
87    pub const fn period(&self) -> usize {
88        self.period
89    }
90
91    /// Current value if available.
92    pub const fn value(&self) -> Option<f64> {
93        self.last_value
94    }
95
96    /// Dynamic lookback for the current volatility, clamped to `[5, 30]`.
97    fn dynamic_period(&self, vol: f64, vol_avg: f64) -> usize {
98        if vol_avg <= 0.0 || vol <= 0.0 {
99            // No measurable volatility -> slowest (calmest) lookback.
100            return MAX_PERIOD;
101        }
102        let vi = vol / vol_avg;
103        let td = (self.period as f64 / vi).round();
104        // td is finite and positive here; clamp into the valid band.
105        (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        // Track the smoothed volatility on every close.
118        if let Some(v) = self.vol.update(input) {
119            self.last_vol_avg = self.vol_avg.update(v);
120        }
121
122        // Record the price change.
123        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        // Average gains and losses over the last `td` changes.
140        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            // Ratio first, then scale, so `100 * g / g` cannot round above 100.
154            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        // The change buffer (MAX_PERIOD changes => MAX_PERIOD + 1 inputs) is the
171        // binding constraint; the volatility chain (5 + 10 - 1 = 14) is shorter.
172        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    /// Cover the const accessors `period` + `value` and the Indicator-impl
199    /// `warmup_period` + `name`.
200    #[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        // Every change positive -> avg_loss 0 -> 100, regardless of dynamic period.
225        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        // Constant prices: no volatility (dynamic period -> max) and no changes
234        // -> neutral 50.
235        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        // Vi = 2 (vol twice its average) -> td = round(14 / 2) = 7.
255        assert_eq!(dmi.dynamic_period(2.0, 1.0), 7);
256        // Vi = 0.5 (calm) -> td = round(14 / 0.5) = 28.
257        assert_eq!(dmi.dynamic_period(0.5, 1.0), 28);
258        // Extreme calm clamps to MAX_PERIOD; extreme volatility clamps to MIN.
259        assert_eq!(dmi.dynamic_period(0.1, 1.0), MAX_PERIOD);
260        assert_eq!(dmi.dynamic_period(100.0, 1.0), MIN_PERIOD);
261        // Zero volatility -> slowest lookback.
262        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}