Skip to main content

wickra_core/indicators/
bat.rs

1//! Bat harmonic pattern.
2
3use crate::indicators::pattern_swing::{ratios_in, xabcd, SwingTracker, SWING_THRESHOLD};
4use crate::ohlcv::Candle;
5use crate::traits::Indicator;
6
7/// Bat — a 5-point (X-A-B-C-D) harmonic pattern with a shallow B and a deep
8/// `0.886` D completion:
9///
10/// ```text
11/// AB / XA ∈ [0.382, 0.50]
12/// BC / AB ∈ [0.382, 0.886]
13/// CD / BC ∈ [1.618, 2.618]
14/// AD / XA ∈ [0.84, 0.93]   (≈ 0.886 — the defining D completion)
15/// ```
16///
17/// Output is `+1.0` (bullish, D a swing low), `-1.0` (bearish, D a swing high),
18/// or `0.0`; never `None`. See `crates/wickra-core/src/indicators/bat.rs`.
19#[derive(Debug, Clone)]
20pub struct Bat {
21    swing: SwingTracker,
22    has_emitted: bool,
23}
24
25impl Bat {
26    /// Construct a new Bat detector.
27    pub const fn new() -> Self {
28        Self {
29            swing: SwingTracker::new(SWING_THRESHOLD, 5),
30            has_emitted: false,
31        }
32    }
33}
34
35impl Default for Bat {
36    fn default() -> Self {
37        Self::new()
38    }
39}
40
41impl Indicator for Bat {
42    type Input = Candle;
43    type Output = f64;
44
45    fn update(&mut self, candle: Candle) -> Option<f64> {
46        self.has_emitted = true;
47        if !self.swing.update(candle) {
48            return Some(0.0);
49        }
50        let pivots = self.swing.pivots();
51        if pivots.len() < 5 {
52            return Some(0.0);
53        }
54        let p = xabcd(pivots);
55        let xa = (p.a - p.x).abs();
56        let ab = (p.b - p.a).abs();
57        let bc = (p.c - p.b).abs();
58        let cd = (p.d - p.c).abs();
59        let ad = (p.d - p.a).abs();
60        let matched = ratios_in(&[
61            (ab / xa, 0.382, 0.50),
62            (bc / ab, 0.382, 0.886),
63            (cd / bc, 1.618, 2.618),
64            (ad / xa, 0.84, 0.93),
65        ]);
66        if matched {
67            return Some(if p.bullish { 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        6
79    }
80
81    fn is_ready(&self) -> bool {
82        self.has_emitted
83    }
84
85    fn name(&self) -> &'static str {
86        "Bat"
87    }
88}
89
90#[cfg(test)]
91mod tests {
92    use super::*;
93    use crate::indicators::pattern_swing::candles_for_pivots;
94    use crate::traits::BatchExt;
95
96    fn run(pivots: &[f64]) -> Vec<f64> {
97        let mut indicator = Bat::new();
98        candles_for_pivots(pivots)
99            .into_iter()
100            .map(|c| indicator.update(c).unwrap())
101            .collect()
102    }
103
104    #[test]
105    fn accessors_and_metadata() {
106        let indicator = Bat::new();
107        assert_eq!(indicator.name(), "Bat");
108        assert_eq!(indicator.warmup_period(), 6);
109        assert!(!indicator.is_ready());
110        assert!(!Bat::default().is_ready());
111    }
112
113    #[test]
114    fn bullish_bat_is_plus_one() {
115        let out = run(&[150.0, 100.0, 140.0, 122.0, 137.0, 104.56]);
116        assert_eq!(*out.last().unwrap(), 1.0);
117        assert!(out[..out.len() - 1].iter().all(|&x| x == 0.0));
118    }
119
120    #[test]
121    fn bearish_bat_is_minus_one() {
122        let out = run(&[150.0, 110.0, 128.0, 113.0, 145.44]);
123        assert_eq!(*out.last().unwrap(), -1.0);
124    }
125
126    #[test]
127    fn out_of_ratio_does_not_trigger() {
128        let out = run(&[150.0, 100.0, 140.0, 110.0, 135.0, 105.0]);
129        assert_eq!(*out.last().unwrap(), 0.0);
130    }
131
132    #[test]
133    fn reset_clears_state() {
134        let mut indicator = Bat::new();
135        for c in candles_for_pivots(&[150.0, 100.0, 140.0]) {
136            let _ = indicator.update(c);
137        }
138        indicator.reset();
139        assert!(!indicator.is_ready());
140        let c = Candle::new(99.5, 100.0, 99.5, 99.5, 1.0, 0).unwrap();
141        assert_eq!(indicator.update(c), Some(0.0));
142    }
143
144    #[test]
145    fn batch_equals_streaming() {
146        let candles = candles_for_pivots(&[150.0, 100.0, 140.0, 122.0, 137.0, 104.56]);
147        let mut a = Bat::new();
148        let mut b = Bat::new();
149        assert_eq!(
150            a.batch(&candles),
151            candles.iter().map(|x| b.update(*x)).collect::<Vec<_>>()
152        );
153    }
154}