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            provider_id: None,
568        }
569    }
570
571    // ── Output contract ───────────────────────────────────────────────────────
572
573    #[test]
574    fn test_empty_input_returns_empty() {
575        assert!(patterns(&[]).is_empty());
576    }
577
578    #[test]
579    fn test_output_length_matches_input() {
580        let candles: Vec<Candle> = (0..15)
581            .map(|i| c(i as f64 + 0.5, i as f64 + 1.0, i as f64, i as f64 + 0.6))
582            .collect();
583        assert_eq!(patterns(&candles).len(), candles.len());
584    }
585
586    // ── Single-candle ─────────────────────────────────────────────────────────
587
588    #[test]
589    fn test_doji_detected() {
590        // body=0.1, range=4.0 → ratio=0.025 < DOJI_BODY_RATIO
591        let candles = [c(10.0, 12.0, 8.0, 10.1)];
592        assert_eq!(patterns(&candles)[0], Some(CandlePattern::Doji));
593    }
594
595    #[test]
596    fn test_four_price_doji() {
597        // O=H=L=C — the ultimate indecision candle (zero range).
598        let candles = [c(10.0, 10.0, 10.0, 10.0)];
599        assert_eq!(patterns(&candles)[0], Some(CandlePattern::Doji));
600    }
601
602    #[test]
603    fn test_doji_not_on_normal_candle() {
604        // body=1.5, range=3.0 → ratio=0.5 > DOJI_BODY_RATIO
605        let candles = [c(10.0, 12.0, 9.0, 11.5)];
606        assert_ne!(patterns(&candles)[0], Some(CandlePattern::Doji));
607    }
608
609    #[test]
610    fn test_bullish_marubozu() {
611        // open≈low, close≈high → body/range ≈ 0.95
612        let candles = [c(10.0, 20.05, 9.95, 20.0)];
613        assert_eq!(patterns(&candles)[0], Some(CandlePattern::BullishMarubozu));
614    }
615
616    #[test]
617    fn test_bearish_marubozu() {
618        // open≈high, close≈low → body/range ≈ 0.95
619        let candles = [c(20.0, 20.05, 9.95, 10.0)];
620        assert_eq!(patterns(&candles)[0], Some(CandlePattern::BearishMarubozu));
621    }
622
623    #[test]
624    fn test_hammer_in_downtrend() {
625        // Four declining bars establish a downtrend (trend is evaluated on bars
626        // *before* the signal candle, so we need TREND_LOOKBACK + 1 = 4 prior bars).
627        //
628        // Hammer shape requirements:
629        //   body/range > DOJI_BODY_RATIO (5%) so it is NOT classified as a Doji
630        //   lower_wick >= body * LONG_WICK_RATIO (2×)
631        //   upper_wick <= body * SHORT_WICK_RATIO (0.5×)
632        //
633        // Hammer candle: open=12.0, high=13.5, low=4.5, close=13.0
634        //   body = 1.0, range = 9.0, body/range ≈ 0.11 > 0.05  ✓ (not Doji)
635        //   upper_wick = 0.5, lower_wick = 7.5
636        //   0.5 ≤ 1.0*0.5=0.5 ✓, 7.5 ≥ 1.0*2=2.0 ✓
637        //
638        // Trend check: candles[0].close=16.0 > candles[3].close=13.5 → downtrend ✓
639        let prior = [
640            c(16.0, 17.0, 15.0, 16.0),
641            c(15.5, 16.0, 14.5, 15.5),
642            c(15.0, 15.5, 13.5, 14.5),
643            c(14.0, 14.5, 12.5, 13.5),
644        ];
645        let hammer = c(12.0, 13.5, 4.5, 13.0);
646        let mut candles = prior.to_vec();
647        candles.push(hammer);
648        assert_eq!(patterns(&candles)[4], Some(CandlePattern::Hammer));
649    }
650
651    #[test]
652    fn test_shooting_star_in_uptrend() {
653        // Four rising bars establish an uptrend (trend evaluated on bars before
654        // the signal candle, needs TREND_LOOKBACK + 1 = 4 prior bars).
655        //
656        // Inverted-hammer shape: long upper wick ≥ 2× body, tiny lower wick ≤ 0.5× body,
657        // body/range > 5% so it is NOT classified as a Doji.
658        //
659        // Candle: open=9.5, high=18.5, low=9.0, close=10.5
660        //   body = 1.0, range = 9.5, body/range ≈ 0.105 > 0.05  ✓ (not Doji)
661        //   upper_wick = 8.0, lower_wick = 0.5
662        //   8.0 ≥ 1.0*2=2.0 ✓, 0.5 ≤ 1.0*0.5=0.5 ✓
663        //
664        // Trend check: candles[0].close=7.5 < candles[3].close=9.5 → uptrend ✓
665        let prior = [
666            c(7.0, 8.0, 6.5, 7.5),
667            c(7.5, 8.5, 7.0, 8.0),
668            c(8.0, 9.0, 7.5, 8.5),
669            c(8.5, 9.5, 8.0, 9.5),
670        ];
671        let star = c(9.5, 18.5, 9.0, 10.5);
672        let mut candles = prior.to_vec();
673        candles.push(star);
674        assert_eq!(patterns(&candles)[4], Some(CandlePattern::ShootingStar));
675    }
676
677    // ── Two-candle ────────────────────────────────────────────────────────────
678
679    #[test]
680    fn test_bullish_engulfing() {
681        let prev = c(11.0, 11.5, 9.5, 10.0); // bearish (o>c)
682        let curr = c(9.8, 12.0, 9.7, 11.2); // bullish, open ≤ prev.close, close ≥ prev.open
683        let result = patterns(&[prev, curr]);
684        assert_eq!(result[1], Some(CandlePattern::BullishEngulfing));
685    }
686
687    #[test]
688    fn test_bearish_engulfing() {
689        let prev = c(10.0, 11.5, 9.5, 11.0); // bullish (c>o)
690        let curr = c(11.2, 11.3, 9.0, 9.5); // bearish, open ≥ prev.close, close ≤ prev.open
691        let result = patterns(&[prev, curr]);
692        assert_eq!(result[1], Some(CandlePattern::BearishEngulfing));
693    }
694
695    #[test]
696    fn test_bullish_harami() {
697        let prev = c(12.0, 12.5, 9.0, 10.0); // bearish: open=12, close=10
698        let curr = c(10.5, 11.0, 10.4, 10.8); // bullish, inside prev body
699        let result = patterns(&[prev, curr]);
700        assert_eq!(result[1], Some(CandlePattern::BullishHarami));
701    }
702
703    #[test]
704    fn test_bearish_harami() {
705        let prev = c(10.0, 12.5, 9.0, 12.0); // bullish: open=10, close=12
706        let curr = c(11.5, 12.0, 11.2, 11.3); // bearish, inside prev body
707        let result = patterns(&[prev, curr]);
708        assert_eq!(result[1], Some(CandlePattern::BearishHarami));
709    }
710
711    #[test]
712    fn test_bullish_harami_cross_doji_inside() {
713        // A Doji (body ≈ 0) contained within a large bearish body is a
714        // "Harami Cross" — detected as BullishHarami per Nison.
715        let prev = c(12.0, 12.5, 9.0, 10.0); // bearish: open=12, close=10
716        // Doji inside prev body: open≈close, body/range tiny
717        let doji = c(11.0, 11.5, 10.5, 11.05); // body=0.05, range=1.0, ratio=0.05
718        let result = patterns(&[prev, doji]);
719        assert_eq!(result[1], Some(CandlePattern::BullishHarami));
720    }
721
722    #[test]
723    fn test_bearish_harami_cross_doji_inside() {
724        // A Doji contained within a large bullish body → BearishHarami.
725        let prev = c(10.0, 12.5, 9.0, 12.0); // bullish: open=10, close=12
726        let doji = c(11.0, 11.5, 10.5, 11.05); // Doji inside prev body
727        let result = patterns(&[prev, doji]);
728        assert_eq!(result[1], Some(CandlePattern::BearishHarami));
729    }
730
731    #[test]
732    fn test_piercing_line() {
733        // Bearish prev: open=14, close=10 → mid=12
734        // Bullish curr: opens below prev.close=10, closes above mid but below prev.open=14
735        let prev = c(14.0, 15.0, 9.0, 10.0);
736        let curr = c(9.5, 13.0, 9.4, 12.5); // open=9.5 < prev.close=10 ✓, close=12.5 > mid=12 ✓
737        let result = patterns(&[prev, curr]);
738        assert_eq!(result[1], Some(CandlePattern::PiercingLine));
739    }
740
741    #[test]
742    fn test_dark_cloud_cover() {
743        // Bullish prev: open=10, close=14 → mid=12
744        // Bearish curr: opens above prev.close=14, closes below mid=12 but above prev.open=10
745        let prev = c(10.0, 15.0, 9.0, 14.0);
746        let curr = c(14.5, 16.0, 10.5, 11.5); // open=14.5 > prev.close=14 ✓, close=11.5 < mid=12 ✓
747        let result = patterns(&[prev, curr]);
748        assert_eq!(result[1], Some(CandlePattern::DarkCloudCover));
749    }
750
751    #[test]
752    fn test_tweezer_top() {
753        // Both share same high (within tolerance), prev bullish, curr bearish
754        let prev = c(10.0, 12.0, 9.5, 11.5); // bullish
755        let curr = c(11.6, 12.0, 10.8, 11.0); // bearish, same high
756        let result = patterns(&[prev, curr]);
757        assert_eq!(result[1], Some(CandlePattern::TweezerTop));
758    }
759
760    #[test]
761    fn test_tweezer_bottom() {
762        // Both share same low, prev bearish, curr bullish
763        let prev = c(11.5, 12.0, 9.5, 10.0); // bearish
764        let curr = c(9.8, 11.0, 9.5, 10.5); // bullish, same low
765        let result = patterns(&[prev, curr]);
766        assert_eq!(result[1], Some(CandlePattern::TweezerBottom));
767    }
768
769    // ── Three-candle ──────────────────────────────────────────────────────────
770
771    #[test]
772    fn test_three_white_soldiers() {
773        // Each bar is bullish, opens in prior body, closes at new high
774        let c1 = c(10.0, 11.2, 9.8, 11.0);
775        let c2 = c(10.5, 12.2, 10.4, 12.0); // opens in c1 body (10.0..11.0), closes above c1
776        let c3 = c(11.5, 13.2, 11.4, 13.0); // opens in c2 body (10.5..12.0), closes above c2
777        let result = patterns(&[c1, c2, c3]);
778        assert_eq!(result[2], Some(CandlePattern::ThreeWhiteSoldiers));
779    }
780
781    #[test]
782    fn test_three_black_crows() {
783        // Each bar is bearish, opens in prior body, closes at new low
784        let c1 = c(13.0, 13.2, 11.8, 12.0);
785        let c2 = c(12.5, 12.6, 10.8, 11.0); // opens in c1 body (12.0..13.0), closes below c1
786        let c3 = c(11.5, 11.6, 9.8, 10.0); // opens in c2 body (11.0..12.5), closes below c2
787        let result = patterns(&[c1, c2, c3]);
788        assert_eq!(result[2], Some(CandlePattern::ThreeBlackCrows));
789    }
790
791    #[test]
792    fn test_morning_star() {
793        // Large bearish → small star below prior close → large bullish above prior mid
794        // a: open=110, close=102 → mid=106, body=8, range=12, b/r=0.67 ✓
795        // b: small body at 100–101 (below a.close=102) ✓
796        // c: bullish, closes at 108 > 106 ✓
797        let a = c(110.0, 112.0, 100.0, 102.0);
798        let b = c(100.5, 101.0, 99.5, 100.8); // body=0.3, range=1.5, b/r=0.2 ≤ 0.3 ✓
799        // b.open.max(b.close) = 100.8 ≤ a.close=102 ✓
800        let cc = c(101.0, 112.0, 100.0, 108.0); // close=108 > mid=106 ✓
801        let result = patterns(&[a, b, cc]);
802        assert_eq!(result[2], Some(CandlePattern::MorningStar));
803    }
804
805    #[test]
806    fn test_evening_star() {
807        // Large bullish → small star above prior close → large bearish below prior mid
808        // a: open=100, close=110 → mid=105, body=10, range=12, b/r=0.83 ✓
809        // b: small body at 111–112 (above a.close=110) ✓
810        // c: bearish, closes at 103 < 105 ✓
811        let a = c(100.0, 111.0, 99.0, 110.0);
812        let b = c(111.0, 112.5, 110.8, 111.3); // body=0.3, range=1.7, b/r≈0.18 ✓
813        // b.open.min(b.close) = 111.0 >= a.close=110 ✓
814        let cc = c(110.5, 111.0, 102.0, 103.0); // close=103 < mid=105 ✓
815        let result = patterns(&[a, b, cc]);
816        assert_eq!(result[2], Some(CandlePattern::EveningStar));
817    }
818
819    // ── Sentiment ─────────────────────────────────────────────────────────────
820
821    #[test]
822    fn test_sentiment_coverage() {
823        assert_eq!(CandlePattern::Hammer.sentiment(), PatternSentiment::Bullish);
824        assert_eq!(
825            CandlePattern::MorningStar.sentiment(),
826            PatternSentiment::Bullish
827        );
828        assert_eq!(
829            CandlePattern::BullishEngulfing.sentiment(),
830            PatternSentiment::Bullish
831        );
832        assert_eq!(
833            CandlePattern::ShootingStar.sentiment(),
834            PatternSentiment::Bearish
835        );
836        assert_eq!(
837            CandlePattern::EveningStar.sentiment(),
838            PatternSentiment::Bearish
839        );
840        assert_eq!(
841            CandlePattern::BearishEngulfing.sentiment(),
842            PatternSentiment::Bearish
843        );
844        assert_eq!(CandlePattern::Doji.sentiment(), PatternSentiment::Neutral);
845        assert_eq!(
846            CandlePattern::SpinningTop.sentiment(),
847            PatternSentiment::Neutral
848        );
849    }
850
851    // ── Priority (three-bar beats two-bar beats one-bar) ─────────────────────
852
853    #[test]
854    fn test_three_bar_takes_priority_over_two_bar() {
855        // Construct a sequence where the last two bars also form a BullishEngulfing
856        // but the three-bar context makes it a MorningStar confirmation.
857        let a = c(110.0, 112.0, 100.0, 102.0);
858        let b = c(100.5, 101.0, 99.5, 100.8);
859        // Make bar c both a bullish engulfer of b AND complete the morning star
860        let cc = c(99.0, 112.0, 98.0, 108.0); // open < b.close=100.8 ✓ (engulfs b) & > mid(a)=106
861        let result = patterns(&[a, b, cc]);
862        // MorningStar should win
863        assert_eq!(result[2], Some(CandlePattern::MorningStar));
864    }
865}