Skip to main content

fin_primitives/signals/indicators/
engulfing_pattern.rs

1//! Engulfing Pattern indicator.
2
3use crate::error::FinError;
4use crate::signals::{BarInput, Signal, SignalValue};
5use rust_decimal::Decimal;
6
7/// Engulfing Pattern — detects bullish and bearish engulfing candlestick patterns.
8///
9/// A **bullish engulfing** occurs when:
10/// - Previous bar is bearish (`prev_close < prev_open`)
11/// - Current bar is bullish (`close > open`)
12/// - Current bar's body fully engulfs the prior bar's body
13///   (`open <= prev_close` and `close >= prev_open`)
14///
15/// A **bearish engulfing** is the mirror: prior bullish, current bearish, full engulf.
16///
17/// Outputs:
18/// - `+1` → bullish engulfing
19/// - `-1` → bearish engulfing
20/// - `0` → no pattern
21///
22/// Returns [`SignalValue::Unavailable`] on the first bar (requires prior bar).
23///
24/// # Example
25/// ```rust
26/// use fin_primitives::signals::indicators::EngulfingPattern;
27/// use fin_primitives::signals::Signal;
28///
29/// let ep = EngulfingPattern::new("ep").unwrap();
30/// assert_eq!(ep.period(), 2);
31/// ```
32pub struct EngulfingPattern {
33    name: String,
34    prev_open: Option<Decimal>,
35    prev_close: Option<Decimal>,
36}
37
38impl EngulfingPattern {
39    /// # Errors
40    /// Never errors — provided for API consistency.
41    pub fn new(name: impl Into<String>) -> Result<Self, FinError> {
42        Ok(Self { name: name.into(), prev_open: None, prev_close: None })
43    }
44}
45
46impl Signal for EngulfingPattern {
47    fn name(&self) -> &str { &self.name }
48    fn period(&self) -> usize { 2 }
49    fn is_ready(&self) -> bool { self.prev_open.is_some() }
50
51    fn update(&mut self, bar: &BarInput) -> Result<SignalValue, FinError> {
52        let result = match (self.prev_open, self.prev_close) {
53            (Some(po), Some(pc)) => {
54                let prev_bearish = pc < po;
55                let prev_bullish = pc > po;
56                let curr_bullish = bar.is_bullish();
57                let curr_bearish = bar.is_bearish();
58
59                if prev_bearish && curr_bullish
60                    && bar.open <= pc && bar.close >= po
61                {
62                    SignalValue::Scalar(Decimal::ONE)
63                } else if prev_bullish && curr_bearish
64                    && bar.open >= pc && bar.close <= po
65                {
66                    SignalValue::Scalar(Decimal::NEGATIVE_ONE)
67                } else {
68                    SignalValue::Scalar(Decimal::ZERO)
69                }
70            }
71            _ => SignalValue::Unavailable,
72        };
73        self.prev_open  = Some(bar.open);
74        self.prev_close = Some(bar.close);
75        Ok(result)
76    }
77
78    fn reset(&mut self) {
79        self.prev_open  = None;
80        self.prev_close = None;
81    }
82}
83
84#[cfg(test)]
85mod tests {
86    use super::*;
87    use crate::ohlcv::OhlcvBar;
88    use crate::signals::Signal;
89    use crate::types::{NanoTimestamp, Price, Quantity, Symbol};
90    use rust_decimal_macros::dec;
91
92    fn bar(o: &str, h: &str, l: &str, c: &str) -> OhlcvBar {
93        let op = Price::new(o.parse().unwrap()).unwrap();
94        let hp = Price::new(h.parse().unwrap()).unwrap();
95        let lp = Price::new(l.parse().unwrap()).unwrap();
96        let cp = Price::new(c.parse().unwrap()).unwrap();
97        OhlcvBar {
98            symbol: Symbol::new("X").unwrap(),
99            open: op, high: hp, low: lp, close: cp,
100            volume: Quantity::zero(),
101            ts_open: NanoTimestamp::new(0),
102            ts_close: NanoTimestamp::new(1),
103            tick_count: 1,
104        }
105    }
106
107    #[test]
108    fn test_ep_first_bar_unavailable() {
109        let mut ep = EngulfingPattern::new("ep").unwrap();
110        assert_eq!(ep.update_bar(&bar("105","110","95","100")).unwrap(), SignalValue::Unavailable);
111    }
112
113    #[test]
114    fn test_ep_bullish_engulfing() {
115        let mut ep = EngulfingPattern::new("ep").unwrap();
116        // Prior: bearish o=105, c=95
117        ep.update_bar(&bar("105","110","90","95")).unwrap();
118        // Current: bullish, open=94 <= prev_close=95, close=106 >= prev_open=105
119        let r = ep.update_bar(&bar("94","110","90","106")).unwrap();
120        assert_eq!(r, SignalValue::Scalar(dec!(1)));
121    }
122
123    #[test]
124    fn test_ep_bearish_engulfing() {
125        let mut ep = EngulfingPattern::new("ep").unwrap();
126        // Prior: bullish o=95, c=105
127        ep.update_bar(&bar("95","110","90","105")).unwrap();
128        // Current: bearish, open=106 >= prev_close=105, close=94 <= prev_open=95
129        let r = ep.update_bar(&bar("106","110","90","94")).unwrap();
130        assert_eq!(r, SignalValue::Scalar(dec!(-1)));
131    }
132
133    #[test]
134    fn test_ep_no_pattern() {
135        let mut ep = EngulfingPattern::new("ep").unwrap();
136        ep.update_bar(&bar("100","110","90","105")).unwrap();
137        let r = ep.update_bar(&bar("103","110","90","107")).unwrap();
138        assert_eq!(r, SignalValue::Scalar(dec!(0)));
139    }
140
141    #[test]
142    fn test_ep_reset() {
143        let mut ep = EngulfingPattern::new("ep").unwrap();
144        ep.update_bar(&bar("100","110","90","95")).unwrap();
145        assert!(ep.is_ready());
146        ep.reset();
147        assert!(!ep.is_ready());
148    }
149}