Skip to main content

wickra_core/indicators/
td_differential.rs

1#![allow(clippy::doc_markdown)]
2
3//! Tom DeMark TD Differential — a three-close buying/selling-pressure reversal.
4//!
5//! TD Differential flags an exhaustion bar: price has closed lower (higher) two
6//! bars running, yet buying (selling) pressure is already shifting. Pressure is
7//! measured against DeMark's *true* range, which folds in the previous close:
8//! `buying = close − TrueLow`, `selling = TrueHigh − close`, with
9//! `TrueLow = min(low, close[−1])` and `TrueHigh = max(high, close[−1])` (Jason
10//! Perl, *DeMark Indicators*, 2008).
11//!
12//! - **Buy signal** (`+1.0`) on bar `i` when:
13//!   1. `close[i] < close[i − 1]` and `close[i − 1] < close[i − 2]` (two lower closes)
14//!   2. `buying[i]  > buying[i − 1]`                                  (buying pressure rises)
15//!   3. `selling[i] < selling[i − 1]`                                 (selling pressure falls)
16//! - **Sell signal** (`-1.0`) on bar `i` when:
17//!   1. `close[i] > close[i − 1]` and `close[i − 1] > close[i − 2]` (two higher closes)
18//!   2. `selling[i] > selling[i − 1]`
19//!   3. `buying[i]  < buying[i − 1]`
20//! - Otherwise the output is `0.0`.
21//!
22//! The pressure of bar `i − 1` needs the close of bar `i − 2`, so the first
23//! value lands on the third input candle.
24
25use crate::ohlcv::Candle;
26use crate::traits::Indicator;
27
28/// TD Differential — three-close reversal pattern detector.
29/// # Example
30///
31/// ```
32/// use wickra_core::{TdDifferential, Candle, Indicator};
33///
34/// let mut indicator = TdDifferential::new();
35/// // `None` during warmup, then `Some(_)` once enough bars are seen.
36/// let mut out = None;
37/// for i in 0..40i64 {
38///     let p = 100.0 + (i as f64 * 0.4).sin() * 5.0;
39///     let candle = Candle::new(p, p + 1.5, p - 1.5, p + 0.3, 1_000.0, i).unwrap();
40///     out = indicator.update(candle);
41/// }
42/// let _ = out;
43/// ```
44#[derive(Debug, Clone, Default)]
45pub struct TdDifferential {
46    prev2: Option<Candle>,
47    prev: Option<Candle>,
48    last_value: Option<f64>,
49}
50
51impl TdDifferential {
52    /// Construct a new `TdDifferential`.
53    pub fn new() -> Self {
54        Self::default()
55    }
56
57    /// Latest emitted signal if available.
58    pub const fn value(&self) -> Option<f64> {
59        self.last_value
60    }
61}
62
63/// `(buying, selling)` pressure of `bar` against the previous close.
64fn pressures(bar: &Candle, prev_close: f64) -> (f64, f64) {
65    let true_low = bar.low.min(prev_close);
66    let true_high = bar.high.max(prev_close);
67    (bar.close - true_low, true_high - bar.close)
68}
69
70impl Indicator for TdDifferential {
71    type Input = Candle;
72    type Output = f64;
73
74    #[inline]
75    fn update(&mut self, candle: Candle) -> Option<f64> {
76        let (Some(prev2), Some(prev)) = (self.prev2, self.prev) else {
77            self.prev2 = self.prev;
78            self.prev = Some(candle);
79            return None;
80        };
81        let (buying_now, selling_now) = pressures(&candle, prev.close);
82        let (buying_prev, selling_prev) = pressures(&prev, prev2.close);
83
84        let v = if candle.close < prev.close
85            && prev.close < prev2.close
86            && buying_now > buying_prev
87            && selling_now < selling_prev
88        {
89            1.0
90        } else if candle.close > prev.close
91            && prev.close > prev2.close
92            && selling_now > selling_prev
93            && buying_now < buying_prev
94        {
95            -1.0
96        } else {
97            0.0
98        };
99
100        self.prev2 = Some(prev);
101        self.prev = Some(candle);
102        self.last_value = Some(v);
103        Some(v)
104    }
105
106    fn reset(&mut self) {
107        self.prev2 = None;
108        self.prev = None;
109        self.last_value = None;
110    }
111
112    #[inline]
113    fn warmup_period(&self) -> usize {
114        3
115    }
116
117    #[inline]
118    fn is_ready(&self) -> bool {
119        self.last_value.is_some()
120    }
121
122    #[inline]
123    fn name(&self) -> &'static str {
124        "TDDifferential"
125    }
126}
127
128#[cfg(test)]
129mod tests {
130    use super::*;
131    use crate::traits::BatchExt;
132    use approx::assert_relative_eq;
133
134    fn c(high: f64, low: f64, close: f64, ts: i64) -> Candle {
135        Candle::new_unchecked(close, high, low, close, 0.0, ts)
136    }
137
138    #[test]
139    fn buy_signal_after_two_lower_closes_with_shifting_pressure() {
140        // Closes 10 -> 9 -> 8.5 (two lower closes).
141        // Bar 1: TrueLow = min(8, 10) = 8 -> buying 1; TrueHigh = max(10, 10) = 10 -> selling 1.
142        // Bar 2: TrueLow = min(7, 9) = 7 -> buying 1.5 > 1; TrueHigh = max(9, 9) = 9 -> selling 0.5 < 1.
143        let mut td = TdDifferential::new();
144        assert_eq!(td.update(c(11.0, 9.0, 10.0, 0)), None);
145        assert_eq!(td.update(c(10.0, 8.0, 9.0, 1)), None);
146        assert_eq!(td.update(c(9.0, 7.0, 8.5, 2)), Some(1.0));
147    }
148
149    #[test]
150    fn single_lower_close_is_not_enough() {
151        // Same last bar, but the bar before closed higher than its predecessor.
152        let mut td = TdDifferential::new();
153        td.update(c(9.0, 7.0, 8.0, 0));
154        td.update(c(10.0, 8.0, 9.0, 1));
155        assert_eq!(td.update(c(9.0, 7.0, 8.5, 2)), Some(0.0));
156    }
157
158    #[test]
159    fn sell_signal_after_two_higher_closes_with_shifting_pressure() {
160        // Closes 8 -> 9 -> 9.8 (two higher closes).
161        // Bar 1: TrueLow = min(8, 8) = 8 -> buying 1; TrueHigh = max(10, 8) = 10 -> selling 1.
162        // Bar 2: TrueLow = min(9.5, 9) = 9 -> buying 0.8 < 1; TrueHigh = 11.5 -> selling 1.7 > 1.
163        let mut td = TdDifferential::new();
164        assert_eq!(td.update(c(9.0, 7.0, 8.0, 0)), None);
165        assert_eq!(td.update(c(10.0, 8.0, 9.0, 1)), None);
166        assert_relative_eq!(td.update(c(11.5, 9.5, 9.8, 2)).unwrap(), -1.0);
167    }
168
169    #[test]
170    fn no_signal_on_neutral_bars() {
171        // Identical bars -> equality everywhere -> zero.
172        let mut td = TdDifferential::new();
173        assert_eq!(td.update(c(10.0, 8.0, 9.0, 0)), None);
174        assert_eq!(td.update(c(10.0, 8.0, 9.0, 1)), None);
175        assert_eq!(td.update(c(10.0, 8.0, 9.0, 2)), Some(0.0));
176    }
177
178    #[test]
179    fn batch_equals_streaming() {
180        let candles: Vec<Candle> = (0..40)
181            .map(|i| {
182                let m = 100.0 + (f64::from(i) * 0.3).sin() * 5.0;
183                c(m + 1.0, m - 1.0, m, i64::from(i))
184            })
185            .collect();
186        let mut a = TdDifferential::new();
187        let mut b = TdDifferential::new();
188        assert_eq!(
189            a.batch(&candles),
190            candles.iter().map(|x| b.update(*x)).collect::<Vec<_>>()
191        );
192    }
193
194    #[test]
195    fn output_only_in_canonical_set() {
196        // Every emitted value is in {-1, 0, +1}.
197        let candles: Vec<Candle> = (0..120)
198            .map(|i| {
199                let m = 100.0 + (f64::from(i) * 0.5).sin() * 5.0;
200                c(m + 1.0, m - 1.0, m, i64::from(i))
201            })
202            .collect();
203        let mut td = TdDifferential::new();
204        for v in td.batch(&candles).into_iter().flatten() {
205            assert!(v == -1.0 || v == 0.0 || v == 1.0, "unexpected value {v}");
206        }
207    }
208
209    #[test]
210    fn reset_clears_state() {
211        let mut td = TdDifferential::new();
212        td.update(c(10.0, 8.0, 9.0, 0));
213        td.update(c(11.0, 9.0, 10.0, 1));
214        td.update(c(12.0, 10.0, 11.0, 2));
215        assert!(td.is_ready());
216        td.reset();
217        assert!(!td.is_ready());
218        assert_eq!(td.update(c(10.0, 8.0, 9.0, 3)), None);
219        assert_eq!(td.value(), None);
220    }
221
222    #[test]
223    fn accessors_and_metadata() {
224        let td = TdDifferential::new();
225        assert_eq!(td.warmup_period(), 3);
226        assert_eq!(td.name(), "TDDifferential");
227        assert_eq!(td.value(), None);
228    }
229
230    #[test]
231    fn buy_signal_needs_true_high_on_gap_down() {
232        // Bar 1 gaps below the prior close 10: h 9.2, l 8.8, c 9.
233        //   TrueLow = min(8.8, 10) = 8.8 -> buying 0.2
234        //   TrueHigh = max(9.2, 10) = 10 -> selling 1.0 (plain range: 0.2)
235        // Bar 2: h 9, l 8, c 8.5, prior close 9.
236        //   buying = 8.5 - 8 = 0.5 > 0.2; selling = 9 - 8.5 = 0.5 < 1.0 -> +1.
237        // With the plain high the selling test (0.5 < 0.2) would fail.
238        let mut td = TdDifferential::new();
239        assert_eq!(td.update(c(11.0, 9.0, 10.0, 0)), None);
240        assert_eq!(td.update(c(9.2, 8.8, 9.0, 1)), None);
241        assert_eq!(td.update(c(9.0, 8.0, 8.5, 2)), Some(1.0));
242        assert_eq!(td.value(), Some(1.0));
243    }
244
245    #[test]
246    fn sell_signal_needs_true_low_on_gap_up() {
247        // Bar 1 gaps above the prior close 10: h 11.2, l 10.8, c 11.
248        //   TrueLow = min(10.8, 10) = 10 -> buying 1.0 (plain range: 0.2)
249        //   TrueHigh = 11.2 -> selling 0.2
250        // Bar 2: h 12, l 11, c 11.5, prior close 11.
251        //   selling = 12 - 11.5 = 0.5 > 0.2; buying = 11.5 - 11 = 0.5 < 1.0 -> -1.
252        let mut td = TdDifferential::new();
253        assert_eq!(td.update(c(11.0, 9.0, 10.0, 0)), None);
254        assert_eq!(td.update(c(11.2, 10.8, 11.0, 1)), None);
255        assert_eq!(td.update(c(12.0, 11.0, 11.5, 2)), Some(-1.0));
256    }
257
258    #[test]
259    fn two_lower_closes_without_pressure_shift_is_neutral() {
260        // Closes 10 -> 9 -> 8 with identical +-1 ranges.
261        // Bar 1: buying = 9 - 8 = 1, selling = max(10, 10) - 9 = 1.
262        // Bar 2: buying = 8 - 7 = 1 (not > 1) -> no buy signal.
263        let mut td = TdDifferential::new();
264        td.update(c(11.0, 9.0, 10.0, 0));
265        td.update(c(10.0, 8.0, 9.0, 1));
266        assert_eq!(td.update(c(9.0, 7.0, 8.0, 2)), Some(0.0));
267    }
268
269    #[test]
270    fn two_higher_closes_without_pressure_shift_is_neutral() {
271        // Closes 8 -> 9 -> 10 with identical +-1 ranges: selling stays 1.
272        let mut td = TdDifferential::new();
273        td.update(c(9.0, 7.0, 8.0, 0));
274        td.update(c(10.0, 8.0, 9.0, 1));
275        assert_eq!(td.update(c(11.0, 9.0, 10.0, 2)), Some(0.0));
276    }
277
278    #[test]
279    fn pressures_use_true_range() {
280        // Prior close 12 above the bar: TrueHigh 12, TrueLow 9.
281        let (buying, selling) = pressures(&c(11.0, 9.0, 10.0, 0), 12.0);
282        assert_eq!((buying, selling), (1.0, 2.0));
283        // Prior close 7 below the bar: TrueLow 7, TrueHigh 11.
284        let (buying, selling) = pressures(&c(11.0, 9.0, 10.0, 0), 7.0);
285        assert_eq!((buying, selling), (3.0, 1.0));
286    }
287
288    #[test]
289    fn first_value_lands_at_warmup_minus_one() {
290        let candles: Vec<Candle> = (0..6)
291            .map(|i| c(11.0, 9.0, 10.0 + f64::from(i), i64::from(i)))
292            .collect();
293        let mut td = TdDifferential::new();
294        let warm = td.warmup_period();
295        let out = td.batch(&candles);
296        assert!(out[..warm - 1].iter().all(Option::is_none));
297        assert!(out[warm - 1..].iter().all(Option::is_some));
298    }
299
300    #[test]
301    fn reset_reproduces_fresh_run() {
302        let candles: Vec<Candle> = (0..60)
303            .map(|i| {
304                let m = 100.0 + (f64::from(i) * 0.7).sin() * 5.0;
305                c(
306                    m + 1.0 + (f64::from(i) * 0.3).cos(),
307                    m - 1.0,
308                    m,
309                    i64::from(i),
310                )
311            })
312            .collect();
313        let mut fresh = TdDifferential::new();
314        let expected = fresh.batch(&candles);
315        let mut td = TdDifferential::new();
316        td.batch(&candles[..17]);
317        td.reset();
318        assert_eq!(td.batch(&candles), expected);
319    }
320
321    #[test]
322    fn batch_nan_into_matches_streaming() {
323        let candles: Vec<Candle> = (0..60)
324            .map(|i| {
325                let m = 100.0 + (f64::from(i) * 0.7).sin() * 5.0;
326                c(
327                    m + 1.0 + (f64::from(i) * 0.3).cos(),
328                    m - 1.0,
329                    m,
330                    i64::from(i),
331                )
332            })
333            .collect();
334        let mut a = TdDifferential::new();
335        let mut out = vec![0.0; candles.len()];
336        a.batch_nan_into(&candles, &mut out);
337        let mut b = TdDifferential::new();
338        let streamed: Vec<f64> = candles
339            .iter()
340            .map(|x| b.update(*x).unwrap_or(f64::NAN))
341            .collect();
342        assert!(out
343            .iter()
344            .zip(&streamed)
345            .all(|(x, y)| x.to_bits() == y.to_bits()));
346    }
347}