kestrel_chartkit/indicator/
zigzag.rs1use super::{Indicator, IndicatorAlert, IndicatorOutput};
2use crate::model::Bar;
3use std::collections::{HashMap, VecDeque};
4
5#[cfg(feature = "serde")]
6use serde::{Deserialize, Serialize};
7
8#[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#[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, 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) }
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 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 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}