Skip to main content

egui_charts/studies/builtin/
wedge.rs

1//! Rising and falling wedges.
2//!
3//! Detects two converging trendlines through confirmed swing pivots, and confirms the wedge when
4//! the close leaves it on the side the pattern points to.
5//!
6//! # Rule
7//!
8//! Swing pivots are confirmed `right` bars after their bar and alternate between highs and lows
9//! (see [`PivotPointsHighLow`](super::PivotPointsHighLow)). Whenever the swings change, the latest
10//! `touches` pivot highs and `touches` pivot lows form a candidate when:
11//!
12//! ```text
13//! Rising:   highs rise, lows rise, both lines slope up, the lower line more steeply
14//! Falling:  highs fall, lows fall, both lines slope down, the upper line more steeply
15//! Both:     width at the last anchor <= width at the first anchor * (100 - convergence) / 100
16//!           and the anchors span at most max_bars bars
17//! ```
18//!
19//! The upper line joins the first and last pivot high and the lower line the first and last pivot
20//! low; every pivot between them must keep the direction, which is how touches are counted. A
21//! candidate is developing: its lines are output on every bar from the bar it is found until it
22//! is confirmed, replaced or ends. The wedge is confirmed on the first bar that closes below the
23//! lower line of a rising wedge or above the upper line of a falling one. It ends without
24//! confirmation when the close leaves it on the other side, when the lines meet, or after
25//! `max_bars` bars from the first anchor. A confirmed wedge consumes its pivots, so the next
26//! candidate needs new ones.
27//!
28//! The outputs are the developing upper and lower lines and, on the confirmation bar only, the
29//! price of the line the close broke. The table lists the developing wedge and the last `history`
30//! confirmed ones with the times of their anchors and confirmation. Nothing found or confirmed
31//! changes with later bars. Bars with a non-finite high, low or close are skipped. The defaults
32//! are 5 bars on each side of a pivot, 3 touches, a convergence of 10 percent, at most 100 bars and
33//! 5 confirmed wedges.
34
35use super::swings::{Swing, Swings};
36use crate::model::Bar;
37use crate::studies::palette;
38use crate::studies::stepper::Stepper;
39use crate::studies::{Indicator, IndicatorValue, IndicatorValues, StudyCell, StudyTable};
40use chrono::{DateTime, Utc};
41use egui::Color32;
42use std::collections::VecDeque;
43
44/// A wedge that slopes up and narrows, confirmed by a close below its lower line.
45pub type RisingWedge = Wedge<true>;
46/// A wedge that slopes down and narrows, confirmed by a close above its upper line.
47pub type FallingWedge = Wedge<false>;
48
49/// A rising or falling wedge found from confirmed swing pivots.
50#[derive(Clone)]
51pub struct Wedge<const RISING: bool> {
52    left: usize,
53    right: usize,
54    touches: usize,
55    convergence: usize,
56    max_bars: usize,
57    history: usize,
58    values: IndicatorValues,
59    stepper: Stepper<State>,
60    table: StudyTable,
61    colors: [Color32; 3],
62    visible: bool,
63}
64
65/// A line through two points, by position among the usable bars.
66#[derive(Clone, Copy)]
67struct Line {
68    from: (f64, f64),
69    to: (f64, f64),
70}
71
72impl Line {
73    fn at(&self, x: f64) -> f64 {
74        self.from.1 + (self.to.1 - self.from.1) * (x - self.from.0) / (self.to.0 - self.from.0)
75    }
76}
77
78#[derive(Clone)]
79struct Candidate {
80    upper: Line,
81    lower: Line,
82    first: Swing,
83    last: Swing,
84}
85
86#[derive(Clone)]
87struct Confirmed {
88    first: Swing,
89    last: Swing,
90    time: DateTime<Utc>,
91    level: f64,
92}
93
94#[derive(Clone, Default)]
95struct State {
96    swings: Swings,
97    candidate: Option<Candidate>,
98    confirmed: VecDeque<Confirmed>,
99}
100
101impl<const RISING: bool> Wedge<RISING> {
102    /// Pivots with `left` and `right` bars on each side, `touches` pivot highs and lows per
103    /// wedge, a narrowing of at least `convergence` percent, at most `max_bars` bars between the
104    /// first and last anchor and the last `history` confirmed wedges listed. Lengths are raised to
105    /// at least 1, touches to 2 and at most 10, convergence to at most 100 and history to at most 100.
106    pub fn new(
107        left: usize,
108        right: usize,
109        touches: usize,
110        convergence: usize,
111        max_bars: usize,
112        history: usize,
113    ) -> Self {
114        Self {
115            left: left.max(1),
116            right: right.max(1),
117            touches: touches.clamp(2, 10),
118            convergence: convergence.min(100),
119            max_bars: max_bars.max(1),
120            history: history.clamp(1, 100),
121            values: IndicatorValues::new(),
122            stepper: Stepper::default(),
123            table: StudyTable {
124                title: if RISING {
125                    "Rising Wedge"
126                } else {
127                    "Falling Wedge"
128                }
129                .into(),
130                columns: ["Wedge", "Anchors", "Confirmed"].map(String::from).to_vec(),
131                rows: Vec::new(),
132            },
133            colors: if RISING {
134                [palette::GREEN, palette::GREEN, palette::RED]
135            } else {
136                [palette::RED, palette::RED, palette::GREEN]
137            },
138            visible: true,
139        }
140    }
141
142    /// The wedge the latest `touches` pivot highs and lows form, if any.
143    fn detect(&self, pivots: &VecDeque<Swing>) -> Option<Candidate> {
144        let used = self.touches * 2;
145        let pivots = pivots.range(pivots.len().checked_sub(used)?..);
146        let rises = |a: f64, b: f64| if RISING { b > a } else { b < a };
147        let side = |high: bool| -> Option<Vec<Swing>> {
148            let swings: Vec<_> = pivots.clone().filter(|s| s.high == high).copied().collect();
149            swings
150                .windows(2)
151                .all(|pair| rises(pair[0].price, pair[1].price))
152                .then_some(swings)
153        };
154        let (highs, lows) = (side(true)?, side(false)?);
155        let line = |swings: &[Swing]| {
156            let (a, b) = (swings[0], swings[swings.len() - 1]);
157            Line {
158                from: (a.position as f64, a.price),
159                to: (b.position as f64, b.price),
160            }
161        };
162        let (upper, lower) = (line(&highs), line(&lows));
163        let slope = |line: Line| (line.to.1 - line.from.1) / (line.to.0 - line.from.0);
164        let (upper_slope, lower_slope) = (slope(upper), slope(lower));
165        let converging = if RISING {
166            upper_slope > 0.0 && lower_slope > upper_slope
167        } else {
168            lower_slope < 0.0 && upper_slope < lower_slope
169        };
170        let first = *pivots.clone().next()?;
171        let last = *pivots.clone().last()?;
172        let (start, end) = (first.position as f64, last.position as f64);
173        let (before, after) = (
174            upper.at(start) - lower.at(start),
175            upper.at(end) - lower.at(end),
176        );
177        let narrows = before > 0.0
178            && after > 0.0
179            && after * 100.0 <= before * (100 - self.convergence) as f64;
180        (converging && narrows && last.position - first.position <= self.max_bars).then_some(
181            Candidate {
182                upper,
183                lower,
184                first,
185                last,
186            },
187        )
188    }
189}
190
191/// Construct with the conventional default parameters.
192impl<const RISING: bool> Default for Wedge<RISING> {
193    fn default() -> Self {
194        Self::new(5, 5, 3, 10, 100, 5)
195    }
196}
197
198impl<const RISING: bool> Indicator for Wedge<RISING> {
199    crate::studies::inputs::study_inputs!(self;
200        left: length("Pivot bars left", 1, usize::MAX),
201        right: length("Pivot bars right", 1, usize::MAX),
202        touches: length("Touches per line", 2, 10),
203        convergence: length("Minimum convergence (%)", 0, 100),
204        max_bars: length("Maximum bars", 1, usize::MAX),
205        history: length("Confirmed wedges listed", 1, 100)
206    ; {});
207
208    fn name(&self) -> &str {
209        if RISING {
210            "Rising Wedge"
211        } else {
212            "Falling Wedge"
213        }
214    }
215
216    fn desc(&self) -> &str {
217        if RISING {
218            "Rising Wedge - Converging upward trendlines through swing pivots, confirmed by a close below the lower line"
219        } else {
220            "Falling Wedge - Converging downward trendlines through swing pivots, confirmed by a close above the upper line"
221        }
222    }
223
224    fn calculate(&mut self, data: &[Bar]) {
225        self.update(data, 0);
226    }
227
228    fn update(&mut self, data: &[Bar], from: usize) {
229        let capacity = self.touches * 2;
230        let mut stepper = std::mem::take(&mut self.stepper);
231        let mut values = std::mem::take(&mut self.values);
232        stepper.run(data, from, &mut values, |state, bar| {
233            if ![bar.high, bar.low, bar.close].iter().all(|v| v.is_finite()) {
234                state.swings.skip();
235                return IndicatorValue::None;
236            }
237            let x = state.swings.position as f64;
238            if state.swings.push(bar, self.left, self.right, capacity) {
239                state.candidate = self.detect(&state.swings.pivots);
240            }
241            let mut row = [f64::NAN; 3];
242            if let Some(candidate) = state.candidate.clone() {
243                let (upper, lower) = (candidate.upper.at(x), candidate.lower.at(x));
244                let first = candidate.first.position as f64;
245                let broke = if RISING {
246                    bar.close < lower
247                } else {
248                    bar.close > upper
249                };
250                let left_other_side = if RISING {
251                    bar.close > upper
252                } else {
253                    bar.close < lower
254                };
255                if broke {
256                    state.confirmed.push_front(Confirmed {
257                        first: candidate.first,
258                        last: candidate.last,
259                        time: bar.time,
260                        level: if RISING { lower } else { upper },
261                    });
262                    state.confirmed.truncate(self.history);
263                    row = [upper, lower, if RISING { lower } else { upper }];
264                    state.candidate = None;
265                    state.swings.pivots.clear();
266                } else if left_other_side || upper <= lower || x - first > self.max_bars as f64 {
267                    state.candidate = None;
268                } else {
269                    row[..2].copy_from_slice(&[upper, lower]);
270                }
271            }
272            IndicatorValue::Multiple(row.to_vec())
273        });
274        let state = stepper.state();
275        let time = |time: DateTime<Utc>| time.format("%Y-%m-%d %H:%M").to_string();
276        let anchors =
277            |first: &Swing, last: &Swing| format!("{} to {}", time(first.time), time(last.time));
278        let text = |text: String| StudyCell::Text(text);
279        self.table.rows = state
280            .candidate
281            .iter()
282            .map(|c| {
283                vec![
284                    text("Developing".into()),
285                    text(anchors(&c.first, &c.last)),
286                    text("-".into()),
287                ]
288            })
289            .chain(state.confirmed.iter().map(|c| {
290                vec![
291                    text(format!("Broke {:.2}", c.level)),
292                    text(anchors(&c.first, &c.last)),
293                    text(time(c.time)),
294                ]
295            }))
296            .collect();
297        if self.table.rows.is_empty() {
298            self.table
299                .rows
300                .push(["No wedge", "-", "-"].map(|t| text(t.into())).to_vec());
301        }
302        self.stepper = stepper;
303        self.values = values;
304    }
305
306    fn is_incremental(&self) -> bool {
307        true
308    }
309
310    fn table(&self) -> Option<&StudyTable> {
311        Some(&self.table)
312    }
313
314    fn plot_style(&self, line: usize) -> crate::studies::PlotStyle {
315        crate::studies::PlotStyle {
316            color: self.colors.get(line).copied().unwrap_or(Color32::WHITE),
317            width: if line < 2 { 2.0 } else { 1.0 },
318            pattern: crate::model::LineStyle::Solid,
319            plot_type: match line {
320                0 | 1 => crate::studies::PlotType::Line,
321                _ if RISING => crate::studies::PlotType::TriangleDown,
322                _ => crate::studies::PlotType::TriangleUp,
323            },
324            visible: true,
325        }
326    }
327
328    fn values(&self) -> &IndicatorValues {
329        &self.values
330    }
331
332    fn colors(&self) -> Vec<Color32> {
333        self.colors.to_vec()
334    }
335
336    fn set_colors(&mut self, colors: Vec<Color32>) {
337        for (old, new) in self.colors.iter_mut().zip(colors) {
338            *old = new;
339        }
340    }
341
342    fn is_visible(&self) -> bool {
343        self.visible
344    }
345
346    fn set_visible(&mut self, visible: bool) {
347        self.visible = visible;
348    }
349
350    fn clone_box(&self) -> Box<dyn Indicator> {
351        Box::new(self.clone())
352    }
353
354    fn line_names(&self) -> Vec<String> {
355        ["Upper Line", "Lower Line", "Confirmed Breakout"]
356            .map(String::from)
357            .to_vec()
358    }
359}
360
361#[cfg(test)]
362mod tests {
363    use super::*;
364
365    fn flat_bars(closes: &[f64]) -> Vec<Bar> {
366        super::super::hlc_bars(&closes.iter().map(|&c| (c, c, c)).collect::<Vec<_>>())
367    }
368
369    fn lines<const RISING: bool>(wedge: &Wedge<RISING>, bars: &[Bar]) -> [Vec<Option<f64>>; 3] {
370        let mut wedge = wedge.clone();
371        wedge.calculate(bars);
372        [0, 1, 2].map(|line| {
373            (0..bars.len())
374                .map(|at| wedge.values().value(line, at))
375                .collect()
376        })
377    }
378
379    /// One pivot on each side, two touches, by hand. Pivots: low 10 at bar 1, high 20 at 3, low 14
380    /// at 5 and high 23 at 7, confirmed on bar 8. The upper line has slope 3/4 and the lower 1, so
381    /// they meet and the width falls from 8.5 at bar 1 to 7 at bar 7. At bar 8 the lines are 23.75
382    /// and 17, at bar 9 24.5 and 18. Bar 10 closes at 17.5 under the lower line 19: confirmed.
383    /// Negated prices give the falling wedge, confirmed by a close over its upper line.
384    #[test]
385    fn wedges_follow_the_swing_and_breakout_rules() {
386        let rising = [
387            12.0, 10.0, 15.0, 20.0, 17.0, 14.0, 19.0, 23.0, 21.0, 20.0, 17.5,
388        ];
389        let (up, down) = (
390            RisingWedge::new(1, 1, 2, 0, 100, 5),
391            FallingWedge::new(1, 1, 2, 0, 100, 5),
392        );
393        let nan = None;
394        let bars = flat_bars(&rising);
395        assert_eq!(
396            lines(&up, &bars),
397            [
398                [
399                    nan,
400                    nan,
401                    nan,
402                    nan,
403                    nan,
404                    nan,
405                    nan,
406                    nan,
407                    Some(23.75),
408                    Some(24.5),
409                    Some(25.25)
410                ]
411                .to_vec(),
412                [
413                    nan,
414                    nan,
415                    nan,
416                    nan,
417                    nan,
418                    nan,
419                    nan,
420                    nan,
421                    Some(17.0),
422                    Some(18.0),
423                    Some(19.0)
424                ]
425                .to_vec(),
426                [nan, nan, nan, nan, nan, nan, nan, nan, nan, nan, Some(19.0)].to_vec(),
427            ]
428        );
429        let mut wedge = up.clone();
430        wedge.calculate(&bars);
431        let rows: Vec<_> = wedge
432            .table()
433            .unwrap()
434            .rows
435            .iter()
436            .map(|row| format!("{row:?}"))
437            .collect();
438        assert_eq!(rows.len(), 1);
439        assert!(
440            rows[0].contains("Broke 19.00")
441                && rows[0].contains("1970-01-02 00:00 to 1970-01-08 00:00")
442        );
443        assert!(rows[0].contains("1970-01-11 00:00"), "{}", rows[0]);
444        super::super::assert_replay_matches_batch(&up, &bars);
445        // The second high 19 is under the first 20, so no rising wedge forms; the falling one needs
446        // falling lows, and the rising series has none.
447        let ambiguous = flat_bars(&[
448            12.0, 10.0, 15.0, 20.0, 17.0, 14.0, 19.0, 19.0, 18.0, 17.0, 16.0,
449        ]);
450        assert!(lines(&up, &ambiguous).iter().flatten().all(Option::is_none));
451        assert!(lines(&down, &bars).iter().flatten().all(Option::is_none));
452        // The mirror image is the falling wedge, its lines swapped and confirmed over the upper.
453        let mirrored = flat_bars(&rising.map(|price| -price));
454        assert_eq!(
455            lines(&down, &mirrored),
456            [
457                [
458                    nan,
459                    nan,
460                    nan,
461                    nan,
462                    nan,
463                    nan,
464                    nan,
465                    nan,
466                    Some(-17.0),
467                    Some(-18.0),
468                    Some(-19.0)
469                ]
470                .to_vec(),
471                [
472                    nan,
473                    nan,
474                    nan,
475                    nan,
476                    nan,
477                    nan,
478                    nan,
479                    nan,
480                    Some(-23.75),
481                    Some(-24.5),
482                    Some(-25.25)
483                ]
484                .to_vec(),
485                [
486                    nan,
487                    nan,
488                    nan,
489                    nan,
490                    nan,
491                    nan,
492                    nan,
493                    nan,
494                    nan,
495                    nan,
496                    Some(-19.0)
497                ]
498                .to_vec(),
499            ]
500        );
501        // Bar 9 closes at 25 over the upper line 24.5, which ends the wedge unconfirmed. The low 21
502        // on bar 8 is confirmed then and forms a wedge whose upper line is the same, so it ends too.
503        let invalidated = flat_bars(&[
504            12.0, 10.0, 15.0, 20.0, 17.0, 14.0, 19.0, 23.0, 21.0, 25.0, 26.0,
505        ]);
506        let [upper, _, confirmed] = lines(&up, &invalidated);
507        assert_eq!(upper[8], Some(23.75));
508        assert!(upper[9..].iter().chain(&confirmed).all(Option::is_none));
509    }
510}