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)]
49pub struct Tristar {
50 c1: Option<Candle>,
51 c2: Option<Candle>,
52 last_value: Option<f64>,
53}
54
55impl Tristar {
56 #[must_use]
58 pub fn new() -> Self {
59 Self::default()
60 }
61
62 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 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 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 let mut t = Tristar::new();
165 t.update(doji(100.0));
166 t.update(doji(105.0)); 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)); 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)); 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}