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}