Skip to main content

wickra_core/indicators/
td_open.rs

1#![allow(clippy::doc_markdown)]
2
3//! Tom DeMark TD Open — open-vs-prior-range gap-reversal signal.
4//!
5//! TD Open flags bars whose open prints *outside* the prior bar's range
6//! but whose subsequent action recovers back inside it — a classic
7//! gap-and-fade reversal pattern.
8//!
9//! - **Buy signal** (`+1.0`) on bar `i` when:
10//!   1. `open[i] <  low[i - 1]`                  (gap-down open)
11//!   2. `high[i] >  low[i - 1]`                  (high recovers above the prior low)
12//! - **Sell signal** (`-1.0`) on bar `i` when:
13//!   1. `open[i] >  high[i - 1]`                 (gap-up open)
14//!   2. `low[i]  <  high[i - 1]`                 (low fades back under the prior high)
15//! - Otherwise the output is `0.0`.
16//!
17//! The one-bar lookback means the indicator emits its first value on the
18//! second input candle.
19
20use crate::ohlcv::Candle;
21use crate::traits::Indicator;
22
23/// TD Open — gap-and-fade reversal detector.
24#[derive(Debug, Clone, Default)]
25pub struct TdOpen {
26    prev: Option<Candle>,
27    last_value: Option<f64>,
28}
29
30impl TdOpen {
31    /// Construct a new `TdOpen`.
32    pub fn new() -> Self {
33        Self::default()
34    }
35
36    /// Latest emitted signal if available.
37    pub const fn value(&self) -> Option<f64> {
38        self.last_value
39    }
40}
41
42impl Indicator for TdOpen {
43    type Input = Candle;
44    type Output = f64;
45
46    fn update(&mut self, candle: Candle) -> Option<f64> {
47        let Some(prev) = self.prev else {
48            self.prev = Some(candle);
49            return None;
50        };
51        let v = if candle.open < prev.low && candle.high > prev.low {
52            1.0
53        } else if candle.open > prev.high && candle.low < prev.high {
54            -1.0
55        } else {
56            0.0
57        };
58        self.prev = Some(candle);
59        self.last_value = Some(v);
60        Some(v)
61    }
62
63    fn reset(&mut self) {
64        self.prev = None;
65        self.last_value = None;
66    }
67
68    fn warmup_period(&self) -> usize {
69        2
70    }
71
72    fn is_ready(&self) -> bool {
73        self.last_value.is_some()
74    }
75
76    fn name(&self) -> &'static str {
77        "TDOpen"
78    }
79}
80
81#[cfg(test)]
82mod tests {
83    use super::*;
84    use crate::traits::BatchExt;
85
86    fn c(open: f64, high: f64, low: f64, close: f64, ts: i64) -> Candle {
87        Candle::new_unchecked(open, high, low, close, 0.0, ts)
88    }
89
90    #[test]
91    fn buy_signal_on_gap_down_with_recovery() {
92        // Prev bar: low=10. Curr open=9 < 10, curr high=11 > 10 -> buy +1.
93        let mut td = TdOpen::new();
94        assert_eq!(td.update(c(10.0, 11.0, 10.0, 10.5, 0)), None);
95        assert_eq!(td.update(c(9.0, 11.0, 8.5, 9.5, 1)), Some(1.0));
96    }
97
98    #[test]
99    fn sell_signal_on_gap_up_with_fade() {
100        // Prev bar: high=12. Curr open=13 > 12, curr low=11 < 12 -> sell -1.
101        let mut td = TdOpen::new();
102        assert_eq!(td.update(c(10.0, 12.0, 9.0, 11.0, 0)), None);
103        assert_eq!(td.update(c(13.0, 13.5, 11.0, 11.5, 1)), Some(-1.0));
104    }
105
106    #[test]
107    fn no_signal_on_normal_open_within_range() {
108        // Open within previous range -> neither gap condition fires.
109        let mut td = TdOpen::new();
110        assert_eq!(td.update(c(10.0, 12.0, 9.0, 11.0, 0)), None);
111        assert_eq!(td.update(c(10.5, 11.5, 9.5, 11.0, 1)), Some(0.0));
112    }
113
114    #[test]
115    fn gap_down_without_recovery_is_zero() {
116        // Open below prev.low, but high stays below prev.low too -> no signal.
117        let mut td = TdOpen::new();
118        assert_eq!(td.update(c(10.0, 12.0, 10.0, 11.0, 0)), None);
119        // Curr open=9, curr high=9.5 -> high < prev.low (10) -> no buy.
120        assert_eq!(td.update(c(9.0, 9.5, 8.5, 9.0, 1)), Some(0.0));
121    }
122
123    #[test]
124    fn batch_equals_streaming() {
125        let candles: Vec<Candle> = (0..40)
126            .map(|i| {
127                let m = 100.0 + (f64::from(i) * 0.3).sin() * 5.0;
128                c(m, m + 1.0, m - 1.0, m + 0.3, i64::from(i))
129            })
130            .collect();
131        let mut a = TdOpen::new();
132        let mut b = TdOpen::new();
133        assert_eq!(
134            a.batch(&candles),
135            candles.iter().map(|x| b.update(*x)).collect::<Vec<_>>()
136        );
137    }
138
139    #[test]
140    fn output_only_in_canonical_set() {
141        let candles: Vec<Candle> = (0..120)
142            .map(|i| {
143                let m = 100.0 + (f64::from(i) * 0.5).sin() * 5.0;
144                c(m, m + 1.0, m - 1.0, m + 0.3, i64::from(i))
145            })
146            .collect();
147        let mut td = TdOpen::new();
148        for v in td.batch(&candles).into_iter().flatten() {
149            assert!(v == -1.0 || v == 0.0 || v == 1.0, "unexpected value {v}");
150        }
151    }
152
153    #[test]
154    fn reset_clears_state() {
155        let mut td = TdOpen::new();
156        td.update(c(10.0, 11.0, 9.0, 10.0, 0));
157        td.update(c(10.5, 11.5, 9.5, 10.5, 1));
158        assert!(td.is_ready());
159        td.reset();
160        assert!(!td.is_ready());
161        assert_eq!(td.update(c(10.0, 11.0, 9.0, 10.0, 2)), None);
162        assert_eq!(td.value(), None);
163    }
164
165    #[test]
166    fn accessors_and_metadata() {
167        let td = TdOpen::new();
168        assert_eq!(td.warmup_period(), 2);
169        assert_eq!(td.name(), "TDOpen");
170        assert_eq!(td.value(), None);
171    }
172}