Skip to main content

egui_charts/studies/
helpers.rs

1//! Calculations on scalar series for built-in and host-defined studies.
2//!
3//! Outputs retain the input length. Missing results are NaN; lengths of zero are
4//! treated as one, except [`change`], where zero means no change. Complete-window
5//! calculations require finite inputs throughout the window. Recursive averages
6//! skip non-finite inputs, emitting NaN there and retaining their previous state.
7
8use std::collections::VecDeque;
9
10/// Arithmetic mean of each complete trailing window.
11pub fn sma(values: &[f64], period: usize) -> Vec<f64> {
12    windows(values, period.max(1), |window| {
13        window.iter().sum::<f64>() / window.len() as f64
14    })
15}
16
17/// Sum of each complete trailing window, recovering after missing inputs leave it.
18pub fn sum(values: &[f64], period: usize) -> Vec<f64> {
19    windows(values, period.max(1), |window| window.iter().sum())
20}
21
22/// Exponential average with alpha `2 / (period + 1)`, seeded by the first finite value.
23pub fn ema(values: &[f64], period: usize) -> Vec<f64> {
24    average(values, 1, 2.0 / (period.max(1) as f64 + 1.0))
25}
26
27/// Exponential average with alpha `2 / (period + 1)`, seeded by the mean of the
28/// first `period` finite values. Useful for nested averages with a full warm-up.
29pub fn ema_sma(values: &[f64], period: usize) -> Vec<f64> {
30    average(values, period.max(1), 2.0 / (period.max(1) as f64 + 1.0))
31}
32
33/// Wilder's average with alpha `1 / period`, seeded by the mean of the first
34/// `period` finite values.
35pub fn rma(values: &[f64], period: usize) -> Vec<f64> {
36    average(values, period.max(1), 1.0 / period.max(1) as f64)
37}
38
39fn average(values: &[f64], seed: usize, alpha: f64) -> Vec<f64> {
40    let (mut count, mut average) = (0, 0.0);
41    values
42        .iter()
43        .map(|&x| {
44            if !x.is_finite() {
45                return f64::NAN;
46            }
47            if count < seed {
48                count += 1;
49                average += x;
50                if count < seed {
51                    return f64::NAN;
52                }
53                average /= seed as f64;
54            } else {
55                average += (x - average) * alpha;
56            }
57            average
58        })
59        .collect()
60}
61
62/// Linear weighted mean of each complete window, weights `1..=period`, newest heaviest.
63pub fn wma(values: &[f64], period: usize) -> Vec<f64> {
64    let period = period.max(1);
65    let denominator = period as f64 * (period as f64 + 1.0) / 2.0;
66    windows(values, period, |window| {
67        window
68            .iter()
69            .rev()
70            .enumerate()
71            .map(|(j, v)| v * (period - j) as f64)
72            .sum::<f64>()
73            / denominator
74    })
75}
76
77/// Population standard deviation of each complete window, with divisor `period`.
78pub fn stdev(values: &[f64], period: usize) -> Vec<f64> {
79    windows(values, period.max(1), |window| {
80        let mean = window.iter().sum::<f64>() / window.len() as f64;
81        (window.iter().map(|v| (v - mean).powi(2)).sum::<f64>() / window.len() as f64).sqrt()
82    })
83}
84
85fn windows(values: &[f64], period: usize, compute: impl Fn(&[f64]) -> f64) -> Vec<f64> {
86    (0..values.len())
87        .map(|i| {
88            if i + 1 < period {
89                return f64::NAN;
90            }
91            let window = &values[i + 1 - period..=i];
92            if window.iter().all(|v| v.is_finite()) {
93                compute(window)
94            } else {
95                f64::NAN
96            }
97        })
98        .collect()
99}
100
101/// Highest value in the trailing window, including partial warm-up windows.
102/// NaN never wins; an all-NaN window yields negative infinity, as `f64::max` does.
103pub fn highest(values: impl IntoIterator<Item = f64>, period: usize) -> Vec<f64> {
104    rolling(values, period.max(1), f64::NEG_INFINITY, |kept, new| {
105        kept <= new
106    })
107}
108
109/// Lowest value in the trailing window, including partial warm-up windows.
110/// NaN never wins; an all-NaN window yields positive infinity, as `f64::min` does.
111pub fn lowest(values: impl IntoIterator<Item = f64>, period: usize) -> Vec<f64> {
112    rolling(values, period.max(1), f64::INFINITY, |kept, new| {
113        kept >= new
114    })
115}
116
117fn rolling(
118    values: impl IntoIterator<Item = f64>,
119    period: usize,
120    empty: f64,
121    beaten: impl Fn(f64, f64) -> bool,
122) -> Vec<f64> {
123    let mut window = VecDeque::<(usize, f64)>::new();
124    values
125        .into_iter()
126        .enumerate()
127        .map(|(i, v)| {
128            if window.front().is_some_and(|&(j, _)| i - j >= period) {
129                window.pop_front();
130            }
131            if !v.is_nan() {
132                while window.back().is_some_and(|&(_, kept)| beaten(kept, v)) {
133                    window.pop_back();
134                }
135                window.push_back((i, v));
136            }
137            window.front().map_or(empty, |&(_, best)| best)
138        })
139        .collect()
140}
141
142/// Difference from `period` bars ago. Unavailable or non-finite endpoints yield NaN.
143pub fn change(values: &[f64], period: usize) -> Vec<f64> {
144    values
145        .iter()
146        .enumerate()
147        .map(|(i, &value)| {
148            i.checked_sub(period)
149                .filter(|&j| value.is_finite() && values[j].is_finite())
150                .map_or(f64::NAN, |j| value - values[j])
151        })
152        .collect()
153}
154
155/// Percentage change from `period` bars ago. NaN where that bar is unavailable or zero.
156#[cfg(feature = "studies")]
157pub(crate) fn calculate_roc(values: &[f64], period: usize) -> Vec<f64> {
158    let mut roc = Vec::with_capacity(values.len());
159
160    for i in 0..values.len() {
161        if i < period || values[i - period] == 0.0 {
162            roc.push(f64::NAN);
163        } else {
164            let change = (values[i] - values[i - period]) / values[i - period] * 100.0;
165            roc.push(change);
166        }
167    }
168
169    roc
170}
171
172/// True where `a` moves from below `b` to above it. Equal values retain the last
173/// unequal comparison, so a touch and return does not cross. Missing values break
174/// comparisons. Output has `a.len()` entries; missing `b` entries are gaps.
175pub fn crossover(a: &[f64], b: &[f64]) -> Vec<bool> {
176    crossings(a, b, std::cmp::Ordering::Greater)
177}
178
179/// True where `a` moves from above `b` to below it; otherwise as [`crossover`].
180pub fn crossunder(a: &[f64], b: &[f64]) -> Vec<bool> {
181    crossings(a, b, std::cmp::Ordering::Less)
182}
183
184fn crossings(a: &[f64], b: &[f64], direction: std::cmp::Ordering) -> Vec<bool> {
185    let mut previous = None;
186    a.iter()
187        .enumerate()
188        .map(|(i, &a)| {
189            let Some(&b) = b.get(i).filter(|b| a.is_finite() && b.is_finite()) else {
190                previous = None;
191                return false;
192            };
193            let order = a.partial_cmp(&b).unwrap();
194            if order == std::cmp::Ordering::Equal {
195                return false;
196            }
197            let crosses = order == direction && previous.is_some_and(|p| p != order);
198            previous = Some(order);
199            crosses
200        })
201        .collect()
202}