Skip to main content

wickra_core/indicators/
triple_top_bottom.rs

1//! Triple Top / Triple 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/// Triple Top / Triple Bottom — a three-peak (or three-trough) reversal pattern,
10/// a stronger variant of the double top/bottom.
11///
12/// Built on confirmed swing pivots (`SWING_THRESHOLD` = 5%). A pattern is
13/// recognised on the bar that confirms the **third** matching extreme:
14///
15/// ```text
16/// triple top    : High₁ , Low , High₂ , Low , High₃   High₁ ≈ High₂ ≈ High₃ → -1
17/// triple bottom : Low₁  , High, Low₂  , High, Low₃     Low₁  ≈ Low₂  ≈ Low₃  → +1
18/// ```
19///
20/// The three same-direction extremes (positions `n-5`, `n-3`, `n-1` in the pivot
21/// history) must all lie within `LEVEL_TOLERANCE` (3%) of one another.
22///
23/// Output is `+1.0` for a triple bottom, `-1.0` for a triple top, and `0.0`
24/// otherwise; never `None`.
25#[derive(Debug, Clone)]
26pub struct TripleTopBottom {
27    swing: SwingTracker,
28    has_emitted: bool,
29}
30
31impl TripleTopBottom {
32    /// Construct a new Triple Top / Triple Bottom detector.
33    pub const fn new() -> Self {
34        Self {
35            swing: SwingTracker::new(SWING_THRESHOLD, 5),
36            has_emitted: false,
37        }
38    }
39}
40
41impl Default for TripleTopBottom {
42    fn default() -> Self {
43        Self::new()
44    }
45}
46
47impl Indicator for TripleTopBottom {
48    type Input = Candle;
49    type Output = f64;
50
51    #[inline]
52    fn update(&mut self, candle: Candle) -> Option<f64> {
53        let advanced = self.swing.update(candle);
54        let pivots = self.swing.pivots();
55        // Too few pivots to form the shape at all: the indicator cannot
56        // judge yet, which is what `None` means.
57        if pivots.len() < 5 {
58            return None;
59        }
60        self.has_emitted = true;
61        // Armed, but this bar did not close a new pivot, so there is
62        // nothing new to match against.
63        if !advanced {
64            return Some(0.0);
65        }
66        let n = pivots.len();
67        let first = pivots[n - 5];
68        let middle = pivots[n - 3];
69        let last = pivots[n - 1];
70        let outer_match = approx_equal(first.price, middle.price, LEVEL_TOLERANCE);
71        let inner_match = approx_equal(middle.price, last.price, LEVEL_TOLERANCE);
72        if outer_match && inner_match {
73            return Some(if last.direction > 0.0 { -1.0 } else { 1.0 });
74        }
75        Some(0.0)
76    }
77
78    fn reset(&mut self) {
79        self.swing.reset();
80        self.has_emitted = false;
81    }
82
83    #[inline]
84    fn warmup_period(&self) -> usize {
85        // Five confirmed pivots are needed; the earliest bar that can confirm a
86        // fifth pivot is the sixth.
87        6
88    }
89
90    #[inline]
91    fn is_ready(&self) -> bool {
92        self.has_emitted
93    }
94
95    #[inline]
96    fn name(&self) -> &'static str {
97        "TripleTopBottom"
98    }
99}
100
101#[cfg(test)]
102mod tests {
103    use super::*;
104    use crate::indicators::pattern_swing::candles_for_pivots;
105    use crate::traits::BatchExt;
106
107    fn run(pivots: &[f64]) -> Vec<f64> {
108        let mut indicator = TripleTopBottom::new();
109        candles_for_pivots(pivots)
110            .into_iter()
111            .filter_map(|c| indicator.update(c))
112            .collect()
113    }
114
115    #[test]
116    fn accessors_and_metadata() {
117        let indicator = TripleTopBottom::new();
118        assert_eq!(indicator.name(), "TripleTopBottom");
119        assert_eq!(indicator.warmup_period(), 6);
120        assert!(!indicator.is_ready());
121        assert!(!TripleTopBottom::default().is_ready());
122    }
123
124    #[test]
125    fn triple_top_is_minus_one() {
126        // Three ~equal highs (120, 121, 119) → triple top on the third.
127        let out = run(&[120.0, 100.0, 121.0, 99.0, 119.0]);
128        assert_eq!(*out.last().unwrap(), -1.0);
129        assert!(out[..out.len() - 1].iter().all(|&x| x == 0.0));
130    }
131
132    #[test]
133    fn triple_bottom_is_plus_one() {
134        // Lead high then three ~equal lows (100, 99, 101) → triple bottom.
135        let out = run(&[130.0, 100.0, 120.0, 99.0, 122.0, 101.0]);
136        assert_eq!(*out.last().unwrap(), 1.0);
137    }
138
139    #[test]
140    fn unequal_third_peak_does_not_trigger() {
141        // Third high (140) diverges from the first two (120, 121) → no pattern.
142        let out = run(&[120.0, 100.0, 121.0, 99.0, 140.0]);
143        assert_eq!(*out.last().unwrap(), 0.0);
144        assert!(out.iter().all(|&x| x == 0.0));
145    }
146
147    #[test]
148    fn reset_clears_state() {
149        let mut indicator = TripleTopBottom::new();
150        for c in candles_for_pivots(&[120.0, 100.0, 121.0]) {
151            let _ = indicator.update(c);
152        }
153        indicator.reset();
154        assert!(!indicator.is_ready());
155        let c = Candle::new(99.5, 100.0, 99.5, 99.5, 1.0, 0).unwrap();
156        assert_eq!(indicator.update(c), None);
157    }
158
159    #[test]
160    fn batch_equals_streaming() {
161        let candles = candles_for_pivots(&[120.0, 100.0, 121.0, 99.0, 119.0]);
162        let mut a = TripleTopBottom::new();
163        let mut b = TripleTopBottom::new();
164        assert_eq!(
165            a.batch(&candles),
166            candles.iter().map(|x| b.update(*x)).collect::<Vec<_>>()
167        );
168    }
169}