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#[derive(Debug, Clone, Default)]
41pub struct TowerTopBottom {
42    c1: Option<Candle>,
43    c2: Option<Candle>,
44    last_value: Option<f64>,
45}
46
47impl TowerTopBottom {
48    /// Construct a new `TowerTopBottom`.
49    #[must_use]
50    pub fn new() -> Self {
51        Self::default()
52    }
53
54    /// Latest emitted signal if available.
55    pub const fn value(&self) -> Option<f64> {
56        self.last_value
57    }
58}
59
60impl Indicator for TowerTopBottom {
61    type Input = Candle;
62    type Output = f64;
63
64    fn update(&mut self, candle: Candle) -> Option<f64> {
65        let (Some(first), Some(middle)) = (self.c1, self.c2) else {
66            self.c1 = self.c2;
67            self.c2 = Some(candle);
68            self.last_value = Some(0.0);
69            return Some(0.0);
70        };
71        let pause = is_small(middle);
72        let first_tall = is_tall(first);
73        let last_tall = is_tall(candle);
74        let v = if pause && first_tall && last_tall {
75            let first_up = first.close > first.open;
76            let last_up = candle.close > candle.open;
77            if !first_up && last_up {
78                1.0
79            } else if first_up && !last_up {
80                -1.0
81            } else {
82                0.0
83            }
84        } else {
85            0.0
86        };
87        self.c1 = self.c2;
88        self.c2 = Some(candle);
89        self.last_value = Some(v);
90        Some(v)
91    }
92
93    fn reset(&mut self) {
94        self.c1 = None;
95        self.c2 = None;
96        self.last_value = None;
97    }
98
99    fn warmup_period(&self) -> usize {
100        3
101    }
102
103    fn is_ready(&self) -> bool {
104        self.last_value.is_some()
105    }
106
107    fn name(&self) -> &'static str {
108        "TowerTopBottom"
109    }
110}
111
112#[cfg(test)]
113mod tests {
114    use super::*;
115    use crate::traits::BatchExt;
116
117    /// A tall candle from `open` to `close` (body fills most of the range).
118    fn tall(open: f64, close: f64) -> Candle {
119        Candle::new_unchecked(
120            open,
121            open.max(close) + 0.1,
122            open.min(close) - 0.1,
123            close,
124            0.0,
125            0,
126        )
127    }
128
129    /// A small-bodied candle (long shadows, tiny body).
130    fn small(mid: f64) -> Candle {
131        Candle::new_unchecked(mid, mid + 2.0, mid - 2.0, mid + 0.1, 0.0, 0)
132    }
133
134    #[test]
135    fn accessors_and_metadata() {
136        let t = TowerTopBottom::new();
137        assert_eq!(t.warmup_period(), 3);
138        assert_eq!(t.name(), "TowerTopBottom");
139        assert!(!t.is_ready());
140        assert_eq!(t.value(), None);
141    }
142
143    #[test]
144    fn first_two_bars_seed_without_signal() {
145        let mut t = TowerTopBottom::new();
146        assert_eq!(t.update(tall(100.0, 110.0)), Some(0.0));
147        assert_eq!(t.update(small(105.0)), Some(0.0));
148        assert!(t.update(tall(110.0, 100.0)).is_some());
149    }
150
151    #[test]
152    fn tower_top() {
153        // tall bullish, small pause, tall bearish -> top -> -1.
154        let mut t = TowerTopBottom::new();
155        t.update(tall(100.0, 110.0));
156        t.update(small(110.0));
157        assert_eq!(t.update(tall(110.0, 100.0)), Some(-1.0));
158    }
159
160    #[test]
161    fn tower_bottom() {
162        let mut t = TowerTopBottom::new();
163        t.update(tall(110.0, 100.0));
164        t.update(small(100.0));
165        assert_eq!(t.update(tall(100.0, 110.0)), Some(1.0));
166    }
167
168    #[test]
169    fn same_direction_is_zero() {
170        let mut t = TowerTopBottom::new();
171        t.update(tall(100.0, 110.0));
172        t.update(small(110.0));
173        // last bar also bullish -> not a tower -> 0.
174        assert_eq!(t.update(tall(110.0, 120.0)), Some(0.0));
175    }
176
177    #[test]
178    fn no_pause_is_zero() {
179        let mut t = TowerTopBottom::new();
180        t.update(tall(100.0, 110.0));
181        t.update(tall(110.0, 120.0)); // middle is tall, not a pause
182        assert_eq!(t.update(tall(120.0, 110.0)), Some(0.0));
183    }
184
185    #[test]
186    fn reset_clears_state() {
187        let mut t = TowerTopBottom::new();
188        t.update(tall(100.0, 110.0));
189        t.update(small(110.0));
190        t.update(tall(110.0, 100.0));
191        assert!(t.is_ready());
192        t.reset();
193        assert!(!t.is_ready());
194        assert_eq!(t.update(tall(100.0, 110.0)), Some(0.0));
195    }
196
197    #[test]
198    fn zero_range_bar_has_zero_body_fraction() {
199        // A flat bar (high == low) exercises the zero-range body-fraction branch;
200        // it counts as a small "pause" bar, so tall-flat-tall still reverses.
201        fn flat(mid: f64) -> Candle {
202            Candle::new_unchecked(mid, mid, mid, mid, 0.0, 0)
203        }
204        let mut t = TowerTopBottom::new();
205        t.update(tall(100.0, 110.0));
206        t.update(flat(110.0));
207        assert_eq!(t.update(tall(110.0, 100.0)), Some(-1.0));
208    }
209
210    #[test]
211    fn batch_equals_streaming() {
212        let candles: Vec<Candle> = (0..30)
213            .map(|i| match i % 3 {
214                0 => tall(100.0, 110.0),
215                1 => small(110.0),
216                _ => tall(110.0, 100.0),
217            })
218            .collect();
219        let batch = TowerTopBottom::new().batch(&candles);
220        let mut b = TowerTopBottom::new();
221        let streamed: Vec<_> = candles.iter().map(|x| b.update(*x)).collect();
222        assert_eq!(batch, streamed);
223    }
224}