Skip to main content

wickra_core/indicators/
butterfly.rs

1//! Butterfly harmonic pattern.
2
3use crate::indicators::pattern_swing::{ratios_in, xabcd, SwingTracker, SWING_THRESHOLD};
4use crate::ohlcv::Candle;
5use crate::traits::Indicator;
6
7/// Butterfly — a 5-point (X-A-B-C-D) harmonic pattern with a `0.786` B and an
8/// **extended** D that overshoots X:
9///
10/// ```text
11/// AB / XA ∈ [0.74, 0.84]   (≈ 0.786)
12/// BC / AB ∈ [0.382, 0.886]
13/// CD / BC ∈ [1.618, 2.618]
14/// AD / XA ∈ [1.27, 1.618]  (the defining extended 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/butterfly.rs`.
19#[derive(Debug, Clone)]
20pub struct Butterfly {
21    swing: SwingTracker,
22    has_emitted: bool,
23}
24
25impl Butterfly {
26    /// Construct a new Butterfly 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 Butterfly {
36    fn default() -> Self {
37        Self::new()
38    }
39}
40
41impl Indicator for Butterfly {
42    type Input = Candle;
43    type Output = f64;
44
45    #[inline]
46    fn update(&mut self, candle: Candle) -> Option<f64> {
47        let advanced = self.swing.update(candle);
48        let pivots = self.swing.pivots();
49        // Too few pivots to form the shape at all: the indicator cannot
50        // judge yet, which is what `None` means.
51        if pivots.len() < 5 {
52            return None;
53        }
54        self.has_emitted = true;
55        // Armed, but this bar did not close a new pivot, so there is
56        // nothing new to match against.
57        if !advanced {
58            return Some(0.0);
59        }
60        let p = xabcd(pivots);
61        let xa = (p.a - p.x).abs();
62        let ab = (p.b - p.a).abs();
63        let bc = (p.c - p.b).abs();
64        let cd = (p.d - p.c).abs();
65        let ad = (p.d - p.a).abs();
66        let matched = ratios_in(&[
67            (ab / xa, 0.74, 0.84),
68            (bc / ab, 0.382, 0.886),
69            (cd / bc, 1.618, 2.618),
70            (ad / xa, 1.27, 1.618),
71        ]);
72        if matched {
73            return Some(if p.bullish { 1.0 } else { -1.0 });
74        }
75        Some(0.0)
76    }
77
78    fn reset(&mut self) {
79        self.swing.reset();
80        self.has_emitted = false;
81    }
82
83    #[inline]
84    fn warmup_period(&self) -> usize {
85        6
86    }
87
88    #[inline]
89    fn is_ready(&self) -> bool {
90        self.has_emitted
91    }
92
93    #[inline]
94    fn name(&self) -> &'static str {
95        "Butterfly"
96    }
97}
98
99#[cfg(test)]
100mod tests {
101    use super::*;
102    use crate::indicators::pattern_swing::candles_for_pivots;
103    use crate::traits::BatchExt;
104
105    fn run(pivots: &[f64]) -> Vec<f64> {
106        let mut indicator = Butterfly::new();
107        candles_for_pivots(pivots)
108            .into_iter()
109            .filter_map(|c| indicator.update(c))
110            .collect()
111    }
112
113    #[test]
114    fn accessors_and_metadata() {
115        let indicator = Butterfly::new();
116        assert_eq!(indicator.name(), "Butterfly");
117        assert_eq!(indicator.warmup_period(), 6);
118        assert!(!indicator.is_ready());
119        assert!(!Butterfly::default().is_ready());
120    }
121
122    #[test]
123    fn bullish_butterfly_is_plus_one() {
124        let out = run(&[150.0, 100.0, 140.0, 108.6, 128.0, 79.8]);
125        assert_eq!(*out.last().unwrap(), 1.0);
126        assert!(out[..out.len() - 1].iter().all(|&x| x == 0.0));
127    }
128
129    #[test]
130    fn bearish_butterfly_is_minus_one() {
131        let out = run(&[150.0, 110.0, 141.4, 121.4, 170.2]);
132        assert_eq!(*out.last().unwrap(), -1.0);
133    }
134
135    #[test]
136    fn out_of_ratio_does_not_trigger() {
137        let out = run(&[150.0, 100.0, 140.0, 110.0, 135.0, 105.0]);
138        assert_eq!(*out.last().unwrap(), 0.0);
139    }
140
141    #[test]
142    fn reset_clears_state() {
143        let mut indicator = Butterfly::new();
144        for c in candles_for_pivots(&[150.0, 100.0, 140.0]) {
145            let _ = indicator.update(c);
146        }
147        indicator.reset();
148        assert!(!indicator.is_ready());
149        let c = Candle::new(99.5, 100.0, 99.5, 99.5, 1.0, 0).unwrap();
150        assert_eq!(indicator.update(c), None);
151    }
152
153    #[test]
154    fn batch_equals_streaming() {
155        let candles = candles_for_pivots(&[150.0, 100.0, 140.0, 108.6, 128.0, 79.8]);
156        let mut a = Butterfly::new();
157        let mut b = Butterfly::new();
158        assert_eq!(
159            a.batch(&candles),
160            candles.iter().map(|x| b.update(*x)).collect::<Vec<_>>()
161        );
162    }
163}