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 {
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
100pub 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
160pub 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
243pub 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
306pub 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
399pub 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
478pub 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}