Skip to main content

indicators/
functions.rs

1//! Standalone batch indicator functions and incremental structs.
2//!
3//! Ported from the original `indicators` crate lib. These work on slices
4//! (batch mode) or as incremental O(1)-per-tick structs.
5
6use std::collections::VecDeque;
7
8use crate::error::IndicatorError;
9use crate::types::MacdResult;
10
11// ── Batch functions ───────────────────────────────────────────────────────────
12
13/// Exponential Moving Average over a price slice.
14/// Returns a Vec of the same length; leading values are `NaN` until warm-up.
15pub fn ema(prices: &[f64], period: usize) -> Result<Vec<f64>, IndicatorError> {
16    if period == 0 {
17        return Err(IndicatorError::InvalidParameter {
18            name: "period".into(),
19            value: 0.0,
20        });
21    }
22    if prices.len() < period {
23        return Err(IndicatorError::InsufficientData {
24            required: period,
25            available: prices.len(),
26        });
27    }
28    let mut result = vec![f64::NAN; prices.len()];
29    let alpha = 2.0 / (period as f64 + 1.0);
30    let first_sma: f64 = prices.iter().take(period).sum::<f64>() / period as f64;
31    result[period - 1] = first_sma;
32    for i in period..prices.len() {
33        result[i] = prices[i] * alpha + result[i - 1] * (1.0 - alpha);
34    }
35    Ok(result)
36}
37
38/// EMA that handles leading NaN values, matching Python's `ewm(adjust=False)` behaviour.
39///
40/// Unlike [`ema`], which seeds from the arithmetic mean of the first `period`
41/// values, this function seeds from the **first non-NaN value** and applies
42/// the recursive formula from that point on.  All positions before the first
43/// non-NaN value are left as `NaN`.
44///
45/// This is needed wherever EMA is applied to a derived series (e.g. the MACD
46/// line) that already has a leading NaN warm-up period.  Using the standard
47/// [`ema`] on such a series would propagate NaN through the SMA seed and
48/// produce an all-NaN output.
49pub fn ema_nan_aware(prices: &[f64], period: usize) -> Result<Vec<f64>, IndicatorError> {
50    if period == 0 {
51        return Err(IndicatorError::InvalidParameter {
52            name: "period".into(),
53            value: 0.0,
54        });
55    }
56    let mut result = vec![f64::NAN; prices.len()];
57    let alpha = 2.0 / (period as f64 + 1.0);
58
59    // Seed from the first non-NaN value (adjust=False, no SMA warm-up).
60    let start = match prices.iter().position(|v| !v.is_nan()) {
61        Some(i) => i,
62        None => return Ok(result), // all NaN — nothing to compute
63    };
64
65    result[start] = prices[start];
66    for i in (start + 1)..prices.len() {
67        result[i] = if prices[i].is_nan() {
68            f64::NAN
69        } else {
70            prices[i] * alpha + result[i - 1] * (1.0 - alpha)
71        };
72    }
73    Ok(result)
74}
75
76/// Simple Moving Average over a price slice.
77pub fn sma(prices: &[f64], period: usize) -> Result<Vec<f64>, IndicatorError> {
78    if period == 0 {
79        return Err(IndicatorError::InvalidParameter {
80            name: "period".into(),
81            value: 0.0,
82        });
83    }
84    if prices.len() < period {
85        return Err(IndicatorError::InsufficientData {
86            required: period,
87            available: prices.len(),
88        });
89    }
90    let mut result = vec![f64::NAN; prices.len()];
91    for i in (period - 1)..prices.len() {
92        let sum: f64 = prices[(i + 1 - period)..=i].iter().sum();
93        result[i] = sum / period as f64;
94    }
95    Ok(result)
96}
97
98/// True Range = max(H-L, |H-prevC|, |L-prevC|).
99pub fn true_range(high: &[f64], low: &[f64], close: &[f64]) -> Result<Vec<f64>, IndicatorError> {
100    if high.len() != low.len() || high.len() != close.len() {
101        return Err(IndicatorError::InsufficientData {
102            required: high.len(),
103            available: low.len().min(close.len()),
104        });
105    }
106    let mut result = vec![f64::NAN; high.len()];
107    if !high.is_empty() {
108        result[0] = high[0] - low[0];
109    }
110    for i in 1..high.len() {
111        let tr1 = high[i] - low[i];
112        let tr2 = (high[i] - close[i - 1]).abs();
113        let tr3 = (low[i] - close[i - 1]).abs();
114        result[i] = tr1.max(tr2).max(tr3);
115    }
116    Ok(result)
117}
118
119/// Average True Range (EMA-smoothed).
120pub fn atr(
121    high: &[f64],
122    low: &[f64],
123    close: &[f64],
124    period: usize,
125) -> Result<Vec<f64>, IndicatorError> {
126    let tr = true_range(high, low, close)?;
127    ema(&tr, period)
128}
129
130/// Relative Strength Index.
131pub fn rsi(prices: &[f64], period: usize) -> Result<Vec<f64>, IndicatorError> {
132    if prices.len() < period + 1 {
133        return Err(IndicatorError::InsufficientData {
134            required: period + 1,
135            available: prices.len(),
136        });
137    }
138    let mut result = vec![f64::NAN; prices.len()];
139    let mut gains = vec![0.0; prices.len()];
140    let mut losses = vec![0.0; prices.len()];
141    for i in 1..prices.len() {
142        let change = prices[i] - prices[i - 1];
143        if change > 0.0 {
144            gains[i] = change;
145        } else {
146            losses[i] = -change;
147        }
148    }
149    let avg_gains = ema(&gains, period)?;
150    let avg_losses = ema(&losses, period)?;
151    for i in period..prices.len() {
152        if avg_losses[i] == 0.0 {
153            result[i] = 100.0;
154        } else {
155            let rs = avg_gains[i] / avg_losses[i];
156            result[i] = 100.0 - (100.0 / (1.0 + rs));
157        }
158    }
159    Ok(result)
160}
161
162/// MACD — returns (macd_line, signal_line, histogram).
163pub fn macd(
164    prices: &[f64],
165    fast_period: usize,
166    slow_period: usize,
167    signal_period: usize,
168) -> MacdResult {
169    // Use ema_nan_aware to match Python's ewm(span=X, adjust=False), which
170    // seeds from the first value rather than an SMA of the first `period` bars.
171    let fast_ema = ema_nan_aware(prices, fast_period)?;
172    let slow_ema = ema_nan_aware(prices, slow_period)?;
173    let mut macd_line = vec![f64::NAN; prices.len()];
174    for i in 0..prices.len() {
175        if !fast_ema[i].is_nan() && !slow_ema[i].is_nan() {
176            macd_line[i] = fast_ema[i] - slow_ema[i];
177        }
178    }
179    // The macd_line has leading NaN (warm-up from the slow EMA); use the
180    // NaN-aware variant so the signal seeds from the first valid MACD value
181    // rather than an all-NaN SMA, matching Python's ewm(adjust=False).
182    let signal_line = ema_nan_aware(&macd_line, signal_period)?;
183    let mut histogram = vec![f64::NAN; prices.len()];
184    for i in 0..prices.len() {
185        if !macd_line[i].is_nan() && !signal_line[i].is_nan() {
186            histogram[i] = macd_line[i] - signal_line[i];
187        }
188    }
189    Ok((macd_line, signal_line, histogram))
190}
191
192// ── Incremental structs ───────────────────────────────────────────────────────
193
194/// Incremental EMA — O(1) update, SMA warm-up.
195///
196/// Unlike the batch [`ema`] function (which initialises from an SMA over the
197/// first `period` prices), this struct emits its first value *after* it has
198/// accumulated exactly `period` samples and seeds itself from their average.
199/// Both approaches are correct; this one is more natural for streaming use.
200#[derive(Debug, Clone)]
201pub struct EMA {
202    period: usize,
203    alpha: f64,
204    value: f64,
205    initialized: bool,
206    warmup: VecDeque<f64>,
207}
208
209impl EMA {
210    pub fn new(period: usize) -> Self {
211        Self {
212            period,
213            alpha: 2.0 / (period as f64 + 1.0),
214            value: 0.0,
215            initialized: false,
216            warmup: VecDeque::with_capacity(period),
217        }
218    }
219
220    pub fn update(&mut self, price: f64) {
221        if !self.initialized {
222            self.warmup.push_back(price);
223            if self.warmup.len() >= self.period {
224                self.value = self.warmup.iter().sum::<f64>() / self.period as f64;
225                self.initialized = true;
226                self.warmup.clear();
227            }
228        } else {
229            self.value = price * self.alpha + self.value * (1.0 - self.alpha);
230        }
231    }
232
233    pub fn value(&self) -> f64 {
234        if self.initialized {
235            self.value
236        } else {
237            f64::NAN
238        }
239    }
240
241    pub fn is_ready(&self) -> bool {
242        self.initialized
243    }
244
245    pub fn reset(&mut self) {
246        self.value = 0.0;
247        self.initialized = false;
248        self.warmup.clear();
249    }
250}
251
252/// Incremental Wilder ATR.
253#[derive(Debug, Clone)]
254pub struct ATR {
255    #[allow(dead_code)]
256    period: usize,
257    ema: EMA,
258    prev_close: Option<f64>,
259}
260
261impl ATR {
262    pub fn new(period: usize) -> Self {
263        Self {
264            period,
265            ema: EMA::new(period),
266            prev_close: None,
267        }
268    }
269
270    pub fn update(&mut self, high: f64, low: f64, close: f64) {
271        let tr = if let Some(prev) = self.prev_close {
272            (high - low)
273                .max((high - prev).abs())
274                .max((low - prev).abs())
275        } else {
276            high - low
277        };
278        self.ema.update(tr);
279        self.prev_close = Some(close);
280    }
281
282    pub fn value(&self) -> f64 {
283        self.ema.value()
284    }
285
286    pub fn is_ready(&self) -> bool {
287        self.ema.is_ready()
288    }
289}
290
291/// Bundle of per-strategy indicator series.
292#[derive(Debug, Clone)]
293pub struct StrategyIndicators {
294    pub ema_fast: Vec<f64>,
295    pub ema_slow: Vec<f64>,
296    pub atr: Vec<f64>,
297}
298
299/// Multi-period indicator calculator (batch mode).
300#[derive(Debug, Clone)]
301pub struct IndicatorCalculator {
302    pub fast_ema_period: usize,
303    pub slow_ema_period: usize,
304    pub atr_period: usize,
305}
306
307impl Default for IndicatorCalculator {
308    fn default() -> Self {
309        Self {
310            fast_ema_period: 8,
311            slow_ema_period: 21,
312            atr_period: 14,
313        }
314    }
315}
316
317impl IndicatorCalculator {
318    pub fn new(fast_ema: usize, slow_ema: usize, atr_period: usize) -> Self {
319        Self {
320            fast_ema_period: fast_ema,
321            slow_ema_period: slow_ema,
322            atr_period,
323        }
324    }
325
326    pub fn calculate_all(
327        &self,
328        close: &[f64],
329        high: &[f64],
330        low: &[f64],
331    ) -> Result<StrategyIndicators, IndicatorError> {
332        Ok(StrategyIndicators {
333            ema_fast: ema(close, self.fast_ema_period)?,
334            ema_slow: ema(close, self.slow_ema_period)?,
335            atr: atr(high, low, close, self.atr_period)?,
336        })
337    }
338}
339
340#[cfg(test)]
341mod tests {
342    use super::*;
343
344    #[test]
345    fn test_ema_sma_seed() {
346        let prices = vec![22.27, 22.19, 22.08, 22.17, 22.18];
347        let result = ema(&prices, 5).unwrap();
348        let expected = (22.27 + 22.19 + 22.08 + 22.17 + 22.18) / 5.0;
349        assert!((result[4] - expected).abs() < 1e-9);
350    }
351
352    #[test]
353    fn test_true_range_first() {
354        let h = vec![50.0, 52.0];
355        let l = vec![48.0, 49.0];
356        let c = vec![49.0, 51.0];
357        let tr = true_range(&h, &l, &c).unwrap();
358        assert_eq!(tr[0], 2.0);
359        assert_eq!(tr[1], 3.0);
360    }
361
362    #[test]
363    fn test_ema_incremental() {
364        let mut e = EMA::new(3);
365        e.update(10.0);
366        assert!(!e.is_ready());
367        e.update(20.0);
368        assert!(!e.is_ready());
369        e.update(30.0);
370        assert!(e.is_ready());
371        assert!((e.value() - 20.0).abs() < 1e-9);
372    }
373}