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#[derive(Debug, Clone, Default)]
30pub struct HaramiCross {
31    prev: Option<Candle>,
32    last_value: Option<f64>,
33}
34
35impl HaramiCross {
36    /// Construct a new `HaramiCross`.
37    #[must_use]
38    pub fn new() -> Self {
39        Self::default()
40    }
41
42    /// Latest emitted signal if available.
43    pub const fn value(&self) -> Option<f64> {
44        self.last_value
45    }
46}
47
48impl Indicator for HaramiCross {
49    type Input = Candle;
50    type Output = f64;
51
52    fn update(&mut self, candle: Candle) -> Option<f64> {
53        let Some(prev) = self.prev else {
54            self.prev = Some(candle);
55            self.last_value = Some(0.0);
56            return Some(0.0);
57        };
58        let prev_body_low = prev.open.min(prev.close);
59        let prev_body_high = prev.open.max(prev.close);
60        let prev_is_solid = !is_doji(prev);
61        let curr_is_doji = is_doji(candle);
62        let contained = candle.open >= prev_body_low
63            && candle.open <= prev_body_high
64            && candle.close >= prev_body_low
65            && candle.close <= prev_body_high;
66
67        let v = if prev_is_solid && curr_is_doji && contained {
68            if prev.close < prev.open {
69                1.0
70            } else {
71                -1.0
72            }
73        } else {
74            0.0
75        };
76        self.prev = Some(candle);
77        self.last_value = Some(v);
78        Some(v)
79    }
80
81    fn reset(&mut self) {
82        self.prev = None;
83        self.last_value = None;
84    }
85
86    fn warmup_period(&self) -> usize {
87        2
88    }
89
90    fn is_ready(&self) -> bool {
91        self.last_value.is_some()
92    }
93
94    fn name(&self) -> &'static str {
95        "HaramiCross"
96    }
97}
98
99#[cfg(test)]
100mod tests {
101    use super::*;
102    use crate::traits::BatchExt;
103
104    fn solid(open: f64, close: f64) -> Candle {
105        Candle::new_unchecked(
106            open,
107            open.max(close) + 0.2,
108            open.min(close) - 0.2,
109            close,
110            0.0,
111            0,
112        )
113    }
114
115    fn doji(mid: f64) -> Candle {
116        Candle::new_unchecked(mid, mid + 1.0, mid - 1.0, mid + 0.02, 0.0, 0)
117    }
118
119    #[test]
120    fn accessors_and_metadata() {
121        let h = HaramiCross::new();
122        assert_eq!(h.warmup_period(), 2);
123        assert_eq!(h.name(), "HaramiCross");
124        assert!(!h.is_ready());
125        assert_eq!(h.value(), None);
126    }
127
128    #[test]
129    fn first_bar_seeds_without_signal() {
130        let mut h = HaramiCross::new();
131        assert_eq!(h.update(solid(110.0, 100.0)), Some(0.0));
132        assert!(h.update(doji(105.0)).is_some());
133    }
134
135    #[test]
136    fn bullish_harami_cross() {
137        // prior big bearish body [100, 110]; doji centred at 105 inside it -> +1.
138        let mut h = HaramiCross::new();
139        h.update(solid(110.0, 100.0));
140        assert_eq!(h.update(doji(105.0)), Some(1.0));
141    }
142
143    #[test]
144    fn bearish_harami_cross() {
145        // prior big bullish body [100, 110]; doji inside -> -1.
146        let mut h = HaramiCross::new();
147        h.update(solid(100.0, 110.0));
148        assert_eq!(h.update(doji(105.0)), Some(-1.0));
149    }
150
151    #[test]
152    fn doji_outside_body_is_zero() {
153        let mut h = HaramiCross::new();
154        h.update(solid(110.0, 100.0));
155        // doji centred at 120, outside the prior body -> 0.
156        assert_eq!(h.update(doji(120.0)), Some(0.0));
157    }
158
159    #[test]
160    fn non_doji_second_is_zero() {
161        let mut h = HaramiCross::new();
162        h.update(solid(110.0, 100.0));
163        assert_eq!(h.update(solid(104.0, 106.0)), Some(0.0));
164    }
165
166    #[test]
167    fn reset_clears_state() {
168        let mut h = HaramiCross::new();
169        h.update(solid(110.0, 100.0));
170        h.update(doji(105.0));
171        assert!(h.is_ready());
172        h.reset();
173        assert!(!h.is_ready());
174        assert_eq!(h.update(solid(110.0, 100.0)), Some(0.0));
175    }
176
177    #[test]
178    fn batch_equals_streaming() {
179        let candles: Vec<Candle> = (0..40)
180            .map(|i| {
181                if i % 2 == 0 {
182                    solid(110.0, 100.0)
183                } else {
184                    doji(105.0)
185                }
186            })
187            .collect();
188        let batch = HaramiCross::new().batch(&candles);
189        let mut b = HaramiCross::new();
190        let streamed: Vec<_> = candles.iter().map(|x| b.update(*x)).collect();
191        assert_eq!(batch, streamed);
192    }
193}