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#[derive(Debug, Clone, Default)]
34pub struct Tristar {
35    c1: Option<Candle>,
36    c2: Option<Candle>,
37    last_value: Option<f64>,
38}
39
40impl Tristar {
41    /// Construct a new `Tristar`.
42    #[must_use]
43    pub fn new() -> Self {
44        Self::default()
45    }
46
47    /// Latest emitted signal if available.
48    pub const fn value(&self) -> Option<f64> {
49        self.last_value
50    }
51}
52
53impl Indicator for Tristar {
54    type Input = Candle;
55    type Output = f64;
56
57    fn update(&mut self, candle: Candle) -> Option<f64> {
58        let (Some(first), Some(middle)) = (self.c1, self.c2) else {
59            self.c1 = self.c2;
60            self.c2 = Some(candle);
61            self.last_value = Some(0.0);
62            return Some(0.0);
63        };
64        let v = if is_doji(first) && is_doji(middle) && is_doji(candle) {
65            let mid = body_mid(middle);
66            let n1 = body_mid(first);
67            let n3 = body_mid(candle);
68            if mid > n1 && mid > n3 {
69                -1.0
70            } else if mid < n1 && mid < n3 {
71                1.0
72            } else {
73                0.0
74            }
75        } else {
76            0.0
77        };
78        self.c1 = self.c2;
79        self.c2 = Some(candle);
80        self.last_value = Some(v);
81        Some(v)
82    }
83
84    fn reset(&mut self) {
85        self.c1 = None;
86        self.c2 = None;
87        self.last_value = None;
88    }
89
90    fn warmup_period(&self) -> usize {
91        3
92    }
93
94    fn is_ready(&self) -> bool {
95        self.last_value.is_some()
96    }
97
98    fn name(&self) -> &'static str {
99        "Tristar"
100    }
101}
102
103#[cfg(test)]
104mod tests {
105    use super::*;
106    use crate::traits::BatchExt;
107
108    /// A doji centred at `mid` (tiny body, symmetric shadows).
109    fn doji(mid: f64) -> Candle {
110        Candle::new_unchecked(mid, mid + 1.0, mid - 1.0, mid + 0.02, 0.0, 0)
111    }
112
113    /// A non-doji (big body).
114    fn solid(open: f64, close: f64) -> Candle {
115        Candle::new_unchecked(
116            open,
117            open.max(close) + 0.1,
118            open.min(close) - 0.1,
119            close,
120            0.0,
121            0,
122        )
123    }
124
125    #[test]
126    fn accessors_and_metadata() {
127        let t = Tristar::new();
128        assert_eq!(t.warmup_period(), 3);
129        assert_eq!(t.name(), "Tristar");
130        assert!(!t.is_ready());
131        assert_eq!(t.value(), None);
132    }
133
134    #[test]
135    fn first_two_bars_seed_without_signal() {
136        let mut t = Tristar::new();
137        assert_eq!(t.update(doji(100.0)), Some(0.0));
138        assert_eq!(t.update(doji(100.0)), Some(0.0));
139        assert!(t.update(doji(100.0)).is_some());
140    }
141
142    #[test]
143    fn bearish_tristar_top() {
144        // middle doji centred above the two neighbours -> top -> -1.
145        let mut t = Tristar::new();
146        t.update(doji(100.0));
147        t.update(doji(105.0)); // middle, highest
148        assert_eq!(t.update(doji(100.0)), Some(-1.0));
149    }
150
151    #[test]
152    fn bullish_tristar_bottom() {
153        let mut t = Tristar::new();
154        t.update(doji(100.0));
155        t.update(doji(95.0)); // middle, lowest
156        assert_eq!(t.update(doji(100.0)), Some(1.0));
157    }
158
159    #[test]
160    fn non_doji_is_zero() {
161        let mut t = Tristar::new();
162        t.update(doji(100.0));
163        t.update(solid(100.0, 110.0)); // not a doji
164        assert_eq!(t.update(doji(100.0)), Some(0.0));
165    }
166
167    #[test]
168    fn reset_clears_state() {
169        let mut t = Tristar::new();
170        t.update(doji(100.0));
171        t.update(doji(105.0));
172        t.update(doji(100.0));
173        assert!(t.is_ready());
174        t.reset();
175        assert!(!t.is_ready());
176        assert_eq!(t.update(doji(100.0)), Some(0.0));
177    }
178
179    #[test]
180    fn batch_equals_streaming() {
181        let candles: Vec<Candle> = (0..40)
182            .map(|i| doji(100.0 + (f64::from(i) * 0.4).sin() * 5.0))
183            .collect();
184        let batch = Tristar::new().batch(&candles);
185        let mut b = Tristar::new();
186        let streamed: Vec<_> = candles.iter().map(|x| b.update(*x)).collect();
187        assert_eq!(batch, streamed);
188    }
189}