Skip to main content

wickra_core/indicators/
doji.rs

1//! Doji candlestick pattern.
2
3use crate::error::{Error, Result};
4use crate::ohlcv::Candle;
5use crate::traits::Indicator;
6
7/// Doji — a candle whose body is negligible relative to its range.
8///
9/// A Doji prints whenever the absolute distance between open and close is
10/// small compared to the total `high − low` range. It is the canonical
11/// indecision bar and a building block for many three-bar reversal patterns.
12///
13/// ```text
14/// body  = |close − open|
15/// range = high − low
16/// doji  = body <= body_threshold * range
17/// ```
18///
19/// The output is `+1.0` when a Doji is detected and `0.0` otherwise. Doji is
20/// directionless — no `−1.0` is emitted. Pattern-shape check only — no trend
21/// filter is applied; combine with a trend indicator for actionable signals.
22///
23/// # Example
24///
25/// ```
26/// use wickra_core::{Candle, Doji, Indicator};
27///
28/// let mut indicator = Doji::default();
29/// let candle = Candle::new(10.0, 11.0, 9.0, 10.0, 1.0, 0).unwrap();
30/// assert_eq!(indicator.update(candle), Some(1.0));
31/// ```
32#[derive(Debug, Clone)]
33pub struct Doji {
34    body_threshold: f64,
35    has_emitted: bool,
36}
37
38impl Default for Doji {
39    fn default() -> Self {
40        Self::new()
41    }
42}
43
44impl Doji {
45    /// Construct a Doji detector with the default body threshold (`0.1`).
46    pub const fn new() -> Self {
47        Self {
48            body_threshold: 0.1,
49            has_emitted: false,
50        }
51    }
52
53    /// Construct a Doji detector with a custom body / range threshold.
54    ///
55    /// `body_threshold` must lie in `(0, 1]`.
56    pub fn with_threshold(body_threshold: f64) -> Result<Self> {
57        if !(body_threshold > 0.0 && body_threshold <= 1.0) {
58            return Err(Error::InvalidPeriod {
59                message: "doji body threshold must lie in (0, 1]",
60            });
61        }
62        Ok(Self {
63            body_threshold,
64            has_emitted: false,
65        })
66    }
67
68    /// Configured body / range threshold.
69    pub fn body_threshold(&self) -> f64 {
70        self.body_threshold
71    }
72}
73
74impl Indicator for Doji {
75    type Input = Candle;
76    type Output = f64;
77
78    fn update(&mut self, candle: Candle) -> Option<f64> {
79        self.has_emitted = true;
80        let range = candle.high - candle.low;
81        if range <= 0.0 {
82            return Some(0.0);
83        }
84        let body = (candle.close - candle.open).abs();
85        Some(if body <= self.body_threshold * range {
86            1.0
87        } else {
88            0.0
89        })
90    }
91
92    fn reset(&mut self) {
93        self.has_emitted = false;
94    }
95
96    fn warmup_period(&self) -> usize {
97        1
98    }
99
100    fn is_ready(&self) -> bool {
101        self.has_emitted
102    }
103
104    fn name(&self) -> &'static str {
105        "Doji"
106    }
107}
108
109#[cfg(test)]
110mod tests {
111    use super::*;
112    use crate::traits::BatchExt;
113
114    fn c(open: f64, high: f64, low: f64, close: f64, ts: i64) -> Candle {
115        Candle::new(open, high, low, close, 1.0, ts).unwrap()
116    }
117
118    #[test]
119    fn rejects_invalid_threshold() {
120        assert!(Doji::with_threshold(0.0).is_err());
121        assert!(Doji::with_threshold(-0.1).is_err());
122        assert!(Doji::with_threshold(1.5).is_err());
123    }
124
125    #[test]
126    fn accepts_valid_threshold() {
127        let d = Doji::with_threshold(0.05).unwrap();
128        assert!((d.body_threshold() - 0.05).abs() < 1e-12);
129    }
130
131    #[test]
132    fn accessors_and_metadata() {
133        let d = Doji::default();
134        assert_eq!(d.name(), "Doji");
135        assert_eq!(d.warmup_period(), 1);
136        assert!(!d.is_ready());
137        assert!((d.body_threshold() - 0.1).abs() < 1e-12);
138    }
139
140    #[test]
141    fn obvious_doji_is_one() {
142        let mut d = Doji::new();
143        // open == close, full range -> body / range = 0.
144        assert_eq!(d.update(c(10.0, 11.0, 9.0, 10.0, 0)), Some(1.0));
145        assert!(d.is_ready());
146    }
147
148    #[test]
149    fn marubozu_is_not_doji() {
150        // Big body, no shadows -> body / range = 1.0 > 0.1.
151        let mut d = Doji::new();
152        assert_eq!(d.update(c(10.0, 12.0, 10.0, 12.0, 0)), Some(0.0));
153    }
154
155    #[test]
156    fn zero_range_yields_zero() {
157        let mut d = Doji::new();
158        assert_eq!(d.update(c(10.0, 10.0, 10.0, 10.0, 0)), Some(0.0));
159    }
160
161    #[test]
162    fn batch_equals_streaming() {
163        let candles: Vec<Candle> = (0..40)
164            .map(|i| {
165                let base = 100.0 + i as f64;
166                c(base, base + 2.0, base - 2.0, base + 1.0, i)
167            })
168            .collect();
169        let mut a = Doji::new();
170        let mut b = Doji::new();
171        assert_eq!(
172            a.batch(&candles),
173            candles.iter().map(|x| b.update(*x)).collect::<Vec<_>>()
174        );
175    }
176
177    #[test]
178    fn reset_clears_state() {
179        let mut d = Doji::new();
180        d.update(c(10.0, 11.0, 9.0, 10.0, 0));
181        assert!(d.is_ready());
182        d.reset();
183        assert!(!d.is_ready());
184    }
185}