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}
59
60#[derive(Debug, Clone, Copy)]
61struct State {
62    /// Direction of the running trend: `+1.0` (uptrend tracking a pivot high)
63    /// or `-1.0` (downtrend tracking a pivot low).
64    direction: f64,
65    /// The current candidate extreme price (the running pivot).
66    extreme: f64,
67}
68
69impl ZigZag {
70    /// Construct a new `ZigZag` with a fractional reversal threshold (e.g. `0.05`
71    /// for a 5% swing).
72    ///
73    /// # Errors
74    /// Returns [`Error::InvalidPeriod`] if `threshold` is not in `(0.0, 1.0)`
75    /// or is not finite.
76    pub fn new(threshold: f64) -> Result<Self> {
77        if !threshold.is_finite() || threshold <= 0.0 || threshold >= 1.0 {
78            return Err(Error::InvalidPeriod {
79                message: "ZigZag threshold must be a finite fraction in (0, 1)",
80            });
81        }
82        Ok(Self {
83            threshold,
84            state: None,
85        })
86    }
87
88    /// Configured reversal threshold (fractional).
89    pub const fn threshold(&self) -> f64 {
90        self.threshold
91    }
92}
93
94impl Indicator for ZigZag {
95    type Input = Candle;
96    type Output = ZigZagOutput;
97
98    fn update(&mut self, candle: Candle) -> Option<ZigZagOutput> {
99        let Some(s) = self.state else {
100            // Bootstrap: seed an uptrend tracking the first candle's high.
101            self.state = Some(State {
102                direction: 1.0,
103                extreme: candle.high,
104            });
105            return None;
106        };
107
108        if s.direction > 0.0 {
109            // Uptrend: keep raising the candidate high; confirm reversal if
110            // the candle's low has dropped by threshold from the candidate.
111            if candle.high > s.extreme {
112                self.state = Some(State {
113                    direction: 1.0,
114                    extreme: candle.high,
115                });
116                return None;
117            }
118            if candle.low <= s.extreme * (1.0 - self.threshold) {
119                // Confirm the swing high; flip to downtrend tracking this bar's low.
120                let confirmed = ZigZagOutput {
121                    swing: s.extreme,
122                    direction: 1.0,
123                };
124                self.state = Some(State {
125                    direction: -1.0,
126                    extreme: candle.low,
127                });
128                return Some(confirmed);
129            }
130            None
131        } else {
132            // Downtrend: lower the candidate low; confirm reversal if the
133            // candle's high has risen by threshold from the candidate.
134            if candle.low < s.extreme {
135                self.state = Some(State {
136                    direction: -1.0,
137                    extreme: candle.low,
138                });
139                return None;
140            }
141            if candle.high >= s.extreme * (1.0 + self.threshold) {
142                let confirmed = ZigZagOutput {
143                    swing: s.extreme,
144                    direction: -1.0,
145                };
146                self.state = Some(State {
147                    direction: 1.0,
148                    extreme: candle.high,
149                });
150                return Some(confirmed);
151            }
152            None
153        }
154    }
155
156    fn reset(&mut self) {
157        self.state = None;
158    }
159
160    fn warmup_period(&self) -> usize {
161        // Bootstrap takes one bar; confirmation of the first swing needs at
162        // least one more move past the threshold. Best-case the first swing
163        // lands on the second bar.
164        2
165    }
166
167    fn is_ready(&self) -> bool {
168        self.state.is_some()
169    }
170
171    fn name(&self) -> &'static str {
172        "ZigZag"
173    }
174}
175
176#[cfg(test)]
177mod tests {
178    use super::*;
179    use crate::traits::BatchExt;
180
181    fn c(price: f64, ts: i64) -> Candle {
182        Candle::new(price, price + 0.001, price - 0.001, price, 1.0, ts).unwrap()
183    }
184
185    fn c_hl(h: f64, l: f64, ts: i64) -> Candle {
186        Candle::new(l, h, l, l, 1.0, ts).unwrap()
187    }
188
189    #[test]
190    fn rejects_invalid_threshold() {
191        assert!(ZigZag::new(0.0).is_err());
192        assert!(ZigZag::new(-0.1).is_err());
193        assert!(ZigZag::new(1.0).is_err());
194        assert!(ZigZag::new(f64::NAN).is_err());
195        assert!(ZigZag::new(f64::INFINITY).is_err());
196    }
197
198    #[test]
199    fn first_bar_only_bootstraps() {
200        let mut zz = ZigZag::new(0.05).unwrap();
201        assert_eq!(zz.update(c(100.0, 0)), None);
202        assert!(zz.is_ready());
203    }
204
205    #[test]
206    fn confirms_high_swing_on_threshold_drop() {
207        let mut zz = ZigZag::new(0.10).unwrap();
208        // Up to a peak of 120, then a drop to 100 = 16.7% reversal → confirms.
209        let _ = zz.update(c_hl(100.0, 99.5, 0));
210        let _ = zz.update(c_hl(120.0, 119.5, 1));
211        let confirmed = zz.update(c_hl(101.0, 100.0, 2));
212        let o = confirmed.expect("the third bar's drop triggers confirmation");
213        assert!((o.swing - 120.0).abs() < 1e-9);
214        assert_eq!(o.direction, 1.0);
215    }
216
217    #[test]
218    fn confirms_low_swing_on_threshold_rise() {
219        let mut zz = ZigZag::new(0.10).unwrap();
220        // Up to 120 to seed the high pivot, drop to confirm it as a high,
221        // then rise from the new low pivot by 10% to confirm it as a low.
222        let _ = zz.update(c_hl(100.0, 99.5, 0));
223        let _ = zz.update(c_hl(120.0, 119.5, 1));
224        let _ = zz.update(c_hl(101.0, 90.0, 2)); // drop confirms 120-high; new low 90.
225        let _ = zz.update(c_hl(91.0, 90.5, 3));
226        // Rise to 100 from low 90 = 11.1% → confirms low.
227        let confirmed = zz.update(c_hl(100.0, 99.0, 4));
228        let o = confirmed.expect("the rise confirms the low swing");
229        assert!((o.swing - 90.0).abs() < 1e-9);
230        assert_eq!(o.direction, -1.0);
231    }
232
233    #[test]
234    fn small_oscillations_yield_no_swings() {
235        let mut zz = ZigZag::new(0.20).unwrap();
236        let _ = zz.update(c(100.0, 0));
237        for i in 1..20 {
238            // Bounce around 100 ± 5; never crosses the 20% threshold.
239            let p = 100.0 + ((f64::from(i)) * 0.3).sin() * 5.0;
240            assert!(
241                zz.update(c(p, i.into())).is_none(),
242                "unexpected swing at i={i}"
243            );
244        }
245    }
246
247    #[test]
248    fn warmup_and_ready_lifecycle() {
249        let mut zz = ZigZag::new(0.05).unwrap();
250        assert!(!zz.is_ready());
251        assert_eq!(zz.warmup_period(), 2);
252        zz.update(c(100.0, 0));
253        assert!(zz.is_ready());
254    }
255
256    #[test]
257    fn reset_clears_state() {
258        let mut zz = ZigZag::new(0.10).unwrap();
259        let _ = zz.update(c_hl(100.0, 99.0, 0));
260        let _ = zz.update(c_hl(120.0, 119.0, 1));
261        zz.reset();
262        assert!(!zz.is_ready());
263        assert_eq!(zz.update(c_hl(110.0, 109.0, 0)), None);
264    }
265
266    #[test]
267    fn batch_equals_streaming() {
268        let candles: Vec<Candle> = (0..40)
269            .map(|i| {
270                let p = 100.0 + (i as f64 * 0.3).sin() * 15.0;
271                c(p, i)
272            })
273            .collect();
274        let mut a = ZigZag::new(0.05).unwrap();
275        let mut b = ZigZag::new(0.05).unwrap();
276        assert_eq!(
277            a.batch(&candles),
278            candles.iter().map(|x| b.update(*x)).collect::<Vec<_>>()
279        );
280    }
281
282    #[test]
283    fn accessors_and_metadata() {
284        let zz = ZigZag::new(0.05).unwrap();
285        assert!((zz.threshold() - 0.05).abs() < 1e-12);
286        assert_eq!(zz.warmup_period(), 2);
287        assert_eq!(zz.name(), "ZigZag");
288    }
289}