Skip to main content

egui_charts/studies/builtin/
missed_pivots.rs

1//! Missed Pivot Levels.
2//!
3//! Swing highs and lows that price has not returned to. Each level is the price of a pivot
4//! (see [`PivotPointsHighLow`](super::PivotPointsHighLow)) and follows three rules:
5//!
6//! - **Confirmed** `right` bars after its pivot bar, when the pivot window is complete. Bars of
7//!   that window never revisit it.
8//! - **Missed** from then on: a pivot high lies above every later bar and a pivot low below.
9//! - **Revisited** at the first later bar whose high reaches the level of a pivot high, or whose
10//!   low reaches the level of a pivot low, within a tolerance of `tolerance` percent of the level
11//!   (high >= level * (1 - t), low <= level * (1 + t), t = tolerance / 100). A revisited level is
12//!   invalidated: it is drawn up to the revisit bar and takes no further part.
13//!
14//! The latest `max_levels` levels, missed or revisited, are kept. Every level is a zone from its
15//! pivot bar to its revisit bar, or to the last bar while it is missed, in the colour of its
16//! side (pivot highs first, then pivot lows). It has no per-bar values. The defaults are 10
17//! bars on each side, a tolerance of 0.1% and 20 levels. Overlay indicator.
18//!
19//! # Example
20//!
21//! ```rust
22//! use chrono::{TimeZone, Utc};
23//! use egui_charts::model::Bar;
24//! use egui_charts::studies::{Indicator, MissedPivots};
25//!
26//! // A peak at 10, then a fall that never returns to it.
27//! let bars: Vec<Bar> = [5.0, 8.0, 10.0, 8.0, 5.0, 6.0]
28//!     .into_iter()
29//!     .enumerate()
30//!     .map(|(i, p)| Bar::new(Utc.timestamp_opt(i as i64 * 60, 0).unwrap(), p, p, p, p, 1.0))
31//!     .collect();
32//! let mut missed = MissedPivots::new(2, 2, 0.1, 20);
33//! missed.calculate(&bars);
34//! // The pivot high at bar 2 is confirmed at bar 4 and is still missed.
35//! assert_eq!(missed.zones().len(), 1);
36//! assert_eq!((missed.zones()[0].bars.clone(), missed.zones()[0].top), (2..6, 10.0));
37//! ```
38
39use super::clamp_param;
40use super::pivot_points_high_low::Swings;
41use crate::model::Bar;
42use crate::studies::palette;
43use crate::studies::stepper::Stepper;
44use crate::studies::{Indicator, IndicatorValue, IndicatorValues, StudyZone};
45use egui::Color32;
46use std::collections::VecDeque;
47
48/// The most levels that can be kept.
49const MAX_LEVELS: usize = 500;
50
51/// Missed Pivot Levels: swing highs and lows that price has not returned to.
52#[derive(Clone)]
53pub struct MissedPivots {
54    left: usize,
55    right: usize,
56    tolerance: f64,
57    max_levels: usize,
58    values: IndicatorValues,
59    stepper: Stepper<State>,
60    zones: Vec<StudyZone>,
61    /// Missed pivot high, missed pivot low.
62    colors: [Color32; 2],
63    visible: bool,
64}
65
66#[derive(Clone)]
67struct Level {
68    bar: usize,
69    price: f64,
70    high: bool,
71    revisited: Option<usize>,
72}
73
74#[derive(Clone, Default)]
75struct State {
76    source: usize,
77    swings: Swings,
78    levels: VecDeque<Level>,
79}
80
81impl MissedPivots {
82    /// Pivots with `left` and `right` bars on each side (at least 1), revisited within
83    /// `tolerance` percent (0 to 100, NaN to 0) of the level, keeping the latest `max_levels`
84    /// (1 to 500).
85    pub fn new(left: usize, right: usize, tolerance: f64, max_levels: usize) -> Self {
86        Self {
87            left: left.max(1),
88            right: right.max(1),
89            tolerance: clamp_param(tolerance, 0.0, 100.0),
90            max_levels: max_levels.clamp(1, MAX_LEVELS),
91            values: IndicatorValues::new(),
92            stepper: Stepper::default(),
93            zones: Vec::new(),
94            colors: [palette::RED, palette::GREEN],
95            visible: true,
96        }
97    }
98}
99
100/// Construct with the conventional default parameters.
101impl Default for MissedPivots {
102    fn default() -> Self {
103        Self::new(10, 10, 0.1, 20)
104    }
105}
106
107impl Indicator for MissedPivots {
108    crate::studies::inputs::study_inputs!(self;
109        left: length("Pivot left bars", 1, usize::MAX),
110        right: length("Pivot right bars", 1, usize::MAX),
111        tolerance: number("Revisit tolerance (%)", 0.0, 100.0),
112        max_levels: length("Max number of levels", 1, MAX_LEVELS)
113    ; {});
114
115    fn name(&self) -> &str {
116        "Missed Pivot Levels"
117    }
118
119    fn desc(&self) -> &str {
120        "Missed Pivot Levels - Swing highs and lows that price has not returned to"
121    }
122
123    fn calculate(&mut self, data: &[Bar]) {
124        self.update(data, 0);
125    }
126
127    fn update(&mut self, data: &[Bar], from: usize) {
128        let (left, right, max_levels) = (self.left, self.right, self.max_levels);
129        let tolerance = self.tolerance / 100.0;
130        self.stepper
131            .run(data, from, &mut self.values, |state, bar| {
132                let index = state.source;
133                state.source += 1;
134                // A bar without a finite high and low takes no part, as if it were absent.
135                if !(bar.high.is_finite() && bar.low.is_finite()) {
136                    return IndicatorValue::None;
137                }
138                for level in state.levels.iter_mut().filter(|l| l.revisited.is_none()) {
139                    let reach = level.price.abs() * tolerance;
140                    let reached = if level.high {
141                        bar.high >= level.price - reach
142                    } else {
143                        bar.low <= level.price + reach
144                    };
145                    if reached {
146                        level.revisited = Some(index);
147                    }
148                }
149                let confirmed = state.swings.push(index, bar, left, right);
150                for (high, pivot) in [(true, confirmed[0]), (false, confirmed[1])] {
151                    if let Some((bar, price)) = pivot {
152                        state.levels.push_back(Level {
153                            bar,
154                            price,
155                            high,
156                            revisited: None,
157                        });
158                    }
159                }
160                while state.levels.len() > max_levels {
161                    state.levels.pop_front();
162                }
163                IndicatorValue::None
164            });
165        let state = self.stepper.state();
166        self.zones = state
167            .levels
168            .iter()
169            .map(|level| StudyZone {
170                bars: level.bar..level.revisited.map_or(state.source, |at| at + 1),
171                top: level.price,
172                bottom: level.price,
173                plot: usize::from(!level.high),
174            })
175            .collect();
176    }
177
178    fn is_incremental(&self) -> bool {
179        true
180    }
181
182    fn zones(&self) -> &[StudyZone] {
183        &self.zones
184    }
185
186    fn values(&self) -> &IndicatorValues {
187        &self.values
188    }
189
190    fn colors(&self) -> Vec<Color32> {
191        self.colors.to_vec()
192    }
193
194    fn set_colors(&mut self, colors: Vec<Color32>) {
195        for (line, color) in self.colors.iter_mut().zip(colors) {
196            *line = color;
197        }
198    }
199
200    fn is_overlay(&self) -> bool {
201        true
202    }
203
204    fn is_visible(&self) -> bool {
205        self.visible
206    }
207
208    fn set_visible(&mut self, visible: bool) {
209        self.visible = visible;
210    }
211
212    fn clone_box(&self) -> Box<dyn Indicator> {
213        Box::new(self.clone())
214    }
215
216    fn line_names(&self) -> Vec<String> {
217        vec!["Missed high".to_string(), "Missed low".to_string()]
218    }
219}
220
221#[cfg(test)]
222mod tests {
223    use super::*;
224    use chrono::{TimeZone, Utc};
225
226    fn bars(highs_lows: &[(f64, f64)]) -> Vec<Bar> {
227        highs_lows
228            .iter()
229            .enumerate()
230            .map(|(i, &(high, low))| {
231                let time = Utc.timestamp_opt(i as i64 * 60, 0).unwrap();
232                Bar::new(time, low, high, low, low, 1.0)
233            })
234            .collect()
235    }
236
237    /// A hand-labelled history with pivots of one bar each side, given to the study a bar at a
238    /// time. The pivot high 10 at bar 1 is confirmed at bar 2 and revisited at bar 5, whose high
239    /// 9.96 is within 0.5% of 10 (10 - 0.05 = 9.95); the pivot low 4 at bar 3 is confirmed at
240    /// bar 4 and still missed, bar 5 lows staying above 4 * 1.005 = 4.02; the pivot high 9.96 at
241    /// bar 5 is confirmed at bar 6 and missed.
242    #[test]
243    fn levels_are_confirmed_missed_and_revisited() {
244        let data = bars(&[
245            (6.0, 5.0),
246            (10.0, 8.0),
247            (9.0, 7.0),
248            (6.0, 4.0),
249            (7.0, 5.0),
250            (9.96, 8.0),
251            (8.0, 6.0),
252        ]);
253        // Bars seen, then the zones (bars, price, plot) at that point.
254        type Zone = (std::ops::Range<usize>, f64, usize);
255        let want: [(usize, Vec<Zone>); 4] = [
256            (2, vec![]),
257            (3, vec![(1..3, 10.0, 0)]),
258            (5, vec![(1..5, 10.0, 0), (3..5, 4.0, 1)]),
259            (7, vec![(1..6, 10.0, 0), (3..7, 4.0, 1), (5..7, 9.96, 0)]),
260        ];
261        let mut missed = MissedPivots::new(1, 1, 0.5, 20);
262        for (seen, zones) in want {
263            missed.calculate(&data[..seen]);
264            let got: Vec<_> = missed
265                .zones()
266                .iter()
267                .map(|z| (z.bars.clone(), z.top, z.plot))
268                .collect();
269            assert_eq!(got, zones, "after {seen} bars");
270        }
271    }
272}