Skip to main content

finance_query/indicators/
zigzag.rs

1//! ZigZag indicator: filters out price moves smaller than a percentage
2//! threshold, connecting only the significant swing highs and lows.
3
4use super::{IndicatorError, Result};
5use serde::{Deserialize, Serialize};
6
7/// A single confirmed ZigZag swing point.
8#[derive(Debug, Clone, Copy, PartialEq, Serialize, Deserialize)]
9#[non_exhaustive]
10pub struct ZigZagPoint {
11    /// Index into the original `highs`/`lows` slices where this swing occurred.
12    pub index: usize,
13    /// Price at the pivot (the high or low that qualified as a swing point).
14    pub price: f64,
15    /// `true` for a swing high, `false` for a swing low.
16    pub is_high: bool,
17}
18
19/// Calculate ZigZag swing points from high/low series using a percentage
20/// reversal threshold.
21///
22/// Starting from the first bar, price must move by at least `deviation_pct`
23/// (e.g. `5.0` for 5%) away from the running extreme before a reversal is
24/// confirmed and a pivot recorded. Consecutive pivots always alternate
25/// between highs and lows. The final unconfirmed extreme (the most recent
26/// swing-in-progress) is included as the last point.
27///
28/// # Arguments
29///
30/// * `highs` - High prices
31/// * `lows` - Low prices
32/// * `deviation_pct` - Minimum reversal size as a percentage (e.g. `5.0` = 5%)
33///
34/// # Example
35///
36/// ```
37/// use finance_query::indicators::zigzag;
38///
39/// let highs = vec![100.0, 110.0, 90.0, 120.0, 80.0];
40/// let lows = vec![100.0, 110.0, 90.0, 120.0, 80.0];
41/// let pivots = zigzag(&highs, &lows, 5.0).unwrap();
42///
43/// assert_eq!(pivots.len(), 4);
44/// assert!(pivots[0].is_high);
45/// assert!(!pivots[1].is_high);
46/// ```
47pub fn zigzag(highs: &[f64], lows: &[f64], deviation_pct: f64) -> Result<Vec<ZigZagPoint>> {
48    if deviation_pct <= 0.0 {
49        return Err(IndicatorError::InvalidPeriod(
50            "deviation_pct must be greater than 0".to_string(),
51        ));
52    }
53    if highs.len() != lows.len() {
54        return Err(IndicatorError::InvalidPeriod(
55            "highs and lows must have the same length".to_string(),
56        ));
57    }
58    if highs.is_empty() {
59        return Err(IndicatorError::InsufficientData {
60            need: 2,
61            got: highs.len(),
62        });
63    }
64
65    let threshold = deviation_pct / 100.0;
66    let mut pivots = Vec::new();
67
68    let start_price = (highs[0] + lows[0]) / 2.0;
69    let mut trend: Option<bool> = None; // Some(true) = uptrend (tracking a high), Some(false) = downtrend
70    let mut extreme_idx = 0usize;
71    let mut extreme_price = start_price;
72
73    for i in 1..highs.len() {
74        match trend {
75            None => {
76                if highs[i] >= start_price * (1.0 + threshold) {
77                    trend = Some(true);
78                    extreme_idx = i;
79                    extreme_price = highs[i];
80                } else if lows[i] <= start_price * (1.0 - threshold) {
81                    trend = Some(false);
82                    extreme_idx = i;
83                    extreme_price = lows[i];
84                }
85            }
86            Some(true) => {
87                if highs[i] > extreme_price {
88                    extreme_price = highs[i];
89                    extreme_idx = i;
90                } else if lows[i] <= extreme_price * (1.0 - threshold) {
91                    pivots.push(ZigZagPoint {
92                        index: extreme_idx,
93                        price: extreme_price,
94                        is_high: true,
95                    });
96                    trend = Some(false);
97                    extreme_price = lows[i];
98                    extreme_idx = i;
99                }
100            }
101            Some(false) => {
102                if lows[i] < extreme_price {
103                    extreme_price = lows[i];
104                    extreme_idx = i;
105                } else if highs[i] >= extreme_price * (1.0 + threshold) {
106                    pivots.push(ZigZagPoint {
107                        index: extreme_idx,
108                        price: extreme_price,
109                        is_high: false,
110                    });
111                    trend = Some(true);
112                    extreme_price = highs[i];
113                    extreme_idx = i;
114                }
115            }
116        }
117    }
118
119    if let Some(is_high) = trend {
120        pivots.push(ZigZagPoint {
121            index: extreme_idx,
122            price: extreme_price,
123            is_high,
124        });
125    }
126
127    Ok(pivots)
128}
129
130#[cfg(test)]
131mod tests {
132    use super::*;
133
134    #[test]
135    fn test_zigzag_basic() {
136        let highs = vec![100.0, 110.0, 90.0, 120.0, 80.0];
137        let lows = vec![100.0, 110.0, 90.0, 120.0, 80.0];
138        let pivots = zigzag(&highs, &lows, 5.0).unwrap();
139
140        assert_eq!(pivots.len(), 4);
141        assert_eq!(
142            pivots[0],
143            ZigZagPoint {
144                index: 1,
145                price: 110.0,
146                is_high: true
147            }
148        );
149        assert_eq!(
150            pivots[1],
151            ZigZagPoint {
152                index: 2,
153                price: 90.0,
154                is_high: false
155            }
156        );
157        assert_eq!(
158            pivots[2],
159            ZigZagPoint {
160                index: 3,
161                price: 120.0,
162                is_high: true
163            }
164        );
165        assert_eq!(
166            pivots[3],
167            ZigZagPoint {
168                index: 4,
169                price: 80.0,
170                is_high: false
171            }
172        );
173    }
174
175    #[test]
176    fn test_zigzag_alternates() {
177        let highs = vec![100.0, 110.0, 90.0, 120.0, 80.0];
178        let lows = vec![100.0, 110.0, 90.0, 120.0, 80.0];
179        let pivots = zigzag(&highs, &lows, 5.0).unwrap();
180        for w in pivots.windows(2) {
181            assert_ne!(w[0].is_high, w[1].is_high, "pivots must alternate");
182        }
183    }
184
185    #[test]
186    fn test_zigzag_small_moves_filtered() {
187        // Moves within the threshold shouldn't produce intermediate pivots.
188        let highs = vec![100.0, 101.0, 100.5, 101.5, 130.0];
189        let lows = vec![100.0, 100.5, 100.0, 101.0, 129.0];
190        let pivots = zigzag(&highs, &lows, 10.0).unwrap();
191        // Only the final large move to 130 should register (plus possibly the trend start).
192        assert!(pivots.len() <= 2);
193    }
194
195    #[test]
196    fn test_zigzag_invalid_deviation() {
197        assert!(zigzag(&[1.0, 2.0], &[1.0, 2.0], 0.0).is_err());
198        assert!(zigzag(&[1.0, 2.0], &[1.0, 2.0], -1.0).is_err());
199    }
200
201    #[test]
202    fn test_zigzag_mismatched_lengths() {
203        assert!(zigzag(&[1.0, 2.0], &[1.0], 5.0).is_err());
204    }
205
206    #[test]
207    fn test_zigzag_empty() {
208        assert!(zigzag(&[], &[], 5.0).is_err());
209    }
210}