Skip to main content

ggplot_rs/stat/
indicator.rs

1//! `StatIndicator`: technical-analysis indicators for price series, to
2//! overlay on (or stack under) candlestick / line charts:
3//! `geom_line().stat(StatIndicator::new(Indicator::Ema(20)))`.
4//!
5//! Rows are taken in `x` order. The price is `close` when mapped, else `y`;
6//! ATR and the stochastic oscillator also need `high`/`low`, VWAP uses
7//! `weight` as volume. Output keeps `x` and puts the indicator in `y`
8//! (Bollinger bands also in `ymin`/`ymax`); warm-up rows without a value
9//! are dropped. Smoothing follows the TA-Lib conventions: EMA seeded with
10//! the SMA of its first `n` values, RSI and ATR with Wilder's smoothing.
11
12use crate::aes::Aesthetic;
13use crate::data::{DataFrame, Value};
14use crate::scale::ScaleSet;
15
16use super::Stat;
17
18/// Which MACD series to emit.
19#[derive(Clone, Copy, Debug, PartialEq, Eq)]
20pub enum MacdPart {
21    Line,
22    Signal,
23    Histogram,
24}
25
26/// A technical indicator.
27#[derive(Clone, Copy, Debug, PartialEq)]
28pub enum Indicator {
29    /// Simple moving average over `n` periods.
30    Sma(usize),
31    /// Exponential moving average (α = 2/(n+1), SMA-seeded).
32    Ema(usize),
33    /// Linearly weighted moving average.
34    Wma(usize),
35    /// Bollinger bands: SMA(n) ± k population standard deviations.
36    Bollinger(usize, f64),
37    /// MACD(fast, slow, signal).
38    Macd(usize, usize, usize, MacdPart),
39    /// Relative strength index (Wilder), 0–100.
40    Rsi(usize),
41    /// Average true range (Wilder); needs `high`/`low`/`close`.
42    Atr(usize),
43    /// Volume-weighted average price, cumulative; volume from `weight`.
44    Vwap,
45    /// Stochastic %K over `k` periods smoothed by an SMA of `d` (`d = 1`
46    /// for raw %K); needs `high`/`low`/`close`. 0–100.
47    Stochastic(usize, usize),
48    /// ZigZag pivots: points where the price reverses by at least `pct` %.
49    ZigZag(f64),
50}
51
52/// Indicator stat (see the module docs).
53#[derive(Clone, Debug)]
54pub struct StatIndicator {
55    pub indicator: Indicator,
56}
57
58impl StatIndicator {
59    pub fn new(indicator: Indicator) -> Self {
60        StatIndicator { indicator }
61    }
62}
63
64/// Simple moving average; `None` during the warm-up.
65pub fn sma(v: &[f64], n: usize) -> Vec<Option<f64>> {
66    let n = n.max(1);
67    let mut out = vec![None; v.len()];
68    let mut sum = 0.0;
69    for i in 0..v.len() {
70        sum += v[i];
71        if i >= n {
72            sum -= v[i - n];
73        }
74        if i + 1 >= n {
75            out[i] = Some(sum / n as f64);
76        }
77    }
78    out
79}
80
81/// EMA with α = 2/(n+1), seeded with the SMA of the first `n` values.
82pub fn ema(v: &[f64], n: usize) -> Vec<Option<f64>> {
83    smoothed(v, n, 2.0 / (n.max(1) as f64 + 1.0))
84}
85
86/// Exponential smoothing with factor `alpha`, seeded with an `n`-SMA.
87fn smoothed(v: &[f64], n: usize, alpha: f64) -> Vec<Option<f64>> {
88    let n = n.max(1);
89    let mut out = vec![None; v.len()];
90    if v.len() < n {
91        return out;
92    }
93    let mut prev = v[..n].iter().sum::<f64>() / n as f64;
94    out[n - 1] = Some(prev);
95    for i in n..v.len() {
96        prev += alpha * (v[i] - prev);
97        out[i] = Some(prev);
98    }
99    out
100}
101
102/// Linearly weighted moving average (weights 1..n, newest heaviest).
103pub fn wma(v: &[f64], n: usize) -> Vec<Option<f64>> {
104    let n = n.max(1);
105    let denom = (n * (n + 1)) as f64 / 2.0;
106    (0..v.len())
107        .map(|i| {
108            (i + 1 >= n).then(|| {
109                (0..n)
110                    .map(|k| v[i + 1 - n + k] * (k + 1) as f64)
111                    .sum::<f64>()
112                    / denom
113            })
114        })
115        .collect()
116}
117
118/// RSI with Wilder's smoothing (α = 1/n), seeded with the mean gain/loss of
119/// the first `n` changes.
120pub fn rsi(v: &[f64], n: usize) -> Vec<Option<f64>> {
121    let n = n.max(1);
122    let mut out = vec![None; v.len()];
123    if v.len() <= n {
124        return out;
125    }
126    let ch: Vec<f64> = v.windows(2).map(|w| w[1] - w[0]).collect();
127    let (mut gain, mut loss) = (0.0, 0.0);
128    for &c in &ch[..n] {
129        gain += c.max(0.0);
130        loss += (-c).max(0.0);
131    }
132    gain /= n as f64;
133    loss /= n as f64;
134    let value = |g: f64, l: f64| {
135        if l == 0.0 {
136            if g == 0.0 {
137                50.0
138            } else {
139                100.0
140            }
141        } else {
142            100.0 - 100.0 / (1.0 + g / l)
143        }
144    };
145    out[n] = Some(value(gain, loss));
146    for i in n..ch.len() {
147        gain = (gain * (n as f64 - 1.0) + ch[i].max(0.0)) / n as f64;
148        loss = (loss * (n as f64 - 1.0) + (-ch[i]).max(0.0)) / n as f64;
149        out[i + 1] = Some(value(gain, loss));
150    }
151    out
152}
153
154impl Stat for StatIndicator {
155    fn compute_group(&self, data: &DataFrame, _scales: &ScaleSet) -> DataFrame {
156        let Some(x) = data.column("x") else {
157            return DataFrame::new();
158        };
159        let num = |name: &str| data.column(name);
160        let price_col = num("close").or_else(|| num("y"));
161        let Some(price_col) = price_col else {
162            return DataFrame::new();
163        };
164        let key = |v: &Value| v.as_f64().unwrap_or(f64::NAN);
165        let mut order: Vec<usize> = (0..data.nrows())
166            .filter(|&i| price_col[i].as_f64().is_some_and(f64::is_finite))
167            .collect();
168        order.sort_by(|&a, &b| key(&x[a]).total_cmp(&key(&x[b])));
169        let take = |c: Option<&[Value]>| -> Option<Vec<f64>> {
170            let c = c?;
171            Some(
172                order
173                    .iter()
174                    .map(|&i| c[i].as_f64().unwrap_or(f64::NAN))
175                    .collect(),
176            )
177        };
178        let p = take(Some(price_col)).unwrap_or_default();
179        let (high, low) = (take(num("high")), take(num("low")));
180        let n_rows = p.len();
181        // (y, ymin, ymax) per sorted row.
182        let mut val: Vec<(Option<f64>, Option<f64>, Option<f64>)> =
183            vec![(None, None, None); n_rows];
184        let set_y = |val: &mut Vec<(Option<f64>, Option<f64>, Option<f64>)>,
185                     ys: Vec<Option<f64>>| {
186            for (v, y) in val.iter_mut().zip(ys) {
187                v.0 = y;
188            }
189        };
190        match self.indicator {
191            Indicator::Sma(n) => set_y(&mut val, sma(&p, n)),
192            Indicator::Ema(n) => set_y(&mut val, ema(&p, n)),
193            Indicator::Wma(n) => set_y(&mut val, wma(&p, n)),
194            Indicator::Bollinger(n, k) => {
195                let mid = sma(&p, n);
196                for i in 0..n_rows {
197                    if let Some(m) = mid[i] {
198                        let w = &p[i + 1 - n.max(1)..=i];
199                        let sd = (w.iter().map(|v| (v - m).powi(2)).sum::<f64>() / w.len() as f64)
200                            .sqrt();
201                        val[i] = (Some(m), Some(m - k * sd), Some(m + k * sd));
202                    }
203                }
204            }
205            Indicator::Macd(fast, slow, signal, part) => {
206                let (f, s) = (ema(&p, fast), ema(&p, slow));
207                let line: Vec<Option<f64>> =
208                    f.iter().zip(&s).map(|(a, b)| Some((*a)? - (*b)?)).collect();
209                // Signal: EMA of the MACD line over its defined stretch.
210                let start = line.iter().position(Option::is_some).unwrap_or(n_rows);
211                let defined: Vec<f64> = line[start..].iter().map(|v| v.unwrap_or(0.0)).collect();
212                let mut sig = vec![None; start];
213                sig.extend(ema(&defined, signal));
214                let out: Vec<Option<f64>> = match part {
215                    MacdPart::Line => line.iter().zip(&sig).map(|(l, s)| s.and(*l)).collect(),
216                    MacdPart::Signal => sig,
217                    MacdPart::Histogram => line
218                        .iter()
219                        .zip(&sig)
220                        .map(|(l, s)| Some((*l)? - (*s)?))
221                        .collect(),
222                };
223                set_y(&mut val, out);
224            }
225            Indicator::Rsi(n) => set_y(&mut val, rsi(&p, n)),
226            Indicator::Atr(n) => {
227                if let (Some(h), Some(l)) = (&high, &low) {
228                    let tr: Vec<f64> = (0..n_rows)
229                        .map(|i| {
230                            let hl = h[i] - l[i];
231                            if i == 0 {
232                                hl
233                            } else {
234                                hl.max((h[i] - p[i - 1]).abs()).max((l[i] - p[i - 1]).abs())
235                            }
236                        })
237                        .collect();
238                    set_y(&mut val, smoothed(&tr, n, 1.0 / n.max(1) as f64));
239                }
240            }
241            Indicator::Vwap => {
242                let vol = take(num("weight")).unwrap_or_else(|| vec![1.0; n_rows]);
243                let (mut pv, mut vv) = (0.0, 0.0);
244                let out = (0..n_rows)
245                    .map(|i| {
246                        let typical = match (&high, &low) {
247                            (Some(h), Some(l)) => (h[i] + l[i] + p[i]) / 3.0,
248                            _ => p[i],
249                        };
250                        pv += typical * vol[i];
251                        vv += vol[i];
252                        (vv > 0.0).then(|| pv / vv)
253                    })
254                    .collect();
255                set_y(&mut val, out);
256            }
257            Indicator::Stochastic(k, d) => {
258                if let (Some(h), Some(l)) = (&high, &low) {
259                    let k = k.max(1);
260                    let raw: Vec<Option<f64>> = (0..n_rows)
261                        .map(|i| {
262                            (i + 1 >= k).then(|| {
263                                let hh = h[i + 1 - k..=i].iter().copied().fold(f64::MIN, f64::max);
264                                let ll = l[i + 1 - k..=i].iter().copied().fold(f64::MAX, f64::min);
265                                if hh > ll {
266                                    100.0 * (p[i] - ll) / (hh - ll)
267                                } else {
268                                    50.0
269                                }
270                            })
271                        })
272                        .collect();
273                    let start = raw.iter().position(Option::is_some).unwrap_or(n_rows);
274                    let defined: Vec<f64> = raw[start..].iter().map(|v| v.unwrap_or(0.0)).collect();
275                    let mut out = vec![None; start];
276                    out.extend(sma(&defined, d));
277                    set_y(&mut val, out);
278                }
279            }
280            Indicator::ZigZag(pct) => {
281                let th = pct.abs() / 100.0;
282                if n_rows > 0 {
283                    // Track the running extreme; confirm a pivot when the
284                    // price retraces `th` from it.
285                    let mut pivots = vec![0usize];
286                    let mut ext = 0usize;
287                    let mut dir = 0i8;
288                    for i in 1..n_rows {
289                        match dir {
290                            0 => {
291                                if p[i] >= p[0] * (1.0 + th) {
292                                    dir = 1;
293                                    ext = i;
294                                } else if p[i] <= p[0] * (1.0 - th) {
295                                    dir = -1;
296                                    ext = i;
297                                }
298                            }
299                            1 => {
300                                if p[i] > p[ext] {
301                                    ext = i;
302                                } else if p[i] <= p[ext] * (1.0 - th) {
303                                    pivots.push(ext);
304                                    dir = -1;
305                                    ext = i;
306                                }
307                            }
308                            _ => {
309                                if p[i] < p[ext] {
310                                    ext = i;
311                                } else if p[i] >= p[ext] * (1.0 + th) {
312                                    pivots.push(ext);
313                                    dir = 1;
314                                    ext = i;
315                                }
316                            }
317                        }
318                    }
319                    if *pivots.last().unwrap() != ext {
320                        pivots.push(ext);
321                    }
322                    for i in pivots {
323                        val[i].0 = Some(p[i]);
324                    }
325                }
326            }
327        }
328        let keep: Vec<usize> = (0..n_rows)
329            .filter(|&i| val[i].0.is_some_and(f64::is_finite))
330            .collect();
331        let mut out = DataFrame::new();
332        out.add_column(
333            "x".into(),
334            keep.iter().map(|&i| x[order[i]].clone()).collect(),
335        );
336        out.add_column(
337            "y".into(),
338            keep.iter()
339                .map(|&i| Value::Float(val[i].0.unwrap()))
340                .collect(),
341        );
342        if matches!(self.indicator, Indicator::Bollinger(..)) {
343            out.add_column(
344                "ymin".into(),
345                keep.iter()
346                    .map(|&i| Value::Float(val[i].1.unwrap_or(f64::NAN)))
347                    .collect(),
348            );
349            out.add_column(
350                "ymax".into(),
351                keep.iter()
352                    .map(|&i| Value::Float(val[i].2.unwrap_or(f64::NAN)))
353                    .collect(),
354            );
355        }
356        for c in ["color", "fill", "group", "linetype"] {
357            if let Some(col) = data.column(c) {
358                out.add_column(
359                    c.into(),
360                    keep.iter().map(|&i| col[order[i]].clone()).collect(),
361                );
362            }
363        }
364        out
365    }
366
367    fn required_aes(&self) -> Vec<Aesthetic> {
368        vec![Aesthetic::X]
369    }
370
371    /// Indicators run over the whole series.
372    fn panelwise(&self) -> bool {
373        true
374    }
375
376    fn name(&self) -> &str {
377        "indicator"
378    }
379}
380
381#[cfg(test)]
382mod tests {
383    use super::*;
384
385    fn close(a: Option<f64>, b: f64) -> bool {
386        a.is_some_and(|a| (a - b).abs() < 1e-9)
387    }
388
389    #[test]
390    fn moving_averages() {
391        let v = [1.0, 2.0, 3.0, 4.0, 5.0];
392        assert_eq!(
393            sma(&v, 3),
394            vec![None, None, Some(2.0), Some(3.0), Some(4.0)]
395        );
396        let e = ema(&v, 3);
397        // Seed SMA 2, α = 0.5: 3, 4.
398        assert!(close(e[2], 2.0) && close(e[3], 3.0) && close(e[4], 4.0));
399        let w = wma(&v, 3);
400        assert!(close(w[2], (1.0 + 4.0 + 9.0) / 6.0));
401    }
402
403    #[test]
404    fn rsi_extremes_and_wilder() {
405        let up: Vec<f64> = (0..20).map(f64::from).collect();
406        assert!(rsi(&up, 14).iter().flatten().all(|&r| r == 100.0));
407        let flat = vec![5.0; 20];
408        assert!(rsi(&flat, 14).iter().flatten().all(|&r| r == 50.0));
409        // Alternating +1/−1: average gain = average loss → 50.
410        let zig: Vec<f64> = (0..30)
411            .map(|i| if i % 2 == 0 { 10.0 } else { 11.0 })
412            .collect();
413        let r = rsi(&zig, 14);
414        assert!(r[14].is_some_and(|v| (v - 50.0).abs() < 4.0));
415    }
416}