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