Skip to main content

egui_charts/studies/builtin/candlestick_patterns/
mod.rs

1//! All Candlestick Patterns
2//!
3//! Detects the common Japanese candlestick patterns described by Steve Nison and Thomas
4//! Bulkowski, filtered by the prevailing trend, and marks each bar where one completes.
5//!
6//! # Definitions
7//!
8//! Each bar has a body from `min(open, close)` to `max(open, close)`, an upper shadow
9//! `high - max(open, close)`, a lower shadow `min(open, close) - low` and a range
10//! `high - low`. The body average is the EMA of the body over 14 bars, seeded with their
11//! SMA. A body is long above the average and small below it. A bar is white when its close
12//! is above its open and black when below. A doji body is at most 5% of a nonzero range;
13//! a doji has a doji body and shadows within twice each other's length. A shadow is
14//! present when it exceeds 5% of the body. Prices are equal when they differ by at most
15//! 5% of the body average.
16//!
17//! The trend rule decides whether a bar is in a downtrend or an uptrend: `Sma50` compares
18//! the close with SMA(close, 50) (below is down, above is up), `Sma50Sma200` also requires
19//! SMA(close, 50) to be below or above SMA(close, 200), and `NoDetection` treats every bar
20//! as both. Before the averages exist no bar is in either trend. A pattern that requires a
21//! trend checks it on the bar named below.
22//!
23//! | Abbr. | Pattern | Side | Rule (`b` the bar, `p1`, `p2`, ... the bars before it) |
24//! |---|---|---|---|
25//! | AB | Abandoned Baby | bearish | `p2` white in an uptrend, `p1` a doji body whose low gaps above `p2`'s high, `b` black whose high gaps below `p1`'s low |
26//! | AB | Abandoned Baby | bullish | mirror: `p2` black in a downtrend, `p1` doji body gapping below, `b` white gapping above |
27//! | DCC | Dark Cloud Cover | bearish | `p1` long white in an uptrend; `b` black, opening at or above `p1`'s high, closing below the middle of `p1`'s body and above its open |
28//! | D | Doji | neutral | a doji that is neither a dragonfly nor a gravestone doji |
29//! | DS | Doji Star | bearish | `b` in an uptrend; `p1` long white; `b` a doji body gapping above `p1`'s body |
30//! | DS | Doji Star | bullish | `b` in a downtrend; `p1` long black; `b` a doji body gapping below `p1`'s body |
31//! | DTG | Downside Tasuki Gap | bearish | `b` in a downtrend; `p2` long black; `p1` small black, its body gapping below `p2`'s; `b` white, opening inside `p1`'s body and closing inside the gap |
32//! | DD | Dragonfly Doji | bullish | a doji body with an upper shadow no longer than the body |
33//! | BE | Engulfing | bearish | `b` long black in an uptrend; `p1` small white; `b`'s body covers `p1`'s, extending past at least one end |
34//! | BE | Engulfing | bullish | `b` long white in a downtrend; `p1` small black; `b`'s body covers `p1`'s, extending past at least one end |
35//! | EDS | Evening Doji Star | bearish | as Evening Star with a doji body for `p1` |
36//! | ES | Evening Star | bearish | `b` in an uptrend; `p2` long white; `p1` small, its body gapping above `p2`'s; `b` long black, its body gapping below `p1`'s and closing into the lower half of `p2`'s body |
37//! | FTM | Falling Three Methods | bearish | `p4` long black in a downtrend; `p3`, `p2`, `p1` small white, opening and closing within `p4`'s range; `b` long black closing below `p4`'s close |
38//! | FW | Falling Window | bearish | `p1` in a downtrend; `b`'s high below `p1`'s low, neither range zero |
39//! | GD | Gravestone Doji | bearish | a doji body with a lower shadow no longer than the body |
40//! | H | Hammer | bullish | in a downtrend: a small nonzero body above the range midpoint, lower shadow at least twice the body, no upper shadow |
41//! | HM | Hanging Man | bearish | the hammer shape in an uptrend |
42//! | BH | Harami | bearish | `p1` long white in an uptrend; `b` small black within `p1`'s body, shadows included |
43//! | BH | Harami | bullish | `p1` long black in a downtrend; `b` small white within `p1`'s body, shadows included |
44//! | HC | Harami Cross | bearish | as the bearish Harami with a doji body for `b` |
45//! | HC | Harami Cross | bullish | as the bullish Harami with a doji body for `b` |
46//! | IH | Inverted Hammer | bullish | in a downtrend: a small nonzero body below the range midpoint, upper shadow at least twice the body, no lower shadow |
47//! | K | Kicking | bearish | `p1` a white marubozu, `b` a black marubozu whose high gaps below `p1`'s low |
48//! | K | Kicking | bullish | `p1` a black marubozu, `b` a white marubozu whose low gaps above `p1`'s high |
49//! | LLS | Long Lower Shadow | bullish | lower shadow longer than 75% of the range |
50//! | LUS | Long Upper Shadow | bearish | upper shadow longer than 75% of the range |
51//! | MB | Marubozu Black | bearish | long black body, each shadow at most 5% of the body |
52//! | MW | Marubozu White | bullish | long white body, each shadow at most 5% of the body |
53//! | MDS | Morning Doji Star | bullish | as Morning Star with a doji body for `p1` |
54//! | MS | Morning Star | bullish | `b` in a downtrend; `p2` long black; `p1` small, its body gapping below `p2`'s; `b` long white, its body gapping above `p1`'s and closing into the upper half of `p2`'s body |
55//! | ON | On Neck | bearish | `b` in a downtrend; `p1` long black; `b` small white, opening below `p1`'s close and closing equal to `p1`'s low |
56//! | P | Piercing | bullish | `p1` long black in a downtrend; `b` white, opening at or below `p1`'s low, closing above the middle of `p1`'s body and below its open |
57//! | RTM | Rising Three Methods | bullish | `p4` long white in an uptrend; `p3`, `p2`, `p1` small black, opening and closing within `p4`'s range; `b` long white closing above `p4`'s close |
58//! | RW | Rising Window | bullish | `p1` in an uptrend; `b`'s low above `p1`'s high, neither range zero |
59//! | SS | Shooting Star | bearish | the inverted hammer shape in an uptrend |
60//! | STB | Spinning Top Black | neutral | black, not a doji body, each shadow at least 34% of the range |
61//! | STW | Spinning Top White | neutral | white, not a doji body, each shadow at least 34% of the range |
62//! | 3BC | Three Black Crows | bearish | three long black bars, each closing lower, opening within the previous body, with a lower shadow under 5% of its range |
63//! | 3WS | Three White Soldiers | bullish | three long white bars, each closing higher, opening within the previous body, with an upper shadow under 5% of its range |
64//! | 3S | Tri-Star | bearish | three dojis, `p2` in an uptrend, `p1`'s body gapping above both neighbours' bodies |
65//! | 3S | Tri-Star | bullish | three dojis, `p2` in a downtrend, `p1`'s body gapping below both neighbours' bodies |
66//! | TB | Tweezer Bottom | bullish | `p1` long black in a downtrend; `b` white with a low equal to `p1`'s, not a doji body unless it has both shadows |
67//! | TT | Tweezer Top | bearish | `p1` long white in an uptrend; `b` black with a high equal to `p1`'s, not a doji body unless it has both shadows |
68//! | UTG | Upside Tasuki Gap | bullish | `b` in an uptrend; `p2` long white; `p1` small white, its body gapping above `p2`'s; `b` black, opening inside `p1`'s body and closing inside the gap |
69//!
70//! # Outputs
71//!
72//! Three lines: Bullish at the low of a bar with a bullish pattern (an upward triangle),
73//! Bearish at the high of a bar with a bearish one (a downward triangle) and Neutral at the
74//! high of a bar with a doji or spinning top (a circle). Each mark is labelled with the
75//! abbreviations of the patterns found on the bar, one per line; on a bar with a bearish
76//! pattern the neutral ones join the bearish label, so the two never overlap. Every pattern has a toggle,
77//! all on by default, and the pattern type marks both sides (the default, neutral patterns
78//! included) or only the bullish or only the bearish patterns.
79//!
80//! # Example
81//! ```
82//! # let bars: Vec<egui_charts::model::Bar> = (0..60).map(|i| { let p = 100.0 + (i as f64 * 0.3).sin() * 5.0; egui_charts::model::Bar::new(chrono::Utc::now(), p, p + 1.0, p - 1.0, p + 0.5, 1_000.0) }).collect();
83//! use egui_charts::studies::{CandlestickPatterns, Indicator, PatternTrend};
84//!
85//! let mut patterns = CandlestickPatterns::new(PatternTrend::Sma50Sma200);
86//! patterns.calculate(&bars);
87//! let labels: Vec<String> = (0..bars.len()).filter_map(|bar| patterns.plot_text(0, bar)).collect();
88//! ```
89
90use crate::model::Bar;
91use crate::studies::palette;
92use crate::studies::stepper::Stepper;
93use crate::studies::{Indicator, IndicatorValue, IndicatorValues};
94use egui::Color32;
95
96/// How [`CandlestickPatterns`] decides whether a bar is in a downtrend or an uptrend.
97#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
98pub enum PatternTrend {
99    /// The close below or above SMA(close, 50).
100    #[default]
101    Sma50,
102    /// The close below SMA(close, 50) below SMA(close, 200), or the reverse.
103    Sma50Sma200,
104    /// Every bar counts as both, so trend conditions always hold.
105    NoDetection,
106}
107
108/// Which signals [`CandlestickPatterns`] marks.
109#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
110pub enum PatternType {
111    /// Bullish, bearish and neutral patterns.
112    #[default]
113    Both,
114    /// Bullish patterns only.
115    Bullish,
116    /// Bearish patterns only.
117    Bearish,
118}
119
120mod rules;
121
122use rules::Side::{Bearish, Bullish, Neutral};
123use rules::{BODY_AVERAGE, Candle, PATTERNS, Pattern, Side};
124
125/// All Candlestick Patterns: the common candlestick patterns, each with a toggle, marked
126/// and labelled at the bar that completes them.
127#[derive(Clone)]
128pub struct CandlestickPatterns {
129    trend: PatternTrend,
130    pattern_type: PatternType,
131    patterns: [bool; PATTERNS.len()],
132    /// One bit per pattern of [`PATTERNS`] found on each source bar.
133    found: Vec<u64>,
134    values: IndicatorValues,
135    stepper: Stepper<State>,
136    colors: [Color32; 3],
137    visible: bool,
138}
139
140#[derive(Clone, Default)]
141struct State {
142    closes: std::collections::VecDeque<f64>,
143    bodies: std::collections::VecDeque<f64>,
144    average: Option<f64>,
145    candles: std::collections::VecDeque<Candle>,
146}
147
148impl CandlestickPatterns {
149    /// Detect every pattern with the given trend rule.
150    pub fn new(trend: PatternTrend) -> Self {
151        Self {
152            trend,
153            pattern_type: PatternType::Both,
154            patterns: [true; PATTERNS.len()],
155            found: Vec::new(),
156            values: IndicatorValues::new(),
157            stepper: Stepper::default(),
158            colors: [palette::BLUE, palette::RED, palette::GRAY],
159            visible: true,
160        }
161    }
162
163    /// Mark both sides with neutral patterns, or only bullish or only bearish patterns.
164    pub fn with_pattern_type(mut self, pattern_type: PatternType) -> Self {
165        self.pattern_type = pattern_type;
166        self
167    }
168
169    /// Detect only the patterns whose label, such as `"Hammer - Bullish"` or `"Doji"`,
170    /// `keep` accepts.
171    pub fn with_patterns(mut self, keep: impl Fn(&str) -> bool) -> Self {
172        for (on, pattern) in self.patterns.iter_mut().zip(&PATTERNS) {
173            *on = keep(pattern.label);
174        }
175        self
176    }
177
178    /// The patterns found on a source bar, from the last calculation.
179    fn found(&self, bar: usize, side: Side) -> impl Iterator<Item = &'static Pattern> + '_ {
180        let bits = self.found.get(bar).copied().unwrap_or(0);
181        PATTERNS
182            .iter()
183            .enumerate()
184            .filter(move |(k, p)| bits >> k & 1 == 1 && p.side == side)
185            .map(|(_, p)| p)
186    }
187}
188
189/// Construct with the conventional default trend rule and every pattern on.
190impl Default for CandlestickPatterns {
191    fn default() -> Self {
192        Self::new(PatternTrend::Sma50)
193    }
194}
195
196impl Indicator for CandlestickPatterns {
197    crate::studies::inputs::study_inputs!(self;
198        trend: choice("Detect trend based on", [PatternTrend::Sma50, PatternTrend::Sma50Sma200, PatternTrend::NoDetection]),
199        pattern_type: choice("Pattern type", [PatternType::Both, PatternType::Bullish, PatternType::Bearish]),
200        patterns[0]: toggle(PATTERNS[0].label),
201        patterns[1]: toggle(PATTERNS[1].label),
202        patterns[2]: toggle(PATTERNS[2].label),
203        patterns[3]: toggle(PATTERNS[3].label),
204        patterns[4]: toggle(PATTERNS[4].label),
205        patterns[5]: toggle(PATTERNS[5].label),
206        patterns[6]: toggle(PATTERNS[6].label),
207        patterns[7]: toggle(PATTERNS[7].label),
208        patterns[8]: toggle(PATTERNS[8].label),
209        patterns[9]: toggle(PATTERNS[9].label),
210        patterns[10]: toggle(PATTERNS[10].label),
211        patterns[11]: toggle(PATTERNS[11].label),
212        patterns[12]: toggle(PATTERNS[12].label),
213        patterns[13]: toggle(PATTERNS[13].label),
214        patterns[14]: toggle(PATTERNS[14].label),
215        patterns[15]: toggle(PATTERNS[15].label),
216        patterns[16]: toggle(PATTERNS[16].label),
217        patterns[17]: toggle(PATTERNS[17].label),
218        patterns[18]: toggle(PATTERNS[18].label),
219        patterns[19]: toggle(PATTERNS[19].label),
220        patterns[20]: toggle(PATTERNS[20].label),
221        patterns[21]: toggle(PATTERNS[21].label),
222        patterns[22]: toggle(PATTERNS[22].label),
223        patterns[23]: toggle(PATTERNS[23].label),
224        patterns[24]: toggle(PATTERNS[24].label),
225        patterns[25]: toggle(PATTERNS[25].label),
226        patterns[26]: toggle(PATTERNS[26].label),
227        patterns[27]: toggle(PATTERNS[27].label),
228        patterns[28]: toggle(PATTERNS[28].label),
229        patterns[29]: toggle(PATTERNS[29].label),
230        patterns[30]: toggle(PATTERNS[30].label),
231        patterns[31]: toggle(PATTERNS[31].label),
232        patterns[32]: toggle(PATTERNS[32].label),
233        patterns[33]: toggle(PATTERNS[33].label),
234        patterns[34]: toggle(PATTERNS[34].label),
235        patterns[35]: toggle(PATTERNS[35].label),
236        patterns[36]: toggle(PATTERNS[36].label),
237        patterns[37]: toggle(PATTERNS[37].label),
238        patterns[38]: toggle(PATTERNS[38].label),
239        patterns[39]: toggle(PATTERNS[39].label),
240        patterns[40]: toggle(PATTERNS[40].label),
241        patterns[41]: toggle(PATTERNS[41].label),
242        patterns[42]: toggle(PATTERNS[42].label),
243        patterns[43]: toggle(PATTERNS[43].label)
244    ; {});
245
246    fn plot_style(&self, line: usize) -> crate::studies::PlotStyle {
247        crate::studies::PlotStyle {
248            color: self.colors[line.min(2)],
249            width: 1.0,
250            pattern: crate::model::LineStyle::Solid,
251            plot_type: match line {
252                0 => crate::studies::PlotType::TriangleUp,
253                1 => crate::studies::PlotType::TriangleDown,
254                _ => crate::studies::PlotType::Circles,
255            },
256            visible: true,
257        }
258    }
259
260    fn plot_label_colors(&self, line: usize) -> Option<(Color32, Color32)> {
261        self.colors.get(line).map(|&color| (color, Color32::WHITE))
262    }
263
264    fn plot_text(&self, line: usize, bar: usize) -> Option<String> {
265        let side = [Bullish, Bearish, Neutral].get(line).copied()?;
266        let bearish = self.found(bar, Bearish).next().is_some();
267        let text: Vec<&str> = match side {
268            // The bearish and neutral marks share the high, so one label names both.
269            Bearish => self
270                .found(bar, Bearish)
271                .chain(self.found(bar, Neutral))
272                .map(|p| p.abbr)
273                .collect(),
274            Neutral if bearish => Vec::new(),
275            _ => self.found(bar, side).map(|p| p.abbr).collect(),
276        };
277        (!text.is_empty()).then(|| text.join("\n"))
278    }
279
280    fn name(&self) -> &str {
281        "All Candlestick Patterns"
282    }
283
284    fn desc(&self) -> &str {
285        "All Candlestick Patterns - Japanese candlestick patterns with a trend filter"
286    }
287
288    fn calculate(&mut self, data: &[Bar]) {
289        self.update(data, 0);
290    }
291
292    fn update(&mut self, data: &[Bar], from: usize) {
293        let len = self.values.len();
294        let start = if len > 0 && from >= len - 1 && data.len() >= len {
295            from.min(len)
296        } else {
297            0
298        };
299        self.found.truncate(start);
300        self.stepper
301            .run(data, from, &mut self.values, |state, bar| {
302                if ![bar.open, bar.high, bar.low, bar.close]
303                    .iter()
304                    .all(|v| v.is_finite())
305                {
306                    self.found.push(0);
307                    return IndicatorValue::None;
308                }
309                state.closes.push_back(bar.close);
310                if state.closes.len() > 200 {
311                    state.closes.pop_front();
312                }
313                let sma = |period| {
314                    if state.closes.len() < period {
315                        f64::NAN
316                    } else {
317                        state
318                            .closes
319                            .iter()
320                            .skip(state.closes.len() - period)
321                            .sum::<f64>()
322                            / period as f64
323                    }
324                };
325                let (down, up) = match self.trend {
326                    PatternTrend::Sma50 => {
327                        let mean = sma(50);
328                        (bar.close < mean, bar.close > mean)
329                    }
330                    PatternTrend::Sma50Sma200 => {
331                        let (fast, slow) = (sma(50), sma(200));
332                        (
333                            bar.close < fast && fast < slow,
334                            bar.close > fast && fast > slow,
335                        )
336                    }
337                    PatternTrend::NoDetection => (true, true),
338                };
339                let body = (bar.close - bar.open).abs();
340                state.bodies.push_back(body);
341                if state.bodies.len() > BODY_AVERAGE {
342                    state.bodies.pop_front();
343                }
344                let average = match state.average {
345                    Some(previous) => {
346                        previous + (2.0 / (BODY_AVERAGE + 1) as f64) * (body - previous)
347                    }
348                    None if state.bodies.len() == BODY_AVERAGE => {
349                        if state.bodies.iter().all(|v| v.is_finite()) {
350                            state.bodies.iter().sum::<f64>() / BODY_AVERAGE as f64
351                        } else {
352                            f64::NAN
353                        }
354                    }
355                    None => f64::NAN,
356                };
357                if state.bodies.len() == BODY_AVERAGE {
358                    state.average = Some(average);
359                }
360                state.candles.push_back(Candle {
361                    open: bar.open,
362                    high: bar.high,
363                    low: bar.low,
364                    close: bar.close,
365                    average,
366                    down,
367                    up,
368                });
369                let max_bars = PATTERNS.iter().map(|pattern| pattern.bars).max().unwrap();
370                if state.candles.len() > max_bars {
371                    state.candles.pop_front();
372                }
373                let candles = state.candles.make_contiguous();
374                let marked = |side| match self.pattern_type {
375                    PatternType::Both => true,
376                    PatternType::Bullish => side == Bullish,
377                    PatternType::Bearish => side == Bearish,
378                };
379                let mut bits = 0;
380                for (bit, pattern) in PATTERNS.iter().enumerate() {
381                    if self.patterns[bit]
382                        && marked(pattern.side)
383                        && candles.len() >= pattern.bars
384                        && (pattern.found)(&candles[candles.len() - pattern.bars..])
385                    {
386                        bits |= 1 << bit;
387                    }
388                }
389                self.found.push(bits);
390                let found = |side| {
391                    PATTERNS
392                        .iter()
393                        .enumerate()
394                        .any(|(bit, pattern)| bits >> bit & 1 == 1 && pattern.side == side)
395                };
396                let (bull, bear, neutral) = (found(Bullish), found(Bearish), found(Neutral));
397                IndicatorValue::Multiple(vec![
398                    if bull { bar.low } else { f64::NAN },
399                    if bear { bar.high } else { f64::NAN },
400                    if neutral && !bear { bar.high } else { f64::NAN },
401                ])
402            });
403    }
404
405    fn is_incremental(&self) -> bool {
406        true
407    }
408
409    fn values(&self) -> &IndicatorValues {
410        &self.values
411    }
412
413    fn colors(&self) -> Vec<Color32> {
414        self.colors.to_vec()
415    }
416
417    fn set_colors(&mut self, colors: Vec<Color32>) {
418        for (slot, color) in self.colors.iter_mut().zip(colors) {
419            *slot = color;
420        }
421    }
422
423    fn line_names(&self) -> Vec<String> {
424        vec!["Bullish".into(), "Bearish".into(), "Neutral".into()]
425    }
426
427    fn is_visible(&self) -> bool {
428        self.visible
429    }
430
431    fn set_visible(&mut self, visible: bool) {
432        self.visible = visible;
433    }
434
435    fn clone_box(&self) -> Box<dyn Indicator> {
436        Box::new(self.clone())
437    }
438}
439
440#[cfg(test)]
441mod tests {
442    use super::*;
443    use chrono::Utc;
444
445    type Ohlc = (f64, f64, f64, f64);
446    /// Label, line, abbreviation, bars that form the pattern and a near miss.
447    type Case = (
448        &'static str,
449        usize,
450        &'static str,
451        &'static [Ohlc],
452        &'static [Ohlc],
453    );
454
455    /// Label, line, abbreviation, the two bars before the third, the third and whether it forms the pattern.
456    type TasukiCase = (&'static str, usize, &'static str, [Ohlc; 2], Ohlc, bool);
457
458    /// Bars of body 1 through `closes`, each opening one point away from its close.
459    fn walk(closes: impl IntoIterator<Item = f64>) -> Vec<Bar> {
460        let mut prev: Option<f64> = None;
461        closes
462            .into_iter()
463            .map(|close| {
464                let open = prev.unwrap_or(close - 1.0);
465                prev = Some(close);
466                let (top, bottom) = (open.max(close), open.min(close));
467                Bar::new(Utc::now(), open, top + 0.25, bottom - 0.25, close, 1.0)
468            })
469            .collect()
470    }
471
472    /// The text on `line` of the last of `bars` after `before`, with only `label` on.
473    fn text(
474        trend: PatternTrend,
475        before: &[Bar],
476        bars: &[Ohlc],
477        label: &str,
478        line: usize,
479    ) -> Option<String> {
480        let mut data = before.to_vec();
481        data.extend(
482            bars.iter()
483                .map(|&(o, h, l, c)| Bar::new(Utc::now(), o, h, l, c, 1.0)),
484        );
485        let mut study =
486            CandlestickPatterns::new(trend).with_patterns(|p| label.is_empty() || p == label);
487        study.calculate(&data);
488        study.plot_text(line, data.len() - 1)
489    }
490
491    /// Each pattern, after bars of body 1 that set the body average and without a trend
492    /// filter, is marked on its own line with its abbreviation, and the near miss, which
493    /// breaks one rule, is not.
494    #[test]
495    fn patterns_match_their_definitions() {
496        let flat = walk((0..14).map(|i| 100.0 + f64::from(i % 2)));
497        #[rustfmt::skip]
498        let cases: [Case; 44] = [
499            ("Abandoned Baby - Bearish", 1, "AB",
500                &[(100.0, 103.0, 99.5, 102.5), (104.0, 105.0, 103.5, 104.0625), (103.0, 103.25, 100.0, 100.5)],
501                &[(100.0, 103.0, 99.5, 102.5), (104.0, 105.0, 103.5, 104.0625), (103.0, 103.5, 100.0, 100.5)]),
502            ("Abandoned Baby - Bullish", 0, "AB",
503                &[(102.5, 103.0, 99.5, 100.0), (98.5, 99.0, 97.5, 98.5625), (99.5, 102.5, 99.25, 102.0)],
504                &[(102.5, 103.0, 99.5, 100.0), (98.5, 99.0, 97.5, 98.5625), (99.5, 102.5, 99.0, 102.0)]),
505            ("Dark Cloud Cover - Bearish", 1, "DCC",
506                &[(100.0, 103.5, 99.5, 103.0), (104.0, 104.5, 101.0, 101.25)],
507                &[(100.0, 103.5, 99.5, 103.0), (104.0, 104.5, 101.5, 101.75)]),
508            ("Doji", 2, "D", &[(100.0, 102.0, 98.0, 100.0625)], &[(100.0, 102.0, 98.0, 100.5)]),
509            ("Doji Star - Bearish", 1, "DS",
510                &[(100.0, 103.25, 99.75, 103.0), (103.5, 104.5, 102.5, 103.5625)],
511                &[(100.0, 103.25, 99.75, 103.0), (102.75, 103.75, 101.75, 102.8125)]),
512            ("Doji Star - Bullish", 0, "DS",
513                &[(103.0, 103.25, 99.75, 100.0), (99.5, 100.5, 98.5, 99.4375)],
514                &[(103.0, 103.25, 99.75, 100.0), (100.25, 101.25, 99.25, 100.1875)]),
515            ("Downside Tasuki Gap - Bearish", 1, "DTG",
516                &[(104.0, 104.25, 99.75, 100.0), (99.5, 99.75, 98.75, 99.0), (99.25, 100.25, 98.75, 99.75)],
517                &[(104.0, 104.25, 99.75, 100.0), (99.5, 99.75, 98.75, 99.0), (99.25, 100.5, 98.75, 100.25)]),
518            ("Dragonfly Doji - Bullish", 0, "DD",
519                &[(100.0, 100.0625, 96.0, 100.0625)], &[(100.0, 100.5, 96.0, 100.0625)]),
520            ("Engulfing - Bearish", 1, "BE",
521                &[(100.5, 101.25, 100.25, 101.0), (101.25, 101.5, 99.75, 100.0)],
522                &[(100.5, 101.25, 100.25, 101.0), (100.75, 101.0, 99.25, 99.5)]),
523            ("Engulfing - Bullish", 0, "BE",
524                &[(101.0, 101.25, 100.25, 100.5), (100.25, 101.75, 100.0, 101.5)],
525                &[(101.0, 101.25, 100.25, 100.5), (100.75, 102.0, 100.5, 102.0)]),
526            ("Evening Doji Star - Bearish", 1, "EDS",
527                &[(100.0, 104.25, 99.75, 104.0), (105.0, 106.0, 104.5, 105.0625), (104.5, 104.75, 101.0, 101.5)],
528                &[(100.0, 104.25, 99.75, 104.0), (105.0, 106.0, 104.5, 105.5), (104.5, 104.75, 101.0, 101.5)]),
529            ("Evening Star - Bearish", 1, "ES",
530                &[(100.0, 104.25, 99.75, 104.0), (105.0, 106.0, 104.5, 105.5), (104.5, 104.75, 101.0, 101.5)],
531                &[(100.0, 104.25, 99.75, 104.0), (105.0, 106.0, 104.5, 105.5), (104.5, 104.75, 102.0, 102.25)]),
532            ("Falling Three Methods - Bearish", 1, "FTM",
533                &[(104.0, 104.5, 99.5, 100.0), (101.0, 101.75, 100.75, 101.5), (101.0, 101.75, 100.75, 101.5),
534                  (101.0, 101.75, 100.75, 101.5), (101.5, 101.75, 98.5, 99.0)],
535                &[(104.0, 104.5, 99.5, 100.0), (101.0, 101.75, 100.75, 101.5), (101.0, 101.75, 100.75, 101.5),
536                  (101.0, 101.75, 100.75, 101.5), (101.5, 101.75, 100.0, 100.25)]),
537            ("Falling Window - Bearish", 1, "FW",
538                &[(101.0, 101.5, 99.5, 100.0), (99.0, 99.25, 98.0, 98.5)],
539                &[(101.0, 101.5, 99.5, 100.0), (99.0, 99.5, 98.0, 98.5)]),
540            ("Gravestone Doji - Bearish", 1, "GD",
541                &[(100.0, 104.0, 100.0, 100.0625)], &[(100.0, 104.0, 99.5, 100.0625)]),
542            ("Hammer - Bullish", 0, "H", &[(100.0, 100.5, 98.0, 100.5)], &[(100.0, 100.75, 98.0, 100.5)]),
543            ("Hanging Man - Bearish", 1, "HM", &[(100.0, 100.5, 98.0, 100.5)], &[(100.0, 100.75, 98.0, 100.5)]),
544            ("Harami - Bearish", 1, "BH",
545                &[(100.0, 104.5, 99.5, 104.0), (102.5, 103.0, 101.5, 102.0)],
546                &[(100.0, 104.5, 99.5, 104.0), (102.5, 104.25, 101.5, 102.0)]),
547            ("Harami - Bullish", 0, "BH",
548                &[(104.0, 104.5, 99.5, 100.0), (101.5, 103.0, 101.0, 102.0)],
549                &[(104.0, 104.5, 99.5, 100.0), (101.5, 103.0, 99.75, 102.0)]),
550            ("Harami Cross - Bearish", 1, "HC",
551                &[(100.0, 104.5, 99.5, 104.0), (102.0, 103.0, 101.0, 102.0625)],
552                &[(100.0, 104.5, 99.5, 104.0), (102.0, 103.0, 101.0, 102.25)]),
553            ("Harami Cross - Bullish", 0, "HC",
554                &[(104.0, 104.5, 99.5, 100.0), (102.0, 103.0, 101.0, 102.0625)],
555                &[(104.0, 104.5, 99.5, 100.0), (102.0, 103.0, 101.0, 102.25)]),
556            ("Inverted Hammer - Bullish", 0, "IH", &[(100.0, 102.5, 100.0, 100.5)], &[(100.0, 102.5, 99.75, 100.5)]),
557            ("Kicking - Bearish", 1, "K",
558                &[(105.0, 107.0, 105.0, 107.0), (104.0, 104.0, 102.0, 102.0)],
559                &[(105.0, 107.0, 105.0, 107.0), (105.0, 105.0, 103.0, 103.0)]),
560            ("Kicking - Bullish", 0, "K",
561                &[(104.0, 104.0, 102.0, 102.0), (105.0, 107.0, 105.0, 107.0)],
562                &[(104.0, 104.0, 102.0, 102.0), (104.0, 106.0, 104.0, 106.0)]),
563            ("Long Lower Shadow - Bullish", 0, "LLS", &[(100.0, 100.5, 96.0, 100.25)], &[(100.0, 101.0, 97.0, 100.25)]),
564            ("Long Upper Shadow - Bearish", 1, "LUS", &[(100.0, 104.0, 99.5, 99.75)], &[(100.0, 103.0, 99.0, 99.75)]),
565            ("Marubozu Black - Bearish", 1, "MB", &[(102.0, 102.0, 100.0, 100.0)], &[(102.0, 102.0, 99.75, 100.0)]),
566            ("Marubozu White - Bullish", 0, "MW", &[(100.0, 102.0, 100.0, 102.0)], &[(100.0, 102.25, 100.0, 102.0)]),
567            ("Morning Doji Star - Bullish", 0, "MDS",
568                &[(104.0, 104.25, 99.75, 100.0), (98.5, 99.5, 98.0, 98.5625), (99.5, 103.0, 99.25, 102.5)],
569                &[(104.0, 104.25, 99.75, 100.0), (99.0, 99.5, 98.0, 98.5), (99.5, 103.0, 99.25, 102.5)]),
570            ("Morning Star - Bullish", 0, "MS",
571                &[(104.0, 104.25, 99.75, 100.0), (99.0, 99.5, 98.0, 98.5), (99.5, 103.0, 99.25, 102.5)],
572                &[(104.0, 104.25, 99.75, 100.0), (99.0, 99.5, 98.0, 98.5), (99.5, 102.0, 99.25, 101.75)]),
573            ("On Neck - Bearish", 1, "ON",
574                &[(104.0, 104.5, 99.75, 100.0), (99.0, 100.0, 98.75, 99.75)],
575                &[(104.0, 104.5, 99.75, 100.0), (99.0, 100.5, 98.75, 100.25)]),
576            ("Piercing - Bullish", 0, "P",
577                &[(103.0, 103.5, 99.5, 100.0), (99.0, 102.0, 98.5, 101.75)],
578                &[(103.0, 103.5, 99.5, 100.0), (99.0, 102.0, 98.5, 101.25)]),
579            ("Rising Three Methods - Bullish", 0, "RTM",
580                &[(100.0, 104.5, 99.5, 104.0), (103.0, 103.25, 102.25, 102.5), (103.0, 103.25, 102.25, 102.5),
581                  (103.0, 103.25, 102.25, 102.5), (102.5, 105.25, 102.25, 105.0)],
582                &[(100.0, 104.5, 99.5, 104.0), (103.0, 103.25, 102.25, 102.5), (103.0, 103.25, 102.25, 102.5),
583                  (103.0, 103.25, 102.25, 102.5), (102.5, 104.0, 102.25, 103.75)]),
584            ("Rising Window - Bullish", 0, "RW",
585                &[(100.0, 101.5, 99.5, 101.0), (102.0, 103.0, 101.75, 102.5)],
586                &[(100.0, 101.5, 99.5, 101.0), (102.0, 103.0, 101.5, 102.5)]),
587            ("Shooting Star - Bearish", 1, "SS", &[(100.0, 102.5, 100.0, 100.5)], &[(100.0, 102.5, 99.75, 100.5)]),
588            ("Spinning Top Black", 2, "STB", &[(100.5, 102.0, 98.0, 100.0)], &[(100.5, 101.75, 98.0, 100.0)]),
589            ("Spinning Top White", 2, "STW", &[(100.0, 102.0, 98.0, 100.5)], &[(100.0, 101.75, 98.0, 100.5)]),
590            ("Three Black Crows - Bearish", 1, "3BC",
591                &[(104.0, 104.5, 102.0, 102.0), (103.0, 103.5, 101.0, 101.0), (102.0, 102.5, 100.0, 100.0)],
592                &[(104.0, 104.5, 102.0, 102.0), (103.0, 103.5, 101.0, 101.0), (102.0, 102.5, 99.5, 100.0)]),
593            ("Three White Soldiers - Bullish", 0, "3WS",
594                &[(100.0, 102.0, 99.5, 102.0), (101.0, 103.0, 100.5, 103.0), (102.0, 104.0, 101.5, 104.0)],
595                &[(100.0, 102.0, 99.5, 102.0), (101.0, 103.0, 100.5, 103.0), (102.0, 104.5, 101.5, 104.0)]),
596            ("Tri-Star - Bearish", 1, "3S",
597                &[(100.0, 101.0, 99.0, 100.0), (102.0, 103.0, 101.0, 102.0), (100.0, 101.0, 99.0, 100.0)],
598                &[(100.0, 101.0, 99.0, 100.0), (100.0, 101.0, 99.0, 100.0), (100.0, 101.0, 99.0, 100.0)]),
599            ("Tri-Star - Bullish", 0, "3S",
600                &[(100.0, 101.0, 99.0, 100.0), (98.0, 99.0, 97.0, 98.0), (100.0, 101.0, 99.0, 100.0)],
601                &[(100.0, 101.0, 99.0, 100.0), (100.0, 101.0, 99.0, 100.0), (100.0, 101.0, 99.0, 100.0)]),
602            ("Tweezer Bottom - Bullish", 0, "TB",
603                &[(103.0, 103.25, 99.5, 100.0), (100.0, 101.5, 99.5, 101.0)],
604                &[(103.0, 103.25, 99.5, 100.0), (100.0, 101.5, 99.25, 101.0)]),
605            ("Tweezer Top - Bearish", 1, "TT",
606                &[(100.0, 103.5, 99.75, 103.0), (103.0, 103.5, 101.5, 102.0)],
607                &[(100.0, 103.5, 99.75, 103.0), (103.0, 103.75, 101.5, 102.0)]),
608            ("Upside Tasuki Gap - Bullish", 0, "UTG",
609                &[(100.0, 104.25, 99.75, 104.0), (104.5, 105.25, 104.25, 105.0), (104.75, 105.25, 104.0, 104.25)],
610                &[(100.0, 104.25, 99.75, 104.0), (104.5, 105.25, 104.25, 105.0), (104.75, 105.25, 103.5, 103.75)]),
611        ];
612        let none = PatternTrend::NoDetection;
613        // Bars near 100 are in a downtrend after closes falling to 90 and in an uptrend after
614        // closes rising to 99. A reversal needs the trend it ends, a continuation the trend
615        // it continues, and the shapes below need none.
616        let falling = walk((90..150).rev().map(f64::from));
617        let rising = walk((40..100).map(f64::from));
618        for (label, line, abbr, hit, miss) in cases {
619            assert_eq!(
620                text(none, &flat, hit, label, line).as_deref(),
621                Some(abbr),
622                "{label}"
623            );
624            assert_eq!(text(none, &flat, miss, label, 0), None, "{label} near miss");
625            assert_eq!(text(none, &flat, miss, label, 1), None, "{label} near miss");
626            assert_eq!(text(none, &flat, miss, label, 2), None, "{label} near miss");
627            let trendless = [
628                "D", "DD", "GD", "K", "LLS", "LUS", "MB", "MW", "STB", "STW", "3BC", "3WS",
629            ];
630            let continuation = ["DTG", "FTM", "FW", "ON", "RTM", "RW", "UTG"];
631            let (with, against) = if (line == 0) != continuation.contains(&abbr) {
632                (&falling, &rising)
633            } else {
634                (&rising, &falling)
635            };
636            let sma = PatternTrend::Sma50;
637            assert_eq!(
638                text(sma, with, hit, label, line).as_deref(),
639                Some(abbr),
640                "{label} in trend"
641            );
642            assert_eq!(
643                text(sma, against, hit, label, line).as_deref(),
644                trendless.contains(&abbr).then_some(abbr),
645                "{label} against trend"
646            );
647        }
648
649        // The trend is checked on the bar the definition names: a window rising from a bar
650        // below SMA(50) is not marked, even when the gap carries the close above it.
651        let window: &[Ohlc] = &[(90.0, 90.25, 88.75, 89.0), (120.0, 121.0, 119.5, 120.5)];
652        let rw = "Rising Window - Bullish";
653        assert_eq!(text(PatternTrend::Sma50, &falling, window, rw, 0), None);
654
655        // A hammer needs a downtrend: the close below SMA(50), and for the stricter rule also
656        // SMA(50) below SMA(200). After 150 rising and 60 falling closes SMA(50) is below the
657        // close but above SMA(200).
658        let hammer: &[Ohlc] = &[(89.0, 89.5, 87.0, 89.5)];
659        let turned = walk((1..=150).chain((90..150).rev()).map(f64::from));
660        let long_fall = walk((90..300).rev().map(f64::from));
661        let h = "Hammer - Bullish";
662        assert_eq!(
663            text(PatternTrend::Sma50, &falling, hammer, h, 0).as_deref(),
664            Some("H")
665        );
666        assert_eq!(text(PatternTrend::Sma50, &rising, hammer, h, 0), None);
667        assert_eq!(
668            text(PatternTrend::Sma50, &turned, hammer, h, 0).as_deref(),
669            Some("H")
670        );
671        assert_eq!(text(PatternTrend::Sma50Sma200, &turned, hammer, h, 0), None);
672        assert_eq!(
673            text(PatternTrend::Sma50Sma200, &long_fall, hammer, h, 0).as_deref(),
674            Some("H")
675        );
676
677        // Several patterns on a bar are listed in toggle order, one per line.
678        let dragonfly: &[Ohlc] = &[(100.0, 100.0625, 96.0, 100.0625)];
679        assert_eq!(
680            text(none, &flat, dragonfly, "", 0).as_deref(),
681            Some("DD\nH\nLLS")
682        );
683
684        // A neutral doji closing a bearish Tri-Star shares the high, so the bearish label names it.
685        let tri_star: &[Ohlc] = &[
686            (100.0, 101.0, 99.0, 100.0),
687            (102.0, 103.0, 101.0, 102.0),
688            (100.0, 101.0, 99.0, 100.0),
689        ];
690        assert_eq!(text(none, &flat, tri_star, "", 1).as_deref(), Some("3S\nD"));
691        assert_eq!(text(none, &flat, tri_star, "", 2), None);
692
693        // The pattern type keeps one side and drops the neutral patterns.
694        let mut data = flat.clone();
695        data.extend(
696            tri_star
697                .iter()
698                .map(|&(o, h, l, c)| Bar::new(Utc::now(), o, h, l, c, 1.0)),
699        );
700        let mut study = CandlestickPatterns::new(none).with_pattern_type(PatternType::Bullish);
701        study.calculate(&data);
702        let last = data.len() - 1;
703        assert_eq!(study.plot_text(1, last), None);
704        assert_eq!(study.plot_text(2, last), None);
705    }
706
707    /// The third bar of a Tasuki gap opens strictly inside the second body and closes
708    /// strictly inside the gap: an open outside the body or a close on either gap edge is
709    /// no pattern. `calculate` and `update` share the rule.
710    #[test]
711    fn tasuki_gap_needs_open_in_body_and_close_in_gap() {
712        let flat = walk((0..14).map(|i| 100.0 + f64::from(i % 2)));
713        let down: [Ohlc; 2] = [(104.0, 104.25, 99.75, 100.0), (99.5, 99.75, 98.75, 99.0)];
714        let up: [Ohlc; 2] = [
715            (100.0, 104.25, 99.75, 104.0),
716            (104.5, 105.25, 104.25, 105.0),
717        ];
718        #[rustfmt::skip]
719        let cases: [TasukiCase; 10] = [
720            ("Downside Tasuki Gap - Bearish", 1, "DTG", down, (99.25, 100.25, 98.75, 99.75), true),
721            ("Downside Tasuki Gap - Bearish", 1, "DTG", down, (98.75, 99.75, 98.5, 99.75), false),
722            ("Downside Tasuki Gap - Bearish", 1, "DTG", down, (99.75, 100.0, 99.5, 99.875), false),
723            ("Downside Tasuki Gap - Bearish", 1, "DTG", down, (99.25, 99.75, 99.0, 99.5), false),
724            ("Downside Tasuki Gap - Bearish", 1, "DTG", down, (99.25, 100.25, 99.0, 100.0), false),
725            ("Upside Tasuki Gap - Bullish", 0, "UTG", up, (104.75, 105.25, 104.0, 104.25), true),
726            ("Upside Tasuki Gap - Bullish", 0, "UTG", up, (104.25, 104.5, 104.0, 104.125), false),
727            ("Upside Tasuki Gap - Bullish", 0, "UTG", up, (105.5, 105.75, 104.0, 104.25), false),
728            ("Upside Tasuki Gap - Bullish", 0, "UTG", up, (104.75, 105.0, 104.5, 104.5), false),
729            ("Upside Tasuki Gap - Bullish", 0, "UTG", up, (104.75, 105.0, 103.75, 104.0), false),
730        ];
731        for (label, line, abbr, before, third, hit) in cases {
732            let bars = [before[0], before[1], third];
733            assert_eq!(
734                text(PatternTrend::NoDetection, &flat, &bars, label, line).as_deref(),
735                hit.then_some(abbr),
736                "{label} third bar {third:?}"
737            );
738        }
739    }
740}