Skip to main content

wickra_core/indicators/
harami_cross.rs

1#![allow(clippy::doc_markdown)]
2
3//! Harami Cross — a Harami whose second candle is a Doji.
4//!
5//! A Harami Cross is a stronger Harami: a large real body followed by a Doji whose
6//! body sits *within* the prior body. The Doji's total indecision after a strong
7//! move makes the reversal signal more potent than a plain Harami.
8//!
9//! - **Bullish** (`+1.0`): the prior candle is a large **bearish** body
10//!   (`close < open`) and the current candle is a Doji whose open and close lie
11//!   within the prior body.
12//! - **Bearish** (`-1.0`): the prior candle is a large **bullish** body and the
13//!   current is a contained Doji.
14//! - Otherwise the output is `0.0`.
15//!
16//! A doji is a candle whose body is `<= 0.1 * range`. The two-bar lookback means
17//! the first value lands on the second candle.
18
19use crate::ohlcv::Candle;
20use crate::traits::Indicator;
21
22fn is_doji(candle: Candle) -> bool {
23    let body = (candle.close - candle.open).abs();
24    let range = candle.high - candle.low;
25    range > 0.0 && body <= 0.1 * range
26}
27
28/// Harami Cross — large-body-then-contained-doji reversal detector.
29/// # Example
30///
31/// ```
32/// use wickra_core::{HaramiCross, Candle, Indicator};
33///
34/// let mut indicator = HaramiCross::new();
35/// // `None` during warmup, then `Some(_)` once enough bars are seen.
36/// let mut out = None;
37/// for i in 0..40i64 {
38///     let p = 100.0 + (i as f64 * 0.4).sin() * 5.0;
39///     let candle = Candle::new(p, p + 1.5, p - 1.5, p + 0.3, 1_000.0, i).unwrap();
40///     out = indicator.update(candle);
41/// }
42/// let _ = out;
43/// ```
44#[derive(Debug, Clone, Default)]
45pub struct HaramiCross {
46    prev: Option<Candle>,
47    last_value: Option<f64>,
48}
49
50impl HaramiCross {
51    /// Construct a new `HaramiCross`.
52    #[must_use]
53    pub fn new() -> Self {
54        Self::default()
55    }
56
57    /// Latest emitted signal if available.
58    pub const fn value(&self) -> Option<f64> {
59        self.last_value
60    }
61}
62
63impl Indicator for HaramiCross {
64    type Input = Candle;
65    type Output = f64;
66
67    #[inline]
68    fn update(&mut self, candle: Candle) -> Option<f64> {
69        let Some(prev) = self.prev else {
70            self.prev = Some(candle);
71            self.last_value = None;
72            return None;
73        };
74        let prev_body_low = prev.open.min(prev.close);
75        let prev_body_high = prev.open.max(prev.close);
76        let prev_is_solid = !is_doji(prev);
77        let curr_is_doji = is_doji(candle);
78        let contained = candle.open >= prev_body_low
79            && candle.open <= prev_body_high
80            && candle.close >= prev_body_low
81            && candle.close <= prev_body_high;
82
83        let v = if prev_is_solid && curr_is_doji && contained {
84            if prev.close < prev.open {
85                1.0
86            } else {
87                -1.0
88            }
89        } else {
90            0.0
91        };
92        self.prev = Some(candle);
93        self.last_value = Some(v);
94        Some(v)
95    }
96
97    fn reset(&mut self) {
98        self.prev = None;
99        self.last_value = None;
100    }
101
102    #[inline]
103    fn warmup_period(&self) -> usize {
104        2
105    }
106
107    #[inline]
108    fn is_ready(&self) -> bool {
109        self.last_value.is_some()
110    }
111
112    #[inline]
113    fn name(&self) -> &'static str {
114        "HaramiCross"
115    }
116}
117
118#[cfg(test)]
119mod tests {
120    use super::*;
121    use crate::traits::BatchExt;
122
123    fn solid(open: f64, close: f64) -> Candle {
124        Candle::new_unchecked(
125            open,
126            open.max(close) + 0.2,
127            open.min(close) - 0.2,
128            close,
129            0.0,
130            0,
131        )
132    }
133
134    fn doji(mid: f64) -> Candle {
135        Candle::new_unchecked(mid, mid + 1.0, mid - 1.0, mid + 0.02, 0.0, 0)
136    }
137
138    #[test]
139    fn accessors_and_metadata() {
140        let h = HaramiCross::new();
141        assert_eq!(h.warmup_period(), 2);
142        assert_eq!(h.name(), "HaramiCross");
143        assert!(!h.is_ready());
144        assert_eq!(h.value(), None);
145    }
146
147    #[test]
148    fn first_bar_seeds_without_signal() {
149        let mut h = HaramiCross::new();
150        assert_eq!(h.update(solid(110.0, 100.0)), None);
151        assert!(h.update(doji(105.0)).is_some());
152    }
153
154    #[test]
155    fn bullish_harami_cross() {
156        // prior big bearish body [100, 110]; doji centred at 105 inside it -> +1.
157        let mut h = HaramiCross::new();
158        h.update(solid(110.0, 100.0));
159        assert_eq!(h.update(doji(105.0)), Some(1.0));
160    }
161
162    #[test]
163    fn bearish_harami_cross() {
164        // prior big bullish body [100, 110]; doji inside -> -1.
165        let mut h = HaramiCross::new();
166        h.update(solid(100.0, 110.0));
167        assert_eq!(h.update(doji(105.0)), Some(-1.0));
168    }
169
170    #[test]
171    fn doji_outside_body_is_zero() {
172        let mut h = HaramiCross::new();
173        h.update(solid(110.0, 100.0));
174        // doji centred at 120, outside the prior body -> 0.
175        assert_eq!(h.update(doji(120.0)), Some(0.0));
176    }
177
178    #[test]
179    fn non_doji_second_is_zero() {
180        let mut h = HaramiCross::new();
181        h.update(solid(110.0, 100.0));
182        assert_eq!(h.update(solid(104.0, 106.0)), Some(0.0));
183    }
184
185    #[test]
186    fn reset_clears_state() {
187        let mut h = HaramiCross::new();
188        h.update(solid(110.0, 100.0));
189        h.update(doji(105.0));
190        assert!(h.is_ready());
191        h.reset();
192        assert!(!h.is_ready());
193        assert_eq!(h.update(solid(110.0, 100.0)), None);
194    }
195
196    #[test]
197    fn batch_equals_streaming() {
198        let candles: Vec<Candle> = (0..40)
199            .map(|i| {
200                if i % 2 == 0 {
201                    solid(110.0, 100.0)
202                } else {
203                    doji(105.0)
204                }
205            })
206            .collect();
207        let batch = HaramiCross::new().batch(&candles);
208        let mut b = HaramiCross::new();
209        let streamed: Vec<_> = candles.iter().map(|x| b.update(*x)).collect();
210        assert_eq!(batch, streamed);
211    }
212}