Skip to main content

kestrel_chartkit/indicator/
zigzag.rs

1use super::{Indicator, IndicatorAlert, IndicatorOutput};
2use crate::model::Bar;
3use std::collections::{HashMap, VecDeque};
4
5#[cfg(feature = "serde")]
6use serde::{Deserialize, Serialize};
7
8/// ZigZag Swing Node.
9#[derive(Debug, Clone, Copy, PartialEq)]
10#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))]
11pub struct ZigZagNode {
12    pub timestamp: i64,
13    pub price: f64,
14    pub is_high: bool,
15}
16
17/// Advanced ZigZag Engine tracking multi-pivot swing legs and trend reversals.
18#[derive(Debug, Clone)]
19pub struct ZigZagEngine {
20    depth: usize,
21    deviation_pct: f64,
22    bars: VecDeque<Bar>,
23    nodes: Vec<ZigZagNode>,
24    current_direction: i8, // 1 = Bullish leg (up), -1 = Bearish leg (down)
25    last_pivot_price: f64,
26    last_pivot_ts: i64,
27}
28
29impl ZigZagEngine {
30    pub fn new(depth: usize, deviation_pct: f64) -> Self {
31        Self {
32            depth: depth.max(2),
33            deviation_pct: deviation_pct.max(0.0001),
34            bars: VecDeque::with_capacity(depth * 2),
35            nodes: Vec::new(),
36            current_direction: 0,
37            last_pivot_price: 0.0,
38            last_pivot_ts: 0,
39        }
40    }
41
42    pub fn with_defaults() -> Self {
43        Self::new(12, 5.0) // 12 depth, 5.0% deviation
44    }
45
46    pub fn nodes(&self) -> &[ZigZagNode] {
47        &self.nodes
48    }
49}
50
51impl Indicator for ZigZagEngine {
52    fn name(&self) -> &str {
53        "zigzag"
54    }
55
56    fn warmup_period(&self) -> usize {
57        self.depth * 2 + 1
58    }
59
60    fn reset(&mut self) {
61        self.bars.clear();
62        self.nodes.clear();
63        self.current_direction = 0;
64        self.last_pivot_price = 0.0;
65        self.last_pivot_ts = 0;
66    }
67
68    fn on_bar(&mut self, bar: &Bar) -> Option<IndicatorOutput> {
69        self.bars.push_back(bar.clone());
70        if self.bars.len() > self.depth * 2 + 1 {
71            self.bars.pop_front();
72        }
73
74        if self.bars.len() < self.depth * 2 + 1 {
75            return None;
76        }
77
78        let mid_idx = self.depth;
79        let mid_bar = &self.bars[mid_idx];
80
81        let is_pivot_high = self
82            .bars
83            .iter()
84            .enumerate()
85            .all(|(i, b)| i == mid_idx || b.high <= mid_bar.high);
86        let is_pivot_low = self
87            .bars
88            .iter()
89            .enumerate()
90            .all(|(i, b)| i == mid_idx || b.low >= mid_bar.low);
91
92        let dev_thresh = self.deviation_pct / 100.0;
93
94        if is_pivot_high {
95            if self.current_direction <= 0 {
96                let change = if self.last_pivot_price > 0.0 {
97                    (mid_bar.high - self.last_pivot_price) / self.last_pivot_price
98                } else {
99                    1.0
100                };
101                if change >= dev_thresh || self.current_direction == 0 {
102                    self.current_direction = 1;
103                    self.last_pivot_price = mid_bar.high;
104                    self.last_pivot_ts = mid_bar.timestamp;
105                    self.nodes.push(ZigZagNode {
106                        timestamp: mid_bar.timestamp,
107                        price: mid_bar.high,
108                        is_high: true,
109                    });
110                }
111            } else if mid_bar.high > self.last_pivot_price {
112                // Update higher high node
113                self.last_pivot_price = mid_bar.high;
114                self.last_pivot_ts = mid_bar.timestamp;
115                if let Some(last_node) = self.nodes.last_mut() {
116                    if last_node.is_high {
117                        last_node.timestamp = mid_bar.timestamp;
118                        last_node.price = mid_bar.high;
119                    }
120                }
121            }
122        }
123
124        if is_pivot_low {
125            if self.current_direction >= 0 {
126                let change = if self.last_pivot_price > 0.0 {
127                    (self.last_pivot_price - mid_bar.low) / self.last_pivot_price
128                } else {
129                    1.0
130                };
131                if change >= dev_thresh || self.current_direction == 0 {
132                    self.current_direction = -1;
133                    self.last_pivot_price = mid_bar.low;
134                    self.last_pivot_ts = mid_bar.timestamp;
135                    self.nodes.push(ZigZagNode {
136                        timestamp: mid_bar.timestamp,
137                        price: mid_bar.low,
138                        is_high: false,
139                    });
140                }
141            } else if mid_bar.low < self.last_pivot_price {
142                // Update lower low node
143                self.last_pivot_price = mid_bar.low;
144                self.last_pivot_ts = mid_bar.timestamp;
145                if let Some(last_node) = self.nodes.last_mut() {
146                    if !last_node.is_high {
147                        last_node.timestamp = mid_bar.timestamp;
148                        last_node.price = mid_bar.low;
149                    }
150                }
151            }
152        }
153
154        let mut extra = HashMap::new();
155        extra.insert("direction".to_string(), self.current_direction as f64);
156        extra.insert("last_pivot_price".to_string(), self.last_pivot_price);
157
158        Some(IndicatorOutput::with_extra(self.last_pivot_price, extra))
159    }
160
161    fn alerts(&self) -> Vec<IndicatorAlert> {
162        Vec::new()
163    }
164}
165
166#[cfg(test)]
167mod tests {
168    use super::*;
169
170    #[test]
171    fn test_zigzag_nodes() {
172        let mut zz = ZigZagEngine::new(3, 1.0);
173        for i in 0..30 {
174            let price = if (i / 5) % 2 == 0 {
175                100.0 + (i % 5) as f64 * 2.0
176            } else {
177                110.0 - (i % 5) as f64 * 2.0
178            };
179            let bar = Bar::new(i as i64, price, price + 1.0, price - 1.0, price, 1000.0);
180            zz.on_bar(&bar);
181        }
182        assert!(!zz.nodes().is_empty());
183    }
184}