kestrel_chartkit/indicator/
momentum_indicators.rs1use std::collections::{HashMap, VecDeque};
2
3use crate::indicator::{Indicator, IndicatorAlert, IndicatorOutput};
4use crate::model::Bar;
5
6pub 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
107pub 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
171pub 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
260pub 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
326pub 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#[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
511pub 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}