kestrel_chartkit/indicator/
trend_structural.rs1use std::collections::{HashMap, VecDeque};
2
3use crate::indicator::{Indicator, IndicatorAlert, IndicatorOutput};
4use crate::model::Bar;
5
6pub struct DmiEngine {
8 period: usize,
9 prev_bar: Option<Bar>,
10 plus_dms: VecDeque<f64>,
11 minus_dms: VecDeque<f64>,
12 trs: VecDeque<f64>,
13 alerts: Vec<IndicatorAlert>,
14}
15
16impl DmiEngine {
17 pub fn new(period: usize) -> Self {
18 Self {
19 period,
20 prev_bar: None,
21 plus_dms: VecDeque::new(),
22 minus_dms: VecDeque::new(),
23 trs: VecDeque::new(),
24 alerts: Vec::new(),
25 }
26 }
27}
28
29impl Indicator for DmiEngine {
30 fn name(&self) -> &str {
31 "dmi"
32 }
33
34 fn warmup_period(&self) -> usize {
35 self.period + 1
36 }
37
38 fn reset(&mut self) {
39 self.prev_bar = None;
40 self.plus_dms.clear();
41 self.minus_dms.clear();
42 self.trs.clear();
43 self.alerts.clear();
44 }
45
46 fn on_bar(&mut self, bar: &Bar) -> Option<IndicatorOutput> {
47 if let Some(ref prev) = self.prev_bar {
48 let tr = (bar.high - bar.low)
49 .max((bar.high - prev.close).abs())
50 .max((bar.low - prev.close).abs());
51 let up_move = bar.high - prev.high;
52 let down_move = prev.low - bar.low;
53
54 let plus_dm = if up_move > down_move && up_move > 0.0 {
55 up_move
56 } else {
57 0.0
58 };
59 let minus_dm = if down_move > up_move && down_move > 0.0 {
60 down_move
61 } else {
62 0.0
63 };
64
65 self.trs.push_back(tr);
66 self.plus_dms.push_back(plus_dm);
67 self.minus_dms.push_back(minus_dm);
68
69 if self.trs.len() > self.period {
70 self.trs.pop_front();
71 self.plus_dms.pop_front();
72 self.minus_dms.pop_front();
73 }
74 }
75 self.prev_bar = Some(bar.clone());
76
77 self.alerts.clear();
78 if self.trs.len() < self.period {
79 return None;
80 }
81
82 let sum_tr: f64 = self.trs.iter().sum();
83 let sum_plus_dm: f64 = self.plus_dms.iter().sum();
84 let sum_minus_dm: f64 = self.minus_dms.iter().sum();
85
86 let plus_di = if sum_tr > 0.0 {
87 (sum_plus_dm / sum_tr) * 100.0
88 } else {
89 0.0
90 };
91 let minus_di = if sum_tr > 0.0 {
92 (sum_minus_dm / sum_tr) * 100.0
93 } else {
94 0.0
95 };
96
97 let mut extra = HashMap::new();
98 extra.insert("plus_di".to_string(), plus_di.clamp(0.0, 100.0));
99 extra.insert("minus_di".to_string(), minus_di.clamp(0.0, 100.0));
100 extra.insert("di_diff".to_string(), plus_di - minus_di);
101
102 if plus_di > minus_di + 10.0 {
103 self.alerts.push(IndicatorAlert::new(
104 "dmi_bullish_dominance",
105 format!("+DI dominates -DI ({:.1} vs {:.1})", plus_di, minus_di),
106 0.80,
107 ));
108 } else if minus_di > plus_di + 10.0 {
109 self.alerts.push(IndicatorAlert::new(
110 "dmi_bearish_dominance",
111 format!("-DI dominates +DI ({:.1} vs {:.1})", minus_di, plus_di),
112 0.80,
113 ));
114 }
115
116 Some(IndicatorOutput::with_extra(plus_di - minus_di, extra))
117 }
118
119 fn alerts(&self) -> Vec<IndicatorAlert> {
120 self.alerts.clone()
121 }
122}
123
124pub struct AroonEngine {
126 period: usize,
127 bars: VecDeque<Bar>,
128 alerts: Vec<IndicatorAlert>,
129}
130
131impl AroonEngine {
132 pub fn new(period: usize) -> Self {
133 Self {
134 period,
135 bars: VecDeque::new(),
136 alerts: Vec::new(),
137 }
138 }
139}
140
141impl Indicator for AroonEngine {
142 fn name(&self) -> &str {
143 "aroon"
144 }
145
146 fn warmup_period(&self) -> usize {
147 self.period + 1
148 }
149
150 fn reset(&mut self) {
151 self.bars.clear();
152 self.alerts.clear();
153 }
154
155 fn on_bar(&mut self, bar: &Bar) -> Option<IndicatorOutput> {
156 self.bars.push_back(bar.clone());
157 if self.bars.len() > self.period + 1 {
158 self.bars.pop_front();
159 }
160
161 self.alerts.clear();
162 if self.bars.len() < self.period + 1 {
163 return None;
164 }
165
166 let mut high_idx = 0usize;
167 let mut max_high = f64::MIN;
168 let mut low_idx = 0usize;
169 let mut min_low = f64::MAX;
170
171 for (i, b) in self.bars.iter().enumerate() {
172 if b.high >= max_high {
173 max_high = b.high;
174 high_idx = i;
175 }
176 if b.low <= min_low {
177 min_low = b.low;
178 low_idx = i;
179 }
180 }
181
182 let bars_since_high = self.period - high_idx;
183 let bars_since_low = self.period - low_idx;
184
185 let aroon_up = ((self.period - bars_since_high) as f64 / self.period as f64) * 100.0;
186 let aroon_down = ((self.period - bars_since_low) as f64 / self.period as f64) * 100.0;
187 let oscillator = aroon_up - aroon_down;
188
189 let mut extra = HashMap::new();
190 extra.insert("aroon_up".to_string(), aroon_up.clamp(0.0, 100.0));
191 extra.insert("aroon_down".to_string(), aroon_down.clamp(0.0, 100.0));
192 extra.insert("oscillator".to_string(), oscillator.clamp(-100.0, 100.0));
193
194 Some(IndicatorOutput::with_extra(oscillator, extra))
195 }
196
197 fn alerts(&self) -> Vec<IndicatorAlert> {
198 self.alerts.clone()
199 }
200}
201
202pub struct ParabolicSarEngine {
204 step: f64,
205 max_step: f64,
206 is_long: bool,
207 sar: f64,
208 ep: f64,
209 af: f64,
210 prev_bar: Option<Bar>,
211 alerts: Vec<IndicatorAlert>,
212}
213
214impl ParabolicSarEngine {
215 pub fn new(step: f64, max_step: f64) -> Self {
216 Self {
217 step,
218 max_step,
219 is_long: true,
220 sar: 0.0,
221 ep: 0.0,
222 af: step,
223 prev_bar: None,
224 alerts: Vec::new(),
225 }
226 }
227}
228
229impl Indicator for ParabolicSarEngine {
230 fn name(&self) -> &str {
231 "parabolic_sar"
232 }
233
234 fn warmup_period(&self) -> usize {
235 2
236 }
237
238 fn reset(&mut self) {
239 self.is_long = true;
240 self.sar = 0.0;
241 self.ep = 0.0;
242 self.af = self.step;
243 self.prev_bar = None;
244 self.alerts.clear();
245 }
246
247 fn on_bar(&mut self, bar: &Bar) -> Option<IndicatorOutput> {
248 if self.prev_bar.is_none() {
249 self.prev_bar = Some(bar.clone());
250 self.sar = bar.low;
251 self.ep = bar.high;
252 return Some(IndicatorOutput::new(self.sar));
253 }
254
255 let prev = self.prev_bar.as_ref().unwrap();
256 let mut next_sar = self.sar + self.af * (self.ep - self.sar);
257
258 self.alerts.clear();
259 if self.is_long {
260 if bar.low < next_sar {
261 self.is_long = false;
262 next_sar = self.ep;
263 self.ep = bar.low;
264 self.af = self.step;
265 self.alerts.push(IndicatorAlert::new(
266 "psar_reversal_bearish",
267 format!("PSAR Bearish Reversal (${:.2})", next_sar),
268 0.85,
269 ));
270 } else {
271 if bar.high > self.ep {
272 self.ep = bar.high;
273 self.af = (self.af + self.step).min(self.max_step);
274 }
275 next_sar = next_sar.min(prev.low).min(bar.low);
276 }
277 } else {
278 if bar.high > next_sar {
279 self.is_long = true;
280 next_sar = self.ep;
281 self.ep = bar.high;
282 self.af = self.step;
283 self.alerts.push(IndicatorAlert::new(
284 "psar_reversal_bullish",
285 format!("PSAR Bullish Reversal (${:.2})", next_sar),
286 0.85,
287 ));
288 } else {
289 if bar.low < self.ep {
290 self.ep = bar.low;
291 self.af = (self.af + self.step).min(self.max_step);
292 }
293 next_sar = next_sar.max(prev.high).max(bar.high);
294 }
295 }
296
297 self.sar = next_sar;
298 self.prev_bar = Some(bar.clone());
299
300 let mut extra = HashMap::new();
301 extra.insert("is_long".to_string(), if self.is_long { 1.0 } else { 0.0 });
302 extra.insert("af".to_string(), self.af);
303
304 Some(IndicatorOutput::with_extra(self.sar, extra))
305 }
306
307 fn alerts(&self) -> Vec<IndicatorAlert> {
308 self.alerts.clone()
309 }
310}
311
312pub struct SupertrendEngine {
314 period: usize,
315 multiplier: f64,
316 trs: VecDeque<f64>,
317 prev_close: Option<f64>,
318 trend: i32, upper_band: f64,
320 lower_band: f64,
321 supertrend: f64,
322 alerts: Vec<IndicatorAlert>,
323}
324
325impl SupertrendEngine {
326 pub fn new(period: usize, multiplier: f64) -> Self {
327 Self {
328 period,
329 multiplier,
330 trs: VecDeque::new(),
331 prev_close: None,
332 trend: 1,
333 upper_band: 0.0,
334 lower_band: 0.0,
335 supertrend: 0.0,
336 alerts: Vec::new(),
337 }
338 }
339}
340
341impl Indicator for SupertrendEngine {
342 fn name(&self) -> &str {
343 "supertrend"
344 }
345
346 fn warmup_period(&self) -> usize {
347 self.period
348 }
349
350 fn reset(&mut self) {
351 self.trs.clear();
352 self.prev_close = None;
353 self.trend = 1;
354 self.upper_band = 0.0;
355 self.lower_band = 0.0;
356 self.supertrend = 0.0;
357 self.alerts.clear();
358 }
359
360 fn on_bar(&mut self, bar: &Bar) -> Option<IndicatorOutput> {
361 let tr = if let Some(prev) = self.prev_close {
362 (bar.high - bar.low)
363 .max((bar.high - prev).abs())
364 .max((bar.low - prev).abs())
365 } else {
366 bar.high - bar.low
367 };
368
369 self.trs.push_back(tr);
370 if self.trs.len() > self.period {
371 self.trs.pop_front();
372 }
373
374 self.alerts.clear();
375 if self.trs.len() < self.period {
376 self.prev_close = Some(bar.close);
377 return None;
378 }
379
380 let atr = self.trs.iter().sum::<f64>() / self.period as f64;
381 let hl2 = (bar.high + bar.low) / 2.0;
382
383 let basic_upper = hl2 + self.multiplier * atr;
384 let basic_lower = hl2 - self.multiplier * atr;
385
386 let prev_close = self.prev_close.unwrap_or(bar.close);
387
388 let final_upper = if basic_upper < self.upper_band || prev_close > self.upper_band {
389 basic_upper
390 } else {
391 self.upper_band
392 };
393
394 let final_lower = if basic_lower > self.lower_band || prev_close < self.lower_band {
395 basic_lower
396 } else {
397 self.lower_band
398 };
399
400 let prev_trend = self.trend;
401 if self.trend == 1 && bar.close < final_lower {
402 self.trend = -1;
403 } else if self.trend == -1 && bar.close > final_upper {
404 self.trend = 1;
405 }
406
407 self.upper_band = final_upper;
408 self.lower_band = final_lower;
409 self.supertrend = if self.trend == 1 {
410 final_lower
411 } else {
412 final_upper
413 };
414 self.prev_close = Some(bar.close);
415
416 if self.trend != prev_trend {
417 if self.trend == 1 {
418 self.alerts.push(IndicatorAlert::new(
419 "supertrend_bullish",
420 format!("Supertrend Bullish Flip (${:.2})", self.supertrend),
421 0.90,
422 ));
423 } else {
424 self.alerts.push(IndicatorAlert::new(
425 "supertrend_bearish",
426 format!("Supertrend Bearish Flip (${:.2})", self.supertrend),
427 0.90,
428 ));
429 }
430 }
431
432 let mut extra = HashMap::new();
433 extra.insert("trend".to_string(), self.trend as f64);
434 extra.insert("upper".to_string(), final_upper);
435 extra.insert("lower".to_string(), final_lower);
436
437 Some(IndicatorOutput::with_extra(self.supertrend, extra))
438 }
439
440 fn alerts(&self) -> Vec<IndicatorAlert> {
441 self.alerts.clone()
442 }
443}
444
445pub struct IchimokuEngine {
447 tenkan_p: usize,
448 kijun_p: usize,
449 senkou_b_p: usize,
450 bars: VecDeque<Bar>,
451 alerts: Vec<IndicatorAlert>,
452}
453
454impl IchimokuEngine {
455 pub fn new(tenkan_p: usize, kijun_p: usize, senkou_b_p: usize) -> Self {
456 Self {
457 tenkan_p,
458 kijun_p,
459 senkou_b_p,
460 bars: VecDeque::new(),
461 alerts: Vec::new(),
462 }
463 }
464}
465
466impl Indicator for IchimokuEngine {
467 fn name(&self) -> &str {
468 "ichimoku"
469 }
470
471 fn warmup_period(&self) -> usize {
472 self.senkou_b_p
473 }
474
475 fn reset(&mut self) {
476 self.bars.clear();
477 self.alerts.clear();
478 }
479
480 fn on_bar(&mut self, bar: &Bar) -> Option<IndicatorOutput> {
481 self.bars.push_back(bar.clone());
482 if self.bars.len() > self.senkou_b_p {
483 self.bars.pop_front();
484 }
485
486 self.alerts.clear();
487 if self.bars.len() < self.senkou_b_p {
488 return None;
489 }
490
491 let calc_midpoint = |slice: &[Bar]| -> f64 {
492 let h = slice.iter().map(|b| b.high).fold(f64::MIN, f64::max);
493 let l = slice.iter().map(|b| b.low).fold(f64::MAX, f64::min);
494 (h + l) / 2.0
495 };
496
497 self.bars.make_contiguous();
498 let slice = self.bars.as_slices().0;
499 let len = slice.len();
500 let tenkan = calc_midpoint(&slice[len - self.tenkan_p..]);
501 let kijun = calc_midpoint(&slice[len - self.kijun_p..]);
502 let senkou_a = (tenkan + kijun) / 2.0;
503 let senkou_b = calc_midpoint(slice);
504
505 let mut extra = HashMap::new();
506 extra.insert("tenkan".to_string(), tenkan);
507 extra.insert("kijun".to_string(), kijun);
508 extra.insert("senkou_a".to_string(), senkou_a);
509 extra.insert("senkou_b".to_string(), senkou_b);
510
511 if bar.close > senkou_a && bar.close > senkou_b {
512 self.alerts.push(IndicatorAlert::new(
513 "ichimoku_above_cloud",
514 format!(
515 "Price Above Ichimoku Cloud (SpanA ${:.2}, SpanB ${:.2})",
516 senkou_a, senkou_b
517 ),
518 0.85,
519 ));
520 } else if bar.close < senkou_a && bar.close < senkou_b {
521 self.alerts.push(IndicatorAlert::new(
522 "ichimoku_below_cloud",
523 format!(
524 "Price Below Ichimoku Cloud (SpanA ${:.2}, SpanB ${:.2})",
525 senkou_a, senkou_b
526 ),
527 0.85,
528 ));
529 }
530
531 Some(IndicatorOutput::with_extra(tenkan - kijun, extra))
532 }
533
534 fn alerts(&self) -> Vec<IndicatorAlert> {
535 self.alerts.clone()
536 }
537}