Skip to main content

wickra_core/indicators/
opening_marubozu.rs

1//! Opening Marubozu candlestick pattern.
2
3use crate::ohlcv::Candle;
4use crate::traits::Indicator;
5
6/// Opening Marubozu — a single-bar strong-momentum candle with a long body and no
7/// shadow on the *open* end. A white opening marubozu opens right at the low (no
8/// lower shadow) and may carry a closing shadow above; a black one opens right at
9/// the high (no upper shadow) and may carry a closing shadow below. The shaved
10/// open end shows the move took off from the bell without hesitation.
11///
12/// ```text
13/// range = high − low
14/// long body: |close − open| >= 0.7 * range
15/// white: close > open and open − low  <= 0.05 * range   (open at the low)
16/// black: close < open and high − open <= 0.05 * range   (open at the high)
17/// ```
18///
19/// Output is `+1.0` for a white opening marubozu, `−1.0` for a black one, and
20/// `0.0` otherwise. Body and shadow thresholds follow the geometric house style
21/// rather than TA-Lib's rolling averages. TA-Lib has no direct equivalent; this
22/// completes the pair with [`crate::ClosingMarubozu`], which shaves the close end.
23/// Pattern-shape check only — no trend filter is applied; combine with a trend
24/// indicator for actionable signals.
25///
26/// # Signed ±1 encoding
27///
28/// This detector emits the uniform candlestick sign convention shared across the
29/// pattern family — `+1.0` bullish, `−1.0` bearish, `0.0` no pattern — so it drops
30/// straight into a machine-learning feature matrix where the bullish and bearish
31/// variants occupy a single dimension.
32///
33/// # Example
34///
35/// ```
36/// use wickra_core::{Candle, Indicator, OpeningMarubozu};
37///
38/// let mut indicator = OpeningMarubozu::new();
39/// // White: opens at the low, small closing shadow above.
40/// let candle = Candle::new(10.0, 15.0, 10.0, 14.5, 1.0, 0).unwrap();
41/// assert_eq!(indicator.update(candle), Some(1.0));
42/// ```
43#[derive(Debug, Clone, Default)]
44pub struct OpeningMarubozu {
45    has_emitted: bool,
46}
47
48impl OpeningMarubozu {
49    /// Construct a new Opening Marubozu detector.
50    pub const fn new() -> Self {
51        Self { has_emitted: false }
52    }
53}
54
55impl Indicator for OpeningMarubozu {
56    type Input = Candle;
57    type Output = f64;
58
59    #[inline]
60    fn update(&mut self, candle: Candle) -> Option<f64> {
61        self.has_emitted = true;
62        let range = candle.high - candle.low;
63        if range <= 0.0 {
64            return Some(0.0);
65        }
66        let body = candle.close - candle.open;
67        if body.abs() < 0.7 * range {
68            return Some(0.0);
69        }
70        let tol = 0.05 * range;
71        if body > 0.0 && candle.open - candle.low <= tol {
72            return Some(1.0);
73        }
74        if body < 0.0 && candle.high - candle.open <= tol {
75            return Some(-1.0);
76        }
77        Some(0.0)
78    }
79
80    fn reset(&mut self) {
81        self.has_emitted = false;
82    }
83
84    #[inline]
85    fn warmup_period(&self) -> usize {
86        1
87    }
88
89    #[inline]
90    fn is_ready(&self) -> bool {
91        self.has_emitted
92    }
93
94    #[inline]
95    fn name(&self) -> &'static str {
96        "OpeningMarubozu"
97    }
98}
99
100#[cfg(test)]
101mod tests {
102    use super::*;
103    use crate::traits::BatchExt;
104
105    fn c(open: f64, high: f64, low: f64, close: f64, ts: i64) -> Candle {
106        Candle::new(open, high, low, close, 1.0, ts).unwrap()
107    }
108
109    #[test]
110    fn accessors_and_metadata() {
111        let t = OpeningMarubozu::new();
112        assert_eq!(t.name(), "OpeningMarubozu");
113        assert_eq!(t.warmup_period(), 1);
114        assert!(!t.is_ready());
115    }
116
117    #[test]
118    fn white_opening_marubozu_is_plus_one() {
119        let mut t = OpeningMarubozu::new();
120        // Opens at the low, closing shadow above.
121        assert_eq!(t.update(c(10.0, 15.0, 10.0, 14.5, 0)), Some(1.0));
122    }
123
124    #[test]
125    fn black_opening_marubozu_is_minus_one() {
126        let mut t = OpeningMarubozu::new();
127        // Opens at the high, closing shadow below.
128        assert_eq!(t.update(c(15.0, 15.0, 10.0, 10.5, 0)), Some(-1.0));
129    }
130
131    #[test]
132    fn white_with_lower_shadow_yields_zero() {
133        let mut t = OpeningMarubozu::new();
134        // Long white body but a clear lower shadow -> open is not at the low.
135        assert_eq!(t.update(c(11.0, 15.0, 10.0, 15.0, 0)), Some(0.0));
136    }
137
138    #[test]
139    fn black_with_upper_shadow_yields_zero() {
140        let mut t = OpeningMarubozu::new();
141        // Long black body but a clear upper shadow -> open is not at the high.
142        assert_eq!(t.update(c(14.0, 16.0, 10.0, 10.5, 0)), Some(0.0));
143    }
144
145    #[test]
146    fn short_body_yields_zero() {
147        let mut t = OpeningMarubozu::new();
148        // Body is short relative to range.
149        assert_eq!(t.update(c(10.0, 15.0, 10.0, 12.5, 0)), Some(0.0));
150    }
151
152    #[test]
153    fn zero_range_yields_zero() {
154        let mut t = OpeningMarubozu::new();
155        assert_eq!(t.update(c(10.0, 10.0, 10.0, 10.0, 0)), Some(0.0));
156    }
157
158    #[test]
159    fn batch_equals_streaming() {
160        let candles: Vec<Candle> = (0..40)
161            .map(|i| {
162                let base = 100.0 + i as f64;
163                c(base, base + 5.0, base, base + 4.5, i)
164            })
165            .collect();
166        let mut a = OpeningMarubozu::new();
167        let mut b = OpeningMarubozu::new();
168        assert_eq!(
169            a.batch(&candles),
170            candles.iter().map(|x| b.update(*x)).collect::<Vec<_>>()
171        );
172    }
173
174    #[test]
175    fn reset_clears_state() {
176        let mut t = OpeningMarubozu::new();
177        t.update(c(10.0, 15.0, 10.0, 14.5, 0));
178        assert!(t.is_ready());
179        t.reset();
180        assert!(!t.is_ready());
181    }
182}