wickra_core/indicators/
zig_zag.rs1use 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}
59
60#[derive(Debug, Clone, Copy)]
61struct State {
62 direction: f64,
65 extreme: f64,
67}
68
69impl ZigZag {
70 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 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 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 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 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 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 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 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 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)); let _ = zz.update(c_hl(91.0, 90.5, 3));
226 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 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}