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 and five-or-six bars earlier. The canonical TD REI uses a `period` of
8//! 5.
9//!
10//! Per bar `i` (requires history through `i - 7`):
11//!
12//! ```text
13//! cond1 = (high[i] >= low[i-5])  OR (high[i] >= low[i-6])
14//! cond2 = (low[i]  <= high[i-5]) OR (low[i]  <= high[i-6])
15//!
16//! if cond1 AND cond2:
17//!     numerator   = (high[i] - high[i-2]) + (low[i] - low[i-2])
18//! else:
19//!     numerator   = 0
20//!
21//! denominator = |high[i] - high[i-2]| + |low[i] - low[i-2]|
22//!
23//! REI(i) = 100 * sum(numerator, period) / sum(denominator, period)
24//! ```
25//!
26//! When the windowed denominator is zero the indicator falls back to `0` (the
27//! neutral midpoint). Readings above `+60` are typically considered
28//! overbought; below `-60` oversold.
29
30use std::collections::VecDeque;
31
32use crate::error::{Error, Result};
33use crate::ohlcv::Candle;
34use crate::traits::Indicator;
35
36/// TD Range Expansion Index oscillator.
37#[derive(Debug, Clone)]
38pub struct TdRei {
39    period: usize,
40    // Need at least the last 7 candles for the lookback comparisons; we keep a
41    // rolling window long enough for the rule plus enough numerator/
42    // denominator history.
43    candles: VecDeque<Candle>,
44    numerators: VecDeque<f64>,
45    denominators: VecDeque<f64>,
46    last_value: Option<f64>,
47}
48
49/// Minimum history required to evaluate the TD REI per-bar rule. The
50/// numerator and denominator both reference `bar[i-2]` and the long
51/// conditional references `bar[i-5]` and `bar[i-6]`, so we need the candle
52/// six bars before the current one to be available.
53const LOOKBACK: usize = 7;
54
55impl TdRei {
56    /// Construct a TD REI with the given averaging window. The classic
57    /// DeMark configuration is `period = 5`.
58    ///
59    /// # Errors
60    ///
61    /// Returns [`Error::PeriodZero`] if `period == 0`.
62    pub fn new(period: usize) -> Result<Self> {
63        if period == 0 {
64            return Err(Error::PeriodZero);
65        }
66        Ok(Self {
67            period,
68            candles: VecDeque::with_capacity(LOOKBACK),
69            numerators: VecDeque::with_capacity(period),
70            denominators: VecDeque::with_capacity(period),
71            last_value: None,
72        })
73    }
74
75    /// DeMark's classic configuration: `period = 5`.
76    pub fn classic() -> Self {
77        Self::new(5).expect("classic TD REI parameters are valid")
78    }
79
80    /// Configured window.
81    pub const fn period(&self) -> usize {
82        self.period
83    }
84
85    /// Latest emitted value if available.
86    pub const fn value(&self) -> Option<f64> {
87        self.last_value
88    }
89}
90
91impl Indicator for TdRei {
92    type Input = Candle;
93    type Output = f64;
94
95    fn update(&mut self, candle: Candle) -> Option<f64> {
96        // Maintain a rolling window of the last `LOOKBACK` candles (front =
97        // 6 bars ago when full).
98        if self.candles.len() == LOOKBACK {
99            self.candles.pop_front();
100        }
101        if self.candles.len() < LOOKBACK - 1 {
102            // Need 6 previous candles before we can evaluate the rule on the
103            // current one.
104            self.candles.push_back(candle);
105            return None;
106        }
107        // candles currently holds the 6 most recent bars (in order); the new
108        // candle is the 7th. After the rule fires we push it onto the back.
109        // Indexing convention: index 0 is the oldest in the window (i.e. 6
110        // bars ago); index 5 is the bar just before the current one.
111        // For the rule we need:
112        //   bar[i-2] -> candles[len-2]  (here len == 6)
113        //   bar[i-5] -> candles[1]
114        //   bar[i-6] -> candles[0]
115        let prev2 = self.candles[self.candles.len() - 2];
116        let prev5 = self.candles[1];
117        let prev6 = self.candles[0];
118
119        let cond1 = candle.high >= prev5.low || candle.high >= prev6.low;
120        let cond2 = candle.low <= prev5.high || candle.low <= prev6.high;
121
122        let raw_num = (candle.high - prev2.high) + (candle.low - prev2.low);
123        let denominator = (candle.high - prev2.high).abs() + (candle.low - prev2.low).abs();
124        let numerator = if cond1 && cond2 { raw_num } else { 0.0 };
125
126        if self.numerators.len() == self.period {
127            self.numerators.pop_front();
128            self.denominators.pop_front();
129        }
130        self.numerators.push_back(numerator);
131        self.denominators.push_back(denominator);
132        self.candles.push_back(candle);
133
134        if self.numerators.len() < self.period {
135            return None;
136        }
137        let sum_num: f64 = self.numerators.iter().sum();
138        let sum_den: f64 = self.denominators.iter().sum();
139        let v = if sum_den == 0.0 {
140            0.0
141        } else {
142            100.0 * sum_num / sum_den
143        };
144        self.last_value = Some(v);
145        Some(v)
146    }
147
148    fn reset(&mut self) {
149        self.candles.clear();
150        self.numerators.clear();
151        self.denominators.clear();
152        self.last_value = None;
153    }
154
155    fn warmup_period(&self) -> usize {
156        // 6 bars to fill the lookback plus `period` updates to fill the
157        // numerator / denominator buffers.
158        (LOOKBACK - 1) + self.period
159    }
160
161    fn is_ready(&self) -> bool {
162        self.last_value.is_some()
163    }
164
165    fn name(&self) -> &'static str {
166        "TDREI"
167    }
168}
169
170#[cfg(test)]
171mod tests {
172    use super::*;
173    use crate::traits::BatchExt;
174    use approx::assert_relative_eq;
175
176    fn c(high: f64, low: f64, close: f64, ts: i64) -> Candle {
177        Candle::new_unchecked(close, high, low, close, 0.0, ts)
178    }
179
180    #[test]
181    fn flat_market_yields_neutral_zero() {
182        // All highs and lows equal -> denominator is identically zero, so the
183        // indicator emits its neutral fallback of 0.
184        let candles: Vec<Candle> = (0..40).map(|i| c(11.0, 9.0, 10.0, i)).collect();
185        let mut rei = TdRei::classic();
186        let out = rei.batch(&candles);
187        for v in out.iter().skip(rei.warmup_period()).copied().flatten() {
188            assert_relative_eq!(v, 0.0, epsilon = 1e-12);
189        }
190    }
191
192    #[test]
193    fn pure_uptrend_pegs_indicator_at_100() {
194        // Every bar makes strictly higher highs and lows. Both range-overlap
195        // conditions hold (current high > all previous lows; current low > all
196        // previous highs is false, but we need current low <= some prev
197        // high). For a slow steady uptrend cond2 still holds because
198        // current low < prev5/prev6 highs as long as the slope is moderate.
199        // With slope 1 and spread 2 (low to high), cond2 fails after ~3 bars.
200        // Use a smaller slope so cond2 holds throughout.
201        let candles: Vec<Candle> = (0..40)
202            .map(|i| {
203                let m = 100.0 + f64::from(i) * 0.1;
204                c(m + 1.0, m - 1.0, m, i64::from(i))
205            })
206            .collect();
207        let mut rei = TdRei::classic();
208        let last = rei.batch(&candles).into_iter().flatten().last().unwrap();
209        // Every numerator is positive (price moving up) and equals the
210        // denominator in magnitude (no sign flips), so REI saturates at 100.
211        assert_relative_eq!(last, 100.0, epsilon = 1e-9);
212    }
213
214    #[test]
215    fn pure_downtrend_pegs_indicator_at_minus_100() {
216        let candles: Vec<Candle> = (0..40)
217            .map(|i| {
218                let m = 100.0 - f64::from(i) * 0.1;
219                c(m + 1.0, m - 1.0, m, i64::from(i))
220            })
221            .collect();
222        let mut rei = TdRei::classic();
223        let last = rei.batch(&candles).into_iter().flatten().last().unwrap();
224        assert_relative_eq!(last, -100.0, epsilon = 1e-9);
225    }
226
227    #[test]
228    fn stays_in_minus_100_to_100() {
229        let candles: Vec<Candle> = (0..200)
230            .map(|i| {
231                let m = 50.0 + (f64::from(i) * 0.2).sin() * 5.0;
232                c(m + 1.0, m - 1.0, m, i64::from(i))
233            })
234            .collect();
235        let mut rei = TdRei::classic();
236        for v in rei.batch(&candles).into_iter().flatten() {
237            assert!((-100.0..=100.0).contains(&v), "out of range: {v}");
238        }
239    }
240
241    #[test]
242    fn batch_equals_streaming() {
243        let candles: Vec<Candle> = (0..80)
244            .map(|i| {
245                let m = 100.0 + (f64::from(i) * 0.3).sin() * 5.0;
246                c(m + 1.0, m - 1.0, m, i64::from(i))
247            })
248            .collect();
249        let mut a = TdRei::classic();
250        let mut b = TdRei::classic();
251        assert_eq!(
252            a.batch(&candles),
253            candles.iter().map(|x| b.update(*x)).collect::<Vec<_>>()
254        );
255    }
256
257    #[test]
258    fn rejects_zero_period() {
259        assert!(matches!(TdRei::new(0), Err(Error::PeriodZero)));
260    }
261
262    #[test]
263    fn reset_clears_state() {
264        let candles: Vec<Candle> = (0..40)
265            .map(|i| {
266                let m = 100.0 + f64::from(i) * 0.1;
267                c(m + 1.0, m - 1.0, m, i64::from(i))
268            })
269            .collect();
270        let mut rei = TdRei::classic();
271        rei.batch(&candles);
272        assert!(rei.is_ready());
273        rei.reset();
274        assert!(!rei.is_ready());
275        assert_eq!(rei.update(candles[0]), None);
276        assert_eq!(rei.value(), None);
277    }
278
279    #[test]
280    fn accessors_and_metadata() {
281        let rei = TdRei::classic();
282        assert_eq!(rei.period(), 5);
283        assert_eq!(rei.warmup_period(), 6 + 5);
284        assert_eq!(rei.name(), "TDREI");
285    }
286}