Skip to main content

wickra_core/indicators/
tristar.rs

1#![allow(clippy::doc_markdown)]
2
3//! Tristar — a three-doji reversal pattern.
4//!
5//! A Tristar is three consecutive Doji candles where the middle one gaps away
6//! from its neighbours, forming a star. A bearish Tristar (top) has the middle
7//! doji sitting above the other two; a bullish Tristar (bottom) has it below.
8//!
9//! - **Bullish** (`+1.0`): three dojis, the middle doji's body centre below both
10//!   neighbours' body centres.
11//! - **Bearish** (`-1.0`): three dojis, the middle above both neighbours.
12//! - Otherwise the output is `0.0`.
13//!
14//! A doji is a candle whose body is `<= 0.1 * range`. The three-bar lookback means
15//! the first value lands on the third candle.
16
17use crate::ohlcv::Candle;
18use crate::traits::Indicator;
19
20/// Body-centre of a candle.
21fn body_mid(candle: Candle) -> f64 {
22    f64::midpoint(candle.open, candle.close)
23}
24
25/// Whether a candle is a doji (body small relative to range).
26fn is_doji(candle: Candle) -> bool {
27    let body = (candle.close - candle.open).abs();
28    let range = candle.high - candle.low;
29    range > 0.0 && body <= 0.1 * range
30}
31
32/// Tristar — three-doji star reversal detector.
33/// # Example
34///
35/// ```
36/// use wickra_core::{Tristar, Candle, Indicator};
37///
38/// let mut indicator = Tristar::new();
39/// // `None` during warmup, then `Some(_)` once enough bars are seen.
40/// let mut out = None;
41/// for i in 0..40i64 {
42///     let p = 100.0 + (i as f64 * 0.4).sin() * 5.0;
43///     let candle = Candle::new(p, p + 1.5, p - 1.5, p + 0.3, 1_000.0, i).unwrap();
44///     out = indicator.update(candle);
45/// }
46/// let _ = out;
47/// ```
48#[derive(Debug, Clone, Default)]
49pub struct Tristar {
50    c1: Option<Candle>,
51    c2: Option<Candle>,
52    last_value: Option<f64>,
53}
54
55impl Tristar {
56    /// Construct a new `Tristar`.
57    #[must_use]
58    pub fn new() -> Self {
59        Self::default()
60    }
61
62    /// Latest emitted signal if available.
63    pub const fn value(&self) -> Option<f64> {
64        self.last_value
65    }
66}
67
68impl Indicator for Tristar {
69    type Input = Candle;
70    type Output = f64;
71
72    #[inline]
73    fn update(&mut self, candle: Candle) -> Option<f64> {
74        let (Some(first), Some(middle)) = (self.c1, self.c2) else {
75            self.c1 = self.c2;
76            self.c2 = Some(candle);
77            self.last_value = None;
78            return None;
79        };
80        let v = if is_doji(first) && is_doji(middle) && is_doji(candle) {
81            let mid = body_mid(middle);
82            let n1 = body_mid(first);
83            let n3 = body_mid(candle);
84            if mid > n1 && mid > n3 {
85                -1.0
86            } else if mid < n1 && mid < n3 {
87                1.0
88            } else {
89                0.0
90            }
91        } else {
92            0.0
93        };
94        self.c1 = self.c2;
95        self.c2 = Some(candle);
96        self.last_value = Some(v);
97        Some(v)
98    }
99
100    fn reset(&mut self) {
101        self.c1 = None;
102        self.c2 = None;
103        self.last_value = None;
104    }
105
106    #[inline]
107    fn warmup_period(&self) -> usize {
108        3
109    }
110
111    #[inline]
112    fn is_ready(&self) -> bool {
113        self.last_value.is_some()
114    }
115
116    #[inline]
117    fn name(&self) -> &'static str {
118        "Tristar"
119    }
120}
121
122#[cfg(test)]
123mod tests {
124    use super::*;
125    use crate::traits::BatchExt;
126
127    /// A doji centred at `mid` (tiny body, symmetric shadows).
128    fn doji(mid: f64) -> Candle {
129        Candle::new_unchecked(mid, mid + 1.0, mid - 1.0, mid + 0.02, 0.0, 0)
130    }
131
132    /// A non-doji (big body).
133    fn solid(open: f64, close: f64) -> Candle {
134        Candle::new_unchecked(
135            open,
136            open.max(close) + 0.1,
137            open.min(close) - 0.1,
138            close,
139            0.0,
140            0,
141        )
142    }
143
144    #[test]
145    fn accessors_and_metadata() {
146        let t = Tristar::new();
147        assert_eq!(t.warmup_period(), 3);
148        assert_eq!(t.name(), "Tristar");
149        assert!(!t.is_ready());
150        assert_eq!(t.value(), None);
151    }
152
153    #[test]
154    fn first_two_bars_seed_without_signal() {
155        let mut t = Tristar::new();
156        assert_eq!(t.update(doji(100.0)), None);
157        assert_eq!(t.update(doji(100.0)), None);
158        assert!(t.update(doji(100.0)).is_some());
159    }
160
161    #[test]
162    fn bearish_tristar_top() {
163        // middle doji centred above the two neighbours -> top -> -1.
164        let mut t = Tristar::new();
165        t.update(doji(100.0));
166        t.update(doji(105.0)); // middle, highest
167        assert_eq!(t.update(doji(100.0)), Some(-1.0));
168    }
169
170    #[test]
171    fn bullish_tristar_bottom() {
172        let mut t = Tristar::new();
173        t.update(doji(100.0));
174        t.update(doji(95.0)); // middle, lowest
175        assert_eq!(t.update(doji(100.0)), Some(1.0));
176    }
177
178    #[test]
179    fn non_doji_is_zero() {
180        let mut t = Tristar::new();
181        t.update(doji(100.0));
182        t.update(solid(100.0, 110.0)); // not a doji
183        assert_eq!(t.update(doji(100.0)), Some(0.0));
184    }
185
186    #[test]
187    fn reset_clears_state() {
188        let mut t = Tristar::new();
189        t.update(doji(100.0));
190        t.update(doji(105.0));
191        t.update(doji(100.0));
192        assert!(t.is_ready());
193        t.reset();
194        assert!(!t.is_ready());
195        assert_eq!(t.update(doji(100.0)), None);
196    }
197
198    #[test]
199    fn batch_equals_streaming() {
200        let candles: Vec<Candle> = (0..40)
201            .map(|i| doji(100.0 + (f64::from(i) * 0.4).sin() * 5.0))
202            .collect();
203        let batch = Tristar::new().batch(&candles);
204        let mut b = Tristar::new();
205        let streamed: Vec<_> = candles.iter().map(|x| b.update(*x)).collect();
206        assert_eq!(batch, streamed);
207    }
208}