Skip to main content

kestrel_chartkit/indicator/
moving_averages.rs

1use std::collections::VecDeque;
2
3use crate::indicator::smoothing::{Ema, EmaInit};
4use crate::indicator::{Indicator, IndicatorAlert, IndicatorOutput};
5use crate::model::Bar;
6
7/// Simple Moving Average (SMA) over the closing price.
8///
9/// `SMA_t = (close_t + ... + close_{t-period+1}) / period`, the plain mean of the last `period`
10/// closes.
11///
12/// First output: with the `period`-th bar. [`Indicator::reset`] clears the window.
13pub struct SmaEngine {
14    period: usize,
15    closes: VecDeque<f64>,
16    alerts: Vec<IndicatorAlert>,
17}
18
19impl SmaEngine {
20    pub fn new(period: usize) -> Self {
21        Self {
22            period,
23            closes: VecDeque::new(),
24            alerts: Vec::new(),
25        }
26    }
27}
28
29impl Indicator for SmaEngine {
30    fn name(&self) -> &str {
31        "sma"
32    }
33
34    fn warmup_period(&self) -> usize {
35        self.period
36    }
37
38    fn reset(&mut self) {
39        self.closes.clear();
40        self.alerts.clear();
41    }
42
43    fn on_bar(&mut self, bar: &Bar) -> Option<IndicatorOutput> {
44        self.closes.push_back(bar.close);
45        if self.closes.len() > self.period {
46            self.closes.pop_front();
47        }
48
49        self.alerts.clear();
50        if self.closes.len() < self.period {
51            return None;
52        }
53
54        let sma = self.closes.iter().sum::<f64>() / self.period as f64;
55        Some(IndicatorOutput::new(sma))
56    }
57
58    fn alerts(&self) -> Vec<IndicatorAlert> {
59        self.alerts.clone()
60    }
61}
62
63/// Exponential Moving Average (EMA) over the closing price.
64///
65/// Wraps the shared [`Ema`] smoother rather than repeating its recurrence, so both surfaces stay
66/// one calculation. The first output appears with the `period`-th bar in either initialisation:
67/// with [`EmaInit::FirstSample`] the average is already running before that and its early values
68/// are withheld, with [`EmaInit::Sma`] it is not defined before that at all.
69///
70/// [`Indicator::reset`] clears the average and the bar counter, so the next series starts
71/// deterministically.
72#[derive(Debug, Clone)]
73pub struct EmaEngine {
74    period: usize,
75    ema: Ema,
76    count: usize,
77    alerts: Vec<IndicatorAlert>,
78}
79
80impl EmaEngine {
81    pub fn new(period: usize) -> Self {
82        Self {
83            period,
84            ema: Ema::new(period),
85            count: 0,
86            alerts: Vec::new(),
87        }
88    }
89
90    /// Selects the initialisation; see [`EmaInit`]. Additive to [`EmaEngine::new`], which keeps
91    /// the first-sample seed. Indicators that nest EMAs internally (DEMA, TEMA, MACD, ...) are
92    /// deliberately not affected.
93    pub fn with_init(mut self, init: EmaInit) -> Self {
94        self.ema = self.ema.with_init(init);
95        self
96    }
97
98    pub fn init(&self) -> EmaInit {
99        self.ema.init()
100    }
101}
102
103impl Indicator for EmaEngine {
104    fn name(&self) -> &str {
105        "ema"
106    }
107
108    fn warmup_period(&self) -> usize {
109        self.period
110    }
111
112    fn reset(&mut self) {
113        self.ema.reset();
114        self.count = 0;
115        self.alerts.clear();
116    }
117
118    fn on_bar(&mut self, bar: &Bar) -> Option<IndicatorOutput> {
119        self.count += 1;
120        let ema = self.ema.update(bar.close);
121
122        self.alerts.clear();
123        if self.count < self.period {
124            return None;
125        }
126
127        ema.map(IndicatorOutput::new)
128    }
129
130    fn alerts(&self) -> Vec<IndicatorAlert> {
131        self.alerts.clone()
132    }
133}
134
135/// Weighted Moving Average (WMA) over the closing price.
136///
137/// Linearly weighted over the last `period` closes: weight `period` on the most recent close down
138/// to `1` on the oldest, divided by the weight sum `period * (period + 1) / 2`.
139///
140/// First output: with the `period`-th bar. [`Indicator::reset`] clears the window.
141pub struct WmaEngine {
142    period: usize,
143    closes: VecDeque<f64>,
144    alerts: Vec<IndicatorAlert>,
145}
146
147impl WmaEngine {
148    pub fn new(period: usize) -> Self {
149        Self {
150            period,
151            closes: VecDeque::new(),
152            alerts: Vec::new(),
153        }
154    }
155}
156
157impl Indicator for WmaEngine {
158    fn name(&self) -> &str {
159        "wma"
160    }
161
162    fn warmup_period(&self) -> usize {
163        self.period
164    }
165
166    fn reset(&mut self) {
167        self.closes.clear();
168        self.alerts.clear();
169    }
170
171    fn on_bar(&mut self, bar: &Bar) -> Option<IndicatorOutput> {
172        self.closes.push_back(bar.close);
173        if self.closes.len() > self.period {
174            self.closes.pop_front();
175        }
176
177        self.alerts.clear();
178        if self.closes.len() < self.period {
179            return None;
180        }
181
182        let mut weight_sum = 0.0f64;
183        let mut weighted_val = 0.0f64;
184        for (i, &val) in self.closes.iter().enumerate() {
185            let w = (i + 1) as f64;
186            weighted_val += val * w;
187            weight_sum += w;
188        }
189
190        let wma = if weight_sum > 0.0 {
191            weighted_val / weight_sum
192        } else {
193            bar.close
194        };
195        Some(IndicatorOutput::new(wma))
196    }
197
198    fn alerts(&self) -> Vec<IndicatorAlert> {
199        self.alerts.clone()
200    }
201}
202
203/// Volume-Weighted Moving Average (VWMA) over the closing price.
204///
205/// `sum(close * volume) / sum(volume)` over the last `period` bars. A window without any volume
206/// has nothing to weight by; the value is then the current close rather than a division by zero.
207///
208/// First output: with the `period`-th bar. [`Indicator::reset`] clears the window.
209pub struct VwmaEngine {
210    period: usize,
211    bars: VecDeque<Bar>,
212    alerts: Vec<IndicatorAlert>,
213}
214
215impl VwmaEngine {
216    pub fn new(period: usize) -> Self {
217        Self {
218            period,
219            bars: VecDeque::new(),
220            alerts: Vec::new(),
221        }
222    }
223}
224
225impl Indicator for VwmaEngine {
226    fn name(&self) -> &str {
227        "vwma"
228    }
229
230    fn warmup_period(&self) -> usize {
231        self.period
232    }
233
234    fn reset(&mut self) {
235        self.bars.clear();
236        self.alerts.clear();
237    }
238
239    fn on_bar(&mut self, bar: &Bar) -> Option<IndicatorOutput> {
240        self.bars.push_back(bar.clone());
241        if self.bars.len() > self.period {
242            self.bars.pop_front();
243        }
244
245        self.alerts.clear();
246        if self.bars.len() < self.period {
247            return None;
248        }
249
250        let mut pv_sum = 0.0f64;
251        let mut v_sum = 0.0f64;
252        for b in &self.bars {
253            pv_sum += b.close * b.volume;
254            v_sum += b.volume;
255        }
256
257        let vwma = if v_sum > 0.0 {
258            pv_sum / v_sum
259        } else {
260            bar.close
261        };
262        Some(IndicatorOutput::new(vwma))
263    }
264
265    fn alerts(&self) -> Vec<IndicatorAlert> {
266        self.alerts.clone()
267    }
268}
269
270/// Hull Moving Average (HMA) over the closing price.
271///
272/// `HMA = WMA(2 * WMA(half) - WMA(period), root)` with `half = floor(period / 2)` and
273/// `root = round(sqrt(period))`, both at least 1, the rounding half away from zero. The inner
274/// difference is formed once both inner averages exist — from the `period`-th bar on — and the
275/// outer average runs over that difference series.
276///
277/// First output: once the outer average holds `root` differences, i.e. with the
278/// `period + root - 1`-th bar. [`Indicator::reset`] clears all three averages.
279pub struct HmaEngine {
280    period: usize,
281    wma_half: WmaEngine,
282    wma_full: WmaEngine,
283    wma_sqrt: WmaEngine,
284    alerts: Vec<IndicatorAlert>,
285}
286
287impl HmaEngine {
288    pub fn new(period: usize) -> Self {
289        let half = (period / 2).max(1);
290        let sqrt = ((period as f64).sqrt().round() as usize).max(1);
291        Self {
292            period,
293            wma_half: WmaEngine::new(half),
294            wma_full: WmaEngine::new(period),
295            wma_sqrt: WmaEngine::new(sqrt),
296            alerts: Vec::new(),
297        }
298    }
299}
300
301impl Indicator for HmaEngine {
302    fn name(&self) -> &str {
303        "hma"
304    }
305
306    fn warmup_period(&self) -> usize {
307        self.period + ((self.period as f64).sqrt().round() as usize)
308    }
309
310    fn reset(&mut self) {
311        self.wma_half.reset();
312        self.wma_full.reset();
313        self.wma_sqrt.reset();
314        self.alerts.clear();
315    }
316
317    fn on_bar(&mut self, bar: &Bar) -> Option<IndicatorOutput> {
318        let h_out = self.wma_half.on_bar(bar);
319        let f_out = self.wma_full.on_bar(bar);
320
321        self.alerts.clear();
322        if let (Some(h), Some(f)) = (h_out, f_out) {
323            let diff = 2.0 * h.value - f.value;
324            let synthetic_bar = Bar::new(bar.timestamp, diff, diff, diff, diff, 1.0);
325            return self.wma_sqrt.on_bar(&synthetic_bar);
326        }
327
328        None
329    }
330
331    fn alerts(&self) -> Vec<IndicatorAlert> {
332        self.alerts.clone()
333    }
334}
335
336/// Double Exponential Moving Average (DEMA) over the closing price.
337///
338/// `DEMA = 2 * e1 - e2`. `e1` is this crate's EMA of the closes ([`EmaEngine`]: seeded with the
339/// first close, published from the `period`-th bar on); `e2` is the same EMA taken over the
340/// *published* values of `e1`, so the second stage starts with the first published value of the
341/// first rather than with the first close.
342///
343/// That chaining differs from [`super::tema::TemaEngine`], whose three stages all run from the
344/// first close. The two produce different early values and converge as the seeds decay.
345///
346/// First output: once `e2` publishes, i.e. with the `2 * period - 1`-th bar.
347/// [`Indicator::reset`] clears both averages.
348pub struct DemaEngine {
349    period: usize,
350    ema1: EmaEngine,
351    ema2: EmaEngine,
352    alerts: Vec<IndicatorAlert>,
353}
354
355impl DemaEngine {
356    pub fn new(period: usize) -> Self {
357        Self {
358            period,
359            ema1: EmaEngine::new(period),
360            ema2: EmaEngine::new(period),
361            alerts: Vec::new(),
362        }
363    }
364}
365
366impl Indicator for DemaEngine {
367    fn name(&self) -> &str {
368        "dema"
369    }
370
371    fn warmup_period(&self) -> usize {
372        self.period * 2
373    }
374
375    fn reset(&mut self) {
376        self.ema1.reset();
377        self.ema2.reset();
378        self.alerts.clear();
379    }
380
381    fn on_bar(&mut self, bar: &Bar) -> Option<IndicatorOutput> {
382        let e1_out = self.ema1.on_bar(bar);
383        self.alerts.clear();
384
385        if let Some(e1) = e1_out {
386            let synth_bar = Bar::new(bar.timestamp, e1.value, e1.value, e1.value, e1.value, 1.0);
387            let e2_out = self.ema2.on_bar(&synth_bar);
388            if let Some(e2) = e2_out {
389                let dema = 2.0 * e1.value - e2.value;
390                return Some(IndicatorOutput::new(dema));
391            }
392        }
393
394        None
395    }
396
397    fn alerts(&self) -> Vec<IndicatorAlert> {
398        self.alerts.clone()
399    }
400}
401
402/// Kaufman's Adaptive Moving Average (KAMA) over the closing price.
403///
404/// Over the last `period + 1` closes the efficiency ratio is net movement over path length,
405/// `ER = |close_t - close_{t-period}| / sum(|close_i - close_{i-1}|)`, and `0` when nothing moved.
406/// It places the smoothing constant between a fast and a slow exponential constant:
407///
408/// ```text
409/// sc   = (ER * (2/(fast_period+1) - 2/(slow_period+1)) + 2/(slow_period+1))^2
410/// KAMA = KAMA_{t-1} + sc * (close_t - KAMA_{t-1})
411/// ```
412///
413/// The first value is the close of the first bar with a full window; there is no separate seed.
414/// Registry defaults: `period = 10`, `fast_period = 2`, `slow_period = 30`.
415///
416/// First output: with the `period + 1`-th bar. [`Indicator::reset`] clears the window and the
417/// average.
418pub struct KamaEngine {
419    period: usize,
420    fast_period: usize,
421    slow_period: usize,
422    closes: VecDeque<f64>,
423    current_kama: Option<f64>,
424    alerts: Vec<IndicatorAlert>,
425}
426
427impl KamaEngine {
428    pub fn new(period: usize, fast_period: usize, slow_period: usize) -> Self {
429        Self {
430            period,
431            fast_period,
432            slow_period,
433            closes: VecDeque::new(),
434            current_kama: None,
435            alerts: Vec::new(),
436        }
437    }
438}
439
440impl Indicator for KamaEngine {
441    fn name(&self) -> &str {
442        "kama"
443    }
444
445    fn warmup_period(&self) -> usize {
446        self.period + 1
447    }
448
449    fn reset(&mut self) {
450        self.closes.clear();
451        self.current_kama = None;
452        self.alerts.clear();
453    }
454
455    fn on_bar(&mut self, bar: &Bar) -> Option<IndicatorOutput> {
456        self.closes.push_back(bar.close);
457        if self.closes.len() > self.period + 1 {
458            self.closes.pop_front();
459        }
460
461        self.alerts.clear();
462        if self.closes.len() < self.period + 1 {
463            return None;
464        }
465
466        let change = (self.closes.back().unwrap() - self.closes.front().unwrap()).abs();
467        let mut volatility = 0.0f64;
468        for pair in self.closes.iter().collect::<Vec<_>>().windows(2) {
469            volatility += (*pair[1] - *pair[0]).abs();
470        }
471
472        let er = if volatility > 0.0 {
473            change / volatility
474        } else {
475            0.0
476        };
477
478        let fast_sc = 2.0 / (self.fast_period as f64 + 1.0);
479        let slow_sc = 2.0 / (self.slow_period as f64 + 1.0);
480        let sc = (er * (fast_sc - slow_sc) + slow_sc).powi(2);
481
482        let kama = match self.current_kama {
483            Some(prev) => prev + sc * (bar.close - prev),
484            None => bar.close,
485        };
486        self.current_kama = Some(kama);
487
488        Some(IndicatorOutput::new(kama))
489    }
490
491    fn alerts(&self) -> Vec<IndicatorAlert> {
492        self.alerts.clone()
493    }
494}