Skip to main content

wickra_core/indicators/
td_clop.rs

1#![allow(clippy::doc_markdown)]
2
3//! Tom DeMark TD Clop — a 2-bar open/close engulfing reversal.
4//!
5//! TD Clop ("CLose/OPen") fires when the current bar's open opens beyond **both**
6//! the prior bar's open and close, and its close finishes back beyond both — an
7//! open-gap that fully reverses, signalling a turn.
8//!
9//! - **Buy signal** (`+1.0`): `open < open[-1]` AND `open < close[-1]`
10//!   (opens below the whole prior body) AND `close > open[-1]` AND
11//!   `close > close[-1]` (closes above it).
12//! - **Sell signal** (`-1.0`): `open > open[-1]` AND `open > close[-1]` AND
13//!   `close < open[-1]` AND `close < close[-1]`.
14//! - Otherwise the output is `0.0`.
15//!
16//! The one-bar lookback means the first value lands on the second candle.
17
18use crate::ohlcv::Candle;
19use crate::traits::Indicator;
20
21/// TD Clop — 2-bar open/close engulfing reversal detector.
22/// # Example
23///
24/// ```
25/// use wickra_core::{TdClop, Candle, Indicator};
26///
27/// let mut indicator = TdClop::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 TdClop {
39    prev: Option<Candle>,
40    last_value: Option<f64>,
41}
42
43impl TdClop {
44    /// Construct a new `TdClop`.
45    #[must_use]
46    pub fn new() -> Self {
47        Self::default()
48    }
49
50    /// Latest emitted signal if available.
51    pub const fn value(&self) -> Option<f64> {
52        self.last_value
53    }
54}
55
56impl Indicator for TdClop {
57    type Input = Candle;
58    type Output = f64;
59
60    #[inline]
61    fn update(&mut self, candle: Candle) -> Option<f64> {
62        let Some(prev) = self.prev else {
63            self.prev = Some(candle);
64            self.last_value = None;
65            return None;
66        };
67        let below_body = candle.open < prev.open && candle.open < prev.close;
68        let above_body = candle.close > prev.open && candle.close > prev.close;
69        let over_body = candle.open > prev.open && candle.open > prev.close;
70        let under_body = candle.close < prev.open && candle.close < prev.close;
71        let v = if below_body && above_body {
72            1.0
73        } else if over_body && under_body {
74            -1.0
75        } else {
76            0.0
77        };
78        self.prev = Some(candle);
79        self.last_value = Some(v);
80        Some(v)
81    }
82
83    fn reset(&mut self) {
84        self.prev = None;
85        self.last_value = None;
86    }
87
88    #[inline]
89    fn warmup_period(&self) -> usize {
90        2
91    }
92
93    #[inline]
94    fn is_ready(&self) -> bool {
95        self.last_value.is_some()
96    }
97
98    #[inline]
99    fn name(&self) -> &'static str {
100        "TDClop"
101    }
102}
103
104#[cfg(test)]
105mod tests {
106    use super::*;
107    use crate::traits::BatchExt;
108
109    fn c(open: f64, close: f64) -> Candle {
110        let high = open.max(close) + 1.0;
111        let low = open.min(close) - 1.0;
112        Candle::new_unchecked(open, high, low, close, 0.0, 0)
113    }
114
115    #[test]
116    fn accessors_and_metadata() {
117        let td = TdClop::new();
118        assert_eq!(td.warmup_period(), 2);
119        assert_eq!(td.name(), "TDClop");
120        assert!(!td.is_ready());
121        assert_eq!(td.value(), None);
122    }
123
124    #[test]
125    fn first_bar_seeds_without_signal() {
126        let mut td = TdClop::new();
127        assert_eq!(td.update(c(10.0, 11.0)), None);
128        assert!(td.update(c(9.0, 12.0)).is_some());
129    }
130
131    #[test]
132    fn bullish_clop_buy() {
133        // prev body [10, 11]. Current open 9 < both, close 12 > both -> buy.
134        let mut td = TdClop::new();
135        td.update(c(10.0, 11.0));
136        assert_eq!(td.update(c(9.0, 12.0)), Some(1.0));
137    }
138
139    #[test]
140    fn bearish_clop_sell() {
141        // prev body [10, 11]. Current open 12 > both, close 9 < both -> sell.
142        let mut td = TdClop::new();
143        td.update(c(10.0, 11.0));
144        assert_eq!(td.update(c(12.0, 9.0)), Some(-1.0));
145    }
146
147    #[test]
148    fn no_pattern_is_zero() {
149        let mut td = TdClop::new();
150        td.update(c(10.0, 11.0));
151        assert_eq!(td.update(c(10.5, 11.5)), Some(0.0));
152    }
153
154    #[test]
155    fn reset_clears_state() {
156        let mut td = TdClop::new();
157        td.update(c(10.0, 11.0));
158        td.update(c(9.0, 12.0));
159        assert!(td.is_ready());
160        td.reset();
161        assert!(!td.is_ready());
162        assert_eq!(td.update(c(10.0, 11.0)), None);
163    }
164
165    #[test]
166    fn batch_equals_streaming() {
167        let candles: Vec<Candle> = (0..40)
168            .map(|i| {
169                let b = 100.0 + (f64::from(i) * 0.4).sin() * 5.0;
170                c(b, b + 0.5)
171            })
172            .collect();
173        let batch = TdClop::new().batch(&candles);
174        let mut b = TdClop::new();
175        let streamed: Vec<_> = candles.iter().map(|x| b.update(*x)).collect();
176        assert_eq!(batch, streamed);
177    }
178}