Skip to main content

wickra_core/indicators/
td_trap.rs

1#![allow(clippy::doc_markdown)]
2
3//! Tom DeMark TD Trap — an inside-bar ("trap") followed by a range breakout.
4//!
5//! A TD Trap forms when one bar is an **inside bar** (its high below and low above
6//! the prior bar's), coiling the market; the next bar that closes beyond the trap
7//! bar's high or low triggers the directional signal.
8//!
9//! - **Buy signal** (`+1.0`): the prior bar was an inside bar and the current
10//!   `close` is above that inside bar's `high`.
11//! - **Sell signal** (`-1.0`): the prior bar was an inside bar and the current
12//!   `close` is below that inside bar's `low`.
13//! - Otherwise the output is `0.0`.
14//!
15//! The two-bar lookback (one to set the inside bar, one before it) means the first
16//! value lands on the third candle.
17
18use crate::ohlcv::Candle;
19use crate::traits::Indicator;
20
21/// TD Trap — inside-bar breakout signal detector.
22/// # Example
23///
24/// ```
25/// use wickra_core::{TdTrap, Candle, Indicator};
26///
27/// let mut indicator = TdTrap::new();
28/// // `None` during warmup, then `Some(_)` once enough bars are seen.
29/// let mut out = None;
30/// for i in 0..40i64 {
31///     let p = 100.0 + (i as f64 * 0.4).sin() * 5.0;
32///     let candle = Candle::new(p, p + 1.5, p - 1.5, p + 0.3, 1_000.0, i).unwrap();
33///     out = indicator.update(candle);
34/// }
35/// let _ = out;
36/// ```
37#[derive(Debug, Clone, Default)]
38pub struct TdTrap {
39    prev1: Option<Candle>,
40    prev2: Option<Candle>,
41    last_value: Option<f64>,
42}
43
44impl TdTrap {
45    /// Construct a new `TdTrap`.
46    #[must_use]
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 TdTrap {
58    type Input = Candle;
59    type Output = f64;
60
61    fn update(&mut self, candle: Candle) -> Option<f64> {
62        let (Some(trap), Some(before)) = (self.prev1, self.prev2) else {
63            // Not enough history yet: emit a neutral 0.0 while seeding.
64            self.prev2 = self.prev1;
65            self.prev1 = Some(candle);
66            self.last_value = Some(0.0);
67            return Some(0.0);
68        };
69        let is_inside = trap.high < before.high && trap.low > before.low;
70        let v = if is_inside && candle.close > trap.high {
71            1.0
72        } else if is_inside && candle.close < trap.low {
73            -1.0
74        } else {
75            0.0
76        };
77        self.prev2 = self.prev1;
78        self.prev1 = Some(candle);
79        self.last_value = Some(v);
80        Some(v)
81    }
82
83    fn reset(&mut self) {
84        self.prev1 = None;
85        self.prev2 = None;
86        self.last_value = None;
87    }
88
89    fn warmup_period(&self) -> usize {
90        3
91    }
92
93    fn is_ready(&self) -> bool {
94        self.last_value.is_some()
95    }
96
97    fn name(&self) -> &'static str {
98        "TDTrap"
99    }
100}
101
102#[cfg(test)]
103mod tests {
104    use super::*;
105    use crate::traits::BatchExt;
106
107    fn c(high: f64, low: f64, close: f64) -> Candle {
108        Candle::new_unchecked(f64::midpoint(high, low), high, low, close, 0.0, 0)
109    }
110
111    #[test]
112    fn accessors_and_metadata() {
113        let td = TdTrap::new();
114        assert_eq!(td.warmup_period(), 3);
115        assert_eq!(td.name(), "TDTrap");
116        assert!(!td.is_ready());
117        assert_eq!(td.value(), None);
118    }
119
120    #[test]
121    fn first_two_bars_seed_without_signal() {
122        let mut td = TdTrap::new();
123        assert_eq!(td.update(c(110.0, 90.0, 100.0)), Some(0.0));
124        assert_eq!(td.update(c(108.0, 95.0, 102.0)), Some(0.0));
125        assert!(td.update(c(112.0, 100.0, 110.0)).is_some());
126    }
127
128    #[test]
129    fn inside_then_breakout_up_buys() {
130        // bar0 wide [90,110]; bar1 inside [95,108]; bar2 close 109 > 108 -> +1.
131        let mut td = TdTrap::new();
132        td.update(c(110.0, 90.0, 100.0));
133        td.update(c(108.0, 95.0, 102.0)); // inside bar (high<110, low>90)
134        assert_eq!(td.update(c(112.0, 100.0, 109.0)), Some(1.0));
135    }
136
137    #[test]
138    fn inside_then_breakdown_sells() {
139        let mut td = TdTrap::new();
140        td.update(c(110.0, 90.0, 100.0));
141        td.update(c(108.0, 95.0, 102.0)); // inside bar
142        assert_eq!(td.update(c(100.0, 92.0, 94.0)), Some(-1.0)); // close 94 < 95
143    }
144
145    #[test]
146    fn no_inside_bar_is_zero() {
147        let mut td = TdTrap::new();
148        td.update(c(110.0, 90.0, 100.0));
149        td.update(c(115.0, 85.0, 100.0)); // outside bar, not inside
150        assert_eq!(td.update(c(120.0, 110.0, 118.0)), Some(0.0));
151    }
152
153    #[test]
154    fn inside_but_no_breakout_is_zero() {
155        let mut td = TdTrap::new();
156        td.update(c(110.0, 90.0, 100.0));
157        td.update(c(108.0, 95.0, 102.0)); // inside bar
158        assert_eq!(td.update(c(107.0, 96.0, 103.0)), Some(0.0)); // close 103 within [95,108]
159    }
160
161    #[test]
162    fn reset_clears_state() {
163        let mut td = TdTrap::new();
164        td.update(c(110.0, 90.0, 100.0));
165        td.update(c(108.0, 95.0, 102.0));
166        td.update(c(112.0, 100.0, 109.0));
167        assert!(td.is_ready());
168        td.reset();
169        assert!(!td.is_ready());
170        assert_eq!(td.update(c(110.0, 90.0, 100.0)), Some(0.0));
171    }
172
173    #[test]
174    fn batch_equals_streaming() {
175        let candles: Vec<Candle> = (0..40)
176            .map(|i| {
177                let b = 100.0 + (f64::from(i) * 0.4).sin() * 6.0;
178                c(b + 2.0, b - 2.0, b)
179            })
180            .collect();
181        let batch = TdTrap::new().batch(&candles);
182        let mut b = TdTrap::new();
183        let streamed: Vec<_> = candles.iter().map(|x| b.update(*x)).collect();
184        assert_eq!(batch, streamed);
185    }
186}