Skip to main content

egui_charts/studies/builtin/
knoxville_divergence.rs

1//! Knoxville Divergence.
2//!
3//! Marks a divergence between the swings of the price and of its momentum, when the RSI confirms
4//! an extreme: a lower low in price with a higher low in momentum below an oversold RSI is
5//! bullish, a higher high with a lower high in momentum above an overbought RSI is bearish.
6//!
7//! # Rule
8//!
9//! Momentum is the close minus the close `momentum_period` bars before, and the RSI is Wilder's
10//! ([`RSI`](super::RSI)) of the close. Price pivots are found with `left` and `right` bars on each
11//! side as in [`PivotPointsHighLow`](super::PivotPointsHighLow), so a pivot is known, and confirmed,
12//! `right` bars after its bar. Each pivot low `p` is compared with the previous pivot low `q` when
13//! `p - q <= lookback` bars:
14//!
15//! ```text
16//! Bullish: low[p]  < low[q]   and momentum[p] > momentum[q]  and RSI[p] <= oversold
17//! Bearish: high[p] > high[q]  and momentum[p] < momentum[q]  and RSI[p] >= overbought  (pivot highs)
18//! ```
19//!
20//! A signal is marked on its confirmation bar, `right` bars after the pivot `p`, at the momentum
21//! of `p`, and never moves. A pivot or momentum without a value, in the warm-up, takes no part.
22//! Bars with a non-finite high, low or close are skipped. The defaults are a momentum of 20 bars, an
23//! RSI of 14, 5 bars on each side of a pivot, a lookback of 50 bars, and an RSI of 30 and 70.
24
25use super::rsi::State as RsiState;
26use crate::model::Bar;
27use crate::studies::palette;
28use crate::studies::stepper::Stepper;
29use crate::studies::{Indicator, IndicatorValue, IndicatorValues};
30use egui::Color32;
31use std::collections::VecDeque;
32
33/// Knoxville Divergence: price and momentum swings that disagree at an RSI extreme.
34#[derive(Clone)]
35pub struct KnoxvilleDivergence {
36    momentum_period: usize,
37    rsi_period: usize,
38    left: usize,
39    right: usize,
40    lookback: usize,
41    oversold: usize,
42    overbought: usize,
43    values: IndicatorValues,
44    stepper: Stepper<State>,
45    colors: [Color32; 3],
46    visible: bool,
47}
48
49#[derive(Clone, Copy)]
50struct Pivot {
51    position: usize,
52    high: f64,
53    low: f64,
54    momentum: f64,
55    rsi: f64,
56}
57
58#[derive(Clone, Default)]
59struct State {
60    position: usize,
61    rsi: RsiState,
62    closes: VecDeque<f64>,
63    window: VecDeque<Pivot>,
64    /// The previous pivot low and pivot high.
65    previous: [Option<Pivot>; 2],
66}
67
68impl KnoxvilleDivergence {
69    /// Momentum and RSI of the given periods and pivots with `left` and `right` bars on each side,
70    /// each raised to at least 1, divergences between pivots at most `lookback` bars apart, and
71    /// RSI limits of 30 and 70, set through the `oversold` and `overbought` inputs.
72    pub fn new(
73        momentum_period: usize,
74        rsi_period: usize,
75        left: usize,
76        right: usize,
77        lookback: usize,
78    ) -> Self {
79        Self {
80            momentum_period: momentum_period.max(1),
81            rsi_period: rsi_period.max(1),
82            left: left.max(1),
83            right: right.max(1),
84            lookback: lookback.max(1),
85            oversold: 30,
86            overbought: 70,
87            values: IndicatorValues::new(),
88            stepper: Stepper::default(),
89            colors: [palette::ACCENT, palette::GREEN, palette::RED],
90            visible: true,
91        }
92    }
93}
94
95/// Construct with the conventional default parameters.
96impl Default for KnoxvilleDivergence {
97    fn default() -> Self {
98        Self::new(20, 14, 5, 5, 50)
99    }
100}
101
102impl Indicator for KnoxvilleDivergence {
103    crate::studies::inputs::study_inputs!(self;
104        momentum_period: length("Momentum period", 1, usize::MAX),
105        rsi_period: length("RSI period", 1, usize::MAX),
106        left: length("Pivot bars left", 1, usize::MAX),
107        right: length("Pivot bars right", 1, usize::MAX),
108        lookback: length("Bars between pivots", 1, usize::MAX),
109        oversold: length("RSI oversold", 0, 100),
110        overbought: length("RSI overbought", 0, 100)
111    ; {});
112
113    fn name(&self) -> &str {
114        "Knoxville Divergence"
115    }
116
117    fn desc(&self) -> &str {
118        "Knoxville Divergence - Price and momentum swings that disagree at an RSI extreme"
119    }
120
121    fn calculate(&mut self, data: &[Bar]) {
122        self.update(data, 0);
123    }
124
125    fn update(&mut self, data: &[Bar], from: usize) {
126        let keep = self.left.saturating_add(self.right).saturating_add(1);
127        self.stepper
128            .run(data, from, &mut self.values, |state, bar| {
129                if ![bar.high, bar.low, bar.close].iter().all(|v| v.is_finite()) {
130                    return IndicatorValue::None;
131                }
132                let rsi = match state.rsi.next(bar, self.rsi_period) {
133                    IndicatorValue::Single(value) if value.is_finite() => value,
134                    _ => f64::NAN,
135                };
136                state.closes.push_back(bar.close);
137                if state.closes.len() > self.momentum_period.saturating_add(1) {
138                    state.closes.pop_front();
139                }
140                let momentum = if state.closes.len() > self.momentum_period {
141                    bar.close - state.closes[0]
142                } else {
143                    f64::NAN
144                };
145                state.window.push_back(Pivot {
146                    position: state.position,
147                    high: bar.high,
148                    low: bar.low,
149                    momentum,
150                    rsi,
151                });
152                state.position += 1;
153                if state.window.len() > keep {
154                    state.window.pop_front();
155                }
156                let mut row = [momentum, f64::NAN, f64::NAN];
157                if let Some(at) = state
158                    .window
159                    .len()
160                    .checked_sub(self.right.saturating_add(1))
161                    .filter(|&at| at >= self.left)
162                {
163                    let p = state.window[at];
164                    for high in [false, true] {
165                        let price = |pivot: &Pivot| if high { pivot.high } else { pivot.low };
166                        let before = state.window.range(at - self.left..at).all(|v| {
167                            if high {
168                                price(v) <= price(&p)
169                            } else {
170                                price(v) >= price(&p)
171                            }
172                        });
173                        let after = state.window.range(at + 1..).all(|v| {
174                            if high {
175                                price(v) < price(&p)
176                            } else {
177                                price(v) > price(&p)
178                            }
179                        });
180                        if !(before && after) {
181                            continue;
182                        }
183                        let Some(q) = state.previous[usize::from(high)].replace(p) else {
184                            continue;
185                        };
186                        let diverges = if high {
187                            p.high > q.high
188                                && p.momentum < q.momentum
189                                && p.rsi >= self.overbought as f64
190                        } else {
191                            p.low < q.low
192                                && p.momentum > q.momentum
193                                && p.rsi <= self.oversold as f64
194                        };
195                        // A NaN momentum or RSI fails every comparison above.
196                        if diverges && p.position - q.position <= self.lookback {
197                            row[1 + usize::from(high)] = p.momentum;
198                        }
199                    }
200                }
201                IndicatorValue::Multiple(row.to_vec())
202            });
203    }
204
205    fn is_incremental(&self) -> bool {
206        true
207    }
208
209    fn plot_style(&self, line: usize) -> crate::studies::PlotStyle {
210        crate::studies::PlotStyle {
211            color: self.colors.get(line).copied().unwrap_or(Color32::WHITE),
212            width: 1.0,
213            pattern: crate::model::LineStyle::Solid,
214            plot_type: match line {
215                0 => crate::studies::PlotType::Line,
216                1 => crate::studies::PlotType::TriangleUp,
217                _ => crate::studies::PlotType::TriangleDown,
218            },
219            visible: true,
220        }
221    }
222
223    fn values(&self) -> &IndicatorValues {
224        &self.values
225    }
226
227    fn colors(&self) -> Vec<Color32> {
228        self.colors.to_vec()
229    }
230
231    fn set_colors(&mut self, colors: Vec<Color32>) {
232        for (old, new) in self.colors.iter_mut().zip(colors) {
233            *old = new;
234        }
235    }
236
237    fn pane(&self) -> crate::studies::StudyPane {
238        crate::studies::StudyPane {
239            levels: vec![(0.0, palette::GRAY, "Zero")],
240            ..Default::default()
241        }
242    }
243
244    fn is_overlay(&self) -> bool {
245        false
246    }
247
248    fn is_visible(&self) -> bool {
249        self.visible
250    }
251
252    fn set_visible(&mut self, visible: bool) {
253        self.visible = visible;
254    }
255
256    fn clone_box(&self) -> Box<dyn Indicator> {
257        Box::new(self.clone())
258    }
259
260    fn line_names(&self) -> Vec<String> {
261        ["Momentum", "Bullish Divergence", "Bearish Divergence"]
262            .map(String::from)
263            .to_vec()
264    }
265}
266
267#[cfg(test)]
268mod tests {
269    use super::*;
270    use crate::studies::InputValue;
271
272    /// Momentum of 1 bar, an RSI of 2 bars, one bar on each side of a pivot, flat bars. Closes
273    /// 10, 8, 9, 7.5, 8.5 have pivot lows 8 at bar 1 and 7.5 at bar 3, a lower low whose momentum
274    /// -1.5 beats -2. The RSI at bar 3: changes -2, +1, -1.5 average (Wilder, seeded by the first two)
275    /// gain 0.5 then 0.25, loss 1 then 1.25, so RS = 0.2 and RSI = 100 - 100 / 1.2 = 16.67. The
276    /// signal is marked on bar 4, where the pivot is confirmed. Negated prices give the bearish
277    /// mirror with an RSI of 83.33.
278    #[test]
279    fn divergences_need_pivots_in_range_and_an_rsi_extreme() {
280        let closes = [10.0, 8.0, 9.0, 7.5, 8.5];
281        let bullish = super::super::hlc_bars(&closes.map(|c| (c, c, c)));
282        let bearish = super::super::hlc_bars(&closes.map(|c| (-c, -c, -c)));
283        let none = None;
284        let signal = |bars: &[Bar], study: &KnoxvilleDivergence, line| {
285            let mut study = study.clone();
286            study.calculate(bars);
287            (0..bars.len())
288                .map(|at| study.values().value(line, at))
289                .collect::<Vec<_>>()
290        };
291        let study = KnoxvilleDivergence::new(1, 2, 1, 1, 10);
292        super::super::assert_replay_matches_batch(&study, &bullish);
293        super::super::assert_replay_matches_batch(&study, &bearish);
294        assert_eq!(
295            signal(&bullish, &study, 1),
296            [none, none, none, none, Some(-1.5)]
297        );
298        assert_eq!(signal(&bullish, &study, 2), [none; 5]);
299        assert_eq!(
300            signal(&bearish, &study, 2),
301            [none, none, none, none, Some(1.5)]
302        );
303        assert_eq!(signal(&bearish, &study, 1), [none; 5]);
304        // The pivots are 2 bars apart, the RSI 16.67 is over a limit of 10, and the mirror's 83.33 under 90.
305        let limit = |id: &str, value: usize| {
306            let mut study = study.clone();
307            study.set_input(id, InputValue::Length(value)).unwrap();
308            study
309        };
310        for (study, bars, line) in [
311            (KnoxvilleDivergence::new(1, 2, 1, 1, 1), &bullish, 1),
312            (limit("oversold", 10), &bullish, 1),
313            (limit("overbought", 90), &bearish, 2),
314        ] {
315            assert_eq!(signal(bars, &study, line), [none; 5]);
316        }
317    }
318}