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