1#![allow(clippy::doc_markdown)]
2
3use crate::ohlcv::Candle;
23use crate::traits::Indicator;
24
25fn body_top(candle: Candle) -> f64 {
27 candle.open.max(candle.close)
28}
29
30fn body_bottom(candle: Candle) -> f64 {
32 candle.open.min(candle.close)
33}
34
35fn 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#[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 #[must_use]
68 pub fn new() -> Self {
69 Self::default()
70 }
71
72 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 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 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 let mut t = Tristar::new();
174 t.update(doji(100.0));
175 t.update(doji(105.0)); 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)); 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)); 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 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 assert_eq!(run([doji(100.0), doji(100.5), doji(100.1)]), Some(-1.0));
235 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 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 assert_eq!(run([doji(100.0), doji(105.0), doji(105.5)]), Some(0.0));
250 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 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 assert_eq!(run([doji(100.0), doji(95.0), doji(94.5)]), Some(0.0));
264 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 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 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}