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}