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    fn update(&mut self, candle: Candle) -> Option<f64> {
65        self.has_emitted = true;
66        let prev = self.prev;
67        self.prev = Some(candle);
68        let Some(bar1) = prev else {
69            return Some(0.0);
70        };
71        let range1 = bar1.high - bar1.low;
72        let range2 = candle.high - candle.low;
73        if range1 <= 0.0 || range2 <= 0.0 {
74            return Some(0.0);
75        }
76        let body1 = bar1.close - bar1.open;
77        if body1.abs() < 0.5 * range1 {
78            return Some(0.0);
79        }
80        if (candle.close - candle.open).abs() > 0.1 * range2 {
81            return Some(0.0);
82        }
83        let doji_top = candle.open.max(candle.close);
84        let doji_bottom = candle.open.min(candle.close);
85        // Bullish: long black bar, doji body gaps down below it.
86        if body1 < 0.0 && doji_top < bar1.close {
87            return Some(1.0);
88        }
89        // Bearish: long white bar, doji body gaps up above it.
90        if body1 > 0.0 && doji_bottom > bar1.close {
91            return Some(-1.0);
92        }
93        Some(0.0)
94    }
95
96    fn reset(&mut self) {
97        self.prev = None;
98        self.has_emitted = false;
99    }
100
101    fn warmup_period(&self) -> usize {
102        2
103    }
104
105    fn is_ready(&self) -> bool {
106        self.has_emitted
107    }
108
109    fn name(&self) -> &'static str {
110        "DojiStar"
111    }
112}
113
114#[cfg(test)]
115mod tests {
116    use super::*;
117    use crate::traits::BatchExt;
118
119    fn c(open: f64, high: f64, low: f64, close: f64, ts: i64) -> Candle {
120        Candle::new(open, high, low, close, 1.0, ts).unwrap()
121    }
122
123    #[test]
124    fn accessors_and_metadata() {
125        let t = DojiStar::new();
126        assert_eq!(t.name(), "DojiStar");
127        assert_eq!(t.warmup_period(), 2);
128        assert!(!t.is_ready());
129    }
130
131    #[test]
132    fn bullish_doji_star_is_plus_one() {
133        let mut t = DojiStar::new();
134        assert_eq!(t.update(c(20.0, 20.2, 14.8, 15.0, 0)), Some(0.0));
135        assert_eq!(t.update(c(13.0, 13.1, 12.9, 13.0, 1)), Some(1.0));
136    }
137
138    #[test]
139    fn bearish_doji_star_is_minus_one() {
140        let mut t = DojiStar::new();
141        assert_eq!(t.update(c(15.0, 20.2, 14.8, 20.0, 0)), Some(0.0));
142        assert_eq!(t.update(c(22.0, 22.1, 21.9, 22.0, 1)), Some(-1.0));
143    }
144
145    #[test]
146    fn second_bar_not_doji_yields_zero() {
147        let mut t = DojiStar::new();
148        t.update(c(20.0, 20.2, 14.8, 15.0, 0));
149        // Wide body, not a doji.
150        assert_eq!(t.update(c(13.0, 13.2, 11.0, 11.5, 1)), Some(0.0));
151    }
152
153    #[test]
154    fn no_gap_yields_zero() {
155        let mut t = DojiStar::new();
156        t.update(c(20.0, 20.2, 14.8, 15.0, 0));
157        // Doji overlaps bar1's body (no gap down).
158        assert_eq!(t.update(c(16.0, 16.1, 15.9, 16.0, 1)), Some(0.0));
159    }
160
161    #[test]
162    fn short_first_body_yields_zero() {
163        let mut t = DojiStar::new();
164        // First bar body too short to be the "long" leg.
165        t.update(c(20.0, 24.0, 16.0, 19.5, 0));
166        assert_eq!(t.update(c(13.0, 13.1, 12.9, 13.0, 1)), Some(0.0));
167    }
168
169    #[test]
170    fn first_bar_returns_zero() {
171        let mut t = DojiStar::new();
172        assert_eq!(t.update(c(20.0, 20.2, 14.8, 15.0, 0)), Some(0.0));
173    }
174
175    #[test]
176    fn batch_equals_streaming() {
177        let candles: Vec<Candle> = (0..40)
178            .map(|i| {
179                let base = 100.0 + i as f64;
180                if i % 2 == 0 {
181                    c(base + 5.0, base + 5.2, base - 0.2, base, i)
182                } else {
183                    c(base - 3.0, base - 2.9, base - 3.1, base - 3.0, i)
184                }
185            })
186            .collect();
187        let mut a = DojiStar::new();
188        let mut b = DojiStar::new();
189        assert_eq!(
190            a.batch(&candles),
191            candles.iter().map(|x| b.update(*x)).collect::<Vec<_>>()
192        );
193    }
194
195    #[test]
196    fn reset_clears_state() {
197        let mut t = DojiStar::new();
198        t.update(c(20.0, 20.2, 14.8, 15.0, 0));
199        t.update(c(13.0, 13.1, 12.9, 13.0, 1));
200        assert!(t.is_ready());
201        t.reset();
202        assert!(!t.is_ready());
203        assert_eq!(t.update(c(20.0, 20.2, 14.8, 15.0, 0)), Some(0.0));
204    }
205
206    #[test]
207    fn zero_range_yields_zero() {
208        let mut t = DojiStar::new();
209        t.update(c(20.0, 20.2, 14.8, 15.0, 0));
210        // Flat second bar (high == low) -> zero-range guard.
211        assert_eq!(t.update(c(13.0, 13.0, 13.0, 13.0, 1)), Some(0.0));
212    }
213}