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).unwrap();
49///     if i == 4 {
50///         assert_eq!(signal, -1.0);
51///     }
52/// }
53/// ```
54#[derive(Debug, Clone)]
55pub struct DoubleTopBottom {
56    swing: SwingTracker,
57    has_emitted: bool,
58}
59
60impl DoubleTopBottom {
61    /// Construct a new Double Top / Double Bottom detector.
62    pub const fn new() -> Self {
63        Self {
64            swing: SwingTracker::new(SWING_THRESHOLD, 3),
65            has_emitted: false,
66        }
67    }
68}
69
70impl Default for DoubleTopBottom {
71    fn default() -> Self {
72        Self::new()
73    }
74}
75
76impl Indicator for DoubleTopBottom {
77    type Input = Candle;
78    type Output = f64;
79
80    fn update(&mut self, candle: Candle) -> Option<f64> {
81        self.has_emitted = true;
82        if !self.swing.update(candle) {
83            return Some(0.0);
84        }
85        let pivots = self.swing.pivots();
86        if pivots.len() < 3 {
87            return Some(0.0);
88        }
89        let first = pivots[pivots.len() - 3];
90        let last = pivots[pivots.len() - 1];
91        if approx_equal(first.price, last.price, LEVEL_TOLERANCE) {
92            // `last` is the just-confirmed extreme: a high → double top (bearish),
93            // a low → double bottom (bullish).
94            return Some(if last.direction > 0.0 { -1.0 } else { 1.0 });
95        }
96        Some(0.0)
97    }
98
99    fn reset(&mut self) {
100        self.swing.reset();
101        self.has_emitted = false;
102    }
103
104    fn warmup_period(&self) -> usize {
105        // The first complete pattern needs three confirmed pivots; the earliest
106        // bar that can confirm a third pivot is the fifth.
107        5
108    }
109
110    fn is_ready(&self) -> bool {
111        self.has_emitted
112    }
113
114    fn name(&self) -> &'static str {
115        "DoubleTopBottom"
116    }
117}
118
119#[cfg(test)]
120mod tests {
121    use super::*;
122    use crate::indicators::pattern_swing::candles_for_pivots;
123    use crate::traits::BatchExt;
124
125    fn run(pivots: &[f64]) -> Vec<f64> {
126        let mut indicator = DoubleTopBottom::new();
127        candles_for_pivots(pivots)
128            .into_iter()
129            .map(|c| indicator.update(c).unwrap())
130            .collect()
131    }
132
133    #[test]
134    fn accessors_and_metadata() {
135        let indicator = DoubleTopBottom::new();
136        assert_eq!(indicator.name(), "DoubleTopBottom");
137        assert_eq!(indicator.warmup_period(), 5);
138        assert!(!indicator.is_ready());
139        assert!(!DoubleTopBottom::default().is_ready());
140    }
141
142    #[test]
143    fn double_top_is_minus_one() {
144        // Twin highs 120 / 120 with a 100 trough → double top on the second.
145        let out = run(&[120.0, 100.0, 120.0]);
146        assert_eq!(*out.last().unwrap(), -1.0);
147        // All earlier bars are warmup / non-completing.
148        assert!(out[..out.len() - 1].iter().all(|&x| x == 0.0));
149    }
150
151    #[test]
152    fn double_bottom_is_plus_one() {
153        // Lead high, then twin lows 100 / 99 around a 120 peak → double bottom.
154        let out = run(&[130.0, 100.0, 120.0, 99.0]);
155        assert_eq!(*out.last().unwrap(), 1.0);
156    }
157
158    #[test]
159    fn unequal_tops_do_not_trigger() {
160        // Second top 140 diverges from the first (120) → no pattern.
161        let out = run(&[120.0, 100.0, 140.0]);
162        assert_eq!(*out.last().unwrap(), 0.0);
163        assert!(out.iter().all(|&x| x == 0.0));
164    }
165
166    #[test]
167    fn reset_clears_state() {
168        let mut indicator = DoubleTopBottom::new();
169        for c in candles_for_pivots(&[120.0, 100.0, 120.0]) {
170            let _ = indicator.update(c);
171        }
172        indicator.reset();
173        assert!(!indicator.is_ready());
174        let c = Candle::new(99.5, 100.0, 99.5, 99.5, 1.0, 0).unwrap();
175        assert_eq!(indicator.update(c), Some(0.0));
176    }
177
178    #[test]
179    fn batch_equals_streaming() {
180        let candles = candles_for_pivots(&[120.0, 100.0, 120.0]);
181        let mut a = DoubleTopBottom::new();
182        let mut b = DoubleTopBottom::new();
183        assert_eq!(
184            a.batch(&candles),
185            candles.iter().map(|x| b.update(*x)).collect::<Vec<_>>()
186        );
187    }
188}