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//!
6//! # Incremental warm-up contract
7//!
8//! Every `Incremental*` struct has the same `update` shape: it returns
9//! `Option<T>`, where `None` means "no value is defined yet". Structs whose
10//! maths seeds from the first tick (EMA, ATR, MACD) return `Some` from the
11//! first call; the others return `None` until their warm-up completes:
12//!
13//! | Struct | `update` returns | First `Some` |
14//! |---|---|---|
15//! | [`IncrementalEma`] | `Option<f64>` | first tick (seeds from it) |
16//! | [`IncrementalAtr`] | `Option<f64>` | first tick (TR = high − low) |
17//! | [`IncrementalRsi`] | `Option<f64>` | second tick (needs a prior price) |
18//! | [`IncrementalMacd`] | `Option<(f64, f64, f64)>` | first tick (all EMAs seed from it) |
19//! | [`IncrementalBollinger`] | `Option<BollingerBandsValue>` | after `period` ticks |
20//! | [`EMA`] / [`ATR`] | `()` — read via `value()` / `is_ready()` | after `period` ticks (SMA seed) |
21//!
22//! Early EMA/MACD values are mathematically defined but still converging
23//! toward the batch equivalents (which warm up with an SMA seed or leading
24//! NaN); gate on your own bar count if you need fully-converged values.
25//!
26//! None of these structs validate their inputs: feeding NaN poisons the
27//! internal state (NaN propagates through every subsequent value) without
28//! panicking. Filter non-finite ticks upstream.
29
30use std::collections::VecDeque;
31
32use crate::error::IndicatorError;
33use crate::types::MacdResult;
34
35// ── Batch functions ───────────────────────────────────────────────────────────
36
37/// Exponential Moving Average over a price slice.
38/// Returns a Vec of the same length; leading values are `NaN` until warm-up.
39pub fn ema(prices: &[f64], period: usize) -> Result<Vec<f64>, IndicatorError> {
40    if period == 0 {
41        return Err(IndicatorError::InvalidParameter {
42            name: "period".into(),
43            value: 0.0,
44        });
45    }
46    if prices.len() < period {
47        return Err(IndicatorError::InsufficientData {
48            required: period,
49            available: prices.len(),
50        });
51    }
52    let mut result = vec![f64::NAN; prices.len()];
53    let alpha = 2.0 / (period as f64 + 1.0);
54    let first_sma: f64 = prices.iter().take(period).sum::<f64>() / period as f64;
55    result[period - 1] = first_sma;
56    for i in period..prices.len() {
57        result[i] = prices[i] * alpha + result[i - 1] * (1.0 - alpha);
58    }
59    Ok(result)
60}
61
62/// EMA that handles leading NaN values, matching Python's `ewm(adjust=False)` behaviour.
63///
64/// Unlike [`ema`], which seeds from the arithmetic mean of the first `period`
65/// values, this function seeds from the **first non-NaN value** and applies
66/// the recursive formula from that point on.  All positions before the first
67/// non-NaN value are left as `NaN`.
68///
69/// This is needed wherever EMA is applied to a derived series (e.g. the MACD
70/// line) that already has a leading NaN warm-up period.  Using the standard
71/// [`ema`] on such a series would propagate NaN through the SMA seed and
72/// produce an all-NaN output.
73pub fn ema_nan_aware(prices: &[f64], period: usize) -> Result<Vec<f64>, IndicatorError> {
74    if period == 0 {
75        return Err(IndicatorError::InvalidParameter {
76            name: "period".into(),
77            value: 0.0,
78        });
79    }
80    let mut result = vec![f64::NAN; prices.len()];
81    let alpha = 2.0 / (period as f64 + 1.0);
82
83    // Seed from the first non-NaN value (adjust=False, no SMA warm-up).
84    let Some(start) = prices.iter().position(|v| !v.is_nan()) else {
85        return Ok(result); // all NaN — nothing to compute
86    };
87
88    result[start] = prices[start];
89    for i in (start + 1)..prices.len() {
90        result[i] = if prices[i].is_nan() {
91            f64::NAN
92        } else {
93            prices[i] * alpha + result[i - 1] * (1.0 - alpha)
94        };
95    }
96    Ok(result)
97}
98
99/// Simple Moving Average over a price slice.
100pub fn sma(prices: &[f64], period: usize) -> Result<Vec<f64>, IndicatorError> {
101    if period == 0 {
102        return Err(IndicatorError::InvalidParameter {
103            name: "period".into(),
104            value: 0.0,
105        });
106    }
107    if prices.len() < period {
108        return Err(IndicatorError::InsufficientData {
109            required: period,
110            available: prices.len(),
111        });
112    }
113    let mut result = vec![f64::NAN; prices.len()];
114    for i in (period - 1)..prices.len() {
115        let sum: f64 = prices[(i + 1 - period)..=i].iter().sum();
116        result[i] = sum / period as f64;
117    }
118    Ok(result)
119}
120
121/// True Range = max(H-L, |H-prevC|, |L-prevC|).
122pub fn true_range(high: &[f64], low: &[f64], close: &[f64]) -> Result<Vec<f64>, IndicatorError> {
123    if high.len() != low.len() || high.len() != close.len() {
124        return Err(IndicatorError::InsufficientData {
125            required: high.len(),
126            available: low.len().min(close.len()),
127        });
128    }
129    let mut result = vec![f64::NAN; high.len()];
130    if !high.is_empty() {
131        result[0] = high[0] - low[0];
132    }
133    for i in 1..high.len() {
134        let tr1 = high[i] - low[i];
135        let tr2 = (high[i] - close[i - 1]).abs();
136        let tr3 = (low[i] - close[i - 1]).abs();
137        result[i] = tr1.max(tr2).max(tr3);
138    }
139    Ok(result)
140}
141
142/// Average True Range (EMA-smoothed).
143///
144/// Note: smoothing is a plain EMA (`span = period`, Python-parity with
145/// `tr.ewm(...).mean()`), **not** Wilder's RMA (`alpha = 1/period`). The two
146/// differ by their decay constant, so values will not match a Wilder-style ATR.
147pub fn atr(
148    high: &[f64],
149    low: &[f64],
150    close: &[f64],
151    period: usize,
152) -> Result<Vec<f64>, IndicatorError> {
153    let tr = true_range(high, low, close)?;
154    ema(&tr, period)
155}
156
157/// Relative Strength Index.
158pub fn rsi(prices: &[f64], period: usize) -> Result<Vec<f64>, IndicatorError> {
159    if prices.len() < period + 1 {
160        return Err(IndicatorError::InsufficientData {
161            required: period + 1,
162            available: prices.len(),
163        });
164    }
165    let mut result = vec![f64::NAN; prices.len()];
166    let mut gains = vec![0.0; prices.len()];
167    let mut losses = vec![0.0; prices.len()];
168    for i in 1..prices.len() {
169        let change = prices[i] - prices[i - 1];
170        if change > 0.0 {
171            gains[i] = change;
172        } else {
173            losses[i] = -change;
174        }
175    }
176    let avg_gains = ema(&gains, period)?;
177    let avg_losses = ema(&losses, period)?;
178    for i in period..prices.len() {
179        if avg_losses[i] == 0.0 {
180            result[i] = 100.0;
181        } else {
182            let rs = avg_gains[i] / avg_losses[i];
183            result[i] = 100.0 - (100.0 / (1.0 + rs));
184        }
185    }
186    Ok(result)
187}
188
189/// MACD — returns (macd_line, signal_line, histogram).
190pub fn macd(
191    prices: &[f64],
192    fast_period: usize,
193    slow_period: usize,
194    signal_period: usize,
195) -> MacdResult {
196    // Use ema_nan_aware to match Python's ewm(span=X, adjust=False), which
197    // seeds from the first value rather than an SMA of the first `period` bars.
198    let fast_ema = ema_nan_aware(prices, fast_period)?;
199    let slow_ema = ema_nan_aware(prices, slow_period)?;
200    let mut macd_line = vec![f64::NAN; prices.len()];
201    for i in 0..prices.len() {
202        if !fast_ema[i].is_nan() && !slow_ema[i].is_nan() {
203            macd_line[i] = fast_ema[i] - slow_ema[i];
204        }
205    }
206    // The macd_line has leading NaN (warm-up from the slow EMA); use the
207    // NaN-aware variant so the signal seeds from the first valid MACD value
208    // rather than an all-NaN SMA, matching Python's ewm(adjust=False).
209    let signal_line = ema_nan_aware(&macd_line, signal_period)?;
210    let mut histogram = vec![f64::NAN; prices.len()];
211    for i in 0..prices.len() {
212        if !macd_line[i].is_nan() && !signal_line[i].is_nan() {
213            histogram[i] = macd_line[i] - signal_line[i];
214        }
215    }
216    Ok((macd_line, signal_line, histogram))
217}
218
219// ── Incremental structs ───────────────────────────────────────────────────────
220
221/// Incremental EMA — O(1) update, SMA warm-up.
222///
223/// Unlike the batch [`ema`] function (which initialises from an SMA over the
224/// first `period` prices), this struct emits its first value *after* it has
225/// accumulated exactly `period` samples and seeds itself from their average.
226/// Both approaches are correct; this one is more natural for streaming use.
227#[derive(Debug, Clone)]
228pub struct EMA {
229    period: usize,
230    alpha: f64,
231    value: f64,
232    initialized: bool,
233    warmup: VecDeque<f64>,
234}
235
236impl EMA {
237    pub fn new(period: usize) -> Self {
238        Self {
239            period,
240            alpha: 2.0 / (period as f64 + 1.0),
241            value: 0.0,
242            initialized: false,
243            warmup: VecDeque::with_capacity(period),
244        }
245    }
246
247    pub fn update(&mut self, price: f64) {
248        if !self.initialized {
249            self.warmup.push_back(price);
250            if self.warmup.len() >= self.period {
251                self.value = self.warmup.iter().sum::<f64>() / self.period as f64;
252                self.initialized = true;
253                self.warmup.clear();
254            }
255        } else {
256            self.value = price * self.alpha + self.value * (1.0 - self.alpha);
257        }
258    }
259
260    pub fn value(&self) -> f64 {
261        if self.initialized {
262            self.value
263        } else {
264            f64::NAN
265        }
266    }
267
268    pub fn is_ready(&self) -> bool {
269        self.initialized
270    }
271
272    pub fn reset(&mut self) {
273        self.value = 0.0;
274        self.initialized = false;
275        self.warmup.clear();
276    }
277}
278
279/// Incremental Wilder ATR.
280#[derive(Debug, Clone)]
281pub struct ATR {
282    #[allow(dead_code)]
283    period: usize,
284    ema: EMA,
285    prev_close: Option<f64>,
286}
287
288impl ATR {
289    pub fn new(period: usize) -> Self {
290        Self {
291            period,
292            ema: EMA::new(period),
293            prev_close: None,
294        }
295    }
296
297    pub fn update(&mut self, high: f64, low: f64, close: f64) {
298        let tr = if let Some(prev) = self.prev_close {
299            (high - low)
300                .max((high - prev).abs())
301                .max((low - prev).abs())
302        } else {
303            high - low
304        };
305        self.ema.update(tr);
306        self.prev_close = Some(close);
307    }
308
309    pub fn value(&self) -> f64 {
310        self.ema.value()
311    }
312
313    pub fn is_ready(&self) -> bool {
314        self.ema.is_ready()
315    }
316}
317
318/// Incremental Wilder RSI — the streaming counterpart of [`rsi`], in the same
319/// `update` / `value` / `is_ready` shape as [`EMA`] and [`ATR`] (this is the
320/// crate's top-level `RSI` export, so the three read uniformly: `value()` is an
321/// `f64`, NaN until warm). Seeds the average gain/loss over the first `period`
322/// deltas (needs `period + 1` prices), then Wilder-smooths — matching [`rsi`]
323/// and TA-Lib / TradingView.
324#[derive(Debug, Clone)]
325pub struct RSI {
326    period: usize,
327    prev: Option<f64>,
328    avg_gain: f64,
329    avg_loss: f64,
330    seeded: usize,
331    initialized: bool,
332    value: f64,
333}
334
335impl RSI {
336    pub fn new(period: usize) -> Self {
337        Self {
338            period: period.max(1),
339            prev: None,
340            avg_gain: 0.0,
341            avg_loss: 0.0,
342            seeded: 0,
343            initialized: false,
344            value: f64::NAN,
345        }
346    }
347
348    pub fn update(&mut self, price: f64) {
349        let Some(prev) = self.prev else {
350            self.prev = Some(price);
351            return;
352        };
353        let delta = price - prev;
354        let gain = delta.max(0.0);
355        let loss = (-delta).max(0.0);
356        self.prev = Some(price);
357
358        if !self.initialized {
359            // Seed window: simple mean of the first `period` deltas.
360            self.avg_gain += gain;
361            self.avg_loss += loss;
362            self.seeded += 1;
363            if self.seeded >= self.period {
364                self.avg_gain /= self.period as f64;
365                self.avg_loss /= self.period as f64;
366                self.initialized = true;
367                self.value = rsi_from(self.avg_gain, self.avg_loss);
368            }
369        } else {
370            let w = (self.period - 1) as f64;
371            self.avg_gain = (self.avg_gain * w + gain) / self.period as f64;
372            self.avg_loss = (self.avg_loss * w + loss) / self.period as f64;
373            self.value = rsi_from(self.avg_gain, self.avg_loss);
374        }
375    }
376
377    pub fn value(&self) -> f64 {
378        self.value
379    }
380
381    pub fn is_ready(&self) -> bool {
382        self.initialized
383    }
384
385    pub fn reset(&mut self) {
386        *self = Self::new(self.period);
387    }
388}
389
390/// `RSI = 100 − 100 / (1 + avg_gain/avg_loss)`, with the zero-division edges
391/// matching [`rsi`] (no movement → 50, only gains → 100).
392#[inline]
393fn rsi_from(avg_gain: f64, avg_loss: f64) -> f64 {
394    if avg_loss == 0.0 {
395        if avg_gain == 0.0 { 50.0 } else { 100.0 }
396    } else {
397        100.0 - 100.0 / (1.0 + avg_gain / avg_loss)
398    }
399}
400
401#[cfg(test)]
402mod rsi_inc_tests {
403    use super::*;
404
405    #[test]
406    fn warmup_then_matches_wilder_batch() {
407        use crate::indicator::Indicator;
408        use crate::momentum::Rsi;
409        use crate::types::Candle;
410        let prices: Vec<f64> = (0..40).map(|i| 100.0 + (i as f64 * 0.4).sin() * 8.0).collect();
411        let candles: Vec<Candle> = prices
412            .iter()
413            .enumerate()
414            .map(|(i, &c)| Candle { time: i as i64, open: c, high: c, low: c, close: c, volume: 1.0 })
415            .collect();
416        // Matches the standard Wilder batch (momentum::Rsi), not the EMA-smoothed
417        // functions::rsi.
418        let batch = Rsi::with_period(14).calculate(&candles).unwrap();
419        let batch_vals = batch.get("RSI_14").unwrap();
420
421        let mut inc = RSI::new(14);
422        for (i, &p) in prices.iter().enumerate() {
423            inc.update(p);
424            if inc.is_ready() {
425                assert!(
426                    (inc.value() - batch_vals[i]).abs() < 1e-9,
427                    "i={i}: {} vs {}",
428                    inc.value(),
429                    batch_vals[i]
430                );
431            } else {
432                assert!(inc.value().is_nan());
433            }
434        }
435    }
436
437    #[test]
438    fn constant_gains_is_100() {
439        let mut inc = RSI::new(5);
440        for i in 0..10 {
441            inc.update(i as f64);
442        }
443        assert!((inc.value() - 100.0).abs() < 1e-9);
444    }
445}
446
447/// Bundle of per-strategy indicator series.
448#[derive(Debug, Clone)]
449pub struct StrategyIndicators {
450    pub ema_fast: Vec<f64>,
451    pub ema_slow: Vec<f64>,
452    pub atr: Vec<f64>,
453}
454
455/// Multi-period indicator calculator (batch mode).
456#[derive(Debug, Clone)]
457pub struct IndicatorCalculator {
458    pub fast_ema_period: usize,
459    pub slow_ema_period: usize,
460    pub atr_period: usize,
461}
462
463impl Default for IndicatorCalculator {
464    fn default() -> Self {
465        Self {
466            fast_ema_period: 8,
467            slow_ema_period: 21,
468            atr_period: 14,
469        }
470    }
471}
472
473impl IndicatorCalculator {
474    pub fn new(fast_ema: usize, slow_ema: usize, atr_period: usize) -> Self {
475        Self {
476            fast_ema_period: fast_ema,
477            slow_ema_period: slow_ema,
478            atr_period,
479        }
480    }
481
482    pub fn calculate_all(
483        &self,
484        close: &[f64],
485        high: &[f64],
486        low: &[f64],
487    ) -> Result<StrategyIndicators, IndicatorError> {
488        Ok(StrategyIndicators {
489            ema_fast: ema(close, self.fast_ema_period)?,
490            ema_slow: ema(close, self.slow_ema_period)?,
491            atr: atr(high, low, close, self.atr_period)?,
492        })
493    }
494}
495
496/// Incremental EMA — O(1) update per tick that returns the new value each call.
497///
498/// Unlike [`EMA`] (which separates `update` from `value`/`is_ready`), this
499/// seeds from the first sample and returns the EMA on every `update`, which
500/// suits streaming pipelines that consume the value inline.
501#[derive(Debug, Clone)]
502pub struct IncrementalEma {
503    alpha: f64,
504    state: f64,
505    initialized: bool,
506}
507
508impl IncrementalEma {
509    /// Create an incremental EMA for the given period.
510    pub fn new(period: usize) -> Self {
511        Self {
512            alpha: 2.0 / (period as f64 + 1.0),
513            state: 0.0,
514            initialized: false,
515        }
516    }
517
518    /// Feed the next price; returns the updated EMA. Always `Some` — the EMA
519    /// seeds from the first price — but `Option`-shaped to match the warm-up
520    /// contract shared by all incremental structs.
521    pub fn update(&mut self, price: f64) -> Option<f64> {
522        Some(self.step(price))
523    }
524
525    /// Internal non-optional update for composing structs (RSI, MACD, ATR).
526    fn step(&mut self, price: f64) -> f64 {
527        if !self.initialized {
528            self.state = price;
529            self.initialized = true;
530        } else {
531            self.state = self.alpha * price + (1.0 - self.alpha) * self.state;
532        }
533        self.state
534    }
535
536    /// Current EMA value, or `None` before the first `update`.
537    pub fn current(&self) -> Option<f64> {
538        if self.initialized {
539            Some(self.state)
540        } else {
541            None
542        }
543    }
544}
545
546/// Incremental ATR — O(1) per-tick true-range EMA.
547///
548/// Wraps an [`IncrementalEma`] over the true range and returns the smoothed
549/// ATR on each `update`. The first sample's true range is `high - low`.
550pub struct IncrementalAtr {
551    ema: IncrementalEma,
552    prev_close: Option<f64>,
553}
554
555impl IncrementalAtr {
556    /// Create an incremental ATR for the given period.
557    pub fn new(period: usize) -> Self {
558        Self {
559            ema: IncrementalEma::new(period),
560            prev_close: None,
561        }
562    }
563
564    /// Feed the next high/low/close; returns the updated ATR.
565    pub fn update(&mut self, high: f64, low: f64, close: f64) -> Option<f64> {
566        let tr = if let Some(prev) = self.prev_close {
567            let tr1 = high - low;
568            let tr2 = (high - prev).abs();
569            let tr3 = (low - prev).abs();
570            tr1.max(tr2).max(tr3)
571        } else {
572            high - low
573        };
574
575        self.prev_close = Some(close);
576        Some(self.ema.step(tr))
577    }
578}
579
580/// Incremental RSI — O(1) per-tick, EMA-smoothed gains/losses.
581///
582/// Mirrors the batch [`rsi`]: the average gain and loss are tracked with an
583/// [`IncrementalEma`] of the given period and combined as
584/// `100 - 100 / (1 + avg_gain / avg_loss)` (RSI is 100 when the average loss is
585/// zero). Like the other incremental structs it seeds from the first sample
586/// (vs. the batch SMA warm-up), so warm-up values differ slightly from [`rsi`]
587/// but converge — both are valid.
588#[derive(Debug, Clone)]
589pub struct IncrementalRsi {
590    gain_ema: IncrementalEma,
591    loss_ema: IncrementalEma,
592    prev_price: Option<f64>,
593}
594
595impl IncrementalRsi {
596    /// Create an incremental RSI for the given period.
597    pub fn new(period: usize) -> Self {
598        Self {
599            gain_ema: IncrementalEma::new(period),
600            loss_ema: IncrementalEma::new(period),
601            prev_price: None,
602        }
603    }
604
605    /// Feed the next price; returns the updated RSI, or `None` for the very
606    /// first sample (RSI needs a prior price to form the first change).
607    pub fn update(&mut self, price: f64) -> Option<f64> {
608        let prev = self.prev_price.replace(price)?;
609        let change = price - prev;
610        let (gain, loss) = if change > 0.0 {
611            (change, 0.0)
612        } else {
613            (0.0, -change)
614        };
615        let avg_gain = self.gain_ema.step(gain);
616        let avg_loss = self.loss_ema.step(loss);
617        let rsi = if avg_loss == 0.0 {
618            100.0
619        } else {
620            let rs = avg_gain / avg_loss;
621            100.0 - 100.0 / (1.0 + rs)
622        };
623        Some(rsi)
624    }
625}
626
627/// Incremental MACD — O(1) per-tick MACD line, signal, and histogram.
628///
629/// Mirrors the batch [`macd`]: a fast and slow [`IncrementalEma`] give the MACD
630/// line (`fast - slow`), a third EMA over that line is the signal, and the
631/// histogram is `macd - signal`. The EMAs seed from the first sample (matching
632/// the batch's `adjust=false` / first-value seeding via `ema_nan_aware`), so
633/// values are emitted from the first tick rather than after a NaN warm-up.
634#[derive(Debug, Clone)]
635pub struct IncrementalMacd {
636    fast: IncrementalEma,
637    slow: IncrementalEma,
638    signal: IncrementalEma,
639}
640
641impl IncrementalMacd {
642    /// Create an incremental MACD from the fast, slow, and signal periods.
643    pub fn new(fast_period: usize, slow_period: usize, signal_period: usize) -> Self {
644        Self {
645            fast: IncrementalEma::new(fast_period),
646            slow: IncrementalEma::new(slow_period),
647            signal: IncrementalEma::new(signal_period),
648        }
649    }
650
651    /// Feed the next price; returns `(macd, signal, histogram)`. Always `Some`
652    /// — every EMA seeds from the first price — but `Option`-shaped to match
653    /// the warm-up contract shared by all incremental structs.
654    pub fn update(&mut self, price: f64) -> Option<(f64, f64, f64)> {
655        let macd = self.fast.step(price) - self.slow.step(price);
656        let signal = self.signal.step(macd);
657        Some((macd, signal, macd - signal))
658    }
659}
660
661/// Per-tick Bollinger Bands output (see [`IncrementalBollinger`]).
662#[derive(Debug, Clone, Copy, PartialEq)]
663pub struct BollingerBandsValue {
664    /// Middle band — the `period`-SMA.
665    pub middle: f64,
666    /// Upper band — `middle + std_mult * std`.
667    pub upper: f64,
668    /// Lower band — `middle - std_mult * std`.
669    pub lower: f64,
670    /// `(upper - lower) / middle`, or `NaN` when `middle` is 0.
671    pub bandwidth: f64,
672    /// `%b` — `(price - lower) / (upper - lower)`, or `NaN` for a zero-width band.
673    pub percent_b: f64,
674}
675
676/// Incremental Bollinger Bands — per-tick bands over a rolling window.
677///
678/// Mirrors the batch [`BollingerBands`](crate::volatility::BollingerBands):
679/// `middle` is the `period`-SMA, the deviation is the **sample** standard
680/// deviation (ddof = 1) over the window, and `upper`/`lower` are
681/// `middle ± std_mult * std` (2.0 is the common multiplier). Emits `None` until
682/// `period` samples are buffered. Unlike the EMA-based incremental structs this
683/// keeps a `period`-length window, so each `update` is O(period), not O(1).
684#[derive(Debug, Clone)]
685pub struct IncrementalBollinger {
686    window: VecDeque<f64>,
687    period: usize,
688    std_mult: f64,
689}
690
691impl IncrementalBollinger {
692    /// Create incremental Bollinger Bands for the given period and band
693    /// multiplier (`std_mult`, conventionally 2.0).
694    pub fn new(period: usize, std_mult: f64) -> Self {
695        Self {
696            window: VecDeque::with_capacity(period.max(1)),
697            period,
698            std_mult,
699        }
700    }
701
702    /// Feed the next price; returns the bands once `period` samples are buffered
703    /// (and `period >= 2`, so the sample stddev is defined).
704    pub fn update(&mut self, price: f64) -> Option<BollingerBandsValue> {
705        self.window.push_back(price);
706        if self.window.len() > self.period {
707            self.window.pop_front();
708        }
709        if self.window.len() < self.period || self.period < 2 {
710            return None;
711        }
712
713        let mean: f64 = self.window.iter().sum::<f64>() / self.period as f64;
714        // Sample variance (ddof = 1), matching the batch `rolling_std`.
715        let var: f64 =
716            self.window.iter().map(|&x| (x - mean).powi(2)).sum::<f64>() / (self.period - 1) as f64;
717        let std = var.sqrt();
718
719        let upper = mean + self.std_mult * std;
720        let lower = mean - self.std_mult * std;
721        let bandwidth = if mean == 0.0 {
722            f64::NAN
723        } else {
724            (upper - lower) / mean
725        };
726        let band_range = upper - lower;
727        let percent_b = if band_range == 0.0 {
728            f64::NAN
729        } else {
730            (price - lower) / band_range
731        };
732
733        Some(BollingerBandsValue {
734            middle: mean,
735            upper,
736            lower,
737            bandwidth,
738            percent_b,
739        })
740    }
741}
742
743#[cfg(test)]
744mod tests {
745    use super::*;
746
747    #[test]
748    fn test_incremental_bollinger_warmup_then_value() {
749        let mut bb = IncrementalBollinger::new(5, 2.0);
750        for p in [10.0, 11.0, 12.0, 13.0] {
751            assert!(bb.update(p).is_none(), "no value before `period` samples");
752        }
753        assert!(bb.update(14.0).is_some(), "value once the window is full");
754    }
755
756    #[test]
757    fn test_incremental_bollinger_constant_prices_zero_width() {
758        let mut bb = IncrementalBollinger::new(4, 2.0);
759        let mut last = None;
760        for _ in 0..4 {
761            last = bb.update(10.0);
762        }
763        let v = last.unwrap();
764        assert!((v.middle - 10.0).abs() < 1e-12);
765        assert!((v.upper - 10.0).abs() < 1e-12);
766        assert!((v.lower - 10.0).abs() < 1e-12);
767        assert!((v.bandwidth - 0.0).abs() < 1e-12);
768        assert!(v.percent_b.is_nan(), "zero-width band → %b undefined");
769    }
770
771    #[test]
772    fn test_incremental_bollinger_matches_sample_stddev() {
773        // window [2,4,4,4,5,5,7,9]: mean 5, sample variance 32/7, std ≈ 2.138.
774        let mut bb = IncrementalBollinger::new(8, 2.0);
775        let mut last = None;
776        for p in [2.0, 4.0, 4.0, 4.0, 5.0, 5.0, 7.0, 9.0] {
777            last = bb.update(p);
778        }
779        let v = last.unwrap();
780        let std = (32.0_f64 / 7.0).sqrt();
781        assert!((v.middle - 5.0).abs() < 1e-9);
782        assert!((v.upper - (5.0 + 2.0 * std)).abs() < 1e-9);
783        assert!((v.lower - (5.0 - 2.0 * std)).abs() < 1e-9);
784    }
785
786    #[test]
787    fn test_incremental_rsi_first_sample_is_none() {
788        let mut rsi = IncrementalRsi::new(14);
789        assert_eq!(rsi.update(10.0), None);
790        assert!(rsi.update(11.0).is_some());
791    }
792
793    #[test]
794    fn test_incremental_rsi_all_gains_saturates_at_100() {
795        let mut rsi = IncrementalRsi::new(14);
796        let mut last = None;
797        for p in [10.0, 11.0, 12.0, 13.0, 14.0, 15.0] {
798            last = rsi.update(p);
799        }
800        // Monotonically rising → average loss is zero → RSI saturates at 100.
801        assert!((last.unwrap() - 100.0).abs() < 1e-9);
802    }
803
804    #[test]
805    fn test_incremental_rsi_stays_in_bounds() {
806        let mut rsi = IncrementalRsi::new(5);
807        let prices = [44.0, 44.3, 44.1, 44.2, 43.6, 44.3, 44.8, 45.0, 44.7, 44.9];
808        let mut produced = 0;
809        for p in prices {
810            if let Some(v) = rsi.update(p) {
811                assert!((0.0..=100.0).contains(&v), "RSI out of bounds: {v}");
812                produced += 1;
813            }
814        }
815        assert_eq!(produced, prices.len() - 1);
816    }
817
818    #[test]
819    fn test_incremental_macd_composes_like_batch() {
820        let mut m = IncrementalMacd::new(12, 26, 9);
821        let mut fast = IncrementalEma::new(12);
822        let mut slow = IncrementalEma::new(26);
823        let mut sig = IncrementalEma::new(9);
824        for p in [10.0, 11.0, 10.5, 12.0, 13.0, 12.5, 11.0, 11.5] {
825            let (macd, signal, hist) = m.update(p).unwrap();
826            let expect_macd = fast.update(p).unwrap() - slow.update(p).unwrap();
827            let expect_sig = sig.update(expect_macd).unwrap();
828            assert!((macd - expect_macd).abs() < 1e-12);
829            assert!((signal - expect_sig).abs() < 1e-12);
830            assert!((hist - (expect_macd - expect_sig)).abs() < 1e-12);
831        }
832    }
833
834    #[test]
835    fn test_ema_sma_seed() {
836        let prices = vec![22.27, 22.19, 22.08, 22.17, 22.18];
837        let result = ema(&prices, 5).unwrap();
838        let expected = (22.27 + 22.19 + 22.08 + 22.17 + 22.18) / 5.0;
839        assert!((result[4] - expected).abs() < 1e-9);
840    }
841
842    #[test]
843    fn test_true_range_first() {
844        let h = vec![50.0, 52.0];
845        let l = vec![48.0, 49.0];
846        let c = vec![49.0, 51.0];
847        let tr = true_range(&h, &l, &c).unwrap();
848        assert_eq!(tr[0], 2.0);
849        assert_eq!(tr[1], 3.0);
850    }
851
852    #[test]
853    fn test_ema_incremental() {
854        let mut e = EMA::new(3);
855        e.update(10.0);
856        assert!(!e.is_ready());
857        e.update(20.0);
858        assert!(!e.is_ready());
859        e.update(30.0);
860        assert!(e.is_ready());
861        assert!((e.value() - 20.0).abs() < 1e-9);
862    }
863
864    #[test]
865    fn test_incremental_ema_returns_value() {
866        let mut e = IncrementalEma::new(3); // alpha = 0.5
867        assert_eq!(e.current(), None);
868        assert_eq!(e.update(10.0), Some(10.0)); // seeds from first sample
869        assert_eq!(e.current(), Some(10.0));
870        let v = e.update(20.0).unwrap(); // 0.5*20 + 0.5*10
871        assert!((v - 15.0).abs() < 1e-9);
872    }
873
874    #[test]
875    fn test_incremental_atr_first_is_range() {
876        let mut a = IncrementalAtr::new(3);
877        // First sample: TR = high - low, EMA seeds to it.
878        assert_eq!(a.update(12.0, 10.0, 11.0), Some(2.0));
879    }
880}