Skip to main content

egui_charts/studies/builtin/
gaps.rs

1//! Gaps.
2//!
3//! Marks price gaps between consecutive bars as zones that extend right until price fills
4//! them.
5//!
6//! # Rule
7//!
8//! ```text
9//! Up gap at bar i:   low[i] > high[i - 1], zone from high[i - 1] to low[i]
10//! Down gap at bar i: high[i] < low[i - 1], zone from high[i] to low[i - 1]
11//! kept when its height >= deviation% * SMA(high - low, 14) at bar i
12//! ```
13//!
14//! A zone starts at the bar before the gap. A later bar enters an up gap when its low is below
15//! the zone's top, and a down gap when its high is above the zone's bottom; touching the edge
16//! does not enter it. By default the first bar to enter a gap closes it, and the zone ends on
17//! that bar. With `close_partially`, the zone ends only when a bar covers it to its far edge;
18//! a bar that enters it part way narrows it, at that bar, to the part the bar has not covered.
19//! With `limit_trail`, a zone also closes `trail_length` bars after its left edge, the bar
20//! before the gap or, after a partial fill, the bar that filled it.
21//!
22//! Only the latest `max_gaps` gaps are shown, open or closed, each as one [`StudyZone`] per
23//! extent it has held, the next starting on the bar where the previous ends. Zones of up gaps
24//! take the style of output 0, "Up gaps", and zones of down gaps that of output 1, "Down gaps";
25//! neither output has values, so memory grows with the zones and not with the bars. The
26//! defaults are 15 gaps, a deviation of 30%, partial closing off and a trail length of 300
27//! bars, off.
28//!
29//! # Example
30//!
31//! ```rust
32//! use chrono::{TimeZone, Utc};
33//! use egui_charts::model::Bar;
34//! use egui_charts::studies::{Gaps, Indicator, StudyZone};
35//!
36//! // Fourteen bars of range 2 around 100, then a bar that opens a gap from 101 to 102.
37//! let bars: Vec<Bar> = [100.0; 14]
38//!     .into_iter()
39//!     .chain([103.0, 104.0])
40//!     .enumerate()
41//!     .map(|(i, p)| Bar::new(Utc.timestamp_opt(i as i64 * 60, 0).unwrap(), p, p + 1.0, p - 1.0, p, 1.0))
42//!     .collect();
43//! let mut gaps = Gaps::default();
44//! gaps.calculate(&bars);
45//! // The zone starts at the bar before the gap and is still open on the last bar.
46//! let zone = StudyZone { bars: 13..16, top: 102.0, bottom: 101.0, plot: 0 };
47//! assert_eq!(gaps.zones(), [zone]);
48//! ```
49
50use super::clamp_param;
51use crate::model::Bar;
52use crate::studies::palette;
53use crate::studies::stepper::Stepper;
54use crate::studies::{Indicator, IndicatorValue, IndicatorValues, StudyZone};
55use egui::Color32;
56
57/// Most gaps a chart shows.
58const MAX_GAPS: usize = 500;
59
60/// Bars in the average range a gap is measured against.
61const RANGE_LENGTH: usize = 14;
62
63/// Gaps: zones between consecutive bars that do not overlap, open until price fills them.
64#[derive(Clone)]
65pub struct Gaps {
66    max_gaps: usize,
67    deviation: f64,
68    close_partially: bool,
69    limit_trail: bool,
70    trail_length: usize,
71    values: IndicatorValues,
72    stepper: Stepper<State>,
73    closed: Vec<(usize, StudyZone)>,
74    before_last_closed: usize,
75    expired: Vec<(usize, (usize, StudyZone))>,
76    zones: Vec<StudyZone>,
77    up_color: Color32,
78    down_color: Color32,
79    visible: bool,
80}
81
82/// One open gap.
83#[derive(Clone)]
84struct Gap {
85    /// Gaps found before it.
86    index: usize,
87    up: bool,
88    near: f64,
89    far: f64,
90    left: usize,
91    source_left: usize,
92}
93
94impl Gap {
95    /// Its current extent from its left edge to `last`, inclusive.
96    fn zone(&self, last: usize) -> (usize, StudyZone) {
97        let zone = StudyZone {
98            bars: self.source_left..last + 1,
99            top: self.near.max(self.far),
100            bottom: self.near.min(self.far),
101            plot: usize::from(!self.up),
102        };
103        (self.index, zone)
104    }
105}
106
107#[derive(Clone, Default)]
108struct State {
109    source: usize,
110    usable: usize,
111    previous: Option<(usize, [f64; 2])>,
112    last_source: Option<usize>,
113    ranges: std::collections::VecDeque<f64>,
114    open: Vec<Gap>,
115    found: usize,
116}
117
118impl Gaps {
119    /// The latest `max_gaps` gaps (1 to 500) of at least `deviation` percent (1 to 100, NaN to
120    /// 1) of the average bar range.
121    pub fn new(max_gaps: usize, deviation: f64) -> Self {
122        Self {
123            max_gaps: max_gaps.clamp(1, MAX_GAPS),
124            deviation: clamp_param(deviation, 1.0, 100.0),
125            close_partially: false,
126            limit_trail: false,
127            trail_length: 300,
128            values: IndicatorValues::new(),
129            stepper: Stepper::default(),
130            closed: Vec::new(),
131            before_last_closed: 0,
132            expired: Vec::new(),
133            zones: Vec::new(),
134            up_color: palette::GREEN,
135            down_color: palette::RED,
136            visible: true,
137        }
138    }
139
140    /// Shrink a gap a bar enters part way instead of closing it.
141    pub fn with_partial_closing(mut self, close_partially: bool) -> Self {
142        self.close_partially = close_partially;
143        self
144    }
145
146    /// Close every gap `length` bars (at least 1) after its left edge, or `None` for no limit.
147    pub fn with_trail_limit(mut self, length: Option<usize>) -> Self {
148        self.limit_trail = length.is_some();
149        if let Some(length) = length {
150            self.trail_length = length.max(1);
151        }
152        self
153    }
154}
155
156/// Construct with the conventional default parameters.
157impl Default for Gaps {
158    fn default() -> Self {
159        Self::new(15, 30.0)
160    }
161}
162
163impl Indicator for Gaps {
164    crate::studies::inputs::study_inputs!(self;
165        close_partially: toggle("Close gaps partially"),
166        max_gaps: length("Max number of gaps", 1, MAX_GAPS),
167        deviation: number("Minimal deviation (%)", 1.0, 100.0),
168        limit_trail: toggle("Limit max gap trail length"),
169        trail_length: length("Max gap trail length (bars)", 1, usize::MAX)
170    ; {});
171
172    fn name(&self) -> &str {
173        "Gaps"
174    }
175
176    fn desc(&self) -> &str {
177        "Gaps - Price gaps between bars, extended until price fills them"
178    }
179
180    fn calculate(&mut self, data: &[Bar]) {
181        self.update(data, 0);
182    }
183
184    fn update(&mut self, data: &[Bar], from: usize) {
185        let len = self.values.len();
186        if len > 0 && from == len && data.len() == len {
187            return;
188        }
189        if len == 0 || from < len - 1 || data.len() < len {
190            self.closed.clear();
191            self.zones.clear();
192        } else if from == len - 1 {
193            if self.expired.is_empty() {
194                self.closed.truncate(self.before_last_closed);
195                self.zones.truncate(self.before_last_closed);
196            } else {
197                let mut kept = std::mem::take(&mut self.closed).into_iter();
198                let mut expired = std::mem::take(&mut self.expired).into_iter().peekable();
199                for at in 0..self.before_last_closed {
200                    self.closed
201                        .push(if expired.peek().is_some_and(|(index, _)| *index == at) {
202                            expired.next().unwrap().1
203                        } else {
204                            kept.next().unwrap()
205                        });
206                }
207                self.zones.clear();
208            }
209        } else {
210            self.zones.truncate(self.closed.len());
211        }
212        let mut retained = self.zones.len();
213        self.stepper
214            .run(data, from, &mut self.values, |state, bar| {
215                let source = state.source;
216                state.source += 1;
217                let last = source + 1 == data.len();
218                if last {
219                    self.before_last_closed = self.closed.len();
220                    self.expired.clear();
221                }
222                if !bar.high.is_finite() || !bar.low.is_finite() {
223                    return IndicatorValue::None;
224                }
225                let i = state.usable;
226                state.usable += 1;
227                state.last_source = Some(source);
228                state.ranges.push_back(bar.high - bar.low);
229                if state.ranges.len() > RANGE_LENGTH {
230                    state.ranges.pop_front();
231                }
232                let average = if state.ranges.len() == RANGE_LENGTH
233                    && state.ranges.iter().all(|v| v.is_finite())
234                {
235                    state.ranges.iter().sum::<f64>() / RANGE_LENGTH as f64
236                } else {
237                    f64::NAN
238                };
239                state.open.retain_mut(|gap| {
240                    let (entered, covered) = if gap.up {
241                        (bar.low < gap.near, bar.low <= gap.far)
242                    } else {
243                        (bar.high > gap.near, bar.high >= gap.far)
244                    };
245                    if entered {
246                        self.closed.push(gap.zone(source));
247                        if covered || !self.close_partially {
248                            return false;
249                        }
250                        gap.near = if gap.up { bar.low } else { bar.high };
251                        gap.left = i;
252                        gap.source_left = source;
253                    } else if self.limit_trail && i - gap.left >= self.trail_length {
254                        self.closed.push(gap.zone(source));
255                        return false;
256                    }
257                    true
258                });
259                let Some((previous_source, previous)) =
260                    state.previous.replace((source, [bar.high, bar.low]))
261                else {
262                    return IndicatorValue::None;
263                };
264                let threshold = average * self.deviation / 100.0;
265                let (up, near, far) = if bar.low > previous[0] && bar.low - previous[0] >= threshold
266                {
267                    (true, bar.low, previous[0])
268                } else if bar.high < previous[1] && previous[1] - bar.high >= threshold {
269                    (false, bar.high, previous[1])
270                } else {
271                    return IndicatorValue::None;
272                };
273                let gap = Gap {
274                    index: state.found,
275                    up,
276                    near,
277                    far,
278                    left: i - 1,
279                    source_left: previous_source,
280                };
281                state.found += 1;
282                if self.limit_trail && self.trail_length == 1 {
283                    self.closed.push(gap.zone(source));
284                } else {
285                    state.open.push(gap);
286                }
287                let shown = state.found.saturating_sub(self.max_gaps);
288                state.open.retain(|gap| gap.index >= shown);
289                let mut at = 0;
290                self.closed.retain(|zone| {
291                    let keep = zone.0 >= shown;
292                    if !keep && last && at < self.before_last_closed {
293                        self.expired.push((at, zone.clone()));
294                    }
295                    if !keep {
296                        retained = 0;
297                    }
298                    at += 1;
299                    keep
300                });
301                IndicatorValue::None
302            });
303        let state = self.stepper.state();
304        // Completed segments do not move on a tick and must stay outside the cloned checkpoint.
305        self.zones.truncate(retained);
306        self.zones
307            .extend(self.closed[retained..].iter().map(|(_, zone)| zone.clone()));
308        if let Some(last) = state.last_source {
309            self.zones
310                .extend(state.open.iter().map(|gap| gap.zone(last).1));
311        }
312    }
313
314    fn is_incremental(&self) -> bool {
315        true
316    }
317
318    fn zones(&self) -> &[StudyZone] {
319        &self.zones
320    }
321
322    fn values(&self) -> &IndicatorValues {
323        &self.values
324    }
325
326    fn colors(&self) -> Vec<Color32> {
327        vec![self.up_color, self.down_color]
328    }
329
330    fn set_colors(&mut self, colors: Vec<Color32>) {
331        if let Some(&color) = colors.first() {
332            self.up_color = color;
333        }
334        if let Some(&color) = colors.get(1) {
335            self.down_color = color;
336        }
337    }
338
339    fn is_visible(&self) -> bool {
340        self.visible
341    }
342
343    fn set_visible(&mut self, visible: bool) {
344        self.visible = visible;
345    }
346
347    fn clone_box(&self) -> Box<dyn Indicator> {
348        Box::new(self.clone())
349    }
350
351    fn line_names(&self) -> Vec<String> {
352        vec!["Up gaps".to_string(), "Down gaps".to_string()]
353    }
354}
355
356#[cfg(test)]
357mod tests {
358    use super::*;
359    use chrono::{TimeZone, Utc};
360
361    /// Gaps keep no per-bar column, so their memory follows the gaps shown, not the bars.
362    #[test]
363    fn memory_grows_with_the_gaps_shown_not_the_bars() {
364        // Range-2 bars that step 10 higher every 100 bars, leaving 999 gaps no bar fills.
365        let bars = |step: f64| -> Vec<Bar> {
366            (0..100_000)
367                .map(|i| {
368                    let p = 100.0 + step * (i / 100) as f64;
369                    let time = Utc.timestamp_opt(i as i64 * 60, 0).unwrap();
370                    Bar::new(time, p, p + 1.0, p - 1.0, p, 1.0)
371                })
372                .collect()
373        };
374        let mut gaps = Gaps::new(500, 30.0);
375        gaps.calculate(&bars(0.0));
376        assert!(gaps.values().columns().is_empty() && gaps.zones().is_empty());
377        gaps.calculate(&bars(10.0));
378        assert!(gaps.values().columns().is_empty());
379        assert_eq!(gaps.zones().len(), 500);
380        assert!(gaps.zones().iter().all(|zone| zone.bars.end == 100_000));
381        assert_eq!(gaps.zones()[0].bars.start, 49_999);
382    }
383
384    /// A study zone that spans the visible bars widens the auto range to its edges, as a
385    /// per-bar value would, although the bars it began on are out of view.
386    #[test]
387    fn visible_study_zones_auto_scale() {
388        // Fourteen bars around 100, a bar that leaves a gap from 101 to 102, then bars at 150.
389        let bars: Vec<Bar> = [100.0; 14]
390            .into_iter()
391            .chain([103.0])
392            .chain([150.0; 100])
393            .enumerate()
394            .map(|(i, p)| {
395                let t = Utc.timestamp_opt(1_700_000_000 + i as i64 * 60, 0).unwrap();
396                Bar::new(t, p, p + 1.0, p - 1.0, p, 1.0)
397            })
398            .collect();
399        let mut gaps = Gaps::default();
400        gaps.calculate(&bars);
401        let mut state = crate::studies::ChartState::new(crate::model::BarData::from_bars(bars));
402        state.time_scale_mut().set_width(80.0);
403        state.time_scale_mut().set_bar_spacing(8.0);
404        let transform = state.fit_price_scale([&gaps as &dyn Indicator]);
405        let range = state.price_scale().range();
406        assert!(transform.to_price(range.min) <= 101.0, "{range:?}");
407    }
408}