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    fn update(&mut self, candle: Candle) -> Option<f64> {
93        self.has_emitted = true;
94        let prev = self.prev;
95        self.prev = Some(candle);
96        let Some(p) = prev else {
97            return Some(0.0);
98        };
99        let tol_high = self.tolerance * p.high.abs().max(candle.high.abs());
100        let tol_low = self.tolerance * p.low.abs().max(candle.low.abs());
101        let match_low = (candle.low - p.low).abs() <= tol_low;
102        let match_high = (candle.high - p.high).abs() <= tol_high;
103        if match_low {
104            Some(1.0)
105        } else if match_high {
106            Some(-1.0)
107        } else {
108            Some(0.0)
109        }
110    }
111
112    fn reset(&mut self) {
113        self.prev = None;
114        self.has_emitted = false;
115    }
116
117    fn warmup_period(&self) -> usize {
118        2
119    }
120
121    fn is_ready(&self) -> bool {
122        self.has_emitted
123    }
124
125    fn name(&self) -> &'static str {
126        "Tweezer"
127    }
128}
129
130#[cfg(test)]
131mod tests {
132    use super::*;
133    use crate::traits::BatchExt;
134
135    fn c(open: f64, high: f64, low: f64, close: f64, ts: i64) -> Candle {
136        Candle::new(open, high, low, close, 1.0, ts).unwrap()
137    }
138
139    #[test]
140    fn rejects_invalid_tolerance() {
141        assert!(Tweezer::with_tolerance(-0.01).is_err());
142        assert!(Tweezer::with_tolerance(1.0).is_err());
143    }
144
145    #[test]
146    fn accepts_valid_tolerance() {
147        let t = Tweezer::with_tolerance(0.0).unwrap();
148        assert!((t.tolerance() - 0.0).abs() < 1e-12);
149    }
150
151    #[test]
152    fn accessors_and_metadata() {
153        let t = Tweezer::default();
154        assert_eq!(t.name(), "Tweezer");
155        assert_eq!(t.warmup_period(), 2);
156        assert!(!t.is_ready());
157        assert!((t.tolerance() - 0.001).abs() < 1e-12);
158    }
159
160    #[test]
161    fn tweezer_bottom_is_plus_one() {
162        let mut t = Tweezer::new();
163        assert_eq!(t.update(c(11.0, 12.0, 9.5, 9.6, 0)), Some(0.0));
164        // Matching low 9.5.
165        assert_eq!(t.update(c(9.7, 10.5, 9.5, 10.2, 1)), Some(1.0));
166    }
167
168    #[test]
169    fn tweezer_top_is_minus_one() {
170        let mut t = Tweezer::new();
171        assert_eq!(t.update(c(9.0, 12.0, 8.5, 11.0, 0)), Some(0.0));
172        // Matching high 12.0.
173        assert_eq!(t.update(c(11.5, 12.0, 11.0, 11.4, 1)), Some(-1.0));
174    }
175
176    #[test]
177    fn distinct_extremes_yield_zero() {
178        let mut t = Tweezer::new();
179        t.update(c(10.0, 11.0, 9.0, 10.5, 0));
180        assert_eq!(t.update(c(10.6, 11.5, 9.6, 11.2, 1)), Some(0.0));
181    }
182
183    #[test]
184    fn first_bar_returns_zero() {
185        let mut t = Tweezer::new();
186        assert_eq!(t.update(c(10.0, 11.0, 9.0, 10.5, 0)), Some(0.0));
187    }
188
189    #[test]
190    fn matched_both_extremes_prefers_bottom() {
191        // Identical candles match both highs and lows -> bottom (+1.0).
192        let mut t = Tweezer::new();
193        t.update(c(10.0, 11.0, 9.0, 10.5, 0));
194        assert_eq!(t.update(c(10.0, 11.0, 9.0, 10.5, 1)), Some(1.0));
195    }
196
197    #[test]
198    fn batch_equals_streaming() {
199        let candles: Vec<Candle> = (0..40)
200            .map(|i| {
201                let base = 100.0 + (i as f64 * 0.1).sin();
202                c(base, base + 2.0, base - 2.0, base + 0.5, i)
203            })
204            .collect();
205        let mut a = Tweezer::new();
206        let mut b = Tweezer::new();
207        assert_eq!(
208            a.batch(&candles),
209            candles.iter().map(|x| b.update(*x)).collect::<Vec<_>>()
210        );
211    }
212
213    #[test]
214    fn reset_clears_state() {
215        let mut t = Tweezer::new();
216        t.update(c(10.0, 11.0, 9.0, 10.5, 0));
217        t.update(c(10.0, 11.0, 9.0, 10.5, 1));
218        assert!(t.is_ready());
219        t.reset();
220        assert!(!t.is_ready());
221        assert_eq!(t.update(c(10.0, 11.0, 9.0, 10.5, 0)), Some(0.0));
222    }
223}