Skip to main content

wickra_core/indicators/
double_top_bottom.rs

1//! Double Top / Double Bottom reversal chart pattern.
2
3use crate::indicators::pattern_swing::{
4    approx_equal, SwingTracker, LEVEL_TOLERANCE, SWING_THRESHOLD,
5};
6use crate::ohlcv::Candle;
7use crate::traits::Indicator;
8
9/// Double Top / Double Bottom — a two-peak (or two-trough) reversal pattern.
10///
11/// The detector tracks confirmed swing pivots (a non-repainting percent-threshold
12/// zig-zag, `SWING_THRESHOLD` = 5%). A pattern is recognised on the bar that
13/// confirms the **second** matching extreme:
14///
15/// ```text
16/// double top    : … High₁ , Low , High₂   with  High₁ ≈ High₂   → -1 (bearish)
17/// double bottom : … Low₁  , High , Low₂    with  Low₁  ≈ Low₂    → +1 (bullish)
18/// ```
19///
20/// Two extremes count as the same level when they are within
21/// `LEVEL_TOLERANCE` (3%) of each other. Because pivots strictly alternate
22/// high/low, the trough between the twin tops (or the peak between the twin
23/// bottoms) is guaranteed to sit beyond both, so no extra separation check is
24/// needed.
25///
26/// Output is `+1.0` for a double bottom, `-1.0` for a double top, and `0.0` on
27/// every other bar (including warmup and bars that confirm a pivot which does
28/// not complete the pattern). Like the candlestick family this detector never
29/// returns `None`.
30///
31/// # Example
32///
33/// ```
34/// use wickra_core::{Candle, DoubleTopBottom, Indicator};
35///
36/// let mut indicator = DoubleTopBottom::new();
37/// for (i, &(high, low)) in [
38///     (100.0, 99.5),
39///     (120.0, 119.5),
40///     (110.0, 100.0), // confirms the first top at 120
41///     (120.0, 119.0), // confirms the trough at 100
42///     (115.0, 110.0), // confirms the second top at 120 → double top
43/// ]
44/// .iter()
45/// .enumerate()
46/// {
47///     let c = Candle::new(low, high, low, low, 1.0, i as i64).unwrap();
48///     let signal = indicator.update(c);
49///     // Nothing is reported until three pivots exist to compare.
50///     if i == 4 {
51///         assert_eq!(signal, Some(-1.0));
52///     }
53/// }
54/// ```
55#[derive(Debug, Clone)]
56pub struct DoubleTopBottom {
57    swing: SwingTracker,
58    has_emitted: bool,
59}
60
61impl DoubleTopBottom {
62    /// Construct a new Double Top / Double Bottom detector.
63    pub const fn new() -> Self {
64        Self {
65            swing: SwingTracker::new(SWING_THRESHOLD, 3),
66            has_emitted: false,
67        }
68    }
69}
70
71impl Default for DoubleTopBottom {
72    fn default() -> Self {
73        Self::new()
74    }
75}
76
77impl Indicator for DoubleTopBottom {
78    type Input = Candle;
79    type Output = f64;
80
81    #[inline]
82    fn update(&mut self, candle: Candle) -> Option<f64> {
83        let advanced = self.swing.update(candle);
84        let pivots = self.swing.pivots();
85        // Too few pivots to form the shape at all: the indicator cannot
86        // judge yet, which is what `None` means.
87        if pivots.len() < 3 {
88            return None;
89        }
90        self.has_emitted = true;
91        // Armed, but this bar did not close a new pivot, so there is
92        // nothing new to match against.
93        if !advanced {
94            return Some(0.0);
95        }
96        let first = pivots[pivots.len() - 3];
97        let last = pivots[pivots.len() - 1];
98        if approx_equal(first.price, last.price, LEVEL_TOLERANCE) {
99            // `last` is the just-confirmed extreme: a high → double top (bearish),
100            // a low → double bottom (bullish).
101            return Some(if last.direction > 0.0 { -1.0 } else { 1.0 });
102        }
103        Some(0.0)
104    }
105
106    fn reset(&mut self) {
107        self.swing.reset();
108        self.has_emitted = false;
109    }
110
111    #[inline]
112    fn warmup_period(&self) -> usize {
113        // Three confirmed pivots. The tracker seeds on the first bar without
114        // confirming anything and can confirm at most one pivot per bar after
115        // that, so the third arrives on the fourth bar at the earliest.
116        4
117    }
118
119    #[inline]
120    fn is_ready(&self) -> bool {
121        self.has_emitted
122    }
123
124    #[inline]
125    fn name(&self) -> &'static str {
126        "DoubleTopBottom"
127    }
128}
129
130#[cfg(test)]
131mod tests {
132    use super::*;
133    use crate::indicators::pattern_swing::candles_for_pivots;
134    use crate::traits::BatchExt;
135
136    fn run(pivots: &[f64]) -> Vec<f64> {
137        let mut indicator = DoubleTopBottom::new();
138        candles_for_pivots(pivots)
139            .into_iter()
140            .filter_map(|c| indicator.update(c))
141            .collect()
142    }
143
144    #[test]
145    fn accessors_and_metadata() {
146        let indicator = DoubleTopBottom::new();
147        assert_eq!(indicator.name(), "DoubleTopBottom");
148        assert_eq!(indicator.warmup_period(), 4);
149        assert!(!indicator.is_ready());
150        assert!(!DoubleTopBottom::default().is_ready());
151    }
152
153    #[test]
154    fn double_top_is_minus_one() {
155        // Twin highs 120 / 120 with a 100 trough → double top on the second.
156        let out = run(&[120.0, 100.0, 120.0]);
157        assert_eq!(*out.last().unwrap(), -1.0);
158        // All earlier bars are warmup / non-completing.
159        assert!(out[..out.len() - 1].iter().all(|&x| x == 0.0));
160    }
161
162    #[test]
163    fn double_bottom_is_plus_one() {
164        // Lead high, then twin lows 100 / 99 around a 120 peak → double bottom.
165        let out = run(&[130.0, 100.0, 120.0, 99.0]);
166        assert_eq!(*out.last().unwrap(), 1.0);
167    }
168
169    #[test]
170    fn unequal_tops_do_not_trigger() {
171        // Second top 140 diverges from the first (120) → no pattern.
172        let out = run(&[120.0, 100.0, 140.0]);
173        assert_eq!(*out.last().unwrap(), 0.0);
174        assert!(out.iter().all(|&x| x == 0.0));
175    }
176
177    #[test]
178    fn reset_clears_state() {
179        let mut indicator = DoubleTopBottom::new();
180        for c in candles_for_pivots(&[120.0, 100.0, 120.0]) {
181            let _ = indicator.update(c);
182        }
183        indicator.reset();
184        assert!(!indicator.is_ready());
185        let c = Candle::new(99.5, 100.0, 99.5, 99.5, 1.0, 0).unwrap();
186        assert_eq!(indicator.update(c), None);
187    }
188
189    #[test]
190    fn batch_equals_streaming() {
191        let candles = candles_for_pivots(&[120.0, 100.0, 120.0]);
192        let mut a = DoubleTopBottom::new();
193        let mut b = DoubleTopBottom::new();
194        assert_eq!(
195            a.batch(&candles),
196            candles.iter().map(|x| b.update(*x)).collect::<Vec<_>>()
197        );
198    }
199}