Skip to main content

wickra_core/indicators/
tower_top_bottom.rs

1#![allow(clippy::doc_markdown)]
2
3//! Tower Top / Tower Bottom — a tall bar, a pause, then a tall opposite bar.
4//!
5//! A Tower is a reversal where a strong directional bar is followed by a small
6//! "pause" bar and then a strong bar in the *opposite* direction, like two towers
7//! flanking a low wall. This is the compact three-bar form of the classic
8//! multi-bar Tower pattern.
9//!
10//! - **Tower Bottom** (`+1.0`): a tall **bearish** bar, a small-bodied bar, then a
11//!   tall **bullish** bar.
12//! - **Tower Top** (`-1.0`): a tall **bullish** bar, a small-bodied bar, then a
13//!   tall **bearish** bar.
14//! - Otherwise the output is `0.0`.
15//!
16//! "Tall" = body `>= 0.5 * range`; "small" = body `<= 0.3 * range`. The three-bar
17//! lookback means the first value lands on the third candle.
18
19use crate::ohlcv::Candle;
20use crate::traits::Indicator;
21
22fn body_fraction(candle: Candle) -> f64 {
23    let range = candle.high - candle.low;
24    if range > 0.0 {
25        (candle.close - candle.open).abs() / range
26    } else {
27        0.0
28    }
29}
30
31fn is_tall(candle: Candle) -> bool {
32    body_fraction(candle) >= 0.5
33}
34
35fn is_small(candle: Candle) -> bool {
36    body_fraction(candle) <= 0.3
37}
38
39/// Tower Top / Bottom — three-bar reversal detector.
40/// # Example
41///
42/// ```
43/// use wickra_core::{TowerTopBottom, Candle, Indicator};
44///
45/// let mut indicator = TowerTopBottom::new();
46/// // `None` during warmup, then `Some(_)` once enough bars are seen.
47/// let mut out = None;
48/// for i in 0..40i64 {
49///     let p = 100.0 + (i as f64 * 0.4).sin() * 5.0;
50///     let candle = Candle::new(p, p + 1.5, p - 1.5, p + 0.3, 1_000.0, i).unwrap();
51///     out = indicator.update(candle);
52/// }
53/// let _ = out;
54/// ```
55#[derive(Debug, Clone, Default)]
56pub struct TowerTopBottom {
57    c1: Option<Candle>,
58    c2: Option<Candle>,
59    last_value: Option<f64>,
60}
61
62impl TowerTopBottom {
63    /// Construct a new `TowerTopBottom`.
64    #[must_use]
65    pub fn new() -> Self {
66        Self::default()
67    }
68
69    /// Latest emitted signal if available.
70    pub const fn value(&self) -> Option<f64> {
71        self.last_value
72    }
73}
74
75impl Indicator for TowerTopBottom {
76    type Input = Candle;
77    type Output = f64;
78
79    fn update(&mut self, candle: Candle) -> Option<f64> {
80        let (Some(first), Some(middle)) = (self.c1, self.c2) else {
81            self.c1 = self.c2;
82            self.c2 = Some(candle);
83            self.last_value = Some(0.0);
84            return Some(0.0);
85        };
86        let pause = is_small(middle);
87        let first_tall = is_tall(first);
88        let last_tall = is_tall(candle);
89        let v = if pause && first_tall && last_tall {
90            let first_up = first.close > first.open;
91            let last_up = candle.close > candle.open;
92            if !first_up && last_up {
93                1.0
94            } else if first_up && !last_up {
95                -1.0
96            } else {
97                0.0
98            }
99        } else {
100            0.0
101        };
102        self.c1 = self.c2;
103        self.c2 = Some(candle);
104        self.last_value = Some(v);
105        Some(v)
106    }
107
108    fn reset(&mut self) {
109        self.c1 = None;
110        self.c2 = None;
111        self.last_value = None;
112    }
113
114    fn warmup_period(&self) -> usize {
115        3
116    }
117
118    fn is_ready(&self) -> bool {
119        self.last_value.is_some()
120    }
121
122    fn name(&self) -> &'static str {
123        "TowerTopBottom"
124    }
125}
126
127#[cfg(test)]
128mod tests {
129    use super::*;
130    use crate::traits::BatchExt;
131
132    /// A tall candle from `open` to `close` (body fills most of the range).
133    fn tall(open: f64, close: f64) -> Candle {
134        Candle::new_unchecked(
135            open,
136            open.max(close) + 0.1,
137            open.min(close) - 0.1,
138            close,
139            0.0,
140            0,
141        )
142    }
143
144    /// A small-bodied candle (long shadows, tiny body).
145    fn small(mid: f64) -> Candle {
146        Candle::new_unchecked(mid, mid + 2.0, mid - 2.0, mid + 0.1, 0.0, 0)
147    }
148
149    #[test]
150    fn accessors_and_metadata() {
151        let t = TowerTopBottom::new();
152        assert_eq!(t.warmup_period(), 3);
153        assert_eq!(t.name(), "TowerTopBottom");
154        assert!(!t.is_ready());
155        assert_eq!(t.value(), None);
156    }
157
158    #[test]
159    fn first_two_bars_seed_without_signal() {
160        let mut t = TowerTopBottom::new();
161        assert_eq!(t.update(tall(100.0, 110.0)), Some(0.0));
162        assert_eq!(t.update(small(105.0)), Some(0.0));
163        assert!(t.update(tall(110.0, 100.0)).is_some());
164    }
165
166    #[test]
167    fn tower_top() {
168        // tall bullish, small pause, tall bearish -> top -> -1.
169        let mut t = TowerTopBottom::new();
170        t.update(tall(100.0, 110.0));
171        t.update(small(110.0));
172        assert_eq!(t.update(tall(110.0, 100.0)), Some(-1.0));
173    }
174
175    #[test]
176    fn tower_bottom() {
177        let mut t = TowerTopBottom::new();
178        t.update(tall(110.0, 100.0));
179        t.update(small(100.0));
180        assert_eq!(t.update(tall(100.0, 110.0)), Some(1.0));
181    }
182
183    #[test]
184    fn same_direction_is_zero() {
185        let mut t = TowerTopBottom::new();
186        t.update(tall(100.0, 110.0));
187        t.update(small(110.0));
188        // last bar also bullish -> not a tower -> 0.
189        assert_eq!(t.update(tall(110.0, 120.0)), Some(0.0));
190    }
191
192    #[test]
193    fn no_pause_is_zero() {
194        let mut t = TowerTopBottom::new();
195        t.update(tall(100.0, 110.0));
196        t.update(tall(110.0, 120.0)); // middle is tall, not a pause
197        assert_eq!(t.update(tall(120.0, 110.0)), Some(0.0));
198    }
199
200    #[test]
201    fn reset_clears_state() {
202        let mut t = TowerTopBottom::new();
203        t.update(tall(100.0, 110.0));
204        t.update(small(110.0));
205        t.update(tall(110.0, 100.0));
206        assert!(t.is_ready());
207        t.reset();
208        assert!(!t.is_ready());
209        assert_eq!(t.update(tall(100.0, 110.0)), Some(0.0));
210    }
211
212    #[test]
213    fn zero_range_bar_has_zero_body_fraction() {
214        // A flat bar (high == low) exercises the zero-range body-fraction branch;
215        // it counts as a small "pause" bar, so tall-flat-tall still reverses.
216        fn flat(mid: f64) -> Candle {
217            Candle::new_unchecked(mid, mid, mid, mid, 0.0, 0)
218        }
219        let mut t = TowerTopBottom::new();
220        t.update(tall(100.0, 110.0));
221        t.update(flat(110.0));
222        assert_eq!(t.update(tall(110.0, 100.0)), Some(-1.0));
223    }
224
225    #[test]
226    fn batch_equals_streaming() {
227        let candles: Vec<Candle> = (0..30)
228            .map(|i| match i % 3 {
229                0 => tall(100.0, 110.0),
230                1 => small(110.0),
231                _ => tall(110.0, 100.0),
232            })
233            .collect();
234        let batch = TowerTopBottom::new().batch(&candles);
235        let mut b = TowerTopBottom::new();
236        let streamed: Vec<_> = candles.iter().map(|x| b.update(*x)).collect();
237        assert_eq!(batch, streamed);
238    }
239}