wickra_core/indicators/
tristar.rs1#![allow(clippy::doc_markdown)]
2
3use crate::ohlcv::Candle;
18use crate::traits::Indicator;
19
20fn body_mid(candle: Candle) -> f64 {
22 f64::midpoint(candle.open, candle.close)
23}
24
25fn 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#[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 #[must_use]
43 pub fn new() -> Self {
44 Self::default()
45 }
46
47 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 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 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 let mut t = Tristar::new();
146 t.update(doji(100.0));
147 t.update(doji(105.0)); 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)); 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)); 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}