Skip to main content

kestrel_chartkit/indicator/
momentum_indicators.rs

1use std::collections::{HashMap, VecDeque};
2
3use crate::indicator::{Indicator, IndicatorAlert, IndicatorOutput};
4use crate::model::Bar;
5
6/// Stochastic Oscillator: %K and %D.
7///
8/// `%K = 100 * (close - LL) / (HH - LL)` over the last `k_period` bars, `50` for a window without
9/// range, clamped to `0..=100`; `%D = SMA(d_period)` of %K. `value` and `extra["percent_k"]`: %K,
10/// the raw ("fast") one, not smoothed; `extra["percent_d"]`: %D.
11///
12/// First output: with the `k_period + d_period - 1`-th bar. [`Indicator::reset`] clears the
13/// windows.
14pub struct StochasticEngine {
15    k_period: usize,
16    d_period: usize,
17    bars: VecDeque<Bar>,
18    raw_ks: VecDeque<f64>,
19    alerts: Vec<IndicatorAlert>,
20}
21
22impl StochasticEngine {
23    pub fn new(k_period: usize, d_period: usize) -> Self {
24        Self {
25            k_period,
26            d_period,
27            bars: VecDeque::new(),
28            raw_ks: VecDeque::new(),
29            alerts: Vec::new(),
30        }
31    }
32}
33
34impl Indicator for StochasticEngine {
35    fn name(&self) -> &str {
36        "stochastic"
37    }
38
39    fn warmup_period(&self) -> usize {
40        self.k_period + self.d_period
41    }
42
43    fn reset(&mut self) {
44        self.bars.clear();
45        self.raw_ks.clear();
46        self.alerts.clear();
47    }
48
49    fn on_bar(&mut self, bar: &Bar) -> Option<IndicatorOutput> {
50        self.bars.push_back(bar.clone());
51        if self.bars.len() > self.k_period {
52            self.bars.pop_front();
53        }
54
55        if self.bars.len() < self.k_period {
56            return None;
57        }
58
59        let highest_high = self.bars.iter().map(|b| b.high).fold(f64::MIN, f64::max);
60        let lowest_low = self.bars.iter().map(|b| b.low).fold(f64::MAX, f64::min);
61
62        let k = if (highest_high - lowest_low).abs() > 1e-8 {
63            ((bar.close - lowest_low) / (highest_high - lowest_low)) * 100.0
64        } else {
65            50.0
66        }
67        .clamp(0.0, 100.0);
68
69        self.raw_ks.push_back(k);
70        if self.raw_ks.len() > self.d_period {
71            self.raw_ks.pop_front();
72        }
73
74        self.alerts.clear();
75        if self.raw_ks.len() < self.d_period {
76            return None;
77        }
78
79        let d = (self.raw_ks.iter().sum::<f64>() / self.d_period as f64).clamp(0.0, 100.0);
80
81        let mut extra = HashMap::new();
82        extra.insert("percent_k".to_string(), k);
83        extra.insert("percent_d".to_string(), d);
84
85        if k <= 20.0 && d <= 20.0 {
86            self.alerts.push(IndicatorAlert::new(
87                "stoch_oversold",
88                format!("Stochastic Oversold (%K: {:.1}, %D: {:.1})", k, d),
89                0.80,
90            ));
91        } else if k >= 80.0 && d >= 80.0 {
92            self.alerts.push(IndicatorAlert::new(
93                "stoch_overbought",
94                format!("Stochastic Overbought (%K: {:.1}, %D: {:.1})", k, d),
95                0.80,
96            ));
97        }
98
99        Some(IndicatorOutput::with_extra(k, extra))
100    }
101
102    fn alerts(&self) -> Vec<IndicatorAlert> {
103        self.alerts.clone()
104    }
105}
106
107/// Rate of Change in percent.
108///
109/// `ROC = 100 * (close - close_{t-period}) / close_{t-period}`, `0` for a non-positive past close;
110/// `extra["abs_momentum"]` is the plain difference. First output: with the `period + 1`-th bar.
111/// [`Indicator::reset`] clears the window.
112pub struct RocEngine {
113    period: usize,
114    closes: VecDeque<f64>,
115    alerts: Vec<IndicatorAlert>,
116}
117
118impl RocEngine {
119    pub fn new(period: usize) -> Self {
120        Self {
121            period,
122            closes: VecDeque::new(),
123            alerts: Vec::new(),
124        }
125    }
126}
127
128impl Indicator for RocEngine {
129    fn name(&self) -> &str {
130        "roc"
131    }
132
133    fn warmup_period(&self) -> usize {
134        self.period + 1
135    }
136
137    fn reset(&mut self) {
138        self.closes.clear();
139        self.alerts.clear();
140    }
141
142    fn on_bar(&mut self, bar: &Bar) -> Option<IndicatorOutput> {
143        self.closes.push_back(bar.close);
144        if self.closes.len() > self.period + 1 {
145            self.closes.pop_front();
146        }
147
148        self.alerts.clear();
149        if self.closes.len() < self.period + 1 {
150            return None;
151        }
152
153        let past_close = *self.closes.front().unwrap();
154        let roc = if past_close > 0.0 {
155            ((bar.close - past_close) / past_close) * 100.0
156        } else {
157            0.0
158        };
159
160        let mut extra = HashMap::new();
161        extra.insert("abs_momentum".to_string(), bar.close - past_close);
162
163        Some(IndicatorOutput::with_extra(roc, extra))
164    }
165
166    fn alerts(&self) -> Vec<IndicatorAlert> {
167        self.alerts.clone()
168    }
169}
170
171/// Ultimate Oscillator over three windows.
172///
173/// Per bar `BP = close - min(low, prev_close)` and `TR = max(high, prev_close) - min(low,
174/// prev_close)`; `A_n = sum(BP) / sum(TR)` over the last `n` bars (`0` without range), and
175/// `UO = 100 * (4 A_period1 + 2 A_period2 + A_period3) / 7`, clamped to `0..=100`.
176///
177/// First output: with the `period3 + 1`-th bar. [`Indicator::reset`] clears the window.
178pub struct UltimateOscillatorEngine {
179    period1: usize,
180    period2: usize,
181    period3: usize,
182    bars: VecDeque<Bar>,
183    alerts: Vec<IndicatorAlert>,
184}
185
186impl UltimateOscillatorEngine {
187    pub fn new(period1: usize, period2: usize, period3: usize) -> Self {
188        Self {
189            period1,
190            period2,
191            period3,
192            bars: VecDeque::new(),
193            alerts: Vec::new(),
194        }
195    }
196}
197
198impl Indicator for UltimateOscillatorEngine {
199    fn name(&self) -> &str {
200        "ultimate_oscillator"
201    }
202
203    fn warmup_period(&self) -> usize {
204        self.period3 + 1
205    }
206
207    fn reset(&mut self) {
208        self.bars.clear();
209        self.alerts.clear();
210    }
211
212    fn on_bar(&mut self, bar: &Bar) -> Option<IndicatorOutput> {
213        self.bars.push_back(bar.clone());
214        if self.bars.len() > self.period3 + 1 {
215            self.bars.pop_front();
216        }
217
218        self.alerts.clear();
219        if self.bars.len() < self.period3 + 1 {
220            return None;
221        }
222
223        self.bars.make_contiguous();
224        let calc_bp_tr_sums = |p: usize| -> (f64, f64) {
225            let slice = &self.bars.as_slices().0;
226            let len = slice.len();
227            let window_slice = &slice[len - p - 1..];
228            let mut sum_bp = 0.0f64;
229            let mut sum_tr = 0.0f64;
230            for pair in window_slice.windows(2) {
231                let prev_c = pair[0].close;
232                let b = &pair[1];
233                let min_l_pc = b.low.min(prev_c);
234                let max_h_pc = b.high.max(prev_c);
235                let bp = b.close - min_l_pc;
236                let tr = max_h_pc - min_l_pc;
237                sum_bp += bp;
238                sum_tr += tr;
239            }
240            (sum_bp, sum_tr)
241        };
242
243        let (bp1, tr1) = calc_bp_tr_sums(self.period1);
244        let (bp2, tr2) = calc_bp_tr_sums(self.period2);
245        let (bp3, tr3) = calc_bp_tr_sums(self.period3);
246
247        let r1 = if tr1 > 0.0 { bp1 / tr1 } else { 0.0 };
248        let r2 = if tr2 > 0.0 { bp2 / tr2 } else { 0.0 };
249        let r3 = if tr3 > 0.0 { bp3 / tr3 } else { 0.0 };
250
251        let uo = ((4.0 * r1 + 2.0 * r2 + r3) / 7.0) * 100.0;
252        Some(IndicatorOutput::new(uo.clamp(0.0, 100.0)))
253    }
254
255    fn alerts(&self) -> Vec<IndicatorAlert> {
256        self.alerts.clone()
257    }
258}
259
260/// Awesome Oscillator: `SMA(fast_period) - SMA(slow_period)` of the median price
261/// `(high + low) / 2`.
262///
263/// First output: with the `slow_period`-th bar. [`Indicator::reset`] clears the window.
264pub struct AwesomeOscillatorEngine {
265    fast_period: usize,
266    slow_period: usize,
267    hl2_series: VecDeque<f64>,
268    alerts: Vec<IndicatorAlert>,
269}
270
271impl AwesomeOscillatorEngine {
272    pub fn new(fast_period: usize, slow_period: usize) -> Self {
273        Self {
274            fast_period,
275            slow_period,
276            hl2_series: VecDeque::new(),
277            alerts: Vec::new(),
278        }
279    }
280}
281
282impl Indicator for AwesomeOscillatorEngine {
283    fn name(&self) -> &str {
284        "awesome_oscillator"
285    }
286
287    fn warmup_period(&self) -> usize {
288        self.slow_period
289    }
290
291    fn reset(&mut self) {
292        self.hl2_series.clear();
293        self.alerts.clear();
294    }
295
296    fn on_bar(&mut self, bar: &Bar) -> Option<IndicatorOutput> {
297        let hl2 = (bar.high + bar.low) / 2.0;
298        self.hl2_series.push_back(hl2);
299        if self.hl2_series.len() > self.slow_period {
300            self.hl2_series.pop_front();
301        }
302
303        self.alerts.clear();
304        if self.hl2_series.len() < self.slow_period {
305            return None;
306        }
307
308        let len = self.hl2_series.len();
309        let fast_sma: f64 = self
310            .hl2_series
311            .iter()
312            .skip(len - self.fast_period)
313            .sum::<f64>()
314            / self.fast_period as f64;
315        let slow_sma: f64 = self.hl2_series.iter().sum::<f64>() / self.slow_period as f64;
316
317        let ao = fast_sma - slow_sma;
318        Some(IndicatorOutput::new(ao))
319    }
320
321    fn alerts(&self) -> Vec<IndicatorAlert> {
322        self.alerts.clone()
323    }
324}
325
326/// Percentage Price Oscillator.
327///
328/// `PPO = 100 * (EMA(fast) - EMA(slow)) / EMA(slow)`, `0` for a non-positive slow average; both
329/// averages are seeded with the first close and run from the first bar, and the line is published
330/// from the `slow_period`-th bar on. `extra["signal"]` is an EMA over the published PPO values,
331/// seeded with the first, and `extra["hist"]` is `PPO - signal`.
332///
333/// First output: with the `slow_period`-th bar. [`Indicator::reset`] clears all averages.
334pub struct PpoEngine {
335    fast_period: usize,
336    slow_period: usize,
337    signal_period: usize,
338    fast_ema: Option<f64>,
339    slow_ema: Option<f64>,
340    signal_ema: Option<f64>,
341    count: usize,
342    alerts: Vec<IndicatorAlert>,
343}
344
345impl PpoEngine {
346    pub fn new(fast_period: usize, slow_period: usize, signal_period: usize) -> Self {
347        Self {
348            fast_period,
349            slow_period,
350            signal_period,
351            fast_ema: None,
352            slow_ema: None,
353            signal_ema: None,
354            count: 0,
355            alerts: Vec::new(),
356        }
357    }
358}
359
360impl Indicator for PpoEngine {
361    fn name(&self) -> &str {
362        "ppo"
363    }
364
365    fn warmup_period(&self) -> usize {
366        self.slow_period + self.signal_period
367    }
368
369    fn reset(&mut self) {
370        self.fast_ema = None;
371        self.slow_ema = None;
372        self.signal_ema = None;
373        self.count = 0;
374        self.alerts.clear();
375    }
376
377    fn on_bar(&mut self, bar: &Bar) -> Option<IndicatorOutput> {
378        self.count += 1;
379        let k_fast = 2.0 / (self.fast_period as f64 + 1.0);
380        let k_slow = 2.0 / (self.slow_period as f64 + 1.0);
381        let k_sig = 2.0 / (self.signal_period as f64 + 1.0);
382
383        self.fast_ema = Some(match self.fast_ema {
384            Some(prev) => bar.close * k_fast + prev * (1.0 - k_fast),
385            None => bar.close,
386        });
387
388        self.slow_ema = Some(match self.slow_ema {
389            Some(prev) => bar.close * k_slow + prev * (1.0 - k_slow),
390            None => bar.close,
391        });
392
393        self.alerts.clear();
394        if self.count < self.slow_period {
395            return None;
396        }
397
398        let fast = self.fast_ema.unwrap();
399        let slow = self.slow_ema.unwrap();
400        let ppo_line = if slow > 0.0 {
401            ((fast - slow) / slow) * 100.0
402        } else {
403            0.0
404        };
405
406        let sig = match self.signal_ema {
407            Some(prev) => ppo_line * k_sig + prev * (1.0 - k_sig),
408            None => ppo_line,
409        };
410        self.signal_ema = Some(sig);
411
412        let hist = ppo_line - sig;
413
414        let mut extra = HashMap::new();
415        extra.insert("signal".to_string(), sig);
416        extra.insert("hist".to_string(), hist);
417
418        Some(IndicatorOutput::with_extra(ppo_line, extra))
419    }
420
421    fn alerts(&self) -> Vec<IndicatorAlert> {
422        self.alerts.clone()
423    }
424}
425
426/// Chande Momentum Oscillator.
427///
428/// Over the last `period` changes of the close,
429/// `CMO = 100 * (sum(up) - sum(down)) / (sum(up) + sum(down))`, `0` when nothing moved.
430///
431/// First output: with the `period + 1`-th bar. [`Indicator::reset`] clears the windows.
432#[derive(Debug, Clone)]
433pub struct CmoEngine {
434    period: usize,
435    prev_close: Option<f64>,
436    gains: VecDeque<f64>,
437    losses: VecDeque<f64>,
438}
439
440impl CmoEngine {
441    pub fn new(period: usize) -> Self {
442        Self {
443            period: period.max(1),
444            prev_close: None,
445            gains: VecDeque::with_capacity(period),
446            losses: VecDeque::with_capacity(period),
447        }
448    }
449}
450
451impl Indicator for CmoEngine {
452    fn name(&self) -> &str {
453        "cmo"
454    }
455
456    fn warmup_period(&self) -> usize {
457        self.period + 1
458    }
459
460    fn reset(&mut self) {
461        self.prev_close = None;
462        self.gains.clear();
463        self.losses.clear();
464    }
465
466    fn on_bar(&mut self, bar: &Bar) -> Option<IndicatorOutput> {
467        let prev = match self.prev_close {
468            Some(p) => p,
469            None => {
470                self.prev_close = Some(bar.close);
471                return None;
472            }
473        };
474
475        let diff = bar.close - prev;
476        self.prev_close = Some(bar.close);
477
478        let gain = if diff > 0.0 { diff } else { 0.0 };
479        let loss = if diff < 0.0 { diff.abs() } else { 0.0 };
480
481        self.gains.push_back(gain);
482        self.losses.push_back(loss);
483
484        if self.gains.len() > self.period {
485            self.gains.pop_front();
486            self.losses.pop_front();
487        }
488
489        if self.gains.len() < self.period {
490            return None;
491        }
492
493        let sum_gain: f64 = self.gains.iter().sum();
494        let sum_loss: f64 = self.losses.iter().sum();
495        let denom = sum_gain + sum_loss;
496
497        let cmo_val = if denom > 1e-8 {
498            (100.0 * (sum_gain - sum_loss) / denom).clamp(-100.0, 100.0)
499        } else {
500            0.0
501        };
502
503        Some(IndicatorOutput::new(cmo_val))
504    }
505
506    fn alerts(&self) -> Vec<IndicatorAlert> {
507        Vec::new()
508    }
509}
510
511/// Elder Ray: bull and bear power against an EMA of the close.
512///
513/// The EMA is the shared [`crate::indicator::smoothing::Ema`] of `period` with its first-sample
514/// seed, running from the first bar; `bull = high - EMA`, `bear = low - EMA`. `value` and
515/// `extra["bull_power"]`: bull power; `extra["bear_power"]` and `extra["ema"]`.
516///
517/// First output: with the `period`-th bar. [`Indicator::reset`] clears the average.
518pub struct ElderRayEngine {
519    ema_period: usize,
520    ema: crate::indicator::smoothing::Ema,
521    count: usize,
522}
523
524impl ElderRayEngine {
525    pub fn new(ema_period: usize) -> Self {
526        Self {
527            ema_period: ema_period.max(1),
528            ema: crate::indicator::smoothing::Ema::new(ema_period),
529            count: 0,
530        }
531    }
532}
533
534impl Indicator for ElderRayEngine {
535    fn name(&self) -> &str {
536        "elder_ray"
537    }
538
539    fn warmup_period(&self) -> usize {
540        self.ema_period
541    }
542
543    fn reset(&mut self) {
544        self.ema.reset();
545        self.count = 0;
546    }
547
548    fn on_bar(&mut self, bar: &Bar) -> Option<IndicatorOutput> {
549        self.count += 1;
550        let ema_val = self.ema.update(bar.close)?;
551
552        if self.count < self.ema_period {
553            return None;
554        }
555
556        let bull_power = bar.high - ema_val;
557        let bear_power = bar.low - ema_val;
558
559        let mut extra = HashMap::new();
560        extra.insert("bull_power".to_string(), bull_power);
561        extra.insert("bear_power".to_string(), bear_power);
562        extra.insert("ema".to_string(), ema_val);
563
564        Some(IndicatorOutput::with_extra(bull_power, extra))
565    }
566
567    fn alerts(&self) -> Vec<IndicatorAlert> {
568        Vec::new()
569    }
570}