Skip to main content

wickra_core/indicators/
zig_zag.rs

1//! `ZigZag` — percentage-threshold swing detector.
2
3use crate::error::{Error, Result};
4use crate::ohlcv::Candle;
5use crate::traits::Indicator;
6
7/// `ZigZag` output: the price of the bar that completed the most recent swing
8/// and its direction (`+1.0` for a high swing, `-1.0` for a low swing).
9///
10/// The price is the high of the bar at which the high-swing was anchored, or
11/// the low of the bar at which the low-swing was anchored — i.e. the actual
12/// extreme that the swing turns from, not the bar that triggered confirmation.
13#[derive(Debug, Clone, Copy, PartialEq)]
14pub struct ZigZagOutput {
15    /// Price of the confirmed swing extreme.
16    pub swing: f64,
17    /// Direction: `+1.0` if the swing is a high, `-1.0` if a low.
18    pub direction: f64,
19}
20
21/// `ZigZag` — a non-repainting percent-threshold swing detector. Tracks the most
22/// recent extreme (high or low) and confirms a reversal once price has moved
23/// the configured percentage away from it.
24///
25/// ```text
26/// uptrend (last swing was a low):
27///   while highs make new highs, keep updating the pivot high
28///   once close (or low) drops by ≥ threshold·high → confirm pivot high
29///
30/// downtrend (last swing was a high):
31///   while lows make new lows, keep updating the pivot low
32///   once close (or high) rises by ≥ threshold·low  → confirm pivot low
33/// ```
34///
35/// The indicator emits `Some(swing)` only on the bar where a reversal is
36/// confirmed, returning the price and direction of the **just-completed**
37/// extreme. Bars between confirmations return `None`. The first bar bootstraps
38/// the state — it determines an initial reference price but does not emit.
39///
40/// The threshold is a fractional change (`0.05` ≈ 5%); it must be strictly
41/// positive and below `1.0`.
42///
43/// # Example
44///
45/// ```
46/// use wickra_core::{Candle, Indicator, ZigZag};
47///
48/// let mut zz = ZigZag::new(0.10).unwrap();
49/// for (i, p) in [100.0, 105.0, 115.0, 100.0, 90.0, 100.0].iter().enumerate() {
50///     let c = Candle::new(*p, *p + 0.5, *p - 0.5, *p, 1.0, i as i64).unwrap();
51///     let _ = zz.update(c);
52/// }
53/// ```
54#[derive(Debug, Clone)]
55pub struct ZigZag {
56    threshold: f64,
57    state: Option<State>,
58    /// Whether a swing has been confirmed since the last reset. `state` is
59    /// seeded on the very first bar but nothing is emitted then, so keying
60    /// readiness off it reported ready one bar early.
61    has_emitted: bool,
62}
63
64#[derive(Debug, Clone, Copy)]
65struct State {
66    /// Direction of the running trend: `+1.0` (uptrend tracking a pivot high)
67    /// or `-1.0` (downtrend tracking a pivot low).
68    direction: f64,
69    /// The current candidate extreme price (the running pivot).
70    extreme: f64,
71}
72
73impl ZigZag {
74    /// Construct a new `ZigZag` with a fractional reversal threshold (e.g. `0.05`
75    /// for a 5% swing).
76    ///
77    /// # Errors
78    /// Returns [`Error::InvalidPeriod`] if `threshold` is not in `(0.0, 1.0)`
79    /// or is not finite.
80    pub fn new(threshold: f64) -> Result<Self> {
81        if !threshold.is_finite() || threshold <= 0.0 || threshold >= 1.0 {
82            return Err(Error::InvalidPeriod {
83                message: "ZigZag threshold must be a finite fraction in (0, 1)",
84            });
85        }
86        Ok(Self {
87            threshold,
88            state: None,
89            has_emitted: false,
90        })
91    }
92
93    /// Configured reversal threshold (fractional).
94    pub const fn threshold(&self) -> f64 {
95        self.threshold
96    }
97}
98
99impl Indicator for ZigZag {
100    type Input = Candle;
101    type Output = ZigZagOutput;
102
103    fn update(&mut self, candle: Candle) -> Option<ZigZagOutput> {
104        let Some(s) = self.state else {
105            // Bootstrap: seed an uptrend tracking the first candle's high.
106            self.state = Some(State {
107                direction: 1.0,
108                extreme: candle.high,
109            });
110            return None;
111        };
112
113        if s.direction > 0.0 {
114            // Uptrend: keep raising the candidate high; confirm reversal if
115            // the candle's low has dropped by threshold from the candidate.
116            if candle.high > s.extreme {
117                self.state = Some(State {
118                    direction: 1.0,
119                    extreme: candle.high,
120                });
121                return None;
122            }
123            if candle.low <= s.extreme * (1.0 - self.threshold) {
124                // Confirm the swing high; flip to downtrend tracking this bar's low.
125                let confirmed = ZigZagOutput {
126                    swing: s.extreme,
127                    direction: 1.0,
128                };
129                self.state = Some(State {
130                    direction: -1.0,
131                    extreme: candle.low,
132                });
133                self.has_emitted = true;
134                return Some(confirmed);
135            }
136            None
137        } else {
138            // Downtrend: lower the candidate low; confirm reversal if the
139            // candle's high has risen by threshold from the candidate.
140            if candle.low < s.extreme {
141                self.state = Some(State {
142                    direction: -1.0,
143                    extreme: candle.low,
144                });
145                return None;
146            }
147            if candle.high >= s.extreme * (1.0 + self.threshold) {
148                let confirmed = ZigZagOutput {
149                    swing: s.extreme,
150                    direction: -1.0,
151                };
152                self.state = Some(State {
153                    direction: 1.0,
154                    extreme: candle.high,
155                });
156                self.has_emitted = true;
157                return Some(confirmed);
158            }
159            None
160        }
161    }
162
163    fn reset(&mut self) {
164        self.has_emitted = false;
165        self.state = None;
166    }
167
168    #[inline]
169    fn warmup_period(&self) -> usize {
170        // Bootstrap takes one bar; confirmation of the first swing needs at
171        // least one more move past the threshold. Best-case the first swing
172        // lands on the second bar.
173        2
174    }
175
176    #[inline]
177    fn is_ready(&self) -> bool {
178        self.has_emitted
179    }
180
181    #[inline]
182    fn name(&self) -> &'static str {
183        "ZigZag"
184    }
185}
186
187#[cfg(test)]
188mod tests {
189    use super::*;
190    use crate::traits::BatchExt;
191
192    fn c(price: f64, ts: i64) -> Candle {
193        Candle::new(price, price + 0.001, price - 0.001, price, 1.0, ts).unwrap()
194    }
195
196    fn c_hl(h: f64, l: f64, ts: i64) -> Candle {
197        Candle::new(l, h, l, l, 1.0, ts).unwrap()
198    }
199
200    #[test]
201    fn rejects_invalid_threshold() {
202        assert!(ZigZag::new(0.0).is_err());
203        assert!(ZigZag::new(-0.1).is_err());
204        assert!(ZigZag::new(1.0).is_err());
205        assert!(ZigZag::new(f64::NAN).is_err());
206        assert!(ZigZag::new(f64::INFINITY).is_err());
207    }
208
209    #[test]
210    fn first_bar_only_bootstraps() {
211        // The first bar seeds the trend state but emits nothing, so the
212        // indicator is not ready — `is_ready` means a value has been emitted.
213        let mut zz = ZigZag::new(0.05).unwrap();
214        assert_eq!(zz.update(c(100.0, 0)), None);
215        assert!(!zz.is_ready());
216    }
217
218    #[test]
219    fn confirms_high_swing_on_threshold_drop() {
220        let mut zz = ZigZag::new(0.10).unwrap();
221        // Up to a peak of 120, then a drop to 100 = 16.7% reversal → confirms.
222        let _ = zz.update(c_hl(100.0, 99.5, 0));
223        let _ = zz.update(c_hl(120.0, 119.5, 1));
224        let confirmed = zz.update(c_hl(101.0, 100.0, 2));
225        let o = confirmed.expect("the third bar's drop triggers confirmation");
226        assert!((o.swing - 120.0).abs() < 1e-9);
227        assert_eq!(o.direction, 1.0);
228    }
229
230    #[test]
231    fn confirms_low_swing_on_threshold_rise() {
232        let mut zz = ZigZag::new(0.10).unwrap();
233        // Up to 120 to seed the high pivot, drop to confirm it as a high,
234        // then rise from the new low pivot by 10% to confirm it as a low.
235        let _ = zz.update(c_hl(100.0, 99.5, 0));
236        let _ = zz.update(c_hl(120.0, 119.5, 1));
237        let _ = zz.update(c_hl(101.0, 90.0, 2)); // drop confirms 120-high; new low 90.
238        let _ = zz.update(c_hl(91.0, 90.5, 3));
239        // Rise to 100 from low 90 = 11.1% → confirms low.
240        let confirmed = zz.update(c_hl(100.0, 99.0, 4));
241        let o = confirmed.expect("the rise confirms the low swing");
242        assert!((o.swing - 90.0).abs() < 1e-9);
243        assert_eq!(o.direction, -1.0);
244    }
245
246    #[test]
247    fn small_oscillations_yield_no_swings() {
248        let mut zz = ZigZag::new(0.20).unwrap();
249        let _ = zz.update(c(100.0, 0));
250        for i in 1..20 {
251            // Bounce around 100 ± 5; never crosses the 20% threshold.
252            let p = 100.0 + ((f64::from(i)) * 0.3).sin() * 5.0;
253            assert!(
254                zz.update(c(p, i.into())).is_none(),
255                "unexpected swing at i={i}"
256            );
257        }
258    }
259
260    #[test]
261    fn warmup_and_ready_lifecycle() {
262        let mut zz = ZigZag::new(0.10).unwrap();
263        assert!(!zz.is_ready());
264        assert_eq!(zz.warmup_period(), 2);
265        // Seeding the state is not emitting: readiness only begins with the
266        // first confirmed swing, which needs a threshold-sized reversal.
267        assert_eq!(zz.update(c_hl(100.0, 99.5, 0)), None);
268        assert!(!zz.is_ready());
269        assert_eq!(zz.update(c_hl(120.0, 119.5, 1)), None);
270        assert!(!zz.is_ready());
271        assert!(zz.update(c_hl(101.0, 100.0, 2)).is_some());
272        assert!(zz.is_ready());
273        zz.reset();
274        assert!(!zz.is_ready());
275    }
276
277    #[test]
278    fn reset_clears_state() {
279        let mut zz = ZigZag::new(0.10).unwrap();
280        let _ = zz.update(c_hl(100.0, 99.0, 0));
281        let _ = zz.update(c_hl(120.0, 119.0, 1));
282        zz.reset();
283        assert!(!zz.is_ready());
284        assert_eq!(zz.update(c_hl(110.0, 109.0, 0)), None);
285    }
286
287    #[test]
288    fn batch_equals_streaming() {
289        let candles: Vec<Candle> = (0..40)
290            .map(|i| {
291                let p = 100.0 + (i as f64 * 0.3).sin() * 15.0;
292                c(p, i)
293            })
294            .collect();
295        let mut a = ZigZag::new(0.05).unwrap();
296        let mut b = ZigZag::new(0.05).unwrap();
297        assert_eq!(
298            a.batch(&candles),
299            candles.iter().map(|x| b.update(*x)).collect::<Vec<_>>()
300        );
301    }
302
303    #[test]
304    fn accessors_and_metadata() {
305        let zz = ZigZag::new(0.05).unwrap();
306        assert!((zz.threshold() - 0.05).abs() < 1e-12);
307        assert_eq!(zz.warmup_period(), 2);
308        assert_eq!(zz.name(), "ZigZag");
309    }
310}