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