Skip to main content

egui_charts/studies/builtin/
adx_breakout.rs

1//! ADX Breakout.
2//!
3//! Marks a close beyond the high or low of the last `range` bars when the trend was weak on the
4//! bar before, a breakout from a quiet range.
5//!
6//! # Rule
7//!
8//! The ADX is Wilder's ([`ADX`]) of `period` bars. With the `range` bars before the current one:
9//!
10//! ```text
11//! Upward:   ADX[t - 1] <= threshold  and  close[t] > highest high of the range
12//! Downward: ADX[t - 1] <= threshold  and  close[t] < lowest low of the range
13//! ```
14//!
15//! Both outputs hold the ADX of the breakout bar, so the shapes sit on the ADX line. A signal needs
16//! `range` earlier bars and an ADX on the bar before it, is known when its bar closes and never
17//! changes with later bars. Bars with a non-finite high, low or close are skipped. The defaults are
18//! an ADX of 14 bars, a threshold of 20 and a range of 20 bars.
19
20use super::ADX;
21use crate::model::Bar;
22use crate::studies::palette;
23use crate::studies::stepper::Stepper;
24use crate::studies::{Indicator, IndicatorValue, IndicatorValues};
25use egui::Color32;
26use std::collections::VecDeque;
27
28/// ADX Breakout: closes beyond the range of the last bars after a weak trend.
29#[derive(Clone)]
30pub struct AdxBreakout {
31    period: usize,
32    threshold: usize,
33    range: usize,
34    adx: ADX,
35    values: IndicatorValues,
36    stepper: Stepper<State>,
37    colors: [Color32; 3],
38    visible: bool,
39}
40
41#[derive(Clone, Default)]
42struct State {
43    source: usize,
44    previous: Option<f64>,
45    window: VecDeque<(f64, f64)>,
46}
47
48impl AdxBreakout {
49    /// An ADX of `period` bars and a range of `range` bars, each raised to at least 1, and a
50    /// threshold of at most 100.
51    pub fn new(period: usize, threshold: usize, range: usize) -> Self {
52        Self {
53            period: period.max(1),
54            threshold: threshold.min(100),
55            range: range.max(1),
56            adx: ADX::new(period),
57            values: IndicatorValues::new(),
58            stepper: Stepper::default(),
59            colors: [palette::PURPLE, palette::GREEN, palette::RED],
60            visible: true,
61        }
62    }
63}
64
65/// Construct with the conventional default parameters.
66impl Default for AdxBreakout {
67    fn default() -> Self {
68        Self::new(14, 20, 20)
69    }
70}
71
72impl Indicator for AdxBreakout {
73    crate::studies::inputs::study_inputs!(self;
74        period: length("ADX period", 1, usize::MAX),
75        threshold: length("ADX threshold", 0, 100),
76        range: length("Range", 1, usize::MAX)
77    ; {
78        self.adx = ADX::new(self.period);
79    });
80
81    fn name(&self) -> &str {
82        "ADX Breakout"
83    }
84
85    fn desc(&self) -> &str {
86        "ADX Breakout - Closes beyond the range of the last bars after a weak trend"
87    }
88
89    fn calculate(&mut self, data: &[Bar]) {
90        self.update(data, 0);
91    }
92
93    fn update(&mut self, data: &[Bar], from: usize) {
94        self.adx.update(data, from);
95        let (adx, range, threshold) = (self.adx.values(), self.range, self.threshold as f64);
96        self.stepper
97            .run(data, from, &mut self.values, |state, bar| {
98                let source = state.source;
99                state.source += 1;
100                if ![bar.high, bar.low, bar.close].iter().all(|v| v.is_finite()) {
101                    return IndicatorValue::None;
102                }
103                let current = adx.value(0, source);
104                let mut row = [current.unwrap_or(f64::NAN), f64::NAN, f64::NAN];
105                if state.window.len() == range && state.previous.is_some_and(|adx| adx <= threshold)
106                {
107                    // shortcut: scans the window on every bar, upgrade to running extremes if a
108                    // range of thousands of bars is common
109                    let highest = state
110                        .window
111                        .iter()
112                        .map(|b| b.0)
113                        .fold(f64::NEG_INFINITY, f64::max);
114                    let lowest = state
115                        .window
116                        .iter()
117                        .map(|b| b.1)
118                        .fold(f64::INFINITY, f64::min);
119                    if bar.close > highest {
120                        row[1] = row[0];
121                    } else if bar.close < lowest {
122                        row[2] = row[0];
123                    }
124                }
125                state.previous = current;
126                state.window.push_back((bar.high, bar.low));
127                if state.window.len() > range {
128                    state.window.pop_front();
129                }
130                IndicatorValue::Multiple(row.to_vec())
131            });
132    }
133
134    fn is_incremental(&self) -> bool {
135        true
136    }
137
138    fn plot_style(&self, line: usize) -> crate::studies::PlotStyle {
139        crate::studies::PlotStyle {
140            color: self.colors.get(line).copied().unwrap_or(Color32::WHITE),
141            width: 1.0,
142            pattern: crate::model::LineStyle::Solid,
143            plot_type: match line {
144                0 => crate::studies::PlotType::Line,
145                1 => crate::studies::PlotType::TriangleUp,
146                _ => crate::studies::PlotType::TriangleDown,
147            },
148            visible: true,
149        }
150    }
151
152    fn values(&self) -> &IndicatorValues {
153        &self.values
154    }
155
156    fn colors(&self) -> Vec<Color32> {
157        self.colors.to_vec()
158    }
159
160    fn set_colors(&mut self, colors: Vec<Color32>) {
161        for (old, new) in self.colors.iter_mut().zip(colors) {
162            *old = new;
163        }
164    }
165
166    fn pane(&self) -> crate::studies::StudyPane {
167        crate::studies::StudyPane {
168            range: Some((0.0, 100.0)),
169            levels: vec![(self.threshold as f64, palette::GRAY, "Threshold")],
170            ..Default::default()
171        }
172    }
173
174    fn is_overlay(&self) -> bool {
175        false
176    }
177
178    fn is_visible(&self) -> bool {
179        self.visible
180    }
181
182    fn set_visible(&mut self, visible: bool) {
183        self.visible = visible;
184    }
185
186    fn clone_box(&self) -> Box<dyn Indicator> {
187        Box::new(self.clone())
188    }
189
190    fn line_names(&self) -> Vec<String> {
191        ["ADX", "Upward Breakout", "Downward Breakout"]
192            .map(String::from)
193            .to_vec()
194    }
195}
196
197#[cfg(test)]
198mod tests {
199    use super::*;
200
201    /// ADX of 2 bars and a range of 3, by hand. Five identical bars give an ADX of 0 from the
202    /// fourth. Bar 5 closes at 11 over the range high 10 after an ADX of 0 and lifts the ADX to
203    /// (0 + 100) / 2 = 50, since only +DM moves. Bar 6 closes over the range high 11.5 but after an
204    /// ADX of 50, so a threshold of 25 withholds it and 100 does not. Negating the prices gives the
205    /// downward breakout.
206    #[test]
207    fn breakouts_need_a_weak_trend_before_a_close_beyond_the_range() {
208        let up = [
209            (10.0, 9.0, 9.5),
210            (10.0, 9.0, 9.5),
211            (10.0, 9.0, 9.5),
212            (10.0, 9.0, 9.5),
213            (10.0, 9.0, 9.5),
214            (11.5, 9.5, 11.0),
215            (12.5, 10.5, 12.0),
216        ];
217        let down = up.map(|(high, low, close)| (-low, -high, -close));
218        let none = None;
219        for (threshold, prices, line, expected) in [
220            (25, up, 1, [none, none, none, none, none, Some(50.0), none]),
221            (
222                100,
223                up,
224                1,
225                [none, none, none, none, none, Some(50.0), Some(75.0)],
226            ),
227            (
228                25,
229                down,
230                2,
231                [none, none, none, none, none, Some(50.0), none],
232            ),
233        ] {
234            let study = AdxBreakout::new(2, threshold, 3);
235            let bars = super::super::hlc_bars(&prices);
236            super::super::assert_replay_matches_batch(&study, &bars);
237            let mut study = study;
238            study.calculate(&bars);
239            let actual: Vec<_> = (0..bars.len())
240                .map(|at| study.values().value(line, at))
241                .collect();
242            assert_eq!(actual, expected, "threshold {threshold} line {line}");
243            assert_eq!(study.values().value(0, 4), Some(0.0));
244        }
245    }
246}