Skip to main content

kestrel_chartkit/indicator/
zigzag_advanced.rs

1//! Advanced ZigZag: backstep, an ATR-scaled deviation mode, an explicitly exposed running
2//! (unconfirmed) leg, per-node confirmation status, recursive/dual-degree levels, and
3//! higher-timeframe projection — the capabilities [`super::zigzag::ZigZagEngine`]'s fixed-depth,
4//! percent-only pivot detector does not offer. Complements rather than replaces it.
5
6use std::collections::VecDeque;
7
8use crate::model::Bar;
9use crate::timeframe::Timeframe;
10
11use super::smoothing::Rma;
12use super::{Indicator, IndicatorAlert, IndicatorOutput};
13
14/// How the minimum-swing deviation threshold is expressed.
15#[derive(Debug, Clone, Copy, PartialEq)]
16pub enum ZigZagDeviationMode {
17    /// Percent of the prior pivot's price.
18    Percent(f64),
19    /// Multiple of the engine's internal ATR.
20    AtrMultiple(f64),
21}
22
23/// A single ZigZag swing point.
24#[derive(Debug, Clone, Copy, PartialEq)]
25pub struct ZigZagNode {
26    pub timestamp: i64,
27    pub price: f64,
28    pub is_high: bool,
29    /// `false` while this node is still the extreme of the currently-forming leg (may still move
30    /// as new bars extend it); `true` once price has reversed past the deviation threshold,
31    /// permanently fixing this node.
32    pub confirmed: bool,
33}
34
35/// Advanced ZigZag engine with backstep, ATR-mode deviation, and explicit confirmation status.
36pub 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    /// Confirmed swing history, oldest first.
78    pub fn nodes(&self) -> &[ZigZagNode] {
79        &self.nodes
80    }
81
82    /// The still-forming leg's current extreme, if any (may not yet be in [`Self::nodes`] or may
83    /// be the unconfirmed last entry there).
84    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                // Expressed as a fraction of price for uniform comparison with the Percent mode;
93                // callers using ATR mode should compare `mult * atr` directly if they need the
94                // absolute price distance instead.
95                match atr {
96                    Some(a) if a > 0.0 => mult * a,
97                    _ => f64::INFINITY, // ATR not warmed up yet: no pivot can confirm
98                }
99            }
100        }
101    }
102
103    /// Recursively re-simplifies an already-reduced node sequence at a coarser deviation
104    /// threshold, the "dual-/recursive levels" swing-degree technique: apply the same
105    /// alternating-extreme simplification to the higher-degree input instead of raw bars.
106    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                // Same-type extreme: keep whichever is more extreme.
117                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    /// Projects a node sequence onto a higher timeframe's bucket grid, keeping only the most
139    /// extreme high/low node per bucket per side — the confirmed HTF-equivalent swing points an
140    /// LTF zigzag implies.
141    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            // Extending/starting a leg in this direction: update the running (unconfirmed) node.
286            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        // Opposite-direction pivot: only confirms the running leg (and starts a new one) once it
318        // clears both the deviation threshold and the backstep spacing from the last confirmation.
319        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        // ATR (len=100) never warms up within 20 bars, so the deviation threshold stays
407        // infinite and no pivot can confirm.
408        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        // A large deviation must collapse the small 100->102->101 wiggle, keeping only the
446        // genuinely large swings.
447        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        // All three fall inside the same 5-minute (300s) bucket starting at t=0.
476        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}