Skip to main content

wickra_core/indicators/
td_rei.rs

1#![allow(clippy::doc_markdown)]
2
3//! Tom DeMark Range Expansion Index (TD REI).
4//!
5//! The TD REI is a `period`-bar bounded oscillator in `[-100, 100]` that
6//! detects exhaustion via comparisons of the current bar's range to the bars
7//! two, five-or-six and seven-or-eight bars earlier. The canonical TD REI uses
8//! a `period` of 5.
9//!
10//! Per bar `i` (requires history through `i - 8`):
11//!
12//! ```text
13//! overlap      = (high[i]   >= low[i-5]   OR high[i]   >= low[i-6])
14//!            AND (low[i]    <= high[i-5]  OR low[i]    <= high[i-6])
15//! overlap_back = (high[i-2] >= close[i-7] OR high[i-2] >= close[i-8])
16//!            AND (low[i-2]  <= close[i-7] OR low[i-2]  <= close[i-8])
17//!
18//! if overlap OR overlap_back:
19//!     numerator   = (high[i] - high[i-2]) + (low[i] - low[i-2])
20//! else:
21//!     numerator   = 0
22//!
23//! denominator = |high[i] - high[i-2]| + |low[i] - low[i-2]|     (every bar)
24//!
25//! REI(i) = 100 * sum(numerator, period) / sum(denominator, period)
26//! ```
27//!
28//! When the windowed denominator is zero the indicator falls back to `0` (the
29//! neutral midpoint). Readings above `+60` are typically considered
30//! overbought; below `-60` oversold.
31
32use std::collections::VecDeque;
33
34use crate::error::{Error, Result};
35use crate::ohlcv::Candle;
36use crate::traits::Indicator;
37
38/// TD Range Expansion Index oscillator.
39#[derive(Debug, Clone)]
40pub struct TdRei {
41    period: usize,
42    // Need at least the last 9 candles for the lookback comparisons; we keep a
43    // rolling window long enough for the rule plus enough numerator/
44    // denominator history.
45    candles: VecDeque<Candle>,
46    numerators: VecDeque<f64>,
47    denominators: VecDeque<f64>,
48    last_value: Option<f64>,
49}
50
51/// Minimum history required to evaluate the TD REI per-bar rule. The
52/// numerator and denominator reference `bar[i-2]`, the first condition
53/// `bar[i-5]` / `bar[i-6]` and the alternative condition the closes of
54/// `bar[i-7]` / `bar[i-8]`, so the candle eight bars back must be available.
55const LOOKBACK: usize = 9;
56
57impl TdRei {
58    /// Construct a TD REI with the given averaging window. The classic
59    /// DeMark configuration is `period = 5`.
60    ///
61    /// # Errors
62    ///
63    /// Returns [`Error::PeriodZero`] if `period == 0`.
64    pub fn new(period: usize) -> Result<Self> {
65        if period == 0 {
66            return Err(Error::PeriodZero);
67        }
68        if period > crate::error::MAX_PERIOD {
69            return Err(Error::InvalidPeriod {
70                message: crate::error::PERIOD_ABOVE_MAX,
71            });
72        }
73        Ok(Self {
74            period,
75            candles: VecDeque::with_capacity(LOOKBACK),
76            numerators: VecDeque::with_capacity(period),
77            denominators: VecDeque::with_capacity(period),
78            last_value: None,
79        })
80    }
81
82    /// DeMark's classic configuration: `period = 5`.
83    pub fn classic() -> Self {
84        Self::new(5).expect("classic TD REI parameters are valid")
85    }
86
87    /// Configured window.
88    pub const fn period(&self) -> usize {
89        self.period
90    }
91
92    /// Latest emitted value if available.
93    pub const fn value(&self) -> Option<f64> {
94        self.last_value
95    }
96}
97
98impl Indicator for TdRei {
99    type Input = Candle;
100    type Output = f64;
101
102    fn update(&mut self, candle: Candle) -> Option<f64> {
103        // Maintain a rolling window of the last `LOOKBACK` candles (front =
104        // 6 bars ago when full).
105        if self.candles.len() == LOOKBACK {
106            self.candles.pop_front();
107        }
108        if self.candles.len() < LOOKBACK - 1 {
109            // Need 8 previous candles before we can evaluate the rule on the
110            // current one.
111            self.candles.push_back(candle);
112            return None;
113        }
114        // `candles` holds the 8 previous bars, oldest first: index 0 is bar
115        // i-8, index 7 is bar i-1.
116        let prev2 = self.candles[6];
117        let prev5 = self.candles[3];
118        let prev6 = self.candles[2];
119        let close7 = self.candles[1].close;
120        let close8 = self.candles[0].close;
121
122        // The bar's range overlaps the range of 5-6 bars earlier ...
123        let overlap = (candle.high >= prev5.low || candle.high >= prev6.low)
124            && (candle.low <= prev5.high || candle.low <= prev6.high);
125        // ... or the bar two back overlaps the closes of 7-8 bars earlier.
126        let overlap_back = (prev2.high >= close7 || prev2.high >= close8)
127            && (prev2.low <= close7 || prev2.low <= close8);
128
129        let raw_num = (candle.high - prev2.high) + (candle.low - prev2.low);
130        let denominator = (candle.high - prev2.high).abs() + (candle.low - prev2.low).abs();
131        let numerator = if overlap || overlap_back {
132            raw_num
133        } else {
134            0.0
135        };
136
137        if self.numerators.len() == self.period {
138            self.numerators.pop_front();
139            self.denominators.pop_front();
140        }
141        self.numerators.push_back(numerator);
142        self.denominators.push_back(denominator);
143        self.candles.push_back(candle);
144
145        if self.numerators.len() < self.period {
146            return None;
147        }
148        let sum_num: f64 = self.numerators.iter().sum();
149        let sum_den: f64 = self.denominators.iter().sum();
150        let v = if sum_den == 0.0 {
151            0.0
152        } else {
153            // |numerator| <= denominator bar by bar, so the ratio is bounded;
154            // the clamp only absorbs the last-bit rounding of the two sums.
155            (100.0 * sum_num / sum_den).clamp(-100.0, 100.0)
156        };
157        self.last_value = Some(v);
158        Some(v)
159    }
160
161    fn reset(&mut self) {
162        self.candles.clear();
163        self.numerators.clear();
164        self.denominators.clear();
165        self.last_value = None;
166    }
167
168    #[inline]
169    fn warmup_period(&self) -> usize {
170        // 8 bars to fill the lookback plus `period` updates to fill the
171        // numerator / denominator buffers.
172        (LOOKBACK - 1) + self.period
173    }
174
175    #[inline]
176    fn is_ready(&self) -> bool {
177        self.last_value.is_some()
178    }
179
180    #[inline]
181    fn name(&self) -> &'static str {
182        "TDREI"
183    }
184}
185
186#[cfg(test)]
187mod tests {
188    use super::*;
189    use crate::traits::BatchExt;
190    use approx::assert_relative_eq;
191
192    fn c(high: f64, low: f64, close: f64, ts: i64) -> Candle {
193        Candle::new_unchecked(close, high, low, close, 0.0, ts)
194    }
195
196    #[test]
197    fn flat_market_yields_neutral_zero() {
198        // All highs and lows equal -> denominator is identically zero, so the
199        // indicator emits its neutral fallback of 0.
200        let candles: Vec<Candle> = (0..40).map(|i| c(11.0, 9.0, 10.0, i)).collect();
201        let mut rei = TdRei::classic();
202        let out = rei.batch(&candles);
203        for v in out.iter().skip(rei.warmup_period()).copied().flatten() {
204            assert_relative_eq!(v, 0.0, epsilon = 1e-12);
205        }
206    }
207
208    #[test]
209    fn pure_uptrend_pegs_indicator_at_100() {
210        // Every bar makes strictly higher highs and lows. Both range-overlap
211        // conditions hold (current high > all previous lows; current low > all
212        // previous highs is false, but we need current low <= some prev
213        // high). For a slow steady uptrend cond2 still holds because
214        // current low < prev5/prev6 highs as long as the slope is moderate.
215        // With slope 1 and spread 2 (low to high), cond2 fails after ~3 bars.
216        // Use a smaller slope so cond2 holds throughout.
217        let candles: Vec<Candle> = (0..40)
218            .map(|i| {
219                let m = 100.0 + f64::from(i) * 0.1;
220                c(m + 1.0, m - 1.0, m, i64::from(i))
221            })
222            .collect();
223        let mut rei = TdRei::classic();
224        let last = rei.batch(&candles).into_iter().flatten().last().unwrap();
225        // Every numerator is positive (price moving up) and equals the
226        // denominator in magnitude (no sign flips), so REI saturates at 100.
227        assert_relative_eq!(last, 100.0, epsilon = 1e-9);
228    }
229
230    #[test]
231    fn pure_downtrend_pegs_indicator_at_minus_100() {
232        let candles: Vec<Candle> = (0..40)
233            .map(|i| {
234                let m = 100.0 - f64::from(i) * 0.1;
235                c(m + 1.0, m - 1.0, m, i64::from(i))
236            })
237            .collect();
238        let mut rei = TdRei::classic();
239        let last = rei.batch(&candles).into_iter().flatten().last().unwrap();
240        assert_relative_eq!(last, -100.0, epsilon = 1e-9);
241    }
242
243    #[test]
244    fn stays_in_minus_100_to_100() {
245        let candles: Vec<Candle> = (0..200)
246            .map(|i| {
247                let m = 50.0 + (f64::from(i) * 0.2).sin() * 5.0;
248                c(m + 1.0, m - 1.0, m, i64::from(i))
249            })
250            .collect();
251        let mut rei = TdRei::classic();
252        for v in rei.batch(&candles).into_iter().flatten() {
253            assert!((-100.0..=100.0).contains(&v), "out of range: {v}");
254        }
255    }
256
257    #[test]
258    fn batch_equals_streaming() {
259        let candles: Vec<Candle> = (0..80)
260            .map(|i| {
261                let m = 100.0 + (f64::from(i) * 0.3).sin() * 5.0;
262                c(m + 1.0, m - 1.0, m, i64::from(i))
263            })
264            .collect();
265        let mut a = TdRei::classic();
266        let mut b = TdRei::classic();
267        assert_eq!(
268            a.batch(&candles),
269            candles.iter().map(|x| b.update(*x)).collect::<Vec<_>>()
270        );
271    }
272
273    #[test]
274    fn rejects_zero_period() {
275        assert!(matches!(TdRei::new(0), Err(Error::PeriodZero)));
276    }
277
278    #[test]
279    fn reset_clears_state() {
280        let candles: Vec<Candle> = (0..40)
281            .map(|i| {
282                let m = 100.0 + f64::from(i) * 0.1;
283                c(m + 1.0, m - 1.0, m, i64::from(i))
284            })
285            .collect();
286        let mut rei = TdRei::classic();
287        rei.batch(&candles);
288        assert!(rei.is_ready());
289        rei.reset();
290        assert!(!rei.is_ready());
291        assert_eq!(rei.update(candles[0]), None);
292        assert_eq!(rei.value(), None);
293    }
294
295    #[test]
296    fn accessors_and_metadata() {
297        let rei = TdRei::classic();
298        assert_eq!(rei.period(), 5);
299        assert_eq!(rei.warmup_period(), 8 + 5);
300        assert_eq!(rei.name(), "TDREI");
301    }
302
303    /// Eight base bars (idx 0..=7): idx 0 and 1 close at `far` (range +-1),
304    /// idx 2..=7 are h 11, l 9, c 10.
305    fn base(far: f64) -> Vec<Candle> {
306        (0..8)
307            .map(|i| {
308                let m = if i < 2 { far } else { 10.0 };
309                c(m + 1.0, m - 1.0, m, i64::from(i))
310            })
311            .collect()
312    }
313
314    #[test]
315    fn hand_computed_overlap_mixed_sign() {
316        // period 1, bar 8 = h 13, l 8.5. prev2 = idx 6 (h 11, l 9).
317        // overlap: 13 >= 9 and 8.5 <= 11 -> numerator counts.
318        // numerator = (13 - 11) + (8.5 - 9) = 1.5; denominator = 2 + 0.5 = 2.5
319        // REI = 100 * 1.5 / 2.5 = 60.
320        let mut rei = TdRei::new(1).unwrap();
321        let mut candles = base(10.0);
322        candles.push(c(13.0, 8.5, 10.0, 8));
323        let out = rei.batch(&candles);
324        assert_relative_eq!(out[8].unwrap(), 60.0, epsilon = 1e-12);
325    }
326
327    #[test]
328    fn numerator_is_gated_but_denominator_counts() {
329        // Bars 0, 1 close at 50, so overlap_back fails: prev2.high 11 < 50.
330        // Bar 8 = h 30, l 25: low 25 > highs 11 of idx 2/3 -> no overlap.
331        // numerator = 0; denominator = (30 - 11) + (25 - 9) = 35 -> REI 0
332        // (a genuine zero, not the empty-denominator fallback).
333        let mut rei = TdRei::new(1).unwrap();
334        let mut candles = base(50.0);
335        candles.push(c(30.0, 25.0, 27.0, 8));
336        assert_eq!(rei.batch(&candles)[8], Some(0.0));
337        assert_eq!(rei.denominators.back().copied(), Some(35.0));
338        assert_eq!(rei.numerators.back().copied(), Some(0.0));
339    }
340
341    #[test]
342    fn overlap_back_alone_enables_numerator() {
343        // Same bar 8 as above (no overlap), but idx 0, 1 close at 10, so
344        // prev2 (h 11, l 9) brackets close[i-7] = 10 -> overlap_back holds.
345        // numerator = denominator = 19 + 16 = 35 -> REI 100.
346        let mut rei = TdRei::new(1).unwrap();
347        let mut candles = base(10.0);
348        candles.push(c(30.0, 25.0, 27.0, 8));
349        assert_eq!(rei.batch(&candles)[8], Some(100.0));
350    }
351
352    #[test]
353    fn hand_computed_period_two_window() {
354        // period 2, bars 0, 1 close at 50.
355        // Bar 8 (h 30, l 25, c 27): gated as above -> num 0, den 35.
356        // Bar 9 (h 12, l 10, c 11): prev2 = idx 7 (11, 9); prev5 = idx 4,
357        // prev6 = idx 3 (h 11, l 9): 12 >= 9 and 10 <= 11 -> overlap.
358        // num = (12 - 11) + (10 - 9) = 2, den = 2.
359        // REI = 100 * (0 + 2) / (35 + 2) = 200 / 37.
360        let mut rei = TdRei::new(2).unwrap();
361        let mut candles = base(50.0);
362        candles.push(c(30.0, 25.0, 27.0, 8));
363        candles.push(c(12.0, 10.0, 11.0, 9));
364        let out = rei.batch(&candles);
365        assert_eq!(rei.warmup_period(), 10);
366        assert!(out[..9].iter().all(Option::is_none));
367        assert_relative_eq!(out[9].unwrap(), 200.0 / 37.0, epsilon = 1e-12);
368    }
369
370    #[test]
371    fn first_value_lands_at_warmup_minus_one() {
372        let candles: Vec<Candle> = (0..40)
373            .map(|i| {
374                let m = 100.0 + (f64::from(i) * 0.4).sin() * 3.0;
375                c(m + 1.0, m - 1.0, m, i64::from(i))
376            })
377            .collect();
378        for period in [1, 2, 5, 14] {
379            let mut rei = TdRei::new(period).unwrap();
380            let warm = rei.warmup_period();
381            assert_eq!(warm, 8 + period);
382            let out = rei.batch(&candles);
383            assert!(out[..warm - 1].iter().all(Option::is_none));
384            assert!(out[warm - 1..].iter().all(Option::is_some));
385        }
386    }
387
388    #[test]
389    fn rejects_period_above_max() {
390        let err = TdRei::new(crate::error::MAX_PERIOD + 1).unwrap_err();
391        assert!(matches!(err, Error::InvalidPeriod { .. }));
392    }
393
394    #[test]
395    fn reset_reproduces_fresh_run() {
396        let candles: Vec<Candle> = (0..60)
397            .map(|i| {
398                let m = 100.0 + (f64::from(i) * 0.6).sin() * 4.0;
399                c(m + 1.5, m - 0.5, m, i64::from(i))
400            })
401            .collect();
402        let mut fresh = TdRei::classic();
403        let expected = fresh.batch(&candles);
404        let mut rei = TdRei::classic();
405        rei.batch(&candles[..23]);
406        rei.reset();
407        assert_eq!(rei.batch(&candles), expected);
408    }
409
410    #[test]
411    fn batch_nan_into_matches_streaming() {
412        let candles: Vec<Candle> = (0..80)
413            .map(|i| {
414                let m = 100.0 + (f64::from(i) * 0.9).sin() * 6.0;
415                c(m + 1.5, m - 0.5, m, i64::from(i))
416            })
417            .collect();
418        let mut a = TdRei::classic();
419        let mut out = vec![0.0; candles.len()];
420        a.batch_nan_into(&candles, &mut out);
421        let mut b = TdRei::classic();
422        let streamed: Vec<f64> = candles
423            .iter()
424            .map(|x| b.update(*x).unwrap_or(f64::NAN))
425            .collect();
426        assert!(out
427            .iter()
428            .zip(&streamed)
429            .all(|(x, y)| x.to_bits() == y.to_bits()));
430    }
431}