Skip to main content

wickra_core/indicators/
gartley.rs

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