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: +DI and -DI over plain sums.
7///
8/// Per bar from the second one on, +DM, -DM and TR as in [`super::adx::Adx`]. Unlike the ADX they
9/// are **summed** over the last `period` bars rather than Wilder-smoothed:
10/// `+DI = 100 * sum(+DM) / sum(TR)`, `-DI = 100 * sum(-DM) / sum(TR)` (0 without range).
11///
12/// `value`: `+DI - -DI` — the difference of the two lines, **not** the directional index DX that
13/// [`super::adx::Adx`] computes. `extra["plus_di"]` and `extra["minus_di"]` (each clamped to
14/// `0..=100`) and `extra["di_diff"]`. Alerts fire when one line leads the other by more than 10.
15///
16/// First output: with the `period + 1`-th bar. [`Indicator::reset`] clears the windows.
17pub 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
134/// Aroon: how recently the highest high and the lowest low of the last `period + 1` bars
135/// occurred.
136///
137/// Within the window the most recent bar holding the highest high respectively the lowest low
138/// counts (ties go to the later bar). With `bars_since` its distance from the current bar,
139/// `Aroon Up = (period - bars_since_high) / period * 100`, `Aroon Down` likewise for the low, and
140/// the oscillator is `Up - Down`.
141///
142/// `value`: the oscillator; `extra["aroon_up"]`, `extra["aroon_down"]` (clamped to `0..=100`) and
143/// `extra["oscillator"]`. First output: with the `period + 1`-th bar. [`Indicator::reset`] clears
144/// the window.
145pub 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
222/// Parabolic SAR.
223///
224/// Starts long on the first bar with `SAR = low`, `EP = high`, `AF = step`, and publishes that
225/// first SAR. On every following bar the next SAR is `SAR + AF * (EP - SAR)`:
226///
227/// - Long: a low below it reverses to short — the SAR becomes the EP, the EP this bar's low, and
228///   `AF = step`. Otherwise a new high moves the EP and raises `AF` by `step` up to `max_step`,
229///   and the SAR is held at or below the previous and the current low.
230/// - Short: symmetric, with highs.
231///
232/// The hold uses the **previous** bar's extreme only (the current one is already excluded by the
233/// reversal check before it). The original rule holds the SAR beyond the extremes of the two
234/// previous bars, so after a sharp two-bar move this SAR can sit closer to price.
235///
236/// `value`: the SAR; `extra["is_long"]` (`1`/`0`) and `extra["af"]`. First output: with the first
237/// bar. [`Indicator::reset`] returns to the long start.
238pub 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
347/// Supertrend: a trailing band that flips with the close.
348///
349/// `ATR` is the **plain mean** of the last `period` true ranges (the first `high - low`), not a
350/// Wilder average. Basic bands are `(high + low) / 2 ± multiplier * ATR`. A final band only moves
351/// against the trend when the previous close crossed it: the upper band takes the new basic value
352/// if that is lower or the previous close was above the old band, the lower band if it is higher or
353/// the previous close was below. The trend starts up and flips when the close falls below the final
354/// lower band (in an uptrend) or rises above the final upper band (in a downtrend).
355///
356/// `value`: the lower band in an uptrend, the upper band in a downtrend; `extra["trend"]`
357/// (`1`/`-1`), `extra["upper"]`, `extra["lower"]`. First output: with the `period`-th bar.
358/// [`Indicator::reset`] clears the window and the bands.
359pub struct SupertrendEngine {
360    period: usize,
361    multiplier: f64,
362    trs: VecDeque<f64>,
363    prev_close: Option<f64>,
364    trend: i32, // 1 = Bullish, -1 = Bearish
365    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
491/// Ichimoku Kinko Hyo: tenkan-sen, kijun-sen and the two cloud spans.
492///
493/// A line's value is the midpoint of the highest high and the lowest low over its window:
494/// `tenkan` over `tenkan_p` bars, `kijun` over `kijun_p`, `senkou_b` over `senkou_b_p`, and
495/// `senkou_a = (tenkan + kijun) / 2`. The spans are published **unshifted**, on the bar they are
496/// computed for; drawing them `kijun_p` bars ahead, as the cloud is usually shown, is left to the
497/// consumer. There is no chikou span.
498///
499/// `value`: `tenkan - kijun`; `extra` carries all four lines. First output: with the
500/// `senkou_b_p`-th bar. [`Indicator::reset`] clears the window.
501pub 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}