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//! - **Bearish** (`-1.0`): three dojis, the middle doji's body gaps above the
10//!   first (`min(o2, c2) > max(o1, c1)`) and the third does not reach higher
11//!   (`max(o3, c3) < max(o2, c2)`).
12//! - **Bullish** (`+1.0`): three dojis, the middle doji's body gaps below the
13//!   first (`max(o2, c2) < min(o1, c1)`) and the third does not reach lower
14//!   (`min(o3, c3) > min(o2, c2)`).
15//! - Otherwise the output is `0.0`.
16//!
17//! The body gap of the middle doji is the defining feature (Nison; TA-Lib
18//! `CDLTRISTAR`). A doji is a candle whose body is `<= 0.1 * range`. The
19//! three-bar lookback means
20//! the first value lands on the third candle.
21
22use crate::ohlcv::Candle;
23use crate::traits::Indicator;
24
25/// Top of a candle's real body.
26fn body_top(candle: Candle) -> f64 {
27    candle.open.max(candle.close)
28}
29
30/// Bottom of a candle's real body.
31fn body_bottom(candle: Candle) -> f64 {
32    candle.open.min(candle.close)
33}
34
35/// Whether a candle is a doji (body small relative to range).
36fn is_doji(candle: Candle) -> bool {
37    let body = (candle.close - candle.open).abs();
38    let range = candle.high - candle.low;
39    range > 0.0 && body <= 0.1 * range
40}
41
42/// Tristar — three-doji star reversal detector.
43/// # Example
44///
45/// ```
46/// use wickra_core::{Tristar, Candle, Indicator};
47///
48/// let mut indicator = Tristar::new();
49/// // `None` during warmup, then `Some(_)` once enough bars are seen.
50/// let mut out = None;
51/// for i in 0..40i64 {
52///     let p = 100.0 + (i as f64 * 0.4).sin() * 5.0;
53///     let candle = Candle::new(p, p + 1.5, p - 1.5, p + 0.3, 1_000.0, i).unwrap();
54///     out = indicator.update(candle);
55/// }
56/// let _ = out;
57/// ```
58#[derive(Debug, Clone, Default)]
59pub struct Tristar {
60    c1: Option<Candle>,
61    c2: Option<Candle>,
62    last_value: Option<f64>,
63}
64
65impl Tristar {
66    /// Construct a new `Tristar`.
67    #[must_use]
68    pub fn new() -> Self {
69        Self::default()
70    }
71
72    /// Latest emitted signal if available.
73    pub const fn value(&self) -> Option<f64> {
74        self.last_value
75    }
76}
77
78impl Indicator for Tristar {
79    type Input = Candle;
80    type Output = f64;
81
82    #[inline]
83    fn update(&mut self, candle: Candle) -> Option<f64> {
84        let (Some(first), Some(middle)) = (self.c1, self.c2) else {
85            self.c1 = self.c2;
86            self.c2 = Some(candle);
87            self.last_value = None;
88            return None;
89        };
90        let v = if is_doji(first) && is_doji(middle) && is_doji(candle) {
91            if body_bottom(middle) > body_top(first) && body_top(candle) < body_top(middle) {
92                -1.0
93            } else if body_top(middle) < body_bottom(first)
94                && body_bottom(candle) > body_bottom(middle)
95            {
96                1.0
97            } else {
98                0.0
99            }
100        } else {
101            0.0
102        };
103        self.c1 = self.c2;
104        self.c2 = Some(candle);
105        self.last_value = Some(v);
106        Some(v)
107    }
108
109    fn reset(&mut self) {
110        self.c1 = None;
111        self.c2 = None;
112        self.last_value = None;
113    }
114
115    #[inline]
116    fn warmup_period(&self) -> usize {
117        3
118    }
119
120    #[inline]
121    fn is_ready(&self) -> bool {
122        self.last_value.is_some()
123    }
124
125    #[inline]
126    fn name(&self) -> &'static str {
127        "Tristar"
128    }
129}
130
131#[cfg(test)]
132mod tests {
133    use super::*;
134    use crate::traits::BatchExt;
135
136    /// A doji centred at `mid` (tiny body, symmetric shadows).
137    fn doji(mid: f64) -> Candle {
138        Candle::new_unchecked(mid, mid + 1.0, mid - 1.0, mid + 0.02, 0.0, 0)
139    }
140
141    /// A non-doji (big body).
142    fn solid(open: f64, close: f64) -> Candle {
143        Candle::new_unchecked(
144            open,
145            open.max(close) + 0.1,
146            open.min(close) - 0.1,
147            close,
148            0.0,
149            0,
150        )
151    }
152
153    #[test]
154    fn accessors_and_metadata() {
155        let t = Tristar::new();
156        assert_eq!(t.warmup_period(), 3);
157        assert_eq!(t.name(), "Tristar");
158        assert!(!t.is_ready());
159        assert_eq!(t.value(), None);
160    }
161
162    #[test]
163    fn first_two_bars_seed_without_signal() {
164        let mut t = Tristar::new();
165        assert_eq!(t.update(doji(100.0)), None);
166        assert_eq!(t.update(doji(100.0)), None);
167        assert!(t.update(doji(100.0)).is_some());
168    }
169
170    #[test]
171    fn bearish_tristar_top() {
172        // middle doji centred above the two neighbours -> top -> -1.
173        let mut t = Tristar::new();
174        t.update(doji(100.0));
175        t.update(doji(105.0)); // middle, highest
176        assert_eq!(t.update(doji(100.0)), Some(-1.0));
177    }
178
179    #[test]
180    fn bullish_tristar_bottom() {
181        let mut t = Tristar::new();
182        t.update(doji(100.0));
183        t.update(doji(95.0)); // middle, lowest
184        assert_eq!(t.update(doji(100.0)), Some(1.0));
185    }
186
187    #[test]
188    fn non_doji_is_zero() {
189        let mut t = Tristar::new();
190        t.update(doji(100.0));
191        t.update(solid(100.0, 110.0)); // not a doji
192        assert_eq!(t.update(doji(100.0)), Some(0.0));
193    }
194
195    #[test]
196    fn reset_clears_state() {
197        let mut t = Tristar::new();
198        t.update(doji(100.0));
199        t.update(doji(105.0));
200        t.update(doji(100.0));
201        assert!(t.is_ready());
202        t.reset();
203        assert!(!t.is_ready());
204        assert_eq!(t.update(doji(100.0)), None);
205    }
206
207    #[test]
208    fn batch_equals_streaming() {
209        let candles: Vec<Candle> = (0..40)
210            .map(|i| doji(100.0 + (f64::from(i) * 0.4).sin() * 5.0))
211            .collect();
212        let batch = Tristar::new().batch(&candles);
213        let mut b = Tristar::new();
214        let streamed: Vec<_> = candles.iter().map(|x| b.update(*x)).collect();
215        assert_eq!(batch, streamed);
216    }
217
218    /// A doji with an explicit body `open -> close` and a range of 2.
219    fn doji_body(open: f64, close: f64) -> Candle {
220        let mid = f64::midpoint(open, close);
221        Candle::new_unchecked(open, mid + 1.0, mid - 1.0, close, 0.0, 0)
222    }
223
224    fn run(bars: [Candle; 3]) -> Option<f64> {
225        let mut t = Tristar::new();
226        bars.iter().map(|b| t.update(*b)).last().unwrap()
227    }
228
229    #[test]
230    fn hand_computed_body_gap_with_overlapping_shadows() {
231        // doji(100): body 100..100.02, shadows 99..101; doji(100.5): body
232        // 100.5..100.52. Shadows overlap but body bottom 100.5 > top 100.02,
233        // and the third top 100.12 < 100.52 -> bearish.
234        assert_eq!(run([doji(100.0), doji(100.5), doji(100.1)]), Some(-1.0));
235        // Mirror: doji(99.5) body top 99.52 < 100 = first body bottom,
236        // third bottom 99.9 > 99.5 -> bullish.
237        assert_eq!(run([doji(100.0), doji(99.5), doji(99.9)]), Some(1.0));
238    }
239
240    #[test]
241    fn bearish_gap_rules() {
242        // Middle body bottom equal to the first body top: no gap.
243        let first = doji_body(100.0, 100.02);
244        assert_eq!(
245            run([first, doji_body(100.02, 100.04), doji(100.0)]),
246            Some(0.0)
247        );
248        // Third doji reaches higher than the middle body top.
249        assert_eq!(run([doji(100.0), doji(105.0), doji(105.5)]), Some(0.0));
250        // Third doji's body top equal to the middle body top.
251        assert_eq!(run([doji(100.0), doji(105.0), doji(105.0)]), Some(0.0));
252    }
253
254    #[test]
255    fn bullish_gap_rules() {
256        // Middle body top equal to the first body bottom: no gap.
257        let first = doji_body(100.0, 100.02);
258        assert_eq!(
259            run([first, doji_body(99.98, 100.0), doji(100.0)]),
260            Some(0.0)
261        );
262        // Third doji reaches lower than the middle body bottom.
263        assert_eq!(run([doji(100.0), doji(95.0), doji(94.5)]), Some(0.0));
264        // Third doji's body bottom equal to the middle body bottom.
265        assert_eq!(run([doji(100.0), doji(95.0), doji(95.0)]), Some(0.0));
266    }
267
268    #[test]
269    fn every_bar_must_be_a_doji() {
270        assert_eq!(
271            run([solid(100.0, 101.0), doji(105.0), doji(100.0)]),
272            Some(0.0)
273        );
274        assert_eq!(
275            run([doji(100.0), doji(105.0), solid(100.0, 99.0)]),
276            Some(0.0)
277        );
278        assert_eq!(
279            run([doji(100.0), doji(95.0), solid(100.0, 101.0)]),
280            Some(0.0)
281        );
282        // A zero-range bar is never a doji.
283        let flat = Candle::new_unchecked(105.0, 105.0, 105.0, 105.0, 0.0, 0);
284        assert_eq!(run([doji(100.0), flat, doji(100.0)]), Some(0.0));
285    }
286
287    #[test]
288    fn doji_threshold_is_ten_percent_of_range() {
289        // range 2.5, 0.1 * 2.5 = 0.25: body 0.25 is a doji, body 0.5 is not.
290        assert!(is_doji(Candle::new_unchecked(
291            100.0, 101.25, 98.75, 100.25, 0.0, 0
292        )));
293        assert!(!is_doji(Candle::new_unchecked(
294            100.0, 101.25, 98.75, 100.5, 0.0, 0
295        )));
296        assert!(!is_doji(Candle::new_unchecked(
297            100.0, 100.0, 100.0, 100.0, 0.0, 0
298        )));
299    }
300
301    #[test]
302    fn first_value_lands_exactly_at_warmup_index() {
303        let mut t = Tristar::new();
304        let out = t.batch(&[doji(100.0), doji(105.0), doji(100.0), doji(100.0)]);
305        let warm = t.warmup_period();
306        assert!(out[..warm - 1].iter().all(Option::is_none));
307        assert_eq!(out[warm - 1], Some(-1.0));
308        assert_eq!(t.value(), Some(0.0));
309    }
310
311    fn mixed_series() -> Vec<Candle> {
312        let bars = [
313            doji(100.0),
314            doji(105.0),
315            doji(100.0),
316            doji(95.0),
317            doji(100.0),
318            solid(100.0, 104.0),
319        ];
320        bars.iter().cycle().take(30).copied().collect()
321    }
322
323    #[test]
324    fn reset_replays_identically() {
325        let candles = mixed_series();
326        let fresh = Tristar::new().batch(&candles);
327        let mut t = Tristar::new();
328        let _ = t.batch(&candles);
329        t.reset();
330        assert_eq!(t.value(), None);
331        assert_eq!(t.batch(&candles), fresh);
332    }
333
334    #[test]
335    fn batch_nan_into_matches_streaming_bits() {
336        let candles = mixed_series();
337        let mut t = Tristar::new();
338        let streamed: Vec<f64> = candles
339            .iter()
340            .map(|x| t.update(*x).unwrap_or(f64::NAN))
341            .collect();
342        let mut out = vec![0.0; candles.len()];
343        Tristar::new().batch_nan_into(&candles, &mut out);
344        assert!(streamed
345            .iter()
346            .zip(&out)
347            .all(|(a, b)| a.to_bits() == b.to_bits()));
348        assert!(streamed.contains(&1.0) && streamed.contains(&-1.0));
349    }
350}