Skip to main content

kestrel_chartkit/indicator/
trend_structural.rs

1use std::collections::{HashMap, VecDeque};
2
3use crate::indicator::{Indicator, IndicatorAlert, IndicatorOutput};
4use crate::model::Bar;
5
6/// Directional Movement Index (DMI: +DI and -DI).
7pub 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
124/// Aroon Indicator (Aroon Up, Aroon Down, Oscillator).
125pub 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
202/// Parabolic SAR Indicator.
203pub 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
312/// Supertrend Indicator (ATR-based trailing stop).
313pub struct SupertrendEngine {
314    period: usize,
315    multiplier: f64,
316    trs: VecDeque<f64>,
317    prev_close: Option<f64>,
318    trend: i32, // 1 = Bullish, -1 = Bearish
319    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
445/// Ichimoku Kinko Hyo Cloud Indicator (Tenkan-sen, Kijun-sen, Senkou A, Senkou B, Chikou).
446pub 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}