1use crate::error::{Error, Result};
4use crate::ohlcv::Candle;
5use crate::traits::Indicator;
6
7#[derive(Debug, Clone, Copy, PartialEq)]
14pub struct ZigZagOutput {
15 pub swing: f64,
17 pub direction: f64,
19}
20
21#[derive(Debug, Clone)]
55pub struct ZigZag {
56 threshold: f64,
57 state: Option<State>,
58 has_emitted: bool,
62}
63
64#[derive(Debug, Clone, Copy)]
65struct State {
66 direction: f64,
69 extreme: f64,
71}
72
73impl ZigZag {
74 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 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 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 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 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 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 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 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 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 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)); let _ = zz.update(c_hl(91.0, 90.5, 3));
239 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 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 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}