Skip to main content

finance_query/indicators/
patterns.rs

1//! Candlestick pattern recognition.
2//!
3//! Detects 20 common single-, double-, and triple-candle reversal and continuation
4//! patterns. Each output position maps 1:1 to the corresponding input candle;
5//! `None` means no pattern was detected on that bar.
6//!
7//! When multiple patterns are technically valid for the same bar the most specific
8//! (widest lookback) pattern wins: **three-bar → two-bar → one-bar**.
9//!
10//! # Pattern catalogue
11//!
12//! | Bars | Pattern | Signal |
13//! |------|---------|--------|
14//! | 3 | [`CandlePattern::MorningStar`] | Bullish reversal |
15//! | 3 | [`CandlePattern::EveningStar`] | Bearish reversal |
16//! | 3 | [`CandlePattern::ThreeWhiteSoldiers`] | Bullish continuation |
17//! | 3 | [`CandlePattern::ThreeBlackCrows`] | Bearish continuation |
18//! | 2 | [`CandlePattern::BullishEngulfing`] | Bullish reversal |
19//! | 2 | [`CandlePattern::BearishEngulfing`] | Bearish reversal |
20//! | 2 | [`CandlePattern::BullishHarami`] | Bullish reversal |
21//! | 2 | [`CandlePattern::BearishHarami`] | Bearish reversal |
22//! | 2 | [`CandlePattern::PiercingLine`] | Bullish reversal |
23//! | 2 | [`CandlePattern::DarkCloudCover`] | Bearish reversal |
24//! | 2 | [`CandlePattern::TweezerBottom`] | Bullish reversal |
25//! | 2 | [`CandlePattern::TweezerTop`] | Bearish reversal |
26//! | 1 | [`CandlePattern::Hammer`] | Bullish reversal |
27//! | 1 | [`CandlePattern::InvertedHammer`] | Bullish reversal |
28//! | 1 | [`CandlePattern::HangingMan`] | Bearish reversal |
29//! | 1 | [`CandlePattern::ShootingStar`] | Bearish reversal |
30//! | 1 | [`CandlePattern::BullishMarubozu`] | Bullish strength |
31//! | 1 | [`CandlePattern::BearishMarubozu`] | Bearish strength |
32//! | 1 | [`CandlePattern::Doji`] | Indecision |
33//! | 1 | [`CandlePattern::SpinningTop`] | Indecision |
34//!
35//! # Example
36//!
37//! ```no_run
38//! use finance_query::{Ticker, Interval, TimeRange};
39//! use finance_query::indicators::{patterns, CandlePattern};
40//!
41//! # async fn example() -> Result<(), Box<dyn std::error::Error>> {
42//! let ticker = Ticker::new("AAPL").await?;
43//! let chart = ticker.chart(Interval::OneDay, TimeRange::ThreeMonths).await?;
44//!
45//! // Via Chart extension method
46//! let signals = chart.patterns();
47//! for (i, signal) in signals.iter().enumerate() {
48//!     if let Some(p) = signal {
49//!         println!("Bar {i}: {p:?} — {:?}", p.sentiment());
50//!     }
51//! }
52//!
53//! // Or call directly with a candle slice
54//! let signals = patterns(&chart.candles);
55//! # Ok(())
56//! # }
57//! ```
58
59use crate::Candle;
60use serde::{Deserialize, Serialize};
61
62// ── Thresholds ────────────────────────────────────────────────────────────────
63
64/// Body / range ratio at or below which a candle is a doji.
65const DOJI_BODY_RATIO: f64 = 0.05;
66
67/// Body / range ratio at or below which a candle is a spinning top.
68const SPINNING_TOP_BODY_RATIO: f64 = 0.30;
69
70/// Body / range ratio at or above which a candle is a marubozu.
71const MARUBOZU_BODY_RATIO: f64 = 0.90;
72
73/// Minimum long-wick / body ratio for hammer and shooting-star shapes.
74const LONG_WICK_RATIO: f64 = 2.0;
75
76/// Maximum short-wick / body ratio (the opposing, "tiny" wick side).
77const SHORT_WICK_RATIO: f64 = 0.50;
78
79/// Number of prior bars used to classify the short-term trend direction.
80const TREND_LOOKBACK: usize = 3;
81
82/// Fractional high / low tolerance for tweezer top / bottom matching.
83const TWEEZER_TOLERANCE: f64 = 0.001;
84
85/// Minimum effective body size to avoid division-by-zero on flat candles.
86const MIN_BODY: f64 = 1e-9;
87
88// ── Sentiment ─────────────────────────────────────────────────────────────────
89
90/// Directional bias of a candlestick pattern.
91#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
92#[non_exhaustive]
93pub enum PatternSentiment {
94    /// Bullish reversal or continuation signal.
95    Bullish,
96    /// Bearish reversal or continuation signal.
97    Bearish,
98    /// Indecision / neutral signal.
99    Neutral,
100}
101
102// ── CandlePattern ─────────────────────────────────────────────────────────────
103
104/// A detected candlestick pattern.
105///
106/// Returned per-bar by [`patterns`]. Each bar carries at most one pattern;
107/// three-bar patterns take precedence over two-bar, which take precedence over
108/// one-bar.
109#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
110#[non_exhaustive]
111pub enum CandlePattern {
112    // ── Three-bar ─────────────────────────────────────────────────────────
113    /// Bullish three-bar reversal: large bearish → small indecision star →
114    /// large bullish that closes above the first bar's midpoint.
115    MorningStar,
116    /// Bearish three-bar reversal: large bullish → small indecision star →
117    /// large bearish that closes below the first bar's midpoint.
118    EveningStar,
119    /// Bullish continuation: three consecutive bullish bars, each opening
120    /// within the prior bar's body and closing at a new high.
121    ThreeWhiteSoldiers,
122    /// Bearish continuation: three consecutive bearish bars, each opening
123    /// within the prior bar's body and closing at a new low.
124    ThreeBlackCrows,
125
126    // ── Two-bar ───────────────────────────────────────────────────────────
127    /// Bullish reversal: bearish bar followed by a larger bullish bar whose
128    /// body fully engulfs the prior bar's body.
129    BullishEngulfing,
130    /// Bearish reversal: bullish bar followed by a larger bearish bar whose
131    /// body fully engulfs the prior bar's body.
132    BearishEngulfing,
133    /// Bullish reversal: large bearish bar followed by a smaller bar (any
134    /// colour, including Doji) whose body is contained within the prior bar's
135    /// body.  A Doji inner bar is a "Harami Cross" — an even stronger signal.
136    BullishHarami,
137    /// Bearish reversal: large bullish bar followed by a smaller bar (any
138    /// colour, including Doji) whose body is contained within the prior bar's
139    /// body.  A Doji inner bar is a "Harami Cross" — an even stronger signal.
140    BearishHarami,
141    /// Bullish reversal: bearish bar followed by a bullish bar that opens
142    /// below the prior close and closes above the prior body's midpoint.
143    PiercingLine,
144    /// Bearish reversal: bullish bar followed by a bearish bar that opens
145    /// above the prior close and closes below the prior body's midpoint.
146    DarkCloudCover,
147    /// Bearish reversal at resistance: two candles sharing the same high.
148    TweezerTop,
149    /// Bullish reversal at support: two candles sharing the same low.
150    TweezerBottom,
151
152    // ── One-bar ───────────────────────────────────────────────────────────
153    /// Indecision: open ≈ close (body ≤ 5 % of total range), wicks on both sides.
154    Doji,
155    /// Indecision: small body (≤ 30 % of range) with meaningful wicks on both sides.
156    SpinningTop,
157    /// Bullish strength: nearly wick-free bullish candle (body ≥ 90 % of range).
158    BullishMarubozu,
159    /// Bearish strength: nearly wick-free bearish candle (body ≥ 90 % of range).
160    BearishMarubozu,
161    /// Potential bullish reversal: hammer shape (long lower wick) after a downtrend.
162    Hammer,
163    /// Potential bullish reversal: inverted-hammer shape (long upper wick) after a downtrend.
164    InvertedHammer,
165    /// Potential bearish reversal: hammer shape (long lower wick) after an uptrend.
166    HangingMan,
167    /// Potential bearish reversal: inverted-hammer shape (long upper wick) after an uptrend.
168    ShootingStar,
169}
170
171impl CandlePattern {
172    /// Returns the directional bias of this pattern.
173    ///
174    /// # Example
175    ///
176    /// ```
177    /// use finance_query::indicators::{CandlePattern, PatternSentiment};
178    ///
179    /// assert_eq!(CandlePattern::Hammer.sentiment(), PatternSentiment::Bullish);
180    /// assert_eq!(CandlePattern::ShootingStar.sentiment(), PatternSentiment::Bearish);
181    /// assert_eq!(CandlePattern::Doji.sentiment(), PatternSentiment::Neutral);
182    /// ```
183    pub fn sentiment(self) -> PatternSentiment {
184        match self {
185            Self::MorningStar
186            | Self::ThreeWhiteSoldiers
187            | Self::BullishEngulfing
188            | Self::BullishHarami
189            | Self::PiercingLine
190            | Self::TweezerBottom
191            | Self::BullishMarubozu
192            | Self::Hammer
193            | Self::InvertedHammer => PatternSentiment::Bullish,
194
195            Self::EveningStar
196            | Self::ThreeBlackCrows
197            | Self::BearishEngulfing
198            | Self::BearishHarami
199            | Self::DarkCloudCover
200            | Self::TweezerTop
201            | Self::BearishMarubozu
202            | Self::HangingMan
203            | Self::ShootingStar => PatternSentiment::Bearish,
204
205            Self::Doji | Self::SpinningTop => PatternSentiment::Neutral,
206        }
207    }
208}
209
210// ── Candle helpers ────────────────────────────────────────────────────────────
211
212#[inline]
213fn body(c: &Candle) -> f64 {
214    (c.close - c.open).abs()
215}
216
217#[inline]
218fn range(c: &Candle) -> f64 {
219    c.high - c.low
220}
221
222#[inline]
223fn upper_wick(c: &Candle) -> f64 {
224    c.high - c.open.max(c.close)
225}
226
227#[inline]
228fn lower_wick(c: &Candle) -> f64 {
229    c.open.min(c.close) - c.low
230}
231
232#[inline]
233fn is_bullish(c: &Candle) -> bool {
234    c.close > c.open
235}
236
237#[inline]
238fn is_bearish(c: &Candle) -> bool {
239    c.close < c.open
240}
241
242/// Midpoint of a candle's body.
243#[inline]
244fn body_mid(c: &Candle) -> f64 {
245    (c.open + c.close) / 2.0
246}
247
248// ── Trend helpers ─────────────────────────────────────────────────────────────
249
250/// `true` when bar `i` follows a short-term downtrend.
251///
252/// Compares the close `TREND_LOOKBACK` bars back to the close of the bar
253/// immediately *before* the signal candle (`i - 1`), so the signal candle's
254/// own close cannot self-validate the trend.
255#[inline]
256fn prior_downtrend(candles: &[Candle], i: usize) -> bool {
257    i > TREND_LOOKBACK && candles[i - 1 - TREND_LOOKBACK].close > candles[i - 1].close
258}
259
260/// `true` when bar `i` follows a short-term uptrend.
261///
262/// Same principle as [`prior_downtrend`] — the signal candle is excluded from
263/// the trend evaluation.
264#[inline]
265fn prior_uptrend(candles: &[Candle], i: usize) -> bool {
266    i > TREND_LOOKBACK && candles[i - 1 - TREND_LOOKBACK].close < candles[i - 1].close
267}
268
269// ── Single-candle shape predicates ───────────────────────────────────────────
270
271fn is_doji(c: &Candle) -> bool {
272    let r = range(c);
273    // A four-price doji (O=H=L=C) has zero range — still a valid doji.
274    r == 0.0 || body(c) <= r * DOJI_BODY_RATIO
275}
276
277fn is_spinning_top(c: &Candle) -> bool {
278    let r = range(c);
279    let b = body(c);
280    !is_doji(c)
281        && r > 0.0
282        && b <= r * SPINNING_TOP_BODY_RATIO
283        && upper_wick(c) >= b
284        && lower_wick(c) >= b
285}
286
287fn is_bullish_marubozu(c: &Candle) -> bool {
288    let r = range(c);
289    r > 0.0 && is_bullish(c) && body(c) >= r * MARUBOZU_BODY_RATIO
290}
291
292fn is_bearish_marubozu(c: &Candle) -> bool {
293    let r = range(c);
294    r > 0.0 && is_bearish(c) && body(c) >= r * MARUBOZU_BODY_RATIO
295}
296
297/// Hammer shape: small body at the top, long lower wick (≥ `LONG_WICK_RATIO` × body),
298/// tiny upper wick (≤ `SHORT_WICK_RATIO` × body).
299fn is_hammer_shape(c: &Candle) -> bool {
300    let b = body(c).max(MIN_BODY);
301    range(c) > 0.0 && lower_wick(c) >= b * LONG_WICK_RATIO && upper_wick(c) <= b * SHORT_WICK_RATIO
302}
303
304/// Inverted-hammer shape: small body at the bottom, long upper wick (≥ `LONG_WICK_RATIO` × body),
305/// tiny lower wick (≤ `SHORT_WICK_RATIO` × body).
306fn is_inverted_hammer_shape(c: &Candle) -> bool {
307    let b = body(c).max(MIN_BODY);
308    range(c) > 0.0 && upper_wick(c) >= b * LONG_WICK_RATIO && lower_wick(c) <= b * SHORT_WICK_RATIO
309}
310
311// ── Pattern detectors ─────────────────────────────────────────────────────────
312
313/// Detect three-bar patterns at position `i` (signal bar is `candles[i]`).
314fn detect_three_bar(candles: &[Candle], i: usize) -> Option<CandlePattern> {
315    if i < 2 {
316        return None;
317    }
318    let (a, b, c) = (&candles[i - 2], &candles[i - 1], &candles[i]);
319
320    // Three White Soldiers — three bullish bars, each opening within the prior
321    // body and each closing at a new high.
322    if is_bullish(a)
323        && is_bullish(b)
324        && is_bullish(c)
325        && b.close > a.close
326        && c.close > b.close
327        && b.open > a.open
328        && b.open < a.close
329        && c.open > b.open
330        && c.open < b.close
331    {
332        return Some(CandlePattern::ThreeWhiteSoldiers);
333    }
334
335    // Three Black Crows — three bearish bars, each opening within the prior
336    // body and each closing at a new low.
337    if is_bearish(a)
338        && is_bearish(b)
339        && is_bearish(c)
340        && b.close < a.close
341        && c.close < b.close
342        && b.open < a.open
343        && b.open > a.close
344        && c.open < b.open
345        && c.open > b.close
346    {
347        return Some(CandlePattern::ThreeBlackCrows);
348    }
349
350    // Small-body helper used by both star patterns.
351    let b_is_small = body(b) <= range(b).max(MIN_BODY) * SPINNING_TOP_BODY_RATIO;
352
353    // Morning Star — large bearish, small star at or below prior close, then
354    // large bullish closing above the first bar's midpoint.
355    if is_bearish(a)
356        && body(a) >= range(a) * 0.5
357        && b_is_small
358        && b.open.max(b.close) <= a.close
359        && is_bullish(c)
360        && c.close > body_mid(a)
361    {
362        return Some(CandlePattern::MorningStar);
363    }
364
365    // Evening Star — large bullish, small star at or above prior close, then
366    // large bearish closing below the first bar's midpoint.
367    if is_bullish(a)
368        && body(a) >= range(a) * 0.5
369        && b_is_small
370        && b.open.min(b.close) >= a.close
371        && is_bearish(c)
372        && c.close < body_mid(a)
373    {
374        return Some(CandlePattern::EveningStar);
375    }
376
377    None
378}
379
380/// Detect two-bar patterns at position `i` (signal bar is `candles[i]`).
381fn detect_two_bar(candles: &[Candle], i: usize) -> Option<CandlePattern> {
382    if i < 1 {
383        return None;
384    }
385    let (prev, curr) = (&candles[i - 1], &candles[i]);
386
387    // Tweezers — checked first because an exact price match is rare and highly
388    // significant; we don't want it masked by a weaker pattern.
389    if (curr.high - prev.high).abs() <= prev.high * TWEEZER_TOLERANCE
390        && is_bullish(prev)
391        && is_bearish(curr)
392    {
393        return Some(CandlePattern::TweezerTop);
394    }
395    if (curr.low - prev.low).abs() <= prev.low * TWEEZER_TOLERANCE
396        && is_bearish(prev)
397        && is_bullish(curr)
398    {
399        return Some(CandlePattern::TweezerBottom);
400    }
401
402    // Engulfing — current body fully covers the prior body AND is strictly larger.
403    // Same-size opposite bodies are "meeting lines", not engulfing.
404    if is_bearish(prev)
405        && is_bullish(curr)
406        && curr.open <= prev.close
407        && curr.close >= prev.open
408        && body(curr) > body(prev)
409    {
410        return Some(CandlePattern::BullishEngulfing);
411    }
412    if is_bullish(prev)
413        && is_bearish(curr)
414        && curr.open >= prev.close
415        && curr.close <= prev.open
416        && body(curr) > body(prev)
417    {
418        return Some(CandlePattern::BearishEngulfing);
419    }
420
421    // Harami — current body (any colour, including Doji) is contained within
422    // the prior body. A Doji inside the prior body is a "Harami Cross" — an
423    // even stronger signal per Nison — and is captured here rather than
424    // requiring a separate variant.
425    let curr_hi = curr.open.max(curr.close);
426    let curr_lo = curr.open.min(curr.close);
427    if is_bearish(prev) && curr_lo >= prev.close && curr_hi <= prev.open && body(curr) < body(prev)
428    {
429        return Some(CandlePattern::BullishHarami);
430    }
431    if is_bullish(prev) && curr_lo >= prev.open && curr_hi <= prev.close && body(curr) < body(prev)
432    {
433        return Some(CandlePattern::BearishHarami);
434    }
435
436    // Piercing Line — bearish prev, bullish curr opens below the prior close
437    // (Nison's definition) and closes above the prior body's midpoint.
438    if is_bearish(prev)
439        && is_bullish(curr)
440        && curr.open < prev.close
441        && curr.close > body_mid(prev)
442        && curr.close < prev.open
443    {
444        return Some(CandlePattern::PiercingLine);
445    }
446
447    // Dark Cloud Cover — bullish prev, bearish curr opens above the prior close
448    // (Nison's definition) and closes below the prior body's midpoint.
449    if is_bullish(prev)
450        && is_bearish(curr)
451        && curr.open > prev.close
452        && curr.close < body_mid(prev)
453        && curr.close > prev.open
454    {
455        return Some(CandlePattern::DarkCloudCover);
456    }
457
458    None
459}
460
461/// Detect one-bar patterns at position `i`.
462fn detect_one_bar(candles: &[Candle], i: usize) -> Option<CandlePattern> {
463    let c = &candles[i];
464
465    // Doji — must be checked before marubozu and spinning top.
466    if is_doji(c) {
467        return Some(CandlePattern::Doji);
468    }
469
470    // Marubozu — very large body, minimal wicks.
471    if is_bullish_marubozu(c) {
472        return Some(CandlePattern::BullishMarubozu);
473    }
474    if is_bearish_marubozu(c) {
475        return Some(CandlePattern::BearishMarubozu);
476    }
477
478    // Hammer / Hanging Man (same shape, opposite trend context).
479    if is_hammer_shape(c) {
480        if prior_downtrend(candles, i) {
481            return Some(CandlePattern::Hammer);
482        }
483        if prior_uptrend(candles, i) {
484            return Some(CandlePattern::HangingMan);
485        }
486    }
487
488    // Inverted Hammer / Shooting Star (same shape, opposite trend context).
489    if is_inverted_hammer_shape(c) {
490        if prior_downtrend(candles, i) {
491            return Some(CandlePattern::InvertedHammer);
492        }
493        if prior_uptrend(candles, i) {
494            return Some(CandlePattern::ShootingStar);
495        }
496    }
497
498    // Spinning Top — catch-all for small-body indecision candles.
499    if is_spinning_top(c) {
500        return Some(CandlePattern::SpinningTop);
501    }
502
503    None
504}
505
506// ── Public API ────────────────────────────────────────────────────────────────
507
508/// Detect candlestick patterns for each bar in `candles`.
509///
510/// Returns a `Vec<Option<CandlePattern>>` of the same length as the input.
511/// `Some(pattern)` means a pattern was detected on that bar; `None` means no
512/// pattern matched. Input must be in chronological order (oldest candle first).
513///
514/// When multiple patterns are technically valid for the same bar, the most
515/// specific (widest lookback) pattern wins: three-bar patterns take precedence
516/// over two-bar, which take precedence over one-bar.
517///
518/// # Example
519///
520/// ```no_run
521/// use finance_query::{Ticker, Interval, TimeRange};
522/// use finance_query::indicators::{patterns, CandlePattern, PatternSentiment};
523///
524/// # async fn example() -> Result<(), Box<dyn std::error::Error>> {
525/// let ticker = Ticker::new("AAPL").await?;
526/// let chart = ticker.chart(Interval::OneDay, TimeRange::SixMonths).await?;
527/// let signals = patterns(&chart.candles);
528///
529/// let bullish: Vec<_> = signals
530///     .iter()
531///     .enumerate()
532///     .filter(|(_, s)| s.map(|p| p.sentiment() == PatternSentiment::Bullish).unwrap_or(false))
533///     .collect();
534///
535/// println!("{} bullish patterns detected", bullish.len());
536/// # Ok(())
537/// # }
538/// ```
539pub fn patterns(candles: &[Candle]) -> Vec<Option<CandlePattern>> {
540    candles
541        .iter()
542        .enumerate()
543        .map(|(i, _)| {
544            detect_three_bar(candles, i)
545                .or_else(|| detect_two_bar(candles, i))
546                .or_else(|| detect_one_bar(candles, i))
547        })
548        .collect()
549}
550
551// ── Tests ─────────────────────────────────────────────────────────────────────
552
553#[cfg(test)]
554mod tests {
555    use super::*;
556
557    // Convenience constructor — valid within this crate despite #[non_exhaustive].
558    fn c(o: f64, h: f64, l: f64, close: f64) -> Candle {
559        Candle {
560            timestamp: 0,
561            open: o,
562            high: h,
563            low: l,
564            close,
565            volume: 0,
566            adj_close: None,
567        }
568    }
569
570    // ── Output contract ───────────────────────────────────────────────────────
571
572    #[test]
573    fn test_empty_input_returns_empty() {
574        assert!(patterns(&[]).is_empty());
575    }
576
577    #[test]
578    fn test_output_length_matches_input() {
579        let candles: Vec<Candle> = (0..15)
580            .map(|i| c(i as f64 + 0.5, i as f64 + 1.0, i as f64, i as f64 + 0.6))
581            .collect();
582        assert_eq!(patterns(&candles).len(), candles.len());
583    }
584
585    // ── Single-candle ─────────────────────────────────────────────────────────
586
587    #[test]
588    fn test_doji_detected() {
589        // body=0.1, range=4.0 → ratio=0.025 < DOJI_BODY_RATIO
590        let candles = [c(10.0, 12.0, 8.0, 10.1)];
591        assert_eq!(patterns(&candles)[0], Some(CandlePattern::Doji));
592    }
593
594    #[test]
595    fn test_four_price_doji() {
596        // O=H=L=C — the ultimate indecision candle (zero range).
597        let candles = [c(10.0, 10.0, 10.0, 10.0)];
598        assert_eq!(patterns(&candles)[0], Some(CandlePattern::Doji));
599    }
600
601    #[test]
602    fn test_doji_not_on_normal_candle() {
603        // body=1.5, range=3.0 → ratio=0.5 > DOJI_BODY_RATIO
604        let candles = [c(10.0, 12.0, 9.0, 11.5)];
605        assert_ne!(patterns(&candles)[0], Some(CandlePattern::Doji));
606    }
607
608    #[test]
609    fn test_bullish_marubozu() {
610        // open≈low, close≈high → body/range ≈ 0.95
611        let candles = [c(10.0, 20.05, 9.95, 20.0)];
612        assert_eq!(patterns(&candles)[0], Some(CandlePattern::BullishMarubozu));
613    }
614
615    #[test]
616    fn test_bearish_marubozu() {
617        // open≈high, close≈low → body/range ≈ 0.95
618        let candles = [c(20.0, 20.05, 9.95, 10.0)];
619        assert_eq!(patterns(&candles)[0], Some(CandlePattern::BearishMarubozu));
620    }
621
622    #[test]
623    fn test_hammer_in_downtrend() {
624        // Four declining bars establish a downtrend (trend is evaluated on bars
625        // *before* the signal candle, so we need TREND_LOOKBACK + 1 = 4 prior bars).
626        //
627        // Hammer shape requirements:
628        //   body/range > DOJI_BODY_RATIO (5%) so it is NOT classified as a Doji
629        //   lower_wick >= body * LONG_WICK_RATIO (2×)
630        //   upper_wick <= body * SHORT_WICK_RATIO (0.5×)
631        //
632        // Hammer candle: open=12.0, high=13.5, low=4.5, close=13.0
633        //   body = 1.0, range = 9.0, body/range ≈ 0.11 > 0.05  ✓ (not Doji)
634        //   upper_wick = 0.5, lower_wick = 7.5
635        //   0.5 ≤ 1.0*0.5=0.5 ✓, 7.5 ≥ 1.0*2=2.0 ✓
636        //
637        // Trend check: candles[0].close=16.0 > candles[3].close=13.5 → downtrend ✓
638        let prior = [
639            c(16.0, 17.0, 15.0, 16.0),
640            c(15.5, 16.0, 14.5, 15.5),
641            c(15.0, 15.5, 13.5, 14.5),
642            c(14.0, 14.5, 12.5, 13.5),
643        ];
644        let hammer = c(12.0, 13.5, 4.5, 13.0);
645        let mut candles = prior.to_vec();
646        candles.push(hammer);
647        assert_eq!(patterns(&candles)[4], Some(CandlePattern::Hammer));
648    }
649
650    #[test]
651    fn test_shooting_star_in_uptrend() {
652        // Four rising bars establish an uptrend (trend evaluated on bars before
653        // the signal candle, needs TREND_LOOKBACK + 1 = 4 prior bars).
654        //
655        // Inverted-hammer shape: long upper wick ≥ 2× body, tiny lower wick ≤ 0.5× body,
656        // body/range > 5% so it is NOT classified as a Doji.
657        //
658        // Candle: open=9.5, high=18.5, low=9.0, close=10.5
659        //   body = 1.0, range = 9.5, body/range ≈ 0.105 > 0.05  ✓ (not Doji)
660        //   upper_wick = 8.0, lower_wick = 0.5
661        //   8.0 ≥ 1.0*2=2.0 ✓, 0.5 ≤ 1.0*0.5=0.5 ✓
662        //
663        // Trend check: candles[0].close=7.5 < candles[3].close=9.5 → uptrend ✓
664        let prior = [
665            c(7.0, 8.0, 6.5, 7.5),
666            c(7.5, 8.5, 7.0, 8.0),
667            c(8.0, 9.0, 7.5, 8.5),
668            c(8.5, 9.5, 8.0, 9.5),
669        ];
670        let star = c(9.5, 18.5, 9.0, 10.5);
671        let mut candles = prior.to_vec();
672        candles.push(star);
673        assert_eq!(patterns(&candles)[4], Some(CandlePattern::ShootingStar));
674    }
675
676    // ── Two-candle ────────────────────────────────────────────────────────────
677
678    #[test]
679    fn test_bullish_engulfing() {
680        let prev = c(11.0, 11.5, 9.5, 10.0); // bearish (o>c)
681        let curr = c(9.8, 12.0, 9.7, 11.2); // bullish, open ≤ prev.close, close ≥ prev.open
682        let result = patterns(&[prev, curr]);
683        assert_eq!(result[1], Some(CandlePattern::BullishEngulfing));
684    }
685
686    #[test]
687    fn test_bearish_engulfing() {
688        let prev = c(10.0, 11.5, 9.5, 11.0); // bullish (c>o)
689        let curr = c(11.2, 11.3, 9.0, 9.5); // bearish, open ≥ prev.close, close ≤ prev.open
690        let result = patterns(&[prev, curr]);
691        assert_eq!(result[1], Some(CandlePattern::BearishEngulfing));
692    }
693
694    #[test]
695    fn test_bullish_harami() {
696        let prev = c(12.0, 12.5, 9.0, 10.0); // bearish: open=12, close=10
697        let curr = c(10.5, 11.0, 10.4, 10.8); // bullish, inside prev body
698        let result = patterns(&[prev, curr]);
699        assert_eq!(result[1], Some(CandlePattern::BullishHarami));
700    }
701
702    #[test]
703    fn test_bearish_harami() {
704        let prev = c(10.0, 12.5, 9.0, 12.0); // bullish: open=10, close=12
705        let curr = c(11.5, 12.0, 11.2, 11.3); // bearish, inside prev body
706        let result = patterns(&[prev, curr]);
707        assert_eq!(result[1], Some(CandlePattern::BearishHarami));
708    }
709
710    #[test]
711    fn test_bullish_harami_cross_doji_inside() {
712        // A Doji (body ≈ 0) contained within a large bearish body is a
713        // "Harami Cross" — detected as BullishHarami per Nison.
714        let prev = c(12.0, 12.5, 9.0, 10.0); // bearish: open=12, close=10
715        // Doji inside prev body: open≈close, body/range tiny
716        let doji = c(11.0, 11.5, 10.5, 11.05); // body=0.05, range=1.0, ratio=0.05
717        let result = patterns(&[prev, doji]);
718        assert_eq!(result[1], Some(CandlePattern::BullishHarami));
719    }
720
721    #[test]
722    fn test_bearish_harami_cross_doji_inside() {
723        // A Doji contained within a large bullish body → BearishHarami.
724        let prev = c(10.0, 12.5, 9.0, 12.0); // bullish: open=10, close=12
725        let doji = c(11.0, 11.5, 10.5, 11.05); // Doji inside prev body
726        let result = patterns(&[prev, doji]);
727        assert_eq!(result[1], Some(CandlePattern::BearishHarami));
728    }
729
730    #[test]
731    fn test_piercing_line() {
732        // Bearish prev: open=14, close=10 → mid=12
733        // Bullish curr: opens below prev.close=10, closes above mid but below prev.open=14
734        let prev = c(14.0, 15.0, 9.0, 10.0);
735        let curr = c(9.5, 13.0, 9.4, 12.5); // open=9.5 < prev.close=10 ✓, close=12.5 > mid=12 ✓
736        let result = patterns(&[prev, curr]);
737        assert_eq!(result[1], Some(CandlePattern::PiercingLine));
738    }
739
740    #[test]
741    fn test_dark_cloud_cover() {
742        // Bullish prev: open=10, close=14 → mid=12
743        // Bearish curr: opens above prev.close=14, closes below mid=12 but above prev.open=10
744        let prev = c(10.0, 15.0, 9.0, 14.0);
745        let curr = c(14.5, 16.0, 10.5, 11.5); // open=14.5 > prev.close=14 ✓, close=11.5 < mid=12 ✓
746        let result = patterns(&[prev, curr]);
747        assert_eq!(result[1], Some(CandlePattern::DarkCloudCover));
748    }
749
750    #[test]
751    fn test_tweezer_top() {
752        // Both share same high (within tolerance), prev bullish, curr bearish
753        let prev = c(10.0, 12.0, 9.5, 11.5); // bullish
754        let curr = c(11.6, 12.0, 10.8, 11.0); // bearish, same high
755        let result = patterns(&[prev, curr]);
756        assert_eq!(result[1], Some(CandlePattern::TweezerTop));
757    }
758
759    #[test]
760    fn test_tweezer_bottom() {
761        // Both share same low, prev bearish, curr bullish
762        let prev = c(11.5, 12.0, 9.5, 10.0); // bearish
763        let curr = c(9.8, 11.0, 9.5, 10.5); // bullish, same low
764        let result = patterns(&[prev, curr]);
765        assert_eq!(result[1], Some(CandlePattern::TweezerBottom));
766    }
767
768    // ── Three-candle ──────────────────────────────────────────────────────────
769
770    #[test]
771    fn test_three_white_soldiers() {
772        // Each bar is bullish, opens in prior body, closes at new high
773        let c1 = c(10.0, 11.2, 9.8, 11.0);
774        let c2 = c(10.5, 12.2, 10.4, 12.0); // opens in c1 body (10.0..11.0), closes above c1
775        let c3 = c(11.5, 13.2, 11.4, 13.0); // opens in c2 body (10.5..12.0), closes above c2
776        let result = patterns(&[c1, c2, c3]);
777        assert_eq!(result[2], Some(CandlePattern::ThreeWhiteSoldiers));
778    }
779
780    #[test]
781    fn test_three_black_crows() {
782        // Each bar is bearish, opens in prior body, closes at new low
783        let c1 = c(13.0, 13.2, 11.8, 12.0);
784        let c2 = c(12.5, 12.6, 10.8, 11.0); // opens in c1 body (12.0..13.0), closes below c1
785        let c3 = c(11.5, 11.6, 9.8, 10.0); // opens in c2 body (11.0..12.5), closes below c2
786        let result = patterns(&[c1, c2, c3]);
787        assert_eq!(result[2], Some(CandlePattern::ThreeBlackCrows));
788    }
789
790    #[test]
791    fn test_morning_star() {
792        // Large bearish → small star below prior close → large bullish above prior mid
793        // a: open=110, close=102 → mid=106, body=8, range=12, b/r=0.67 ✓
794        // b: small body at 100–101 (below a.close=102) ✓
795        // c: bullish, closes at 108 > 106 ✓
796        let a = c(110.0, 112.0, 100.0, 102.0);
797        let b = c(100.5, 101.0, 99.5, 100.8); // body=0.3, range=1.5, b/r=0.2 ≤ 0.3 ✓
798        // b.open.max(b.close) = 100.8 ≤ a.close=102 ✓
799        let cc = c(101.0, 112.0, 100.0, 108.0); // close=108 > mid=106 ✓
800        let result = patterns(&[a, b, cc]);
801        assert_eq!(result[2], Some(CandlePattern::MorningStar));
802    }
803
804    #[test]
805    fn test_evening_star() {
806        // Large bullish → small star above prior close → large bearish below prior mid
807        // a: open=100, close=110 → mid=105, body=10, range=12, b/r=0.83 ✓
808        // b: small body at 111–112 (above a.close=110) ✓
809        // c: bearish, closes at 103 < 105 ✓
810        let a = c(100.0, 111.0, 99.0, 110.0);
811        let b = c(111.0, 112.5, 110.8, 111.3); // body=0.3, range=1.7, b/r≈0.18 ✓
812        // b.open.min(b.close) = 111.0 >= a.close=110 ✓
813        let cc = c(110.5, 111.0, 102.0, 103.0); // close=103 < mid=105 ✓
814        let result = patterns(&[a, b, cc]);
815        assert_eq!(result[2], Some(CandlePattern::EveningStar));
816    }
817
818    // ── Sentiment ─────────────────────────────────────────────────────────────
819
820    #[test]
821    fn test_sentiment_coverage() {
822        assert_eq!(CandlePattern::Hammer.sentiment(), PatternSentiment::Bullish);
823        assert_eq!(
824            CandlePattern::MorningStar.sentiment(),
825            PatternSentiment::Bullish
826        );
827        assert_eq!(
828            CandlePattern::BullishEngulfing.sentiment(),
829            PatternSentiment::Bullish
830        );
831        assert_eq!(
832            CandlePattern::ShootingStar.sentiment(),
833            PatternSentiment::Bearish
834        );
835        assert_eq!(
836            CandlePattern::EveningStar.sentiment(),
837            PatternSentiment::Bearish
838        );
839        assert_eq!(
840            CandlePattern::BearishEngulfing.sentiment(),
841            PatternSentiment::Bearish
842        );
843        assert_eq!(CandlePattern::Doji.sentiment(), PatternSentiment::Neutral);
844        assert_eq!(
845            CandlePattern::SpinningTop.sentiment(),
846            PatternSentiment::Neutral
847        );
848    }
849
850    // ── Priority (three-bar beats two-bar beats one-bar) ─────────────────────
851
852    #[test]
853    fn test_three_bar_takes_priority_over_two_bar() {
854        // Construct a sequence where the last two bars also form a BullishEngulfing
855        // but the three-bar context makes it a MorningStar confirmation.
856        let a = c(110.0, 112.0, 100.0, 102.0);
857        let b = c(100.5, 101.0, 99.5, 100.8);
858        // Make bar c both a bullish engulfer of b AND complete the morning star
859        let cc = c(99.0, 112.0, 98.0, 108.0); // open < b.close=100.8 ✓ (engulfs b) & > mid(a)=106
860        let result = patterns(&[a, b, cc]);
861        // MorningStar should win
862        assert_eq!(result[2], Some(CandlePattern::MorningStar));
863    }
864}