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    fn update(&mut self, candle: Candle) -> Option<f64> {
52        self.has_emitted = true;
53        if !self.swing.update(candle) {
54            return Some(0.0);
55        }
56        let pivots = self.swing.pivots();
57        if pivots.len() < 5 {
58            return Some(0.0);
59        }
60        let n = pivots.len();
61        let first = pivots[n - 5];
62        let middle = pivots[n - 3];
63        let last = pivots[n - 1];
64        let outer_match = approx_equal(first.price, middle.price, LEVEL_TOLERANCE);
65        let inner_match = approx_equal(middle.price, last.price, LEVEL_TOLERANCE);
66        if outer_match && inner_match {
67            return Some(if last.direction > 0.0 { -1.0 } else { 1.0 });
68        }
69        Some(0.0)
70    }
71
72    fn reset(&mut self) {
73        self.swing.reset();
74        self.has_emitted = false;
75    }
76
77    fn warmup_period(&self) -> usize {
78        // Five confirmed pivots are needed; the earliest bar that can confirm a
79        // fifth pivot is the sixth.
80        6
81    }
82
83    fn is_ready(&self) -> bool {
84        self.has_emitted
85    }
86
87    fn name(&self) -> &'static str {
88        "TripleTopBottom"
89    }
90}
91
92#[cfg(test)]
93mod tests {
94    use super::*;
95    use crate::indicators::pattern_swing::candles_for_pivots;
96    use crate::traits::BatchExt;
97
98    fn run(pivots: &[f64]) -> Vec<f64> {
99        let mut indicator = TripleTopBottom::new();
100        candles_for_pivots(pivots)
101            .into_iter()
102            .map(|c| indicator.update(c).unwrap())
103            .collect()
104    }
105
106    #[test]
107    fn accessors_and_metadata() {
108        let indicator = TripleTopBottom::new();
109        assert_eq!(indicator.name(), "TripleTopBottom");
110        assert_eq!(indicator.warmup_period(), 6);
111        assert!(!indicator.is_ready());
112        assert!(!TripleTopBottom::default().is_ready());
113    }
114
115    #[test]
116    fn triple_top_is_minus_one() {
117        // Three ~equal highs (120, 121, 119) → triple top on the third.
118        let out = run(&[120.0, 100.0, 121.0, 99.0, 119.0]);
119        assert_eq!(*out.last().unwrap(), -1.0);
120        assert!(out[..out.len() - 1].iter().all(|&x| x == 0.0));
121    }
122
123    #[test]
124    fn triple_bottom_is_plus_one() {
125        // Lead high then three ~equal lows (100, 99, 101) → triple bottom.
126        let out = run(&[130.0, 100.0, 120.0, 99.0, 122.0, 101.0]);
127        assert_eq!(*out.last().unwrap(), 1.0);
128    }
129
130    #[test]
131    fn unequal_third_peak_does_not_trigger() {
132        // Third high (140) diverges from the first two (120, 121) → no pattern.
133        let out = run(&[120.0, 100.0, 121.0, 99.0, 140.0]);
134        assert_eq!(*out.last().unwrap(), 0.0);
135        assert!(out.iter().all(|&x| x == 0.0));
136    }
137
138    #[test]
139    fn reset_clears_state() {
140        let mut indicator = TripleTopBottom::new();
141        for c in candles_for_pivots(&[120.0, 100.0, 121.0]) {
142            let _ = indicator.update(c);
143        }
144        indicator.reset();
145        assert!(!indicator.is_ready());
146        let c = Candle::new(99.5, 100.0, 99.5, 99.5, 1.0, 0).unwrap();
147        assert_eq!(indicator.update(c), Some(0.0));
148    }
149
150    #[test]
151    fn batch_equals_streaming() {
152        let candles = candles_for_pivots(&[120.0, 100.0, 121.0, 99.0, 119.0]);
153        let mut a = TripleTopBottom::new();
154        let mut b = TripleTopBottom::new();
155        assert_eq!(
156            a.batch(&candles),
157            candles.iter().map(|x| b.update(*x)).collect::<Vec<_>>()
158        );
159    }
160}