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]`).
314#[inline]
315fn detect_three_bar(candles: &[Candle], i: usize) -> Option<CandlePattern> {
316    if i < 2 {
317        return None;
318    }
319    let (a, b, c) = (&candles[i - 2], &candles[i - 1], &candles[i]);
320
321    // Three White Soldiers — three bullish bars, each opening within the prior
322    // body and each closing at a new high.
323    if is_bullish(a)
324        && is_bullish(b)
325        && is_bullish(c)
326        && b.close > a.close
327        && c.close > b.close
328        && b.open > a.open
329        && b.open < a.close
330        && c.open > b.open
331        && c.open < b.close
332    {
333        return Some(CandlePattern::ThreeWhiteSoldiers);
334    }
335
336    // Three Black Crows — three bearish bars, each opening within the prior
337    // body and each closing at a new low.
338    if is_bearish(a)
339        && is_bearish(b)
340        && is_bearish(c)
341        && b.close < a.close
342        && c.close < b.close
343        && b.open < a.open
344        && b.open > a.close
345        && c.open < b.open
346        && c.open > b.close
347    {
348        return Some(CandlePattern::ThreeBlackCrows);
349    }
350
351    // Small-body helper used by both star patterns.
352    let b_is_small = body(b) <= range(b).max(MIN_BODY) * SPINNING_TOP_BODY_RATIO;
353
354    // Morning Star — large bearish, small star at or below prior close, then
355    // large bullish closing above the first bar's midpoint.
356    if is_bearish(a)
357        && body(a) >= range(a) * 0.5
358        && b_is_small
359        && b.open.max(b.close) <= a.close
360        && is_bullish(c)
361        && c.close > body_mid(a)
362    {
363        return Some(CandlePattern::MorningStar);
364    }
365
366    // Evening Star — large bullish, small star at or above prior close, then
367    // large bearish closing below the first bar's midpoint.
368    if is_bullish(a)
369        && body(a) >= range(a) * 0.5
370        && b_is_small
371        && b.open.min(b.close) >= a.close
372        && is_bearish(c)
373        && c.close < body_mid(a)
374    {
375        return Some(CandlePattern::EveningStar);
376    }
377
378    None
379}
380
381/// Detect two-bar patterns at position `i` (signal bar is `candles[i]`).
382#[inline]
383fn detect_two_bar(candles: &[Candle], i: usize) -> Option<CandlePattern> {
384    if i < 1 {
385        return None;
386    }
387    let (prev, curr) = (&candles[i - 1], &candles[i]);
388
389    // Tweezers — checked first because an exact price match is rare and highly
390    // significant; we don't want it masked by a weaker pattern.
391    if (curr.high - prev.high).abs() <= prev.high * TWEEZER_TOLERANCE
392        && is_bullish(prev)
393        && is_bearish(curr)
394    {
395        return Some(CandlePattern::TweezerTop);
396    }
397    if (curr.low - prev.low).abs() <= prev.low * TWEEZER_TOLERANCE
398        && is_bearish(prev)
399        && is_bullish(curr)
400    {
401        return Some(CandlePattern::TweezerBottom);
402    }
403
404    // Engulfing — current body fully covers the prior body AND is strictly larger.
405    // Same-size opposite bodies are "meeting lines", not engulfing.
406    if is_bearish(prev)
407        && is_bullish(curr)
408        && curr.open <= prev.close
409        && curr.close >= prev.open
410        && body(curr) > body(prev)
411    {
412        return Some(CandlePattern::BullishEngulfing);
413    }
414    if is_bullish(prev)
415        && is_bearish(curr)
416        && curr.open >= prev.close
417        && curr.close <= prev.open
418        && body(curr) > body(prev)
419    {
420        return Some(CandlePattern::BearishEngulfing);
421    }
422
423    // Harami — current body (any colour, including Doji) is contained within
424    // the prior body. A Doji inside the prior body is a "Harami Cross" — an
425    // even stronger signal per Nison — and is captured here rather than
426    // requiring a separate variant.
427    let curr_hi = curr.open.max(curr.close);
428    let curr_lo = curr.open.min(curr.close);
429    if is_bearish(prev) && curr_lo >= prev.close && curr_hi <= prev.open && body(curr) < body(prev)
430    {
431        return Some(CandlePattern::BullishHarami);
432    }
433    if is_bullish(prev) && curr_lo >= prev.open && curr_hi <= prev.close && body(curr) < body(prev)
434    {
435        return Some(CandlePattern::BearishHarami);
436    }
437
438    // Piercing Line — bearish prev, bullish curr opens below the prior close
439    // (Nison's definition) and closes above the prior body's midpoint.
440    if is_bearish(prev)
441        && is_bullish(curr)
442        && curr.open < prev.close
443        && curr.close > body_mid(prev)
444        && curr.close < prev.open
445    {
446        return Some(CandlePattern::PiercingLine);
447    }
448
449    // Dark Cloud Cover — bullish prev, bearish curr opens above the prior close
450    // (Nison's definition) and closes below the prior body's midpoint.
451    if is_bullish(prev)
452        && is_bearish(curr)
453        && curr.open > prev.close
454        && curr.close < body_mid(prev)
455        && curr.close > prev.open
456    {
457        return Some(CandlePattern::DarkCloudCover);
458    }
459
460    None
461}
462
463/// Detect one-bar patterns at position `i`.
464#[inline]
465fn detect_one_bar(candles: &[Candle], i: usize) -> Option<CandlePattern> {
466    let c = &candles[i];
467
468    // Doji — must be checked before marubozu and spinning top.
469    if is_doji(c) {
470        return Some(CandlePattern::Doji);
471    }
472
473    // Marubozu — very large body, minimal wicks.
474    if is_bullish_marubozu(c) {
475        return Some(CandlePattern::BullishMarubozu);
476    }
477    if is_bearish_marubozu(c) {
478        return Some(CandlePattern::BearishMarubozu);
479    }
480
481    // Hammer / Hanging Man (same shape, opposite trend context).
482    if is_hammer_shape(c) {
483        if prior_downtrend(candles, i) {
484            return Some(CandlePattern::Hammer);
485        }
486        if prior_uptrend(candles, i) {
487            return Some(CandlePattern::HangingMan);
488        }
489    }
490
491    // Inverted Hammer / Shooting Star (same shape, opposite trend context).
492    if is_inverted_hammer_shape(c) {
493        if prior_downtrend(candles, i) {
494            return Some(CandlePattern::InvertedHammer);
495        }
496        if prior_uptrend(candles, i) {
497            return Some(CandlePattern::ShootingStar);
498        }
499    }
500
501    // Spinning Top — catch-all for small-body indecision candles.
502    if is_spinning_top(c) {
503        return Some(CandlePattern::SpinningTop);
504    }
505
506    None
507}
508
509// ── Public API ────────────────────────────────────────────────────────────────
510
511/// Detect candlestick patterns for each bar in `candles`.
512///
513/// Returns a `Vec<Option<CandlePattern>>` of the same length as the input.
514/// `Some(pattern)` means a pattern was detected on that bar; `None` means no
515/// pattern matched. Input must be in chronological order (oldest candle first).
516///
517/// When multiple patterns are technically valid for the same bar, the most
518/// specific (widest lookback) pattern wins: three-bar patterns take precedence
519/// over two-bar, which take precedence over one-bar.
520///
521/// # Example
522///
523/// ```no_run
524/// use finance_query::{Ticker, Interval, TimeRange};
525/// use finance_query::indicators::{patterns, CandlePattern, PatternSentiment};
526///
527/// # async fn example() -> Result<(), Box<dyn std::error::Error>> {
528/// let ticker = Ticker::new("AAPL").await?;
529/// let chart = ticker.chart(Interval::OneDay, TimeRange::SixMonths).await?;
530/// let signals = patterns(&chart.candles);
531///
532/// let bullish: Vec<_> = signals
533///     .iter()
534///     .enumerate()
535///     .filter(|(_, s)| s.map(|p| p.sentiment() == PatternSentiment::Bullish).unwrap_or(false))
536///     .collect();
537///
538/// println!("{} bullish patterns detected", bullish.len());
539/// # Ok(())
540/// # }
541/// ```
542pub fn patterns(candles: &[Candle]) -> Vec<Option<CandlePattern>> {
543    candles
544        .iter()
545        .enumerate()
546        .map(|(i, _)| {
547            detect_three_bar(candles, i)
548                .or_else(|| detect_two_bar(candles, i))
549                .or_else(|| detect_one_bar(candles, i))
550        })
551        .collect()
552}
553
554// ── Tests ─────────────────────────────────────────────────────────────────────
555
556#[cfg(test)]
557mod tests {
558    use super::*;
559
560    // Convenience constructor — valid within this crate despite #[non_exhaustive].
561    fn c(o: f64, h: f64, l: f64, close: f64) -> Candle {
562        Candle {
563            timestamp: 0,
564            open: o,
565            high: h,
566            low: l,
567            close,
568            volume: 0,
569            adj_close: None,
570            provider_id: None,
571        }
572    }
573
574    // ── Output contract ───────────────────────────────────────────────────────
575
576    #[test]
577    fn test_empty_input_returns_empty() {
578        assert!(patterns(&[]).is_empty());
579    }
580
581    #[test]
582    fn test_output_length_matches_input() {
583        let candles: Vec<Candle> = (0..15)
584            .map(|i| c(i as f64 + 0.5, i as f64 + 1.0, i as f64, i as f64 + 0.6))
585            .collect();
586        assert_eq!(patterns(&candles).len(), candles.len());
587    }
588
589    // ── Single-candle ─────────────────────────────────────────────────────────
590
591    #[test]
592    fn test_doji_detected() {
593        // body=0.1, range=4.0 → ratio=0.025 < DOJI_BODY_RATIO
594        let candles = [c(10.0, 12.0, 8.0, 10.1)];
595        assert_eq!(patterns(&candles)[0], Some(CandlePattern::Doji));
596    }
597
598    #[test]
599    fn test_four_price_doji() {
600        // O=H=L=C — the ultimate indecision candle (zero range).
601        let candles = [c(10.0, 10.0, 10.0, 10.0)];
602        assert_eq!(patterns(&candles)[0], Some(CandlePattern::Doji));
603    }
604
605    #[test]
606    fn test_doji_not_on_normal_candle() {
607        // body=1.5, range=3.0 → ratio=0.5 > DOJI_BODY_RATIO
608        let candles = [c(10.0, 12.0, 9.0, 11.5)];
609        assert_ne!(patterns(&candles)[0], Some(CandlePattern::Doji));
610    }
611
612    #[test]
613    fn test_bullish_marubozu() {
614        // open≈low, close≈high → body/range ≈ 0.95
615        let candles = [c(10.0, 20.05, 9.95, 20.0)];
616        assert_eq!(patterns(&candles)[0], Some(CandlePattern::BullishMarubozu));
617    }
618
619    #[test]
620    fn test_bearish_marubozu() {
621        // open≈high, close≈low → body/range ≈ 0.95
622        let candles = [c(20.0, 20.05, 9.95, 10.0)];
623        assert_eq!(patterns(&candles)[0], Some(CandlePattern::BearishMarubozu));
624    }
625
626    #[test]
627    fn test_hammer_in_downtrend() {
628        // Four declining bars establish a downtrend (trend is evaluated on bars
629        // *before* the signal candle, so we need TREND_LOOKBACK + 1 = 4 prior bars).
630        //
631        // Hammer shape requirements:
632        //   body/range > DOJI_BODY_RATIO (5%) so it is NOT classified as a Doji
633        //   lower_wick >= body * LONG_WICK_RATIO (2×)
634        //   upper_wick <= body * SHORT_WICK_RATIO (0.5×)
635        //
636        // Hammer candle: open=12.0, high=13.5, low=4.5, close=13.0
637        //   body = 1.0, range = 9.0, body/range ≈ 0.11 > 0.05  ✓ (not Doji)
638        //   upper_wick = 0.5, lower_wick = 7.5
639        //   0.5 ≤ 1.0*0.5=0.5 ✓, 7.5 ≥ 1.0*2=2.0 ✓
640        //
641        // Trend check: candles[0].close=16.0 > candles[3].close=13.5 → downtrend ✓
642        let prior = [
643            c(16.0, 17.0, 15.0, 16.0),
644            c(15.5, 16.0, 14.5, 15.5),
645            c(15.0, 15.5, 13.5, 14.5),
646            c(14.0, 14.5, 12.5, 13.5),
647        ];
648        let hammer = c(12.0, 13.5, 4.5, 13.0);
649        let mut candles = prior.to_vec();
650        candles.push(hammer);
651        assert_eq!(patterns(&candles)[4], Some(CandlePattern::Hammer));
652    }
653
654    #[test]
655    fn test_shooting_star_in_uptrend() {
656        // Four rising bars establish an uptrend (trend evaluated on bars before
657        // the signal candle, needs TREND_LOOKBACK + 1 = 4 prior bars).
658        //
659        // Inverted-hammer shape: long upper wick ≥ 2× body, tiny lower wick ≤ 0.5× body,
660        // body/range > 5% so it is NOT classified as a Doji.
661        //
662        // Candle: open=9.5, high=18.5, low=9.0, close=10.5
663        //   body = 1.0, range = 9.5, body/range ≈ 0.105 > 0.05  ✓ (not Doji)
664        //   upper_wick = 8.0, lower_wick = 0.5
665        //   8.0 ≥ 1.0*2=2.0 ✓, 0.5 ≤ 1.0*0.5=0.5 ✓
666        //
667        // Trend check: candles[0].close=7.5 < candles[3].close=9.5 → uptrend ✓
668        let prior = [
669            c(7.0, 8.0, 6.5, 7.5),
670            c(7.5, 8.5, 7.0, 8.0),
671            c(8.0, 9.0, 7.5, 8.5),
672            c(8.5, 9.5, 8.0, 9.5),
673        ];
674        let star = c(9.5, 18.5, 9.0, 10.5);
675        let mut candles = prior.to_vec();
676        candles.push(star);
677        assert_eq!(patterns(&candles)[4], Some(CandlePattern::ShootingStar));
678    }
679
680    // ── Two-candle ────────────────────────────────────────────────────────────
681
682    #[test]
683    fn test_bullish_engulfing() {
684        let prev = c(11.0, 11.5, 9.5, 10.0); // bearish (o>c)
685        let curr = c(9.8, 12.0, 9.7, 11.2); // bullish, open ≤ prev.close, close ≥ prev.open
686        let result = patterns(&[prev, curr]);
687        assert_eq!(result[1], Some(CandlePattern::BullishEngulfing));
688    }
689
690    #[test]
691    fn test_bearish_engulfing() {
692        let prev = c(10.0, 11.5, 9.5, 11.0); // bullish (c>o)
693        let curr = c(11.2, 11.3, 9.0, 9.5); // bearish, open ≥ prev.close, close ≤ prev.open
694        let result = patterns(&[prev, curr]);
695        assert_eq!(result[1], Some(CandlePattern::BearishEngulfing));
696    }
697
698    #[test]
699    fn test_bullish_harami() {
700        let prev = c(12.0, 12.5, 9.0, 10.0); // bearish: open=12, close=10
701        let curr = c(10.5, 11.0, 10.4, 10.8); // bullish, inside prev body
702        let result = patterns(&[prev, curr]);
703        assert_eq!(result[1], Some(CandlePattern::BullishHarami));
704    }
705
706    #[test]
707    fn test_bearish_harami() {
708        let prev = c(10.0, 12.5, 9.0, 12.0); // bullish: open=10, close=12
709        let curr = c(11.5, 12.0, 11.2, 11.3); // bearish, inside prev body
710        let result = patterns(&[prev, curr]);
711        assert_eq!(result[1], Some(CandlePattern::BearishHarami));
712    }
713
714    #[test]
715    fn test_bullish_harami_cross_doji_inside() {
716        // A Doji (body ≈ 0) contained within a large bearish body is a
717        // "Harami Cross" — detected as BullishHarami per Nison.
718        let prev = c(12.0, 12.5, 9.0, 10.0); // bearish: open=12, close=10
719        // Doji inside prev body: open≈close, body/range tiny
720        let doji = c(11.0, 11.5, 10.5, 11.05); // body=0.05, range=1.0, ratio=0.05
721        let result = patterns(&[prev, doji]);
722        assert_eq!(result[1], Some(CandlePattern::BullishHarami));
723    }
724
725    #[test]
726    fn test_bearish_harami_cross_doji_inside() {
727        // A Doji contained within a large bullish body → BearishHarami.
728        let prev = c(10.0, 12.5, 9.0, 12.0); // bullish: open=10, close=12
729        let doji = c(11.0, 11.5, 10.5, 11.05); // Doji inside prev body
730        let result = patterns(&[prev, doji]);
731        assert_eq!(result[1], Some(CandlePattern::BearishHarami));
732    }
733
734    #[test]
735    fn test_piercing_line() {
736        // Bearish prev: open=14, close=10 → mid=12
737        // Bullish curr: opens below prev.close=10, closes above mid but below prev.open=14
738        let prev = c(14.0, 15.0, 9.0, 10.0);
739        let curr = c(9.5, 13.0, 9.4, 12.5); // open=9.5 < prev.close=10 ✓, close=12.5 > mid=12 ✓
740        let result = patterns(&[prev, curr]);
741        assert_eq!(result[1], Some(CandlePattern::PiercingLine));
742    }
743
744    #[test]
745    fn test_dark_cloud_cover() {
746        // Bullish prev: open=10, close=14 → mid=12
747        // Bearish curr: opens above prev.close=14, closes below mid=12 but above prev.open=10
748        let prev = c(10.0, 15.0, 9.0, 14.0);
749        let curr = c(14.5, 16.0, 10.5, 11.5); // open=14.5 > prev.close=14 ✓, close=11.5 < mid=12 ✓
750        let result = patterns(&[prev, curr]);
751        assert_eq!(result[1], Some(CandlePattern::DarkCloudCover));
752    }
753
754    #[test]
755    fn test_tweezer_top() {
756        // Both share same high (within tolerance), prev bullish, curr bearish
757        let prev = c(10.0, 12.0, 9.5, 11.5); // bullish
758        let curr = c(11.6, 12.0, 10.8, 11.0); // bearish, same high
759        let result = patterns(&[prev, curr]);
760        assert_eq!(result[1], Some(CandlePattern::TweezerTop));
761    }
762
763    #[test]
764    fn test_tweezer_bottom() {
765        // Both share same low, prev bearish, curr bullish
766        let prev = c(11.5, 12.0, 9.5, 10.0); // bearish
767        let curr = c(9.8, 11.0, 9.5, 10.5); // bullish, same low
768        let result = patterns(&[prev, curr]);
769        assert_eq!(result[1], Some(CandlePattern::TweezerBottom));
770    }
771
772    // ── Three-candle ──────────────────────────────────────────────────────────
773
774    #[test]
775    fn test_three_white_soldiers() {
776        // Each bar is bullish, opens in prior body, closes at new high
777        let c1 = c(10.0, 11.2, 9.8, 11.0);
778        let c2 = c(10.5, 12.2, 10.4, 12.0); // opens in c1 body (10.0..11.0), closes above c1
779        let c3 = c(11.5, 13.2, 11.4, 13.0); // opens in c2 body (10.5..12.0), closes above c2
780        let result = patterns(&[c1, c2, c3]);
781        assert_eq!(result[2], Some(CandlePattern::ThreeWhiteSoldiers));
782    }
783
784    #[test]
785    fn test_three_black_crows() {
786        // Each bar is bearish, opens in prior body, closes at new low
787        let c1 = c(13.0, 13.2, 11.8, 12.0);
788        let c2 = c(12.5, 12.6, 10.8, 11.0); // opens in c1 body (12.0..13.0), closes below c1
789        let c3 = c(11.5, 11.6, 9.8, 10.0); // opens in c2 body (11.0..12.5), closes below c2
790        let result = patterns(&[c1, c2, c3]);
791        assert_eq!(result[2], Some(CandlePattern::ThreeBlackCrows));
792    }
793
794    #[test]
795    fn test_morning_star() {
796        // Large bearish → small star below prior close → large bullish above prior mid
797        // a: open=110, close=102 → mid=106, body=8, range=12, b/r=0.67 ✓
798        // b: small body at 100–101 (below a.close=102) ✓
799        // c: bullish, closes at 108 > 106 ✓
800        let a = c(110.0, 112.0, 100.0, 102.0);
801        let b = c(100.5, 101.0, 99.5, 100.8); // body=0.3, range=1.5, b/r=0.2 ≤ 0.3 ✓
802        // b.open.max(b.close) = 100.8 ≤ a.close=102 ✓
803        let cc = c(101.0, 112.0, 100.0, 108.0); // close=108 > mid=106 ✓
804        let result = patterns(&[a, b, cc]);
805        assert_eq!(result[2], Some(CandlePattern::MorningStar));
806    }
807
808    #[test]
809    fn test_evening_star() {
810        // Large bullish → small star above prior close → large bearish below prior mid
811        // a: open=100, close=110 → mid=105, body=10, range=12, b/r=0.83 ✓
812        // b: small body at 111–112 (above a.close=110) ✓
813        // c: bearish, closes at 103 < 105 ✓
814        let a = c(100.0, 111.0, 99.0, 110.0);
815        let b = c(111.0, 112.5, 110.8, 111.3); // body=0.3, range=1.7, b/r≈0.18 ✓
816        // b.open.min(b.close) = 111.0 >= a.close=110 ✓
817        let cc = c(110.5, 111.0, 102.0, 103.0); // close=103 < mid=105 ✓
818        let result = patterns(&[a, b, cc]);
819        assert_eq!(result[2], Some(CandlePattern::EveningStar));
820    }
821
822    // ── Sentiment ─────────────────────────────────────────────────────────────
823
824    #[test]
825    fn test_sentiment_coverage() {
826        assert_eq!(CandlePattern::Hammer.sentiment(), PatternSentiment::Bullish);
827        assert_eq!(
828            CandlePattern::MorningStar.sentiment(),
829            PatternSentiment::Bullish
830        );
831        assert_eq!(
832            CandlePattern::BullishEngulfing.sentiment(),
833            PatternSentiment::Bullish
834        );
835        assert_eq!(
836            CandlePattern::ShootingStar.sentiment(),
837            PatternSentiment::Bearish
838        );
839        assert_eq!(
840            CandlePattern::EveningStar.sentiment(),
841            PatternSentiment::Bearish
842        );
843        assert_eq!(
844            CandlePattern::BearishEngulfing.sentiment(),
845            PatternSentiment::Bearish
846        );
847        assert_eq!(CandlePattern::Doji.sentiment(), PatternSentiment::Neutral);
848        assert_eq!(
849            CandlePattern::SpinningTop.sentiment(),
850            PatternSentiment::Neutral
851        );
852    }
853
854    // ── Priority (three-bar beats two-bar beats one-bar) ─────────────────────
855
856    #[test]
857    fn test_three_bar_takes_priority_over_two_bar() {
858        // Construct a sequence where the last two bars also form a BullishEngulfing
859        // but the three-bar context makes it a MorningStar confirmation.
860        let a = c(110.0, 112.0, 100.0, 102.0);
861        let b = c(100.5, 101.0, 99.5, 100.8);
862        // Make bar c both a bullish engulfer of b AND complete the morning star
863        let cc = c(99.0, 112.0, 98.0, 108.0); // open < b.close=100.8 ✓ (engulfs b) & > mid(a)=106
864        let result = patterns(&[a, b, cc]);
865        // MorningStar should win
866        assert_eq!(result[2], Some(CandlePattern::MorningStar));
867    }
868}