Skip to main content

wickra_core/indicators/
doji_star.rs

1//! Doji Star candlestick pattern.
2
3use crate::ohlcv::Candle;
4use crate::traits::Indicator;
5
6/// Doji Star — a 2-bar reversal warning. A long trending body is followed by a
7/// doji whose tiny body gaps away in the direction of the trend, the indecision
8/// hinting the move is about to turn.
9///
10/// ```text
11/// long body  = |close − open| >= 0.5 * (high − low)        (bar1)
12/// doji       = |close − open| <= 0.1 * (high − low)        (bar2)
13/// bullish (+1.0): bar1 black, doji body gaps DOWN below it  (max(o2,c2) < close1)
14/// bearish (−1.0): bar1 white, doji body gaps UP above it    (min(o2,c2) > close1)
15/// ```
16///
17/// Output is `+1.0` (bullish star, after a black bar) or `−1.0` (bearish star,
18/// after a white bar) when the pattern completes, and `0.0` otherwise. The first
19/// bar always returns `0.0` because the two-bar window is not yet filled. Doji
20/// thresholds follow the geometric house style (fixed half-range body for the
21/// long bar, tenth-range body for the doji) rather than TA-Lib's rolling
22/// averages. Pattern-shape check only — no trend filter is applied; combine with
23/// 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
29/// drops straight into a machine-learning feature matrix where the bullish and
30/// bearish variants occupy a single dimension.
31///
32/// # Example
33///
34/// ```
35/// use wickra_core::{Candle, DojiStar, Indicator};
36///
37/// let mut indicator = DojiStar::new();
38/// // Long black bar, then a doji gapping down -> bullish star.
39/// indicator.update(Candle::new(20.0, 20.2, 14.8, 15.0, 1.0, 0).unwrap());
40/// let out = indicator
41///     .update(Candle::new(13.0, 13.1, 12.9, 13.0, 1.0, 1).unwrap());
42/// assert_eq!(out, Some(1.0));
43/// ```
44#[derive(Debug, Clone, Default)]
45pub struct DojiStar {
46    prev: Option<Candle>,
47    has_emitted: bool,
48}
49
50impl DojiStar {
51    /// Construct a new Doji Star detector.
52    pub const fn new() -> Self {
53        Self {
54            prev: None,
55            has_emitted: false,
56        }
57    }
58}
59
60impl Indicator for DojiStar {
61    type Input = Candle;
62    type Output = f64;
63
64    #[inline]
65    fn update(&mut self, candle: Candle) -> Option<f64> {
66        let prev = self.prev;
67        self.prev = Some(candle);
68        let bar1 = prev?;
69        self.has_emitted = true;
70        let range1 = bar1.high - bar1.low;
71        let range2 = candle.high - candle.low;
72        if range1 <= 0.0 || range2 <= 0.0 {
73            return Some(0.0);
74        }
75        let body1 = bar1.close - bar1.open;
76        if body1.abs() < 0.5 * range1 {
77            return Some(0.0);
78        }
79        if (candle.close - candle.open).abs() > 0.1 * range2 {
80            return Some(0.0);
81        }
82        let doji_top = candle.open.max(candle.close);
83        let doji_bottom = candle.open.min(candle.close);
84        // Bullish: long black bar, doji body gaps down below it.
85        if body1 < 0.0 && doji_top < bar1.close {
86            return Some(1.0);
87        }
88        // Bearish: long white bar, doji body gaps up above it.
89        if body1 > 0.0 && doji_bottom > bar1.close {
90            return Some(-1.0);
91        }
92        Some(0.0)
93    }
94
95    fn reset(&mut self) {
96        self.prev = None;
97        self.has_emitted = false;
98    }
99
100    #[inline]
101    fn warmup_period(&self) -> usize {
102        2
103    }
104
105    #[inline]
106    fn is_ready(&self) -> bool {
107        self.has_emitted
108    }
109
110    #[inline]
111    fn name(&self) -> &'static str {
112        "DojiStar"
113    }
114}
115
116#[cfg(test)]
117mod tests {
118    use super::*;
119    use crate::traits::BatchExt;
120
121    fn c(open: f64, high: f64, low: f64, close: f64, ts: i64) -> Candle {
122        Candle::new(open, high, low, close, 1.0, ts).unwrap()
123    }
124
125    #[test]
126    fn accessors_and_metadata() {
127        let t = DojiStar::new();
128        assert_eq!(t.name(), "DojiStar");
129        assert_eq!(t.warmup_period(), 2);
130        assert!(!t.is_ready());
131    }
132
133    #[test]
134    fn bullish_doji_star_is_plus_one() {
135        let mut t = DojiStar::new();
136        assert_eq!(t.update(c(20.0, 20.2, 14.8, 15.0, 0)), None);
137        assert_eq!(t.update(c(13.0, 13.1, 12.9, 13.0, 1)), Some(1.0));
138    }
139
140    #[test]
141    fn bearish_doji_star_is_minus_one() {
142        let mut t = DojiStar::new();
143        assert_eq!(t.update(c(15.0, 20.2, 14.8, 20.0, 0)), None);
144        assert_eq!(t.update(c(22.0, 22.1, 21.9, 22.0, 1)), Some(-1.0));
145    }
146
147    #[test]
148    fn second_bar_not_doji_yields_zero() {
149        let mut t = DojiStar::new();
150        t.update(c(20.0, 20.2, 14.8, 15.0, 0));
151        // Wide body, not a doji.
152        assert_eq!(t.update(c(13.0, 13.2, 11.0, 11.5, 1)), Some(0.0));
153    }
154
155    #[test]
156    fn no_gap_yields_zero() {
157        let mut t = DojiStar::new();
158        t.update(c(20.0, 20.2, 14.8, 15.0, 0));
159        // Doji overlaps bar1's body (no gap down).
160        assert_eq!(t.update(c(16.0, 16.1, 15.9, 16.0, 1)), Some(0.0));
161    }
162
163    #[test]
164    fn short_first_body_yields_zero() {
165        let mut t = DojiStar::new();
166        // First bar body too short to be the "long" leg.
167        t.update(c(20.0, 24.0, 16.0, 19.5, 0));
168        assert_eq!(t.update(c(13.0, 13.1, 12.9, 13.0, 1)), Some(0.0));
169    }
170
171    #[test]
172    fn first_bar_returns_zero() {
173        let mut t = DojiStar::new();
174        assert_eq!(t.update(c(20.0, 20.2, 14.8, 15.0, 0)), None);
175    }
176
177    #[test]
178    fn batch_equals_streaming() {
179        let candles: Vec<Candle> = (0..40)
180            .map(|i| {
181                let base = 100.0 + i as f64;
182                if i % 2 == 0 {
183                    c(base + 5.0, base + 5.2, base - 0.2, base, i)
184                } else {
185                    c(base - 3.0, base - 2.9, base - 3.1, base - 3.0, i)
186                }
187            })
188            .collect();
189        let mut a = DojiStar::new();
190        let mut b = DojiStar::new();
191        assert_eq!(
192            a.batch(&candles),
193            candles.iter().map(|x| b.update(*x)).collect::<Vec<_>>()
194        );
195    }
196
197    #[test]
198    fn reset_clears_state() {
199        let mut t = DojiStar::new();
200        t.update(c(20.0, 20.2, 14.8, 15.0, 0));
201        t.update(c(13.0, 13.1, 12.9, 13.0, 1));
202        assert!(t.is_ready());
203        t.reset();
204        assert!(!t.is_ready());
205        assert_eq!(t.update(c(20.0, 20.2, 14.8, 15.0, 0)), None);
206    }
207
208    #[test]
209    fn zero_range_yields_zero() {
210        let mut t = DojiStar::new();
211        t.update(c(20.0, 20.2, 14.8, 15.0, 0));
212        // Flat second bar (high == low) -> zero-range guard.
213        assert_eq!(t.update(c(13.0, 13.0, 13.0, 13.0, 1)), Some(0.0));
214    }
215}