Skip to main content

wickra_core/indicators/
tweezer.rs

1//! Tweezer Top / Bottom candlestick pattern.
2
3use crate::error::{Error, Result};
4use crate::ohlcv::Candle;
5use crate::traits::Indicator;
6
7/// Tweezer — a 2-bar reversal pattern where two consecutive candles share an
8/// extreme.
9///
10/// ```text
11/// tol           = tolerance * |prev.high| + tolerance * |prev.low|   (per leg)
12/// tweezer_top   = |curr.high − prev.high| <= tol_high
13/// tweezer_bot   = |curr.low  − prev.low|  <= tol_low
14/// ```
15///
16/// The output is `−1.0` for a Tweezer Top (matched highs), `+1.0` for a
17/// Tweezer Bottom (matched lows), and `0.0` otherwise. If *both* extremes
18/// match — a flat pair of candles — the bottom wins by convention (bullish
19/// rejection of the low). `tolerance` defaults to `0.001` (10 bps relative)
20/// and must lie in `[0, 1)`.
21///
22/// Pattern-shape check only — no trend filter is applied; combine with a trend
23/// indicator for actionable signals.
24///
25/// # Signed ±1 encoding
26///
27/// This detector already emits the uniform candlestick sign convention shared
28/// across the pattern family — `+1.0` bullish, `−1.0` bearish, `0.0` no
29/// pattern — so it drops straight into a machine-learning feature matrix where
30/// the bullish and bearish variants of the pattern occupy a single dimension.
31///
32/// # Example
33///
34/// ```
35/// use wickra_core::{Candle, Indicator, Tweezer};
36///
37/// let mut indicator = Tweezer::new();
38/// indicator.update(Candle::new(11.0, 12.0, 9.5, 9.6, 1.0, 0).unwrap());
39/// // Matching low.
40/// let out = indicator.update(Candle::new(9.7, 10.5, 9.5, 10.2, 1.0, 1).unwrap());
41/// assert_eq!(out, Some(1.0));
42/// ```
43#[derive(Debug, Clone)]
44pub struct Tweezer {
45    tolerance: f64,
46    prev: Option<Candle>,
47    has_emitted: bool,
48}
49
50impl Default for Tweezer {
51    fn default() -> Self {
52        Self::new()
53    }
54}
55
56impl Tweezer {
57    /// Construct a Tweezer detector with the default relative tolerance (1e-3).
58    pub const fn new() -> Self {
59        Self {
60            tolerance: 0.001,
61            prev: None,
62            has_emitted: false,
63        }
64    }
65
66    /// Construct a Tweezer detector with a custom relative tolerance.
67    ///
68    /// `tolerance` must lie in `[0, 1)`.
69    pub fn with_tolerance(tolerance: f64) -> Result<Self> {
70        if !(0.0..1.0).contains(&tolerance) {
71            return Err(Error::InvalidPeriod {
72                message: "tweezer tolerance must lie in [0, 1)",
73            });
74        }
75        Ok(Self {
76            tolerance,
77            prev: None,
78            has_emitted: false,
79        })
80    }
81
82    /// Configured relative tolerance.
83    pub fn tolerance(&self) -> f64 {
84        self.tolerance
85    }
86}
87
88impl Indicator for Tweezer {
89    type Input = Candle;
90    type Output = f64;
91
92    #[inline]
93    fn update(&mut self, candle: Candle) -> Option<f64> {
94        let prev = self.prev;
95        self.prev = Some(candle);
96        let p = prev?;
97        self.has_emitted = true;
98        let tol_high = self.tolerance * p.high.abs().max(candle.high.abs());
99        let tol_low = self.tolerance * p.low.abs().max(candle.low.abs());
100        let match_low = (candle.low - p.low).abs() <= tol_low;
101        let match_high = (candle.high - p.high).abs() <= tol_high;
102        if match_low {
103            Some(1.0)
104        } else if match_high {
105            Some(-1.0)
106        } else {
107            Some(0.0)
108        }
109    }
110
111    fn reset(&mut self) {
112        self.prev = None;
113        self.has_emitted = false;
114    }
115
116    #[inline]
117    fn warmup_period(&self) -> usize {
118        2
119    }
120
121    #[inline]
122    fn is_ready(&self) -> bool {
123        self.has_emitted
124    }
125
126    #[inline]
127    fn name(&self) -> &'static str {
128        "Tweezer"
129    }
130}
131
132#[cfg(test)]
133mod tests {
134    use super::*;
135    use crate::traits::BatchExt;
136
137    fn c(open: f64, high: f64, low: f64, close: f64, ts: i64) -> Candle {
138        Candle::new(open, high, low, close, 1.0, ts).unwrap()
139    }
140
141    #[test]
142    fn rejects_invalid_tolerance() {
143        assert!(Tweezer::with_tolerance(-0.01).is_err());
144        assert!(Tweezer::with_tolerance(1.0).is_err());
145    }
146
147    #[test]
148    fn accepts_valid_tolerance() {
149        let t = Tweezer::with_tolerance(0.0).unwrap();
150        assert!((t.tolerance() - 0.0).abs() < 1e-12);
151    }
152
153    #[test]
154    fn accessors_and_metadata() {
155        let t = Tweezer::default();
156        assert_eq!(t.name(), "Tweezer");
157        assert_eq!(t.warmup_period(), 2);
158        assert!(!t.is_ready());
159        assert!((t.tolerance() - 0.001).abs() < 1e-12);
160    }
161
162    #[test]
163    fn tweezer_bottom_is_plus_one() {
164        let mut t = Tweezer::new();
165        assert_eq!(t.update(c(11.0, 12.0, 9.5, 9.6, 0)), None);
166        // Matching low 9.5.
167        assert_eq!(t.update(c(9.7, 10.5, 9.5, 10.2, 1)), Some(1.0));
168    }
169
170    #[test]
171    fn tweezer_top_is_minus_one() {
172        let mut t = Tweezer::new();
173        assert_eq!(t.update(c(9.0, 12.0, 8.5, 11.0, 0)), None);
174        // Matching high 12.0.
175        assert_eq!(t.update(c(11.5, 12.0, 11.0, 11.4, 1)), Some(-1.0));
176    }
177
178    #[test]
179    fn distinct_extremes_yield_zero() {
180        let mut t = Tweezer::new();
181        t.update(c(10.0, 11.0, 9.0, 10.5, 0));
182        assert_eq!(t.update(c(10.6, 11.5, 9.6, 11.2, 1)), Some(0.0));
183    }
184
185    #[test]
186    fn first_bar_returns_zero() {
187        let mut t = Tweezer::new();
188        assert_eq!(t.update(c(10.0, 11.0, 9.0, 10.5, 0)), None);
189    }
190
191    #[test]
192    fn matched_both_extremes_prefers_bottom() {
193        // Identical candles match both highs and lows -> bottom (+1.0).
194        let mut t = Tweezer::new();
195        t.update(c(10.0, 11.0, 9.0, 10.5, 0));
196        assert_eq!(t.update(c(10.0, 11.0, 9.0, 10.5, 1)), Some(1.0));
197    }
198
199    #[test]
200    fn batch_equals_streaming() {
201        let candles: Vec<Candle> = (0..40)
202            .map(|i| {
203                let base = 100.0 + (i as f64 * 0.1).sin();
204                c(base, base + 2.0, base - 2.0, base + 0.5, i)
205            })
206            .collect();
207        let mut a = Tweezer::new();
208        let mut b = Tweezer::new();
209        assert_eq!(
210            a.batch(&candles),
211            candles.iter().map(|x| b.update(*x)).collect::<Vec<_>>()
212        );
213    }
214
215    #[test]
216    fn reset_clears_state() {
217        let mut t = Tweezer::new();
218        t.update(c(10.0, 11.0, 9.0, 10.5, 0));
219        t.update(c(10.0, 11.0, 9.0, 10.5, 1));
220        assert!(t.is_ready());
221        t.reset();
222        assert!(!t.is_ready());
223        assert_eq!(t.update(c(10.0, 11.0, 9.0, 10.5, 0)), None);
224    }
225}