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/// # Example
25///
26/// ```
27/// use wickra_core::{TdOpen, Candle, Indicator};
28///
29/// let mut indicator = TdOpen::new();
30/// // `None` during warmup, then `Some(_)` once enough bars are seen.
31/// let mut out = None;
32/// for i in 0..40i64 {
33///     let p = 100.0 + (i as f64 * 0.4).sin() * 5.0;
34///     let candle = Candle::new(p, p + 1.5, p - 1.5, p + 0.3, 1_000.0, i).unwrap();
35///     out = indicator.update(candle);
36/// }
37/// let _ = out;
38/// ```
39#[derive(Debug, Clone, Default)]
40pub struct TdOpen {
41    prev: Option<Candle>,
42    last_value: Option<f64>,
43}
44
45impl TdOpen {
46    /// Construct a new `TdOpen`.
47    pub fn new() -> Self {
48        Self::default()
49    }
50
51    /// Latest emitted signal if available.
52    pub const fn value(&self) -> Option<f64> {
53        self.last_value
54    }
55}
56
57impl Indicator for TdOpen {
58    type Input = Candle;
59    type Output = f64;
60
61    fn update(&mut self, candle: Candle) -> Option<f64> {
62        let Some(prev) = self.prev else {
63            self.prev = Some(candle);
64            return None;
65        };
66        let v = if candle.open < prev.low && candle.high > prev.low {
67            1.0
68        } else if candle.open > prev.high && candle.low < prev.high {
69            -1.0
70        } else {
71            0.0
72        };
73        self.prev = Some(candle);
74        self.last_value = Some(v);
75        Some(v)
76    }
77
78    fn reset(&mut self) {
79        self.prev = None;
80        self.last_value = None;
81    }
82
83    fn warmup_period(&self) -> usize {
84        2
85    }
86
87    fn is_ready(&self) -> bool {
88        self.last_value.is_some()
89    }
90
91    fn name(&self) -> &'static str {
92        "TDOpen"
93    }
94}
95
96#[cfg(test)]
97mod tests {
98    use super::*;
99    use crate::traits::BatchExt;
100
101    fn c(open: f64, high: f64, low: f64, close: f64, ts: i64) -> Candle {
102        Candle::new_unchecked(open, high, low, close, 0.0, ts)
103    }
104
105    #[test]
106    fn buy_signal_on_gap_down_with_recovery() {
107        // Prev bar: low=10. Curr open=9 < 10, curr high=11 > 10 -> buy +1.
108        let mut td = TdOpen::new();
109        assert_eq!(td.update(c(10.0, 11.0, 10.0, 10.5, 0)), None);
110        assert_eq!(td.update(c(9.0, 11.0, 8.5, 9.5, 1)), Some(1.0));
111    }
112
113    #[test]
114    fn sell_signal_on_gap_up_with_fade() {
115        // Prev bar: high=12. Curr open=13 > 12, curr low=11 < 12 -> sell -1.
116        let mut td = TdOpen::new();
117        assert_eq!(td.update(c(10.0, 12.0, 9.0, 11.0, 0)), None);
118        assert_eq!(td.update(c(13.0, 13.5, 11.0, 11.5, 1)), Some(-1.0));
119    }
120
121    #[test]
122    fn no_signal_on_normal_open_within_range() {
123        // Open within previous range -> neither gap condition fires.
124        let mut td = TdOpen::new();
125        assert_eq!(td.update(c(10.0, 12.0, 9.0, 11.0, 0)), None);
126        assert_eq!(td.update(c(10.5, 11.5, 9.5, 11.0, 1)), Some(0.0));
127    }
128
129    #[test]
130    fn gap_down_without_recovery_is_zero() {
131        // Open below prev.low, but high stays below prev.low too -> no signal.
132        let mut td = TdOpen::new();
133        assert_eq!(td.update(c(10.0, 12.0, 10.0, 11.0, 0)), None);
134        // Curr open=9, curr high=9.5 -> high < prev.low (10) -> no buy.
135        assert_eq!(td.update(c(9.0, 9.5, 8.5, 9.0, 1)), Some(0.0));
136    }
137
138    #[test]
139    fn batch_equals_streaming() {
140        let candles: Vec<Candle> = (0..40)
141            .map(|i| {
142                let m = 100.0 + (f64::from(i) * 0.3).sin() * 5.0;
143                c(m, m + 1.0, m - 1.0, m + 0.3, i64::from(i))
144            })
145            .collect();
146        let mut a = TdOpen::new();
147        let mut b = TdOpen::new();
148        assert_eq!(
149            a.batch(&candles),
150            candles.iter().map(|x| b.update(*x)).collect::<Vec<_>>()
151        );
152    }
153
154    #[test]
155    fn output_only_in_canonical_set() {
156        let candles: Vec<Candle> = (0..120)
157            .map(|i| {
158                let m = 100.0 + (f64::from(i) * 0.5).sin() * 5.0;
159                c(m, m + 1.0, m - 1.0, m + 0.3, i64::from(i))
160            })
161            .collect();
162        let mut td = TdOpen::new();
163        for v in td.batch(&candles).into_iter().flatten() {
164            assert!(v == -1.0 || v == 0.0 || v == 1.0, "unexpected value {v}");
165        }
166    }
167
168    #[test]
169    fn reset_clears_state() {
170        let mut td = TdOpen::new();
171        td.update(c(10.0, 11.0, 9.0, 10.0, 0));
172        td.update(c(10.5, 11.5, 9.5, 10.5, 1));
173        assert!(td.is_ready());
174        td.reset();
175        assert!(!td.is_ready());
176        assert_eq!(td.update(c(10.0, 11.0, 9.0, 10.0, 2)), None);
177        assert_eq!(td.value(), None);
178    }
179
180    #[test]
181    fn accessors_and_metadata() {
182        let td = TdOpen::new();
183        assert_eq!(td.warmup_period(), 2);
184        assert_eq!(td.name(), "TDOpen");
185        assert_eq!(td.value(), None);
186    }
187}