Skip to main content

fin_primitives/signals/indicators/
candle_pattern.rs

1//! Candle Pattern detector.
2
3use crate::error::FinError;
4use crate::signals::{BarInput, Signal, SignalValue};
5use rust_decimal::Decimal;
6
7/// Candle Pattern — detects common single-bar and two-bar price action patterns.
8///
9/// Returns:
10/// * `+2` — Bullish Engulfing (two-bar): current bullish body engulfs prior bearish body
11/// * `+1` — Hammer: small body at top of range, long lower wick (≥ 2× body), no upper wick
12/// * `-1` — Shooting Star: small body at bottom of range, long upper wick (≥ 2× body)
13/// * `-2` — Bearish Engulfing: current bearish body engulfs prior bullish body
14/// * `0`  — No pattern detected this bar
15///
16/// Returns [`SignalValue::Unavailable`] until the second bar (needed for engulfing patterns).
17///
18/// # Example
19/// ```rust
20/// use fin_primitives::signals::indicators::CandlePattern;
21/// use fin_primitives::signals::Signal;
22///
23/// let cp = CandlePattern::new("cp").unwrap();
24/// assert_eq!(cp.period(), 2);
25/// ```
26pub struct CandlePattern {
27    name: String,
28    prev: Option<BarInput>,
29}
30
31impl CandlePattern {
32    /// Creates a new `CandlePattern`.
33    ///
34    /// # Errors
35    /// Always succeeds.
36    pub fn new(name: impl Into<String>) -> Result<Self, FinError> {
37        Ok(Self { name: name.into(), prev: None })
38    }
39}
40
41impl Signal for CandlePattern {
42    fn name(&self) -> &str { &self.name }
43
44    fn update(&mut self, bar: &BarInput) -> Result<SignalValue, FinError> {
45        let range = bar.range();
46        let body = bar.body_size();
47
48        let prev = self.prev.replace(*bar);
49
50        let signal = if let Some(p) = prev {
51            // Two-bar patterns
52            let prev_body = (p.close - p.open).abs();
53            let curr_bull = bar.is_bullish();
54            let curr_bear = bar.is_bearish();
55            let prev_bull = p.close > p.open;
56            let prev_bear = p.close < p.open;
57
58            if curr_bull && prev_bear
59                && bar.open <= p.close
60                && bar.close >= p.open
61                && body > prev_body
62            {
63                Decimal::from(2i32)  // Bullish engulfing
64            } else if curr_bear && prev_bull
65                && bar.open >= p.close
66                && bar.close <= p.open
67                && body > prev_body
68            {
69                Decimal::from(-2i32)  // Bearish engulfing
70            } else {
71                // Single-bar patterns (use current bar only)
72                let upper_wick = bar.upper_wick();
73                let lower_wick = bar.lower_wick();
74                let body_pct = if range.is_zero() { Decimal::ZERO } else { body / range };
75
76                if range.is_zero() {
77                    Decimal::ZERO  // No pattern for doji/flat bars
78                } else if body_pct < Decimal::new(3, 1) && lower_wick >= body * Decimal::TWO && upper_wick <= body && lower_wick > Decimal::ZERO {
79                    Decimal::ONE   // Hammer
80                } else if body_pct < Decimal::new(3, 1) && upper_wick >= body * Decimal::TWO && lower_wick <= body && upper_wick > Decimal::ZERO {
81                    -Decimal::ONE  // Shooting star
82                } else {
83                    Decimal::ZERO
84                }
85            }
86        } else {
87            Decimal::ZERO
88        };
89
90        if prev.is_none() {
91            return Ok(SignalValue::Unavailable);
92        }
93
94        Ok(SignalValue::Scalar(signal))
95    }
96
97    fn is_ready(&self) -> bool {
98        self.prev.is_some()
99    }
100
101    fn period(&self) -> usize {
102        2
103    }
104
105    fn reset(&mut self) {
106        self.prev = None;
107    }
108}
109
110#[cfg(test)]
111mod tests {
112    use super::*;
113    use crate::ohlcv::OhlcvBar;
114    use crate::types::{NanoTimestamp, Price, Quantity, Symbol};
115    use rust_decimal_macros::dec;
116
117    fn bar_ohlc(o: &str, h: &str, l: &str, c: &str) -> OhlcvBar {
118        OhlcvBar {
119            symbol: Symbol::new("X").unwrap(),
120            open: Price::new(o.parse().unwrap()).unwrap(),
121            high: Price::new(h.parse().unwrap()).unwrap(),
122            low:  Price::new(l.parse().unwrap()).unwrap(),
123            close: Price::new(c.parse().unwrap()).unwrap(),
124            volume: Quantity::zero(),
125            ts_open: NanoTimestamp::new(0),
126            ts_close: NanoTimestamp::new(1),
127            tick_count: 1,
128        }
129    }
130
131    fn flat_bar(c: &str) -> OhlcvBar { bar_ohlc(c, c, c, c) }
132
133    #[test]
134    fn test_candle_first_bar_unavailable() {
135        let mut cp = CandlePattern::new("cp").unwrap();
136        assert_eq!(cp.update_bar(&flat_bar("100")).unwrap(), SignalValue::Unavailable);
137    }
138
139    #[test]
140    fn test_candle_flat_is_zero() {
141        let mut cp = CandlePattern::new("cp").unwrap();
142        cp.update_bar(&flat_bar("100")).unwrap();
143        if let SignalValue::Scalar(v) = cp.update_bar(&flat_bar("100")).unwrap() {
144            assert_eq!(v, dec!(0));
145        } else { panic!("expected Scalar"); }
146    }
147
148    #[test]
149    fn test_bullish_engulfing() {
150        let mut cp = CandlePattern::new("cp").unwrap();
151        // Prior bearish bar: open=110, close=100
152        cp.update_bar(&bar_ohlc("110", "110", "100", "100")).unwrap();
153        // Current bullish bar engulfs: open=99, close=111, body > prev body
154        if let SignalValue::Scalar(v) = cp.update_bar(&bar_ohlc("99", "111", "99", "111")).unwrap() {
155            assert_eq!(v, dec!(2), "bullish engulfing should be +2: {v}");
156        } else { panic!("expected Scalar"); }
157    }
158
159    #[test]
160    fn test_bearish_engulfing() {
161        let mut cp = CandlePattern::new("cp").unwrap();
162        // Prior bullish bar: open=100, close=110
163        cp.update_bar(&bar_ohlc("100", "110", "100", "110")).unwrap();
164        // Current bearish bar engulfs: open=111, close=99, body > prev body
165        if let SignalValue::Scalar(v) = cp.update_bar(&bar_ohlc("111", "111", "99", "99")).unwrap() {
166            assert_eq!(v, dec!(-2), "bearish engulfing should be -2: {v}");
167        } else { panic!("expected Scalar"); }
168    }
169
170    #[test]
171    fn test_reset() {
172        let mut cp = CandlePattern::new("cp").unwrap();
173        cp.update_bar(&flat_bar("100")).unwrap();
174        assert!(cp.is_ready());
175        cp.reset();
176        assert!(!cp.is_ready());
177        assert_eq!(cp.update_bar(&flat_bar("100")).unwrap(), SignalValue::Unavailable);
178    }
179}