kestrel_chartkit/indicator/
zigzag_advanced.rs1use std::collections::VecDeque;
7
8use crate::model::Bar;
9use crate::timeframe::Timeframe;
10
11use super::smoothing::Rma;
12use super::{Indicator, IndicatorAlert, IndicatorOutput};
13
14#[derive(Debug, Clone, Copy, PartialEq)]
16pub enum ZigZagDeviationMode {
17 Percent(f64),
19 AtrMultiple(f64),
21}
22
23#[derive(Debug, Clone, Copy, PartialEq)]
25pub struct ZigZagNode {
26 pub timestamp: i64,
27 pub price: f64,
28 pub is_high: bool,
29 pub confirmed: bool,
33}
34
35pub struct AdvancedZigZagEngine {
37 depth: usize,
38 backstep: usize,
39 deviation: ZigZagDeviationMode,
40 atr: Rma,
41 prev_close: Option<f64>,
42 bars: VecDeque<Bar>,
43 bar_index: usize,
44 nodes: Vec<ZigZagNode>,
45 current_direction: i8,
46 last_confirmed_bar_index: Option<usize>,
47 alerts: Vec<IndicatorAlert>,
48}
49
50impl AdvancedZigZagEngine {
51 pub fn new(
52 depth: usize,
53 backstep: usize,
54 deviation: ZigZagDeviationMode,
55 atr_len: usize,
56 ) -> Self {
57 let depth = depth.max(1);
58 Self {
59 depth,
60 backstep,
61 deviation,
62 atr: Rma::new(atr_len.max(1)),
63 prev_close: None,
64 bars: VecDeque::with_capacity(depth * 2 + 1),
65 bar_index: 0,
66 nodes: Vec::new(),
67 current_direction: 0,
68 last_confirmed_bar_index: None,
69 alerts: Vec::new(),
70 }
71 }
72
73 pub fn with_defaults() -> Self {
74 Self::new(3, 2, ZigZagDeviationMode::Percent(1.0), 14)
75 }
76
77 pub fn nodes(&self) -> &[ZigZagNode] {
79 &self.nodes
80 }
81
82 pub fn current_leg(&self) -> Option<&ZigZagNode> {
85 self.nodes.last().filter(|n| !n.confirmed)
86 }
87
88 fn deviation_threshold(&self, atr: Option<f64>) -> f64 {
89 match self.deviation {
90 ZigZagDeviationMode::Percent(pct) => pct / 100.0,
91 ZigZagDeviationMode::AtrMultiple(mult) => {
92 match atr {
96 Some(a) if a > 0.0 => mult * a,
97 _ => f64::INFINITY, }
99 }
100 }
101 }
102
103 pub fn reduce(nodes: &[ZigZagNode], deviation_pct: f64) -> Vec<ZigZagNode> {
107 if nodes.is_empty() {
108 return Vec::new();
109 }
110 let threshold = deviation_pct / 100.0;
111 let mut reduced: Vec<ZigZagNode> = vec![nodes[0]];
112
113 for &node in &nodes[1..] {
114 let last = *reduced.last().expect("seeded with nodes[0]");
115 if node.is_high == last.is_high {
116 let replace = (node.is_high && node.price > last.price)
118 || (!node.is_high && node.price < last.price);
119 if replace {
120 *reduced.last_mut().unwrap() = node;
121 }
122 continue;
123 }
124
125 let change = if last.price != 0.0 {
126 (node.price - last.price).abs() / last.price.abs()
127 } else {
128 f64::INFINITY
129 };
130 if change >= threshold {
131 reduced.push(node);
132 }
133 }
134
135 reduced
136 }
137
138 pub fn project_to_timeframe(
142 nodes: &[ZigZagNode],
143 target_tf: Timeframe,
144 utc_offset_seconds: i32,
145 ) -> Vec<ZigZagNode> {
146 use std::collections::BTreeMap;
147
148 let mut buckets: BTreeMap<(i64, bool), ZigZagNode> = BTreeMap::new();
149 for &node in nodes {
150 let bucket = target_tf.bucket_start(node.timestamp, utc_offset_seconds);
151 let key = (bucket, node.is_high);
152 buckets
153 .entry(key)
154 .and_modify(|existing| {
155 let more_extreme = (node.is_high && node.price > existing.price)
156 || (!node.is_high && node.price < existing.price);
157 if more_extreme {
158 *existing = node;
159 }
160 })
161 .or_insert(node);
162 }
163
164 let mut projected: Vec<ZigZagNode> = buckets.into_values().collect();
165 projected.sort_by_key(|n| n.timestamp);
166 projected
167 }
168}
169
170impl Indicator for AdvancedZigZagEngine {
171 fn name(&self) -> &str {
172 "zigzag_advanced"
173 }
174
175 fn warmup_period(&self) -> usize {
176 self.depth * 2 + 1
177 }
178
179 fn reset(&mut self) {
180 self.atr.reset();
181 self.prev_close = None;
182 self.bars.clear();
183 self.bar_index = 0;
184 self.nodes.clear();
185 self.current_direction = 0;
186 self.last_confirmed_bar_index = None;
187 self.alerts.clear();
188 }
189
190 fn on_bar(&mut self, bar: &Bar) -> Option<IndicatorOutput> {
191 self.alerts.clear();
192
193 let tr = match self.prev_close {
194 Some(pc) => (bar.high - bar.low)
195 .max((bar.high - pc).abs())
196 .max((bar.low - pc).abs()),
197 None => bar.high - bar.low,
198 };
199 self.prev_close = Some(bar.close);
200 let atr = self.atr.update(tr);
201
202 self.bars.push_back(bar.clone());
203 if self.bars.len() > self.depth * 2 + 1 {
204 self.bars.pop_front();
205 }
206 let current_bar_index = self.bar_index;
207 self.bar_index += 1;
208
209 if self.bars.len() < self.depth * 2 + 1 {
210 return None;
211 }
212
213 let mid_idx = self.depth;
214 let mid_bar = self.bars[mid_idx].clone();
215 let mid_bar_index = current_bar_index - self.depth;
216
217 let is_pivot_high = self
218 .bars
219 .iter()
220 .enumerate()
221 .all(|(i, b)| i == mid_idx || b.high <= mid_bar.high);
222 let is_pivot_low = self
223 .bars
224 .iter()
225 .enumerate()
226 .all(|(i, b)| i == mid_idx || b.low >= mid_bar.low);
227
228 let threshold = self.deviation_threshold(atr);
229
230 let backstep_ok = self
231 .last_confirmed_bar_index
232 .map(|last| mid_bar_index >= last + self.backstep)
233 .unwrap_or(true);
234
235 if is_pivot_high {
236 self.try_extend(
237 true,
238 mid_bar.high,
239 mid_bar.timestamp,
240 mid_bar_index,
241 threshold,
242 backstep_ok,
243 );
244 }
245 if is_pivot_low {
246 self.try_extend(
247 false,
248 mid_bar.low,
249 mid_bar.timestamp,
250 mid_bar_index,
251 threshold,
252 backstep_ok,
253 );
254 }
255
256 let leg_price = self.nodes.last().map(|n| n.price).unwrap_or(mid_bar.close);
257 Some(
258 IndicatorOutput::new(leg_price).with_state(if self.current_leg().is_some() {
259 "running"
260 } else {
261 "confirmed"
262 }),
263 )
264 }
265
266 fn alerts(&self) -> Vec<IndicatorAlert> {
267 self.alerts.clone()
268 }
269}
270
271impl AdvancedZigZagEngine {
272 #[allow(clippy::too_many_arguments)]
273 fn try_extend(
274 &mut self,
275 is_high: bool,
276 price: f64,
277 timestamp: i64,
278 bar_index: usize,
279 threshold: f64,
280 backstep_ok: bool,
281 ) {
282 let opposite_direction = if is_high { 1 } else { -1 };
283
284 if self.current_direction == opposite_direction || self.current_direction == 0 {
285 let should_replace = match self.nodes.last() {
287 Some(last) if !last.confirmed && last.is_high == is_high => {
288 (is_high && price > last.price) || (!is_high && price < last.price)
289 }
290 _ => true,
291 };
292 if should_replace {
293 if let Some(last) = self
294 .nodes
295 .last_mut()
296 .filter(|n| !n.confirmed && n.is_high == is_high)
297 {
298 *last = ZigZagNode {
299 timestamp,
300 price,
301 is_high,
302 confirmed: false,
303 };
304 } else {
305 self.nodes.push(ZigZagNode {
306 timestamp,
307 price,
308 is_high,
309 confirmed: false,
310 });
311 }
312 self.current_direction = opposite_direction;
313 }
314 return;
315 }
316
317 let last_price = self.nodes.last().map(|n| n.price);
320 let change = match last_price {
321 Some(lp) if lp != 0.0 => (price - lp).abs() / lp.abs(),
322 _ => f64::INFINITY,
323 };
324
325 if change >= threshold && backstep_ok {
326 if let Some(last) = self.nodes.last_mut() {
327 last.confirmed = true;
328 }
329 self.nodes.push(ZigZagNode {
330 timestamp,
331 price,
332 is_high,
333 confirmed: false,
334 });
335 self.current_direction = opposite_direction;
336 self.last_confirmed_bar_index = Some(bar_index);
337 self.alerts.push(IndicatorAlert::new(
338 "zigzag_pivot_confirmed",
339 if is_high {
340 "ZigZag confirmed a swing low"
341 } else {
342 "ZigZag confirmed a swing high"
343 },
344 0.6,
345 ));
346 }
347 }
348}
349
350#[cfg(test)]
351mod tests {
352 use super::*;
353
354 fn sine_bars(n: usize) -> Vec<Bar> {
355 (0..n)
356 .map(|i| {
357 let price = if (i / 5) % 2 == 0 {
358 100.0 + (i % 5) as f64 * 4.0
359 } else {
360 120.0 - (i % 5) as f64 * 4.0
361 };
362 Bar::new(i as i64 * 60, price, price + 1.0, price - 1.0, price, 100.0)
363 })
364 .collect()
365 }
366
367 #[test]
368 fn test_produces_confirmed_and_running_nodes() {
369 let mut engine = AdvancedZigZagEngine::new(2, 1, ZigZagDeviationMode::Percent(1.0), 5);
370 for bar in sine_bars(40) {
371 engine.on_bar(&bar);
372 }
373 assert!(!engine.nodes().is_empty());
374 assert!(engine.nodes().iter().any(|n| n.confirmed));
375 }
376
377 #[test]
378 fn test_backstep_suppresses_pivots_too_close_together() {
379 let lenient = {
380 let mut e = AdvancedZigZagEngine::new(2, 0, ZigZagDeviationMode::Percent(0.01), 5);
381 for bar in sine_bars(40) {
382 e.on_bar(&bar);
383 }
384 e.nodes().iter().filter(|n| n.confirmed).count()
385 };
386 let strict = {
387 let mut e = AdvancedZigZagEngine::new(2, 20, ZigZagDeviationMode::Percent(0.01), 5);
388 for bar in sine_bars(40) {
389 e.on_bar(&bar);
390 }
391 e.nodes().iter().filter(|n| n.confirmed).count()
392 };
393 assert!(
394 strict <= lenient,
395 "a large backstep must never confirm more pivots than a near-zero one"
396 );
397 }
398
399 #[test]
400 fn test_atr_mode_requires_warm_atr_before_confirming() {
401 let mut engine =
402 AdvancedZigZagEngine::new(2, 0, ZigZagDeviationMode::AtrMultiple(0.5), 100);
403 for bar in sine_bars(20) {
404 engine.on_bar(&bar);
405 }
406 assert!(engine.nodes().iter().all(|n| !n.confirmed));
409 }
410
411 #[test]
412 fn test_reduce_produces_a_coarser_recursive_level() {
413 let base = vec![
414 ZigZagNode {
415 timestamp: 0,
416 price: 100.0,
417 is_high: false,
418 confirmed: true,
419 },
420 ZigZagNode {
421 timestamp: 1,
422 price: 102.0,
423 is_high: true,
424 confirmed: true,
425 },
426 ZigZagNode {
427 timestamp: 2,
428 price: 101.0,
429 is_high: false,
430 confirmed: true,
431 },
432 ZigZagNode {
433 timestamp: 3,
434 price: 110.0,
435 is_high: true,
436 confirmed: true,
437 },
438 ZigZagNode {
439 timestamp: 4,
440 price: 95.0,
441 is_high: false,
442 confirmed: true,
443 },
444 ];
445 let coarse = AdvancedZigZagEngine::reduce(&base, 5.0);
448 assert!(coarse.len() < base.len());
449 assert_eq!(coarse.first().unwrap().price, 100.0);
450 assert_eq!(coarse.last().unwrap().price, 95.0);
451 }
452
453 #[test]
454 fn test_project_to_timeframe_keeps_most_extreme_per_bucket() {
455 let nodes = vec![
456 ZigZagNode {
457 timestamp: 0,
458 price: 100.0,
459 is_high: true,
460 confirmed: true,
461 },
462 ZigZagNode {
463 timestamp: 60,
464 price: 105.0,
465 is_high: true,
466 confirmed: true,
467 },
468 ZigZagNode {
469 timestamp: 120,
470 price: 102.0,
471 is_high: true,
472 confirmed: true,
473 },
474 ];
475 let projected = AdvancedZigZagEngine::project_to_timeframe(&nodes, Timeframe::Minute(5), 0);
477 assert_eq!(projected.len(), 1);
478 assert_eq!(
479 projected[0].price, 105.0,
480 "must keep the highest high within the bucket"
481 );
482 }
483}