Skip to main content

egui_charts/studies/builtin/
zigzag.rs

1use super::clamp_param;
2use crate::model::Bar;
3use crate::scales::StudyFormat;
4use crate::studies::palette;
5use crate::studies::{Indicator, IndicatorValue, IndicatorValues, LabelPlacement, StudyLabel};
6use egui::Color32;
7
8/// ZigZag mode - what price to use
9#[derive(Clone, Copy, Debug, PartialEq, Eq, Default)]
10pub enum ZigZagMode {
11    /// Use high/low for zigzag points
12    #[default]
13    HighLow,
14    /// Use close price only
15    Close,
16    /// Use typical price (HLC/3)
17    Typical,
18}
19
20/// How a Zig Zag label writes the change from the previous pivot.
21#[derive(Clone, Copy, Debug, PartialEq, Eq, Default)]
22#[non_exhaustive]
23pub enum ZigZagChange {
24    /// The price difference, signed.
25    Price,
26    /// The percentage of the previous pivot's price, signed.
27    #[default]
28    Percent,
29}
30
31/// One swing extreme in source-bar order. The last pivot is provisional and can move.
32#[derive(Clone, Copy, Debug, PartialEq)]
33pub struct ZigZagPivot {
34    /// Index in the bars passed to [`Indicator::calculate`], including skipped bars.
35    pub bar_index: usize,
36    /// Price of the swing extreme.
37    pub price: f64,
38    /// Whether this is a high; highs and lows alternate.
39    pub is_high: bool,
40}
41
42/// Zig Zag: the swings of at least `deviation_percent` (Merrill's filtered waves), drawn
43/// as straight lines between consecutive pivots.
44///
45/// - A bar whose prices in the chosen mode are not finite has no value and is not counted in
46///   `depth`, as if it were not in the series. A bar whose prices are not positive is skipped:
47///   it is never a pivot and never confirms one, and a leg is drawn across it. A series with no
48///   positive prices has no values, because the percentage filter needs positive prices.
49///   A deviation of 0 filters nothing, so it gives no values either.
50/// - Until the first reversal the study tracks the highest high and the lowest low. A rise
51///   of `deviation_percent` from the lowest low confirms that low as the first pivot; a fall
52///   from the highest high confirms that high. When one bar confirms both, the earlier
53///   extreme is the first pivot, and the low when both are on one bar.
54/// - Each bar first extends the current leg (a higher high in an up leg, a lower low in a
55///   down leg) and only otherwise is tested for a reversal. A reversal needs at least
56///   `depth` bars after the extreme it is measured from, so an outside bar, which both
57///   extends the leg and reverses from the old extreme, extends the leg.
58/// - Pivots sit on the swing extremes. When a reversal is confirmed, the new leg starts at
59///   the lowest low (for a down leg) or the highest high (for an up leg) since the pivot it
60///   reverses from, which may be before the confirming bar, and the bars after that extreme
61///   up to the confirming bar are judged against the new leg as if it had started there,
62///   so a reversal they already confirm is drawn too. `depth` is the minimum number of bars
63///   from a pivot to the bar that confirms the next reversal, not the distance between
64///   pivots: two pivots can be fewer than `depth` bars apart, and a swing shorter than
65///   `depth` bars is drawn when a later bar confirms it.
66/// - The last pivot is the extreme of the current leg, which moves while price extends it.
67///   Bars before the first pivot and after the last have no value.
68/// - Optional labels show each pivot's price, change and leg volume; the last leg, whose end
69///   still moves, is drawn dashed.
70#[derive(Clone)]
71pub struct ZigZag {
72    /// Min percentage change to register a swing
73    deviation_percent: f64,
74    /// Min number of bars from a pivot to the bar that confirms the next reversal
75    depth: usize,
76    /// Price mode
77    mode: ZigZagMode,
78    values: IndicatorValues,
79    pivots: Vec<ZigZagPivot>,
80    source_len: usize,
81    finite: Vec<usize>,
82    prices: Vec<(usize, f64, f64)>,
83    completed: Vec<((usize, f64), bool)>,
84    detector: Detector,
85    before_last: Checkpoint,
86    color: Color32,
87    visible: bool,
88    price_labels: bool,
89    change_labels: bool,
90    change_mode: ZigZagChange,
91    volume_labels: bool,
92    /// The instrument's decimals and those of the study's presentation settings.
93    precision: Option<usize>,
94    label_precision: Option<usize>,
95    labels: Vec<StudyLabel>,
96}
97
98#[derive(Clone, Copy, Default)]
99struct Detector {
100    extremes: Option<((usize, f64), (usize, f64))>,
101    trend: Option<(bool, (usize, f64))>,
102}
103
104#[derive(Clone, Default)]
105struct Checkpoint {
106    detector: Detector,
107    finite: usize,
108    prices: usize,
109    completed: usize,
110}
111
112impl ZigZag {
113    /// `deviation_percent` is clamped into 0 to `f64::MAX`, NaN to 0; at 0 there are no values.
114    pub fn new(deviation_percent: f64, depth: usize) -> Self {
115        Self {
116            deviation_percent: clamp_param(deviation_percent, 0.0, f64::MAX),
117            depth: depth.max(1),
118            mode: ZigZagMode::HighLow,
119            values: IndicatorValues::new(),
120            pivots: Vec::new(),
121            source_len: 0,
122            finite: Vec::new(),
123            prices: Vec::new(),
124            completed: Vec::new(),
125            detector: Detector::default(),
126            before_last: Checkpoint::default(),
127            color: palette::BLUE,
128            visible: true,
129            price_labels: false,
130            change_labels: false,
131            change_mode: ZigZagChange::Percent,
132            volume_labels: false,
133            precision: None,
134            label_precision: None,
135            labels: Vec::new(),
136        }
137    }
138
139    /// Swing extremes used to draw the study, in source-bar order.
140    /// All except the last are confirmed; the last follows the current leg.
141    pub fn pivots(&self) -> &[ZigZagPivot] {
142        &self.pivots
143    }
144
145    /// Select high/low, close or typical prices for reversal detection.
146    pub fn with_mode(mut self, mode: ZigZagMode) -> Self {
147        self.mode = mode;
148        self
149    }
150
151    /// Set the plot color.
152    pub fn with_color(mut self, color: Color32) -> Self {
153        self.color = color;
154        self
155    }
156
157    /// Labels each pivot, above highs and below lows, with its price, its change from the
158    /// previous pivot (`None` leaves it out) and the volume of the leg that ends at it, from the
159    /// bar after the previous pivot through the pivot's bar. No labels by default.
160    pub fn with_labels(mut self, price: bool, change: Option<ZigZagChange>, volume: bool) -> Self {
161        self.price_labels = price;
162        self.change_labels = change.is_some();
163        self.change_mode = change.unwrap_or(self.change_mode);
164        self.volume_labels = volume;
165        self
166    }
167
168    /// Rewrites the labels of the pivots from `fixed` on: the earlier ones and their legs are
169    /// final, and the last one moves.
170    fn refresh_labels(&mut self, data: &[Bar], fixed: usize) {
171        if !(self.price_labels || self.change_labels || self.volume_labels) {
172            self.labels.clear();
173            return;
174        }
175        let kept = fixed.checked_sub(1).map_or(0, |last| {
176            let bar = self.pivots[last].bar_index;
177            self.labels.partition_point(|label| label.bar <= bar)
178        });
179        self.labels.truncate(kept);
180        let precision = self.label_precision.or(self.precision);
181        for (k, pivot) in self.pivots.iter().enumerate().skip(fixed) {
182            let mut lines = Vec::new();
183            if self.price_labels {
184                lines.push(StudyFormat::Price.format(pivot.price, precision));
185            }
186            if let Some(previous) = k.checked_sub(1).map(|k| self.pivots[k]) {
187                let change = pivot.price - previous.price;
188                let sign = if change > 0.0 { "+" } else { "" };
189                if self.change_labels {
190                    lines.push(match self.change_mode {
191                        ZigZagChange::Price => {
192                            format!("{sign}{}", StudyFormat::Price.format(change, precision))
193                        }
194                        ZigZagChange::Percent => {
195                            let percent = change / previous.price * 100.0;
196                            format!("{sign}{}", StudyFormat::Percent.format(percent, Some(2)))
197                        }
198                    });
199                }
200                if self.volume_labels {
201                    let first = self.finite.binary_search(&previous.bar_index).unwrap() + 1;
202                    let last = self.finite.binary_search(&pivot.bar_index).unwrap();
203                    let volume: f64 = self.finite[first..=last]
204                        .iter()
205                        .map(|&bar| data[bar].volume)
206                        .filter(|volume| volume.is_finite())
207                        .sum();
208                    lines.push(StudyFormat::Volume.format(volume, None));
209                }
210            }
211            if !lines.is_empty() {
212                self.labels.push(StudyLabel {
213                    bar: pivot.bar_index,
214                    price: pivot.price,
215                    text: lines.join("\n"),
216                    placement: if pivot.is_high {
217                        LabelPlacement::Above
218                    } else {
219                        LabelPlacement::Below
220                    },
221                    plot: 0,
222                });
223            }
224        }
225    }
226
227    /// Get price value based on mode
228    fn get_price(&self, bar: &Bar, is_high: bool) -> f64 {
229        match self.mode {
230            ZigZagMode::HighLow => {
231                if is_high {
232                    bar.high
233                } else {
234                    bar.low
235                }
236            }
237            ZigZagMode::Close => bar.close,
238            ZigZagMode::Typical => (bar.high + bar.low + bar.close) / 3.0,
239        }
240    }
241
242    fn tail_from(&self, data: &[Bar], from: usize) -> usize {
243        if self.source_len > 0 && from >= self.source_len - 1 && data.len() >= self.source_len {
244            from.min(self.source_len)
245        } else {
246            0
247        }
248    }
249
250    /// Resume swing detection without interpolating lines that automatic drawings do not use.
251    /// `threshold` gives, for a source bar index, the percentage a reversal confirmed on that
252    /// bar must reach, in place of the fixed deviation; a threshold that is not a positive
253    /// number confirms no reversal.
254    pub(super) fn update_swings(
255        &mut self,
256        data: &[Bar],
257        from: usize,
258        threshold: Option<&dyn Fn(usize) -> f64>,
259    ) -> usize {
260        let start = self.tail_from(data, from);
261        if start == 0 {
262            self.finite.clear();
263            self.prices.clear();
264            self.completed.clear();
265            self.detector = Detector::default();
266            self.before_last = Checkpoint::default();
267        } else if start < self.source_len {
268            self.finite.truncate(self.before_last.finite);
269            self.prices.truncate(self.before_last.prices);
270            self.completed.truncate(self.before_last.completed);
271            self.detector = self.before_last.detector;
272        }
273        let fixed = self.completed.len();
274        self.pivots.truncate(fixed);
275        let deviation = self.deviation_percent;
276        let fixed_threshold = move |_: usize| deviation;
277        let threshold = threshold.unwrap_or(&fixed_threshold);
278        for (source, bar) in data.iter().enumerate().skip(start) {
279            if source + 1 == data.len() {
280                self.before_last = Checkpoint {
281                    detector: self.detector,
282                    finite: self.finite.len(),
283                    prices: self.prices.len(),
284                    completed: self.completed.len(),
285                };
286            }
287            let (high, low) = (self.get_price(bar, true), self.get_price(bar, false));
288            if !high.is_finite() || !low.is_finite() {
289                continue;
290            }
291            let sequence = self.finite.len();
292            self.finite.push(source);
293            if high <= 0.0 || low <= 0.0 {
294                continue;
295            }
296            self.prices.push((sequence, high, low));
297            self.process(self.prices.len() - 1, threshold);
298        }
299        self.pivots.extend(
300            self.completed[fixed..]
301                .iter()
302                .map(|&((point, price), is_high)| ZigZagPivot {
303                    bar_index: self.finite[self.prices[point].0],
304                    price,
305                    is_high,
306                }),
307        );
308        if let Some((up, (point, price))) = self.detector.trend {
309            self.pivots.push(ZigZagPivot {
310                bar_index: self.finite[self.prices[point].0],
311                price,
312                is_high: up,
313            });
314        }
315        self.source_len = data.len();
316        fixed
317    }
318
319    fn process(&mut self, mut p: usize, threshold: &dyn Fn(usize) -> f64) {
320        let bars = &self.prices;
321        let Some((mut top, mut bottom)) = self.detector.extremes else {
322            self.detector.extremes = Some(((p, bars[p].1), (p, bars[p].2)));
323            return;
324        };
325        let depth = self.depth;
326        let finite = &self.finite;
327        let reaches = |p: usize, change: f64| {
328            let threshold = threshold(finite[bars[p].0]);
329            threshold > 0.0 && change >= threshold
330        };
331        let rises = |from: (usize, f64), p: usize| {
332            bars[p].0 - bars[from.0].0 >= depth && reaches(p, (bars[p].1 - from.1) / from.1 * 100.0)
333        };
334        let falls = |from: (usize, f64), p: usize| {
335            bars[p].0 - bars[from.0].0 >= depth && reaches(p, (from.1 - bars[p].2) / from.1 * 100.0)
336        };
337        while p < bars.len() {
338            let (_, high, low) = bars[p];
339            // The pivot a reversal confirms at this bar, and whether the new leg goes up.
340            let mut reversal = None;
341            match &mut self.detector.trend {
342                None => {
343                    if high > top.1 {
344                        top = (p, high);
345                    }
346                    if low < bottom.1 {
347                        bottom = (p, low);
348                    }
349                    // A bar confirming both reversals confirms the earlier extreme.
350                    let (rise, fall) = (rises(bottom, p), falls(top, p));
351                    if rise && !(fall && top.0 < bottom.0) {
352                        reversal = Some((bottom, true));
353                    } else if fall {
354                        reversal = Some((top, false));
355                    }
356                }
357                Some((up, extreme)) => {
358                    let (extends, reverses) = if *up {
359                        (high > extreme.1, falls(*extreme, p))
360                    } else {
361                        (low < extreme.1, rises(*extreme, p))
362                    };
363                    if extends {
364                        *extreme = (p, if *up { high } else { low });
365                    } else if reverses {
366                        reversal = Some((*extreme, !*up));
367                    }
368                }
369            }
370            if let Some((pivot, up)) = reversal {
371                self.completed.push((pivot, !up));
372                // The new leg starts at the swing's extreme since the pivot, not at the confirming
373                // bar; the bars after it are judged again against the leg.
374                let extreme = (pivot.0 + 1..=p)
375                    .map(|q| (q, if up { bars[q].1 } else { bars[q].2 }))
376                    .reduce(|best, c| {
377                        if (c.1 > best.1) == up && c.1 != best.1 {
378                            c
379                        } else {
380                            best
381                        }
382                    })
383                    .expect("the pivot comes before this bar");
384                self.detector.trend = Some((up, extreme));
385                p = extreme.0;
386            }
387            p += 1;
388        }
389        self.detector.extremes = Some((top, bottom));
390    }
391}
392
393/// Construct with the conventional default parameters.
394impl Default for ZigZag {
395    fn default() -> Self {
396        Self::new(5.0, 10)
397    }
398}
399
400impl Indicator for ZigZag {
401    fn line_style(&self, _line: usize) -> crate::studies::IndicatorLineStyle {
402        crate::studies::IndicatorLineStyle {
403            width: 2.0,
404            ..Default::default()
405        }
406    }
407
408    crate::studies::inputs::study_inputs!(self;
409        deviation_percent: number("Deviation percent", 0.0, f64::MAX),
410        depth: length("Depth", 1, usize::MAX),
411        mode: choice("Mode", [ZigZagMode::HighLow, ZigZagMode::Close, ZigZagMode::Typical]),
412        price_labels: toggle("Show price"),
413        change_labels: toggle("Show change"),
414        change_mode: choice("Change as", [ZigZagChange::Price, ZigZagChange::Percent]),
415        volume_labels: toggle("Show leg volume")
416    ; {});
417
418    fn name(&self) -> &str {
419        "ZigZag"
420    }
421
422    fn desc(&self) -> &str {
423        "ZigZag - Filters out minor price movements to show significant swings"
424    }
425
426    fn calculate(&mut self, data: &[Bar]) {
427        self.update(data, 0);
428    }
429
430    fn update(&mut self, data: &[Bar], from: usize) {
431        let start = self.tail_from(data, from);
432        if start == 0 {
433            self.values = IndicatorValues::missing(data.len());
434        } else {
435            let fixed = if start < self.source_len {
436                self.before_last.completed
437            } else {
438                self.completed.len()
439            };
440            if let (Some(first), Some(last)) =
441                (self.pivots.get(fixed.saturating_sub(1)), self.pivots.last())
442                && first.bar_index < last.bar_index
443            {
444                let first = self.finite.partition_point(|&bar| bar < first.bar_index);
445                let end = self.finite.partition_point(|&bar| bar <= last.bar_index);
446                for &bar in &self.finite[first..end] {
447                    self.values.set(bar, IndicatorValue::None);
448                }
449            }
450            self.values.resize(data.len(), IndicatorValue::None);
451        }
452        let fixed = self.update_swings(data, from, None);
453        for leg in self.pivots[fixed.saturating_sub(1)..].windows(2) {
454            let first = self.finite.binary_search(&leg[0].bar_index).unwrap();
455            let end = self.finite.binary_search(&leg[1].bar_index).unwrap();
456            for position in first..=end {
457                let t = (position - first) as f64 / (end - first) as f64;
458                self.values.set(
459                    self.finite[position],
460                    IndicatorValue::Single(leg[0].price + (leg[1].price - leg[0].price) * t),
461                );
462            }
463        }
464        self.refresh_labels(data, fixed);
465    }
466
467    fn is_incremental(&self) -> bool {
468        true
469    }
470
471    fn set_instrument(&mut self, instrument: Option<&crate::model::Instrument>) -> bool {
472        let precision = instrument
473            .and_then(crate::scales::instrument_price_precision)
474            .map(usize::from);
475        let changed = precision != self.precision;
476        self.precision = precision;
477        changed
478    }
479
480    fn labels(&self) -> &[StudyLabel] {
481        &self.labels
482    }
483
484    fn set_label_precision(&mut self, precision: Option<usize>) {
485        self.label_precision = precision;
486    }
487
488    /// The last pivot before the leg that still moves.
489    fn provisional_after(&self, line: usize) -> Option<usize> {
490        let before_last = self.pivots.len().checked_sub(2)?;
491        (line == 0).then(|| self.pivots[before_last].bar_index)
492    }
493
494    fn values(&self) -> &IndicatorValues {
495        &self.values
496    }
497
498    fn colors(&self) -> Vec<Color32> {
499        vec![self.color]
500    }
501
502    fn set_colors(&mut self, colors: Vec<Color32>) {
503        if !colors.is_empty() {
504            self.color = colors[0];
505        }
506    }
507
508    fn is_overlay(&self) -> bool {
509        true // Drawn on price chart
510    }
511
512    fn is_visible(&self) -> bool {
513        self.visible
514    }
515
516    fn set_visible(&mut self, visible: bool) {
517        self.visible = visible;
518    }
519
520    fn clone_box(&self) -> Box<dyn Indicator> {
521        Box::new(self.clone())
522    }
523
524    fn line_names(&self) -> Vec<String> {
525        vec![format!(
526            "ZigZag({:.1}%, {})",
527            self.deviation_percent, self.depth
528        )]
529    }
530}
531
532#[cfg(test)]
533mod tests {
534    use super::*;
535    use crate::studies::test_support::make_range_bar as make_bar;
536
537    /// Pivots of the "rises from the first bar" series: a low of 100 on bar 0, a high of 110 on
538    /// bar 3 and a low of 103 on bar 4, the last. The leg to bar 3 holds volumes 1200, 300 and
539    /// 500, and (103 - 110) / 110 = -6.36%.
540    #[test]
541    fn pivots_are_labelled_and_the_last_leg_is_provisional() {
542        let data: Vec<Bar> = [
543            (101.0, 100.0, 100.0),
544            (103.0, 102.0, 1200.0),
545            (106.0, 105.0, 300.0),
546            (110.0, 109.0, 500.0),
547            (104.0, 103.0, 750.0),
548            (105.0, 104.0, 40.0),
549        ]
550        .into_iter()
551        .map(|(high, low, volume)| Bar {
552            volume,
553            ..make_bar(high, low, low)
554        })
555        .collect();
556        let study = || ZigZag::new(5.0, 1);
557        for (price, change, volume, want) in [
558            (
559                true,
560                Some(ZigZagChange::Price),
561                true,
562                &[
563                    (0, "100.00"),
564                    (3, "110.00\n+10.00\n2.00K"),
565                    (4, "103.00\n-7.00\n750"),
566                ][..],
567            ),
568            (
569                true,
570                Some(ZigZagChange::Percent),
571                true,
572                &[
573                    (0, "100.00"),
574                    (3, "110.00\n+10.00%\n2.00K"),
575                    (4, "103.00\n-6.36%\n750"),
576                ][..],
577            ),
578            (
579                false,
580                Some(ZigZagChange::Percent),
581                false,
582                &[(3, "+10.00%"), (4, "-6.36%")][..],
583            ),
584            (false, None, false, &[][..]),
585        ] {
586            let mut zigzag = study().with_labels(price, change, volume);
587            zigzag.calculate(&data);
588            let got: Vec<_> = zigzag
589                .labels()
590                .iter()
591                .map(|label| (label.bar, label.text.as_str()))
592                .collect();
593            assert_eq!(got, want);
594            for label in zigzag.labels() {
595                let placement = if label.bar == 3 {
596                    LabelPlacement::Above
597                } else {
598                    LabelPlacement::Below
599                };
600                assert_eq!(label.placement, placement);
601            }
602        }
603
604        let mut zigzag = study();
605        zigzag.calculate(&data[..4]);
606        assert_eq!(zigzag.provisional_after(0), Some(0));
607        zigzag.update(&data[..5], 4);
608        assert_eq!(zigzag.provisional_after(0), Some(3));
609        zigzag.update(&data, 5);
610        assert_eq!(zigzag.provisional_after(0), Some(3));
611        assert_eq!(zigzag.provisional_after(1), None);
612    }
613
614    /// Expected values from an independent percentage-filter reference
615    /// (Merrill's filtered waves, drawn as straight lines between pivots).
616    #[test]
617    fn zigzag_draws_lines_between_pivots() {
618        // (name, study, (high, low) per bar, expected value per bar)
619        type Case<'a> = (&'a str, ZigZag, &'a [(f64, f64)], &'a [Option<f64>]);
620        let nan = f64::NAN;
621        let sine: Vec<(f64, f64)> = (0..20)
622            .map(|i| match i {
623                0 => (nan, nan),
624                _ => {
625                    let c = 100.0 + 5.0 * (0.7 * f64::from(i)).sin();
626                    (c + 1.0, c - 1.0)
627                }
628            })
629            .collect();
630        let cases: &[Case] = &[
631            (
632                // Closes are the lows here; the highs must not set the pivots.
633                "close mode",
634                ZigZag::new(5.0, 1).with_mode(ZigZagMode::Close),
635                &[(120.0, 100.0), (130.0, 110.0), (120.0, 100.0)],
636                &[Some(100.0), Some(110.0), Some(100.0)],
637            ),
638            (
639                // Warm-up bar, NaN bar inside a leg (no value, the leg is drawn as if it were absent),
640                // bars after the last pivot.
641                "swings",
642                ZigZag::new(5.0, 1),
643                &[
644                    (99.0, 98.5),
645                    (100.0, 98.0),
646                    (99.0, 96.0),
647                    (97.0, 94.0),
648                    (96.0, 93.0),
649                    (nan, nan),
650                    (97.0, 94.0),
651                    (99.0, 95.0),
652                    (101.0, 97.0),
653                    (100.0, 96.0),
654                    (98.0, 95.0),
655                    (97.0, 95.5),
656                    (96.0, 95.2),
657                ],
658                &[
659                    None,
660                    Some(100.0),
661                    Some(97.666_666_666_666_67),
662                    Some(95.333_333_333_333_33),
663                    Some(93.0),
664                    None,
665                    Some(93.0 + 8.0 / 3.0),
666                    Some(93.0 + 16.0 / 3.0),
667                    Some(101.0),
668                    Some(98.0),
669                    Some(95.0),
670                    None,
671                    None,
672                ],
673            ),
674            (
675                "flat",
676                ZigZag::new(5.0, 1),
677                &[(100.0, 100.0); 6],
678                &[None; 6],
679            ),
680            (
681                // Bar 3 confirms both the fall from 110 and the rise from 90: the earlier
682                // extreme is the first pivot. The fall's low is on bar 1, and bar 3 is 2 bars
683                // after it, so it confirms the rise too: the pivots are at bars 0, 1 and 2.
684                "both reversals at once",
685                ZigZag::new(5.0, 2),
686                &[(110.0, 109.0), (95.0, 90.0), (108.0, 105.0), (106.0, 104.0)],
687                &[Some(110.0), Some(90.0), Some(108.0), None],
688            ),
689            (
690                // The fall from 113 bottoms at 100 on bar 5, which is only 2 bars after the top
691                // and so is confirmed on bar 6: the pivot is bar 5, not bar 6.
692                "swing extreme before the confirming bar",
693                ZigZag::new(5.0, 3),
694                &[
695                    (101.0, 100.0),
696                    (105.0, 104.0),
697                    (108.0, 107.0),
698                    (113.0, 112.0),
699                    (110.0, 104.0),
700                    (103.0, 100.0),
701                    (107.0, 106.0),
702                    (110.0, 109.0),
703                    (114.0, 113.0),
704                    (118.0, 117.0),
705                ],
706                &[
707                    Some(100.0),
708                    Some(313.0 / 3.0),
709                    Some(326.0 / 3.0),
710                    Some(113.0),
711                    Some(106.5),
712                    Some(100.0),
713                    Some(104.5),
714                    Some(109.0),
715                    Some(113.5),
716                    Some(118.0),
717                ],
718            ),
719            (
720                // A deviation of 0 filters nothing, so there are no swings to draw.
721                "deviation 0",
722                ZigZag::new(0.0, 1),
723                &[
724                    (101.0, 100.0),
725                    (103.0, 102.0),
726                    (106.0, 105.0),
727                    (110.0, 109.0),
728                ],
729                &[None; 4],
730            ),
731            (
732                // The first leg starts at the low of bar 0, not at the first high.
733                "rises from the first bar",
734                ZigZag::new(5.0, 1),
735                &[
736                    (101.0, 100.0),
737                    (103.0, 102.0),
738                    (106.0, 105.0),
739                    (110.0, 109.0),
740                    (104.0, 103.0),
741                    (105.0, 104.0),
742                ],
743                &[
744                    Some(100.0),
745                    Some(103.333_333_333_333_33),
746                    Some(106.666_666_666_666_67),
747                    Some(110.0),
748                    Some(103.0),
749                    None,
750                ],
751            ),
752            (
753                // Bar 2 makes a new high and falls 5% from the previous one: it extends the leg.
754                "outside bar",
755                ZigZag::new(5.0, 1),
756                &[
757                    (101.0, 100.0),
758                    (110.0, 108.0),
759                    (112.0, 104.0),
760                    (111.0, 109.0),
761                    (118.0, 116.0),
762                ],
763                &[
764                    Some(100.0),
765                    Some(104.5),
766                    Some(109.0),
767                    Some(113.5),
768                    Some(118.0),
769                ],
770            ),
771            (
772                // The highest high, on bar 0, comes before the low that the first reversal confirms.
773                "high before the low",
774                ZigZag::new(5.0, 1),
775                &[(100.0, 99.0), (96.0, 95.1), (99.9, 99.0)],
776                &[None, Some(95.1), Some(99.9)],
777            ),
778            (
779                // Bar 2 has a low of 0, so it is skipped and the leg is drawn across it.
780                "touches zero",
781                ZigZag::new(5.0, 1),
782                &[(5.0, 4.0), (3.0, 2.0), (1.0, 0.0), (1.02, 0.5)],
783                &[Some(5.0), Some(3.5), Some(2.0), Some(0.5)],
784            ),
785            (
786                // The NaN bar is not in the series: it has no value and is not a bar of `depth`,
787                // so bar 3 is 1 bar after the top and does not confirm a fall.
788                "NaN bar inside a leg",
789                ZigZag::new(5.0, 2),
790                &[(100.0, 99.0), (110.0, 109.0), (nan, nan), (104.0, 103.0)],
791                &[Some(99.0), Some(110.0), None, None],
792            ),
793            (
794                "negative prices",
795                ZigZag::new(5.0, 1),
796                &[
797                    (-1.0, -2.0),
798                    (-3.0, -4.0),
799                    (-5.0, -6.0),
800                    (-1.0, -2.0),
801                    (3.0, 2.0),
802                    (-4.0, -5.0),
803                ],
804                &[None; 6],
805            ),
806            (
807                // High and low 1 above and below 100 + 5 sin(0.7 i), NaN on bar 0.
808                "NaN first bar",
809                ZigZag::new(5.0, 1),
810                &sine,
811                &[
812                    None,
813                    None,
814                    Some(105.9272486499423),
815                    Some(103.5593463073295),
816                    Some(101.19144396471671),
817                    Some(98.82354162210393),
818                    Some(96.45563927949114),
819                    Some(94.08773693687834),
820                    Some(97.05101299500501),
821                    Some(100.01428905313168),
822                    Some(102.97756511125833),
823                    Some(105.940841169385),
824                    Some(103.57349520635668),
825                    Some(101.20614924332837),
826                    Some(98.83880328030004),
827                    Some(96.47145731727173),
828                    Some(94.10411135424341),
829                    Some(97.51869050648995),
830                    Some(100.93326965873648),
831                    Some(104.34784881098301),
832                ],
833            ),
834        ];
835
836        for (name, zz, bars, expected) in cases {
837            let data: Vec<Bar> = bars.iter().map(|&(h, l)| make_bar(h, l, l)).collect();
838            let mut zz = zz.clone();
839            zz.calculate(&data);
840            let extremes: &[(usize, bool)] = match *name {
841                "close mode" => &[(0, false), (1, true), (2, false)],
842                "swings" => &[(1, true), (4, false), (8, true), (10, false)],
843                "both reversals at once" => &[(0, true), (1, false), (2, true)],
844                "swing extreme before the confirming bar" => {
845                    &[(0, false), (3, true), (5, false), (9, true)]
846                }
847                "rises from the first bar" => &[(0, false), (3, true), (4, false)],
848                "outside bar" => &[(0, false), (4, true)],
849                "high before the low" => &[(1, false), (2, true)],
850                "touches zero" => &[(0, true), (3, false)],
851                "NaN bar inside a leg" => &[(0, false), (1, true)],
852                "NaN first bar" => &[(2, true), (7, false), (11, true), (16, false), (19, true)],
853                "flat" | "deviation 0" | "negative prices" => &[],
854                _ => unreachable!("case needs its pivot expectations"),
855            };
856            assert_eq!(zz.pivots().len(), extremes.len(), "{name}");
857            for (pivot, &(bar, high)) in zz.pivots().iter().zip(extremes) {
858                assert_eq!((pivot.bar_index, pivot.is_high), (bar, high), "{name}");
859                assert!(
860                    (pivot.price - expected[bar].unwrap()).abs() < 1e-9,
861                    "{name}"
862                );
863            }
864            let got: Vec<Option<f64>> = zz
865                .values()
866                .to_rows()
867                .iter()
868                .map(|v| match v {
869                    IndicatorValue::Single(x) => Some(*x),
870                    _ => None,
871                })
872                .collect();
873            assert_eq!(got.len(), expected.len(), "{name}");
874            for (i, (g, e)) in got.iter().zip(expected.iter()).enumerate() {
875                match (g, e) {
876                    (Some(g), Some(e)) => {
877                        assert!((g - e).abs() < 1e-9, "{name} bar {i}: {g} != {e}")
878                    }
879                    (None, None) => {}
880                    _ => panic!("{name} bar {i}: got {g:?}, expected {e:?}\n  got {got:?}"),
881                }
882            }
883            zz.set_input("deviation_percent", crate::studies::InputValue::Number(0.0))
884                .unwrap();
885            zz.calculate(&data);
886            for _ in 0..2 {
887                zz.update(&data, data.len().saturating_sub(1));
888                assert!(zz.pivots().is_empty(), "{name}: disabled swings");
889                assert!((0..data.len()).all(|bar| zz.values().value(0, bar).is_none()));
890            }
891        }
892    }
893}