Skip to main content

wickra_core/indicators/
rwi.rs

1//! Random Walk Index (RWI).
2
3use std::collections::VecDeque;
4
5use crate::error::{Error, Result};
6use crate::ohlcv::Candle;
7use crate::traits::Indicator;
8
9/// Random Walk Index output: the bullish (high) and bearish (low) lines.
10#[derive(Debug, Clone, Copy, PartialEq)]
11pub struct RwiOutput {
12    /// `RWI_High` — strength of the trend up vs. a random walk.
13    pub high: f64,
14    /// `RWI_Low` — strength of the trend down vs. a random walk.
15    pub low: f64,
16}
17
18/// Mike Poulos' Random Walk Index — a trend-vs.-random-walk indicator that
19/// asks "how many standard deviations away from a random walk is the current
20/// move?".
21///
22/// For each lookback `i ∈ [2, period]`, RWI computes the ratio of the actual
23/// price displacement over `i` bars to the expected displacement of a random
24/// walk of the same length:
25///
26/// ```text
27/// RWI_High_t(i) = (high_t  − low_{t-i+1})  / (ATR_i(t) * sqrt(i))
28/// RWI_Low_t(i)  = (high_{t-i+1} − low_t)   / (ATR_i(t) * sqrt(i))
29/// ```
30///
31/// where `ATR_i(t)` is the simple average of true-range over the most recent
32/// `i` bars. The reported `RWI_High_t` / `RWI_Low_t` are the maxima of these
33/// ratios across all lookbacks `i ∈ [2, period]`.
34///
35/// `RWI_High` crossing above `RWI_Low` and exceeding 1 (`> 2` is the typical
36/// strong-trend threshold) signals an uptrend dominating random-walk; the
37/// mirror situation flags a downtrend. When both lines are below 1, neither
38/// direction beats a random walk and the market is read as ranging.
39///
40/// The first output is emitted after `period` candles (the second one provides
41/// the first `period = 2` lookback, so the indicator emits at index
42/// `period - 1`).
43///
44/// # Example
45///
46/// ```
47/// use wickra_core::{Candle, Indicator, Rwi};
48///
49/// let mut indicator = Rwi::new(14).unwrap();
50/// let mut last = None;
51/// for i in 0..80 {
52///     let base = 100.0 + f64::from(i);
53///     let candle =
54///         Candle::new(base, base + 2.0, base - 2.0, base + 1.0, 10.0, i64::from(i)).unwrap();
55///     last = indicator.update(candle);
56/// }
57/// assert!(last.is_some());
58/// ```
59#[derive(Debug, Clone)]
60pub struct Rwi {
61    period: usize,
62    /// Rolling window of the most recent `period` candles (oldest at the front).
63    candles: VecDeque<Candle>,
64    /// Rolling window of `period` true-range values aligned with `candles`
65    /// after the first bar (so `tr[0]` corresponds to `candles[1]`).
66    trs: VecDeque<f64>,
67    /// Reusable contiguous copy of `trs`, so the per-lookback ATR can be
68    /// summed over a slice without allocating per `update`.
69    scratch: Vec<f64>,
70    last: Option<RwiOutput>,
71}
72
73impl Rwi {
74    /// Construct a new RWI with the given lookback period.
75    ///
76    /// # Errors
77    ///
78    /// Returns [`Error::PeriodZero`] if `period == 0`.
79    /// Returns [`Error::InvalidPeriod`] if `period < 2` — RWI's shortest
80    /// lookback is `i = 2`, so a one-bar window would emit nothing.
81    pub fn new(period: usize) -> Result<Self> {
82        if period == 0 {
83            return Err(Error::PeriodZero);
84        }
85        if period > crate::error::MAX_PERIOD {
86            return Err(Error::InvalidPeriod {
87                message: crate::error::PERIOD_ABOVE_MAX,
88            });
89        }
90        if period < 2 {
91            return Err(Error::InvalidPeriod {
92                message: "RWI requires period >= 2",
93            });
94        }
95        Ok(Self {
96            period,
97            candles: VecDeque::with_capacity(period),
98            trs: VecDeque::with_capacity(period),
99            scratch: Vec::with_capacity(period),
100            last: None,
101        })
102    }
103
104    /// Configured period.
105    pub const fn period(&self) -> usize {
106        self.period
107    }
108
109    /// Current value if available.
110    pub const fn value(&self) -> Option<RwiOutput> {
111        self.last
112    }
113}
114
115impl Indicator for Rwi {
116    type Input = Candle;
117    type Output = RwiOutput;
118
119    fn update(&mut self, candle: Candle) -> Option<RwiOutput> {
120        // Compute the true range of this candle vs. the previous close (if any),
121        // then slide the windows.
122        let tr = if let Some(prev) = self.candles.back() {
123            candle.true_range(Some(prev.close))
124        } else {
125            candle.high - candle.low
126        };
127
128        if self.candles.len() == self.period {
129            self.candles.pop_front();
130        }
131        self.candles.push_back(candle);
132
133        // `trs` aligns with `candles` from index 1 onward; only push once we
134        // have at least one previous candle (the bar's TR-vs-prev is what we
135        // store). With the first bar in `candles`, no TR is recorded yet.
136        if self.candles.len() >= 2 {
137            if self.trs.len() == self.period - 1 {
138                self.trs.pop_front();
139            }
140            self.trs.push_back(tr);
141        }
142
143        // Need a full `period` candles before we can scan lookbacks i ∈ [2,period].
144        if self.candles.len() < self.period {
145            return None;
146        }
147
148        // `candles` is indexed at single positions, which a `VecDeque` does
149        // directly; only `trs` needs a contiguous slice, for the range sums.
150        let candles = &self.candles;
151        self.scratch.clear();
152        self.scratch.extend(self.trs.iter().copied());
153        let trs = &self.scratch;
154        let n = candles.len(); // == self.period
155        let last_high = candles[n - 1].high;
156        let last_low = candles[n - 1].low;
157
158        let mut rwi_high = 0.0_f64;
159        let mut rwi_low = 0.0_f64;
160        // For lookback i in [2, period]: compare bar `n - 1` to bar `n - i`.
161        // The TRs covered are those at trs indices [n - i .. n - 1], which is
162        // `i - 1` TR values (TR at index n - i is the TR of candle n - i + 1
163        // vs. candle n - i, the first TR contributing to the i-bar ATR... or
164        // strictly the ATR over the i-bar window is the mean of the i-1 TRs
165        // _between_ those bars). We use the i-1-TR mean to keep the indicator
166        // strictly causal.
167        for i in 2..=self.period {
168            // Trs slice indices (within trs Vec): start = n - i, end = n - 1 (excl.).
169            // trs has length n - 1; trs[k] = TR of candle k+1 vs candle k.
170            // count = i - 1, which is >= 1 for i >= 2.
171            let tr_start = n - i;
172            let tr_end = n - 1;
173            let count = tr_end - tr_start;
174            let atr_i: f64 = trs[tr_start..tr_end].iter().sum::<f64>() / (count as f64);
175            let denom = atr_i * (i as f64).sqrt();
176            if denom == 0.0 {
177                continue;
178            }
179            let old_low = candles[n - i].low;
180            let old_high = candles[n - i].high;
181            let h = (last_high - old_low) / denom;
182            let l = (old_high - last_low) / denom;
183            if h > rwi_high {
184                rwi_high = h;
185            }
186            if l > rwi_low {
187                rwi_low = l;
188            }
189        }
190
191        let out = RwiOutput {
192            high: rwi_high,
193            low: rwi_low,
194        };
195        self.last = Some(out);
196        Some(out)
197    }
198
199    fn reset(&mut self) {
200        self.candles.clear();
201        self.trs.clear();
202        self.scratch.clear();
203        self.last = None;
204    }
205
206    #[inline]
207    fn warmup_period(&self) -> usize {
208        // First emission once the rolling window holds `period` candles.
209        self.period
210    }
211
212    #[inline]
213    fn is_ready(&self) -> bool {
214        self.last.is_some()
215    }
216
217    #[inline]
218    fn name(&self) -> &'static str {
219        "RWI"
220    }
221}
222
223#[cfg(test)]
224mod tests {
225    use super::*;
226    use crate::traits::BatchExt;
227
228    fn candle(h: f64, l: f64, c: f64, ts: i64) -> Candle {
229        Candle::new(c, h, l, c, 1.0, ts).unwrap()
230    }
231
232    #[test]
233    fn rejects_zero_period() {
234        assert!(matches!(Rwi::new(0), Err(Error::PeriodZero)));
235    }
236
237    #[test]
238    fn rejects_period_one() {
239        assert!(matches!(Rwi::new(1), Err(Error::InvalidPeriod { .. })));
240    }
241
242    #[test]
243    fn accessors_and_metadata() {
244        let mut r = Rwi::new(14).unwrap();
245        assert_eq!(r.period(), 14);
246        assert_eq!(r.warmup_period(), 14);
247        assert_eq!(r.name(), "RWI");
248        assert!(r.value().is_none());
249        for i in 0..30_i64 {
250            let p = 100.0 + (i as f64);
251            r.update(candle(p + 1.0, p - 1.0, p, i));
252        }
253        assert!(r.value().is_some());
254    }
255
256    #[test]
257    fn first_emission_at_warmup_period() {
258        let candles: Vec<Candle> = (0..40_i64)
259            .map(|i| {
260                let p = 100.0 + ((i as f64) * 0.3).sin() * 5.0;
261                candle(p + 1.0, p - 1.0, p, i)
262            })
263            .collect();
264        let mut r = Rwi::new(5).unwrap();
265        let out = r.batch(&candles);
266        for v in out.iter().take(4) {
267            assert!(v.is_none());
268        }
269        assert!(out[4].is_some());
270    }
271
272    #[test]
273    fn constant_series_yields_zero_outputs() {
274        // Flat market: ATR is zero, so all lookbacks short-circuit on the
275        // denom-zero guard and both lines stay at 0.
276        let candles: Vec<Candle> = (0..30_i64).map(|i| candle(10.0, 10.0, 10.0, i)).collect();
277        let mut r = Rwi::new(5).unwrap();
278        let last = r.batch(&candles).into_iter().flatten().last().unwrap();
279        assert_eq!(last.high, 0.0);
280        assert_eq!(last.low, 0.0);
281    }
282
283    #[test]
284    fn pure_uptrend_high_dominates_low() {
285        // A monotone uptrend should produce RWI_High >> RWI_Low.
286        let candles: Vec<Candle> = (0..40_i64)
287            .map(|i| {
288                let base = 100.0 + (i as f64) * 2.0;
289                candle(base + 1.0, base - 0.5, base + 0.5, i)
290            })
291            .collect();
292        let mut r = Rwi::new(14).unwrap();
293        let last = r.batch(&candles).into_iter().flatten().last().unwrap();
294        assert!(
295            last.high > last.low,
296            "RWI_High {} should exceed RWI_Low {}",
297            last.high,
298            last.low
299        );
300        assert!(
301            last.high > 1.0,
302            "strong uptrend should exceed 1, got {}",
303            last.high
304        );
305    }
306
307    #[test]
308    fn pure_downtrend_low_dominates_high() {
309        let candles: Vec<Candle> = (0..40_i64)
310            .rev()
311            .map(|i| {
312                let base = 100.0 + (i as f64) * 2.0;
313                candle(base + 0.5, base - 1.0, base - 0.5, 40 - i)
314            })
315            .collect();
316        let mut r = Rwi::new(14).unwrap();
317        let last = r.batch(&candles).into_iter().flatten().last().unwrap();
318        assert!(last.low > last.high);
319        assert!(last.low > 1.0);
320    }
321
322    #[test]
323    fn outputs_non_negative() {
324        let candles: Vec<Candle> = (0..120_i64)
325            .map(|i| {
326                let p = 100.0 + ((i as f64) * 0.25).sin() * 6.0;
327                candle(p + 1.5, p - 1.5, p, i)
328            })
329            .collect();
330        let mut r = Rwi::new(10).unwrap();
331        for v in r.batch(&candles).into_iter().flatten() {
332            assert!(v.high >= 0.0 && v.low >= 0.0);
333            assert!(v.high.is_finite() && v.low.is_finite());
334        }
335    }
336
337    #[test]
338    fn batch_equals_streaming() {
339        let candles: Vec<Candle> = (0..80_i64)
340            .map(|i| {
341                let p = 100.0 + ((i as f64) * 0.3).sin() * 5.0;
342                candle(p + 1.0, p - 1.0, p, i)
343            })
344            .collect();
345        let mut a = Rwi::new(7).unwrap();
346        let mut b = Rwi::new(7).unwrap();
347        assert_eq!(
348            a.batch(&candles),
349            candles.iter().map(|c| b.update(*c)).collect::<Vec<_>>()
350        );
351    }
352
353    #[test]
354    fn reset_clears_state() {
355        let candles: Vec<Candle> = (0..30_i64).map(|i| candle(11.0, 9.0, 10.0, i)).collect();
356        let mut r = Rwi::new(5).unwrap();
357        r.batch(&candles);
358        assert!(r.is_ready());
359        r.reset();
360        assert!(!r.is_ready());
361        assert_eq!(r.update(candles[0]), None);
362    }
363}