Skip to main content

wickra_core/indicators/
closing_marubozu.rs

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