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    fn update(&mut self, candle: Candle) -> Option<f64> {
60        self.has_emitted = true;
61        let range = candle.high - candle.low;
62        if range <= 0.0 {
63            return Some(0.0);
64        }
65        let body = candle.close - candle.open;
66        if body.abs() < 0.7 * range {
67            return Some(0.0);
68        }
69        let tol = 0.05 * range;
70        if body > 0.0 && candle.open - candle.low <= tol {
71            return Some(1.0);
72        }
73        if body < 0.0 && candle.high - candle.open <= tol {
74            return Some(-1.0);
75        }
76        Some(0.0)
77    }
78
79    fn reset(&mut self) {
80        self.has_emitted = false;
81    }
82
83    fn warmup_period(&self) -> usize {
84        1
85    }
86
87    fn is_ready(&self) -> bool {
88        self.has_emitted
89    }
90
91    fn name(&self) -> &'static str {
92        "OpeningMarubozu"
93    }
94}
95
96#[cfg(test)]
97mod tests {
98    use super::*;
99    use crate::traits::BatchExt;
100
101    fn c(open: f64, high: f64, low: f64, close: f64, ts: i64) -> Candle {
102        Candle::new(open, high, low, close, 1.0, ts).unwrap()
103    }
104
105    #[test]
106    fn accessors_and_metadata() {
107        let t = OpeningMarubozu::new();
108        assert_eq!(t.name(), "OpeningMarubozu");
109        assert_eq!(t.warmup_period(), 1);
110        assert!(!t.is_ready());
111    }
112
113    #[test]
114    fn white_opening_marubozu_is_plus_one() {
115        let mut t = OpeningMarubozu::new();
116        // Opens at the low, closing shadow above.
117        assert_eq!(t.update(c(10.0, 15.0, 10.0, 14.5, 0)), Some(1.0));
118    }
119
120    #[test]
121    fn black_opening_marubozu_is_minus_one() {
122        let mut t = OpeningMarubozu::new();
123        // Opens at the high, closing shadow below.
124        assert_eq!(t.update(c(15.0, 15.0, 10.0, 10.5, 0)), Some(-1.0));
125    }
126
127    #[test]
128    fn white_with_lower_shadow_yields_zero() {
129        let mut t = OpeningMarubozu::new();
130        // Long white body but a clear lower shadow -> open is not at the low.
131        assert_eq!(t.update(c(11.0, 15.0, 10.0, 15.0, 0)), Some(0.0));
132    }
133
134    #[test]
135    fn black_with_upper_shadow_yields_zero() {
136        let mut t = OpeningMarubozu::new();
137        // Long black body but a clear upper shadow -> open is not at the high.
138        assert_eq!(t.update(c(14.0, 16.0, 10.0, 10.5, 0)), Some(0.0));
139    }
140
141    #[test]
142    fn short_body_yields_zero() {
143        let mut t = OpeningMarubozu::new();
144        // Body is short relative to range.
145        assert_eq!(t.update(c(10.0, 15.0, 10.0, 12.5, 0)), Some(0.0));
146    }
147
148    #[test]
149    fn zero_range_yields_zero() {
150        let mut t = OpeningMarubozu::new();
151        assert_eq!(t.update(c(10.0, 10.0, 10.0, 10.0, 0)), Some(0.0));
152    }
153
154    #[test]
155    fn batch_equals_streaming() {
156        let candles: Vec<Candle> = (0..40)
157            .map(|i| {
158                let base = 100.0 + i as f64;
159                c(base, base + 5.0, base, base + 4.5, i)
160            })
161            .collect();
162        let mut a = OpeningMarubozu::new();
163        let mut b = OpeningMarubozu::new();
164        assert_eq!(
165            a.batch(&candles),
166            candles.iter().map(|x| b.update(*x)).collect::<Vec<_>>()
167        );
168    }
169
170    #[test]
171    fn reset_clears_state() {
172        let mut t = OpeningMarubozu::new();
173        t.update(c(10.0, 15.0, 10.0, 14.5, 0));
174        assert!(t.is_ready());
175        t.reset();
176        assert!(!t.is_ready());
177    }
178}