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    #[inline]
80    fn update(&mut self, candle: Candle) -> Option<f64> {
81        let (Some(first), Some(middle)) = (self.c1, self.c2) else {
82            self.c1 = self.c2;
83            self.c2 = Some(candle);
84            self.last_value = None;
85            return None;
86        };
87        let pause = is_small(middle);
88        let first_tall = is_tall(first);
89        let last_tall = is_tall(candle);
90        let v = if pause && first_tall && last_tall {
91            let first_up = first.close > first.open;
92            let last_up = candle.close > candle.open;
93            if !first_up && last_up {
94                1.0
95            } else if first_up && !last_up {
96                -1.0
97            } else {
98                0.0
99            }
100        } else {
101            0.0
102        };
103        self.c1 = self.c2;
104        self.c2 = Some(candle);
105        self.last_value = Some(v);
106        Some(v)
107    }
108
109    fn reset(&mut self) {
110        self.c1 = None;
111        self.c2 = None;
112        self.last_value = None;
113    }
114
115    #[inline]
116    fn warmup_period(&self) -> usize {
117        3
118    }
119
120    #[inline]
121    fn is_ready(&self) -> bool {
122        self.last_value.is_some()
123    }
124
125    #[inline]
126    fn name(&self) -> &'static str {
127        "TowerTopBottom"
128    }
129}
130
131#[cfg(test)]
132mod tests {
133    use super::*;
134    use crate::traits::BatchExt;
135
136    /// A tall candle from `open` to `close` (body fills most of the range).
137    fn tall(open: f64, close: f64) -> Candle {
138        Candle::new_unchecked(
139            open,
140            open.max(close) + 0.1,
141            open.min(close) - 0.1,
142            close,
143            0.0,
144            0,
145        )
146    }
147
148    /// A small-bodied candle (long shadows, tiny body).
149    fn small(mid: f64) -> Candle {
150        Candle::new_unchecked(mid, mid + 2.0, mid - 2.0, mid + 0.1, 0.0, 0)
151    }
152
153    #[test]
154    fn accessors_and_metadata() {
155        let t = TowerTopBottom::new();
156        assert_eq!(t.warmup_period(), 3);
157        assert_eq!(t.name(), "TowerTopBottom");
158        assert!(!t.is_ready());
159        assert_eq!(t.value(), None);
160    }
161
162    #[test]
163    fn first_two_bars_seed_without_signal() {
164        let mut t = TowerTopBottom::new();
165        assert_eq!(t.update(tall(100.0, 110.0)), None);
166        assert_eq!(t.update(small(105.0)), None);
167        assert!(t.update(tall(110.0, 100.0)).is_some());
168    }
169
170    #[test]
171    fn tower_top() {
172        // tall bullish, small pause, tall bearish -> top -> -1.
173        let mut t = TowerTopBottom::new();
174        t.update(tall(100.0, 110.0));
175        t.update(small(110.0));
176        assert_eq!(t.update(tall(110.0, 100.0)), Some(-1.0));
177    }
178
179    #[test]
180    fn tower_bottom() {
181        let mut t = TowerTopBottom::new();
182        t.update(tall(110.0, 100.0));
183        t.update(small(100.0));
184        assert_eq!(t.update(tall(100.0, 110.0)), Some(1.0));
185    }
186
187    #[test]
188    fn same_direction_is_zero() {
189        let mut t = TowerTopBottom::new();
190        t.update(tall(100.0, 110.0));
191        t.update(small(110.0));
192        // last bar also bullish -> not a tower -> 0.
193        assert_eq!(t.update(tall(110.0, 120.0)), Some(0.0));
194    }
195
196    #[test]
197    fn no_pause_is_zero() {
198        let mut t = TowerTopBottom::new();
199        t.update(tall(100.0, 110.0));
200        t.update(tall(110.0, 120.0)); // middle is tall, not a pause
201        assert_eq!(t.update(tall(120.0, 110.0)), Some(0.0));
202    }
203
204    #[test]
205    fn reset_clears_state() {
206        let mut t = TowerTopBottom::new();
207        t.update(tall(100.0, 110.0));
208        t.update(small(110.0));
209        t.update(tall(110.0, 100.0));
210        assert!(t.is_ready());
211        t.reset();
212        assert!(!t.is_ready());
213        assert_eq!(t.update(tall(100.0, 110.0)), None);
214    }
215
216    #[test]
217    fn zero_range_bar_has_zero_body_fraction() {
218        // A flat bar (high == low) exercises the zero-range body-fraction branch;
219        // it counts as a small "pause" bar, so tall-flat-tall still reverses.
220        fn flat(mid: f64) -> Candle {
221            Candle::new_unchecked(mid, mid, mid, mid, 0.0, 0)
222        }
223        let mut t = TowerTopBottom::new();
224        t.update(tall(100.0, 110.0));
225        t.update(flat(110.0));
226        assert_eq!(t.update(tall(110.0, 100.0)), Some(-1.0));
227    }
228
229    #[test]
230    fn batch_equals_streaming() {
231        let candles: Vec<Candle> = (0..30)
232            .map(|i| match i % 3 {
233                0 => tall(100.0, 110.0),
234                1 => small(110.0),
235                _ => tall(110.0, 100.0),
236            })
237            .collect();
238        let batch = TowerTopBottom::new().batch(&candles);
239        let mut b = TowerTopBottom::new();
240        let streamed: Vec<_> = candles.iter().map(|x| b.update(*x)).collect();
241        assert_eq!(batch, streamed);
242    }
243}