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