Skip to main content

kestrel_chartkit/indicator/
smoothing.rs

1use std::collections::VecDeque;
2
3#[cfg(feature = "serde")]
4use serde::{Deserialize, Serialize};
5
6/// How an [`Ema`] produces its first value.
7///
8/// The recurrence `alpha * src + (1 - alpha) * prev` with `alpha = 2/(len+1)` is the same in both
9/// modes; only the value it starts from differs, and with it how long the series carries the
10/// influence of that start. At `len == 1` (`alpha == 1`) both modes produce the same values.
11#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
12#[cfg_attr(
13    feature = "serde",
14    derive(Serialize, Deserialize),
15    serde(rename_all = "snake_case")
16)]
17pub enum EmaInit {
18    /// Seeds with the input value itself and emits from the first sample on. The default, and the
19    /// historical behaviour of this type.
20    #[default]
21    FirstSample,
22    /// Seeds with the SMA of the first `len` samples and emits from the `len`-th sample on.
23    /// Earlier samples produce no value — a partially accumulated average is not an EMA.
24    Sma,
25}
26
27/// Exponential moving average over a scalar stream.
28///
29/// `EMA_t = alpha * src_t + (1 - alpha) * EMA_{t-1}` with `alpha = 2/(len + 1)`; the first value
30/// comes from [`EmaInit`]. Returns `None` while the seed is not ready, which in the default
31/// [`EmaInit::FirstSample`] mode never happens: there the very first sample is already the seed.
32///
33/// [`Ema::reset`] clears the state and any partially accumulated seed, so the next series starts
34/// deterministically.
35#[derive(Debug, Clone, Copy, Default)]
36pub struct Ema {
37    len: usize,
38    init: EmaInit,
39    state: Option<f64>,
40    seed_sum: f64,
41    seed_count: usize,
42}
43
44impl Ema {
45    pub fn new(len: usize) -> Self {
46        Self {
47            len,
48            init: EmaInit::FirstSample,
49            state: None,
50            seed_sum: 0.0,
51            seed_count: 0,
52        }
53    }
54
55    /// Selects the initialisation; see [`EmaInit`].
56    ///
57    /// Additive to [`Ema::new`], which keeps the first-sample seed. Nested users of this type
58    /// (MACD, TEMA, ...) are deliberately not switched over by this: a seed change inside a chain
59    /// is a separate contract question per indicator.
60    pub fn with_init(mut self, init: EmaInit) -> Self {
61        self.init = init;
62        self
63    }
64
65    pub fn init(&self) -> EmaInit {
66        self.init
67    }
68
69    pub fn update(&mut self, src: f64) -> Option<f64> {
70        let alpha = 2.0 / (self.len as f64 + 1.0);
71        if let Some(prev) = self.state {
72            let next = alpha * src + (1.0 - alpha) * prev;
73            self.state = Some(next);
74            return Some(next);
75        }
76
77        let seed = match self.init {
78            EmaInit::FirstSample => src,
79            EmaInit::Sma => {
80                self.seed_sum += src;
81                self.seed_count += 1;
82                if self.seed_count < self.len {
83                    return None;
84                }
85                self.seed_sum / self.len as f64
86            }
87        };
88        self.state = Some(seed);
89        Some(seed)
90    }
91
92    /// Bars needed before [`Ema::update`] first returns `Some`.
93    pub fn warmup_period(&self) -> usize {
94        match self.init {
95            EmaInit::FirstSample => 0,
96            EmaInit::Sma => self.len,
97        }
98    }
99
100    pub fn reset(&mut self) {
101        self.state = None;
102        self.seed_sum = 0.0;
103        self.seed_count = 0;
104    }
105}
106
107/// Weighted moving average for scalar streams: weight `len` on the most recent sample
108#[derive(Debug, Clone)]
109pub struct Wma {
110    len: usize,
111    window: VecDeque<f64>,
112}
113
114impl Wma {
115    pub fn new(len: usize) -> Self {
116        Self {
117            len: len.max(1),
118            window: VecDeque::with_capacity(len),
119        }
120    }
121
122    pub fn update(&mut self, src: f64) -> Option<f64> {
123        self.window.push_back(src);
124        if self.window.len() > self.len {
125            self.window.pop_front();
126        }
127        if self.window.len() < self.len {
128            return None;
129        }
130
131        let denom = (self.len * (self.len + 1)) as f64 / 2.0;
132        let mut sum = 0.0;
133        for (i, &val) in self.window.iter().enumerate() {
134            sum += val * (i + 1) as f64;
135        }
136        Some(sum / denom)
137    }
138
139    pub fn reset(&mut self) {
140        self.window.clear();
141    }
142}
143
144/// Wilder smoothing (`alpha = 1/len`): seeds with the SMA of the first `len` samples
145#[derive(Debug, Clone)]
146pub struct Rma {
147    len: usize,
148    seed: VecDeque<f64>,
149    state: Option<f64>,
150}
151
152impl Rma {
153    pub fn new(len: usize) -> Self {
154        Self {
155            len,
156            seed: VecDeque::with_capacity(len),
157            state: None,
158        }
159    }
160
161    pub fn update(&mut self, src: f64) -> Option<f64> {
162        if let Some(prev) = self.state {
163            let alpha = 1.0 / self.len as f64;
164            let next = alpha * src + (1.0 - alpha) * prev;
165            self.state = Some(next);
166            return Some(next);
167        }
168        self.seed.push_back(src);
169        if self.seed.len() < self.len {
170            return None;
171        }
172        let sma = self.seed.iter().sum::<f64>() / self.len as f64;
173        self.state = Some(sma);
174        Some(sma)
175    }
176
177    pub fn reset(&mut self) {
178        self.seed.clear();
179        self.state = None;
180    }
181}
182
183/// Plain windowed average
184#[derive(Debug, Clone)]
185pub struct Sma {
186    len: usize,
187    window: VecDeque<f64>,
188    sum: f64,
189}
190
191impl Sma {
192    pub fn new(len: usize) -> Self {
193        Self {
194            len,
195            window: VecDeque::with_capacity(len),
196            sum: 0.0,
197        }
198    }
199
200    pub fn update(&mut self, src: f64) -> Option<f64> {
201        self.window.push_back(src);
202        self.sum += src;
203        if self.window.len() > self.len {
204            self.sum -= self.window.pop_front().unwrap();
205        }
206        if self.window.len() < self.len {
207            return None;
208        }
209        Some(self.sum / self.len as f64)
210    }
211
212    pub fn reset(&mut self) {
213        self.window.clear();
214        self.sum = 0.0;
215    }
216}
217
218/// Rolling window for finding highest and lowest values
219#[derive(Debug, Clone)]
220pub struct ExtremeWindow {
221    len: usize,
222    window: VecDeque<f64>,
223}
224
225impl ExtremeWindow {
226    pub fn new(len: usize) -> Self {
227        Self {
228            len,
229            window: VecDeque::with_capacity(len),
230        }
231    }
232
233    pub fn push(&mut self, value: f64) -> Option<(f64, f64)> {
234        if self.window.len() == self.len {
235            self.window.pop_front();
236        }
237        self.window.push_back(value);
238        if self.window.len() < self.len {
239            return None;
240        }
241        let lowest = self.window.iter().cloned().fold(f64::INFINITY, f64::min);
242        let highest = self
243            .window
244            .iter()
245            .cloned()
246            .fold(f64::NEG_INFINITY, f64::max);
247        Some((lowest, highest))
248    }
249
250    pub fn reset(&mut self) {
251        self.window.clear();
252    }
253}
254
255pub fn crossed_over(prev_a: f64, prev_b: f64, a: f64, b: f64) -> bool {
256    prev_a <= prev_b && a > b
257}
258
259pub fn crossed_under(prev_a: f64, prev_b: f64, a: f64, b: f64) -> bool {
260    prev_a >= prev_b && a < b
261}
262
263#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
264#[cfg_attr(
265    feature = "serde",
266    derive(Serialize, Deserialize),
267    serde(rename_all = "snake_case")
268)]
269pub enum SmootherKind {
270    #[default]
271    Ema,
272    Sma,
273    Rma,
274    Alma,
275    Jma,
276    SuperSmoother,
277    Kama,
278}
279
280impl SmootherKind {
281    /// Builds a boxed [`Smoother`] of this kind with `len` bars of lookback/decay, using common
282    /// defaults for `Alma` (`offset = 0.85`, `sigma = 6.0`), `Jma` (`phase = 0.0`,
283    /// `power = 2.0`) and `Kama` (Kaufman's own `fast_period = 2`, `slow_period = 30`). Use the
284    /// concrete constructors directly to override those.
285    pub fn build(self, len: usize) -> Box<dyn Smoother> {
286        match self {
287            SmootherKind::Ema => Box::new(Ema::new(len)),
288            SmootherKind::Sma => Box::new(Sma::new(len)),
289            SmootherKind::Rma => Box::new(Rma::new(len)),
290            SmootherKind::Alma => Box::new(Alma::new(len, 0.85, 6.0)),
291            SmootherKind::Jma => Box::new(Jma::new(len, 0.0, 2.0)),
292            SmootherKind::SuperSmoother => Box::new(SuperSmoother::new(len)),
293            SmootherKind::Kama => Box::new(Kama::with_defaults(len)),
294        }
295    }
296}
297
298/// Common contract for streaming smoothers, letting them be chained ([`SmootherChain`]) or
299/// selected dynamically ([`SmootherKind::build`]) regardless of concrete type.
300pub trait Smoother: Send + Sync {
301    /// Feeds one value. Returns `None` while still inside this smoother's own warmup.
302    fn update(&mut self, src: f64) -> Option<f64>;
303    fn reset(&mut self);
304    /// Bars needed before this smoother first returns `Some`. `0` for smoothers that emit from
305    /// the first sample (`Ema`, `Jma`).
306    fn warmup_period(&self) -> usize {
307        0
308    }
309}
310
311impl Smoother for Ema {
312    fn update(&mut self, src: f64) -> Option<f64> {
313        Ema::update(self, src)
314    }
315    fn reset(&mut self) {
316        Ema::reset(self)
317    }
318    fn warmup_period(&self) -> usize {
319        Ema::warmup_period(self)
320    }
321}
322
323impl Smoother for Sma {
324    fn update(&mut self, src: f64) -> Option<f64> {
325        Sma::update(self, src)
326    }
327    fn reset(&mut self) {
328        Sma::reset(self)
329    }
330    fn warmup_period(&self) -> usize {
331        self.len
332    }
333}
334
335impl Smoother for Rma {
336    fn update(&mut self, src: f64) -> Option<f64> {
337        Rma::update(self, src)
338    }
339    fn reset(&mut self) {
340        Rma::reset(self)
341    }
342    fn warmup_period(&self) -> usize {
343        self.len
344    }
345}
346
347impl Smoother for Wma {
348    fn update(&mut self, src: f64) -> Option<f64> {
349        Wma::update(self, src)
350    }
351    fn reset(&mut self) {
352        Wma::reset(self)
353    }
354    fn warmup_period(&self) -> usize {
355        self.len
356    }
357}
358
359impl Smoother for Alma {
360    fn update(&mut self, src: f64) -> Option<f64> {
361        Alma::update(self, src)
362    }
363    fn reset(&mut self) {
364        Alma::reset(self)
365    }
366    fn warmup_period(&self) -> usize {
367        self.len
368    }
369}
370
371impl Smoother for Jma {
372    fn update(&mut self, src: f64) -> Option<f64> {
373        Some(Jma::update(self, src))
374    }
375    fn reset(&mut self) {
376        Jma::reset(self)
377    }
378}
379
380impl Smoother for SuperSmoother {
381    fn update(&mut self, src: f64) -> Option<f64> {
382        Some(SuperSmoother::update(self, src))
383    }
384    fn reset(&mut self) {
385        SuperSmoother::reset(self)
386    }
387}
388
389impl Smoother for Kama {
390    fn update(&mut self, src: f64) -> Option<f64> {
391        Kama::update(self, src)
392    }
393    fn reset(&mut self) {
394        Kama::reset(self)
395    }
396    fn warmup_period(&self) -> usize {
397        self.period + 1
398    }
399}
400
401/// A typed, ordered pipeline of [`Smoother`] stages: each stage's output feeds the next stage's
402/// input. Its own warmup/reset contract composes cleanly from its stages':
403/// [`SmootherChain::warmup_period`] is the sum of every stage's warmup (a downstream stage cannot
404/// start accumulating until its upstream first emits), and [`SmootherChain::reset`] resets every
405/// stage. Works with any mix of existing `Smoother` impls, and with future ones without changes
406/// here — implement [`Smoother`] and it is chainable.
407pub struct SmootherChain {
408    stages: Vec<Box<dyn Smoother>>,
409}
410
411impl SmootherChain {
412    pub fn new(stages: Vec<Box<dyn Smoother>>) -> Self {
413        Self { stages }
414    }
415
416    pub fn warmup_period(&self) -> usize {
417        self.stages.iter().map(|s| s.warmup_period()).sum()
418    }
419
420    pub fn reset(&mut self) {
421        for stage in &mut self.stages {
422            stage.reset();
423        }
424    }
425
426    /// Feeds `src` through every stage in order. Returns `None` if any stage is still inside its
427    /// own warmup this bar.
428    pub fn update(&mut self, src: f64) -> Option<f64> {
429        let mut value = src;
430        for stage in &mut self.stages {
431            value = stage.update(value)?;
432        }
433        Some(value)
434    }
435}
436
437/// A [`SmootherChain`] is itself a [`Smoother`], so chains compose (a chain can be one stage of
438/// another chain) and can be used anywhere a single smoother is expected, e.g. as one leg of
439/// [`super::trend_relationship::AdaptiveTrendRelationship`].
440impl Smoother for SmootherChain {
441    fn update(&mut self, src: f64) -> Option<f64> {
442        SmootherChain::update(self, src)
443    }
444    fn reset(&mut self) {
445        SmootherChain::reset(self)
446    }
447    fn warmup_period(&self) -> usize {
448        SmootherChain::warmup_period(self)
449    }
450}
451
452/// Arnaud Legoux Moving Average (ALMA)
453#[derive(Debug, Clone)]
454pub struct Alma {
455    len: usize,
456    offset: f64,
457    sigma: f64,
458    window: VecDeque<f64>,
459    weights: Vec<f64>,
460    sum_weights: f64,
461}
462
463impl Alma {
464    pub fn new(len: usize, offset: f64, sigma: f64) -> Self {
465        let len = len.max(1);
466        let m = offset * (len - 1) as f64;
467        let s = (len as f64 / sigma).max(1e-6);
468
469        let mut weights = Vec::with_capacity(len);
470        let mut sum_weights = 0.0;
471        for i in 0..len {
472            let w = (-(i as f64 - m).powi(2) / (2.0 * s * s)).exp();
473            weights.push(w);
474            sum_weights += w;
475        }
476
477        Self {
478            len,
479            offset,
480            sigma,
481            window: VecDeque::with_capacity(len),
482            weights,
483            sum_weights,
484        }
485    }
486
487    pub fn offset(&self) -> f64 {
488        self.offset
489    }
490
491    pub fn sigma(&self) -> f64 {
492        self.sigma
493    }
494
495    pub fn update(&mut self, src: f64) -> Option<f64> {
496        self.window.push_back(src);
497        if self.window.len() > self.len {
498            self.window.pop_front();
499        }
500        if self.window.len() < self.len {
501            return None;
502        }
503
504        let mut weighted_sum = 0.0;
505        for (i, &val) in self.window.iter().enumerate() {
506            weighted_sum += val * self.weights[i];
507        }
508        Some(weighted_sum / self.sum_weights)
509    }
510
511    pub fn reset(&mut self) {
512        self.window.clear();
513    }
514}
515
516/// Open Jurik-style moving-average approximation.
517#[derive(Debug, Clone)]
518pub struct Jma {
519    len: usize,
520    phase: f64,
521    power: f64,
522    e0: f64,
523    e1: f64,
524    e2: f64,
525    jma: f64,
526    initialized: bool,
527}
528
529impl Jma {
530    pub fn new(len: usize, phase: f64, power: f64) -> Self {
531        Self {
532            len: len.max(1),
533            phase: phase.clamp(-100.0, 100.0),
534            power: power.max(1.0),
535            e0: 0.0,
536            e1: 0.0,
537            e2: 0.0,
538            jma: 0.0,
539            initialized: false,
540        }
541    }
542
543    pub fn phase(&self) -> f64 {
544        self.phase
545    }
546
547    pub fn update(&mut self, src: f64) -> f64 {
548        if !self.initialized {
549            self.e0 = src;
550            self.e1 = 0.0;
551            self.e2 = 0.0;
552            self.jma = src;
553            self.initialized = true;
554            return src;
555        }
556
557        let phase_ratio = self.phase / 100.0 + 1.5;
558        let length_term = 0.45 * (self.len.saturating_sub(1)) as f64;
559        let beta = length_term / (length_term + 2.0);
560        let alpha = beta.powf(self.power);
561        self.e0 = (1.0 - alpha) * src + alpha * self.e0;
562        self.e1 = (src - self.e0) * (1.0 - beta) + beta * self.e1;
563        self.e2 = (self.e0 + phase_ratio * self.e1 - self.jma) * (1.0 - alpha).powi(2)
564            + alpha.powi(2) * self.e2;
565        self.jma += self.e2;
566        self.jma
567    }
568
569    pub fn reset(&mut self) {
570        self.e0 = 0.0;
571        self.e1 = 0.0;
572        self.e2 = 0.0;
573        self.jma = 0.0;
574        self.initialized = false;
575    }
576}
577
578/// Ehlers' SuperSmoother, a 2-pole Butterworth low-pass filter:
579/// `a1 = exp(-1.414 · π / len)`, `c2 = 2 · a1 · cos(1.414 · π / len)`, `c3 = -a1²`,
580/// `c1 = 1 - c2 - c3`, and `ss_t = c1 · (src_t + src_(t-1)) / 2 + c2 · ss_(t-1) + c3 · ss_(t-2)`.
581/// Valid from the first sample: the missing `src[1]`/`ss[1]`/`ss[2]` terms on the first bars
582/// default to `0`, producing a short transient rather than a `None` warmup.
583#[derive(Debug, Clone)]
584pub struct SuperSmoother {
585    c1: f64,
586    c2: f64,
587    c3: f64,
588    prev_src: f64,
589    prev1: f64,
590    prev2: f64,
591}
592
593impl SuperSmoother {
594    pub fn new(len: usize) -> Self {
595        let len = len.max(1) as f64;
596        let a1 = (-1.414 * std::f64::consts::PI / len).exp();
597        let b1 = 2.0 * a1 * (1.414 * std::f64::consts::PI / len).cos();
598        let c2 = b1;
599        let c3 = -(a1 * a1);
600        let c1 = 1.0 - c2 - c3;
601        Self {
602            c1,
603            c2,
604            c3,
605            prev_src: 0.0,
606            prev1: 0.0,
607            prev2: 0.0,
608        }
609    }
610
611    pub fn update(&mut self, src: f64) -> f64 {
612        let ss =
613            self.c1 * (src + self.prev_src) / 2.0 + self.c2 * self.prev1 + self.c3 * self.prev2;
614        self.prev2 = self.prev1;
615        self.prev1 = ss;
616        self.prev_src = src;
617        ss
618    }
619
620    pub fn reset(&mut self) {
621        self.prev_src = 0.0;
622        self.prev1 = 0.0;
623        self.prev2 = 0.0;
624    }
625}
626
627/// Kaufman's Adaptive Moving Average as a scalar-stream [`Smoother`] stage — same
628/// efficiency-ratio-derived adaptive-alpha formula as
629/// [`KamaEngine`](super::moving_averages::KamaEngine), decoupled from [`crate::model::Bar`] so it
630/// can be one stage of a [`Smoother`]/[`SmootherChain`] pipeline instead of only a stand-alone bar
631/// indicator. It reuses `KamaEngine`'s math with configurable `fast_period`/`slow_period` rather
632/// than adding a second variant with fixed 2/30 periods.
633#[derive(Debug, Clone)]
634pub struct Kama {
635    period: usize,
636    fast_period: usize,
637    slow_period: usize,
638    window: VecDeque<f64>,
639    state: Option<f64>,
640}
641
642impl Kama {
643    pub fn new(period: usize, fast_period: usize, slow_period: usize) -> Self {
644        let period = period.max(1);
645        Self {
646            period,
647            fast_period,
648            slow_period,
649            window: VecDeque::with_capacity(period + 1),
650            state: None,
651        }
652    }
653
654    /// Kaufman's own defaults (`fast_period = 2`, `slow_period = 30`), matching
655    /// [`KamaEngine`](super::moving_averages::KamaEngine)'s catalog defaults.
656    pub fn with_defaults(period: usize) -> Self {
657        Self::new(period, 2, 30)
658    }
659
660    pub fn update(&mut self, src: f64) -> Option<f64> {
661        self.window.push_back(src);
662        if self.window.len() > self.period + 1 {
663            self.window.pop_front();
664        }
665        if self.window.len() < self.period + 1 {
666            return None;
667        }
668
669        let change = (self.window.back().unwrap() - self.window.front().unwrap()).abs();
670        let mut volatility = 0.0f64;
671        for pair in self.window.iter().collect::<Vec<_>>().windows(2) {
672            volatility += (*pair[1] - *pair[0]).abs();
673        }
674        let er = if volatility > 0.0 {
675            change / volatility
676        } else {
677            0.0
678        };
679
680        let fast_sc = 2.0 / (self.fast_period as f64 + 1.0);
681        let slow_sc = 2.0 / (self.slow_period as f64 + 1.0);
682        let sc = (er * (fast_sc - slow_sc) + slow_sc).powi(2);
683
684        let next = match self.state {
685            Some(prev) => prev + sc * (src - prev),
686            None => src,
687        };
688        self.state = Some(next);
689        Some(next)
690    }
691
692    pub fn reset(&mut self) {
693        self.window.clear();
694        self.state = None;
695    }
696}
697
698#[cfg(test)]
699mod jma_tests {
700    use super::Jma;
701
702    #[test]
703    fn phase_changes_the_open_jurik_approximation() {
704        let mut leading = Jma::new(7, 100.0, 2.0);
705        let mut lagging = Jma::new(7, -100.0, 2.0);
706        let input = [10.0, 11.0, 13.0, 12.0, 15.0];
707        let leading_value = input.into_iter().map(|v| leading.update(v)).last().unwrap();
708        let lagging_value = input.into_iter().map(|v| lagging.update(v)).last().unwrap();
709        assert!(leading_value > lagging_value);
710    }
711
712    #[test]
713    fn matches_reference_formula_fixture() {
714        let mut jma = Jma::new(3, 0.0, 2.0);
715        let actual: Vec<_> = [1.0, 2.0, 3.0, 4.0]
716            .into_iter()
717            .map(|value| jma.update(value))
718            .collect();
719        let expected = [
720            1.0,
721            1.819_360_773_771_215_6,
722            2.819_354_006_908_239,
723            3.831_322_396_018_062,
724        ];
725        for (actual, expected) in actual.iter().zip(expected) {
726            assert!((actual - expected).abs() < 1e-12, "{actual} != {expected}");
727        }
728    }
729}
730
731#[cfg(test)]
732mod supersmoother_tests {
733    use super::SuperSmoother;
734
735    /// Reference values independently derived (Python, from the documented Ehlers 2-pole
736    /// Butterworth formula transcribed in `SuperSmoother::new`/`update`'s doc comments — not by
737    /// running this Rust code):
738    /// ```python
739    /// import math
740    /// def super_smoother(prices, length):
741    ///     a1 = math.exp(-1.414 * math.pi / length)
742    ///     b1 = 2 * a1 * math.cos(1.414 * math.pi / length)
743    ///     c2, c3 = b1, -(a1 * a1)
744    ///     c1 = 1 - c2 - c3
745    ///     prev_src = prev1 = prev2 = 0.0
746    ///     out = []
747    ///     for src in prices:
748    ///         ss = c1 * (src + prev_src) / 2.0 + c2 * prev1 + c3 * prev2
749    ///         prev2, prev1, prev_src = prev1, ss, src
750    ///         out.append(ss)
751    ///     return out
752    /// ```
753    #[test]
754    fn matches_independently_derived_reference_formula_fixture() {
755        let mut ss = SuperSmoother::new(3);
756        let actual: Vec<_> = [1.0, 2.0, 3.0, 4.0, 5.0, 6.0]
757            .into_iter()
758            .map(|value| ss.update(value))
759            .collect();
760        let expected = [
761            0.505_413_249_748_865_4,
762            1.536_919_267_057_587,
763            2.563_799_552_420_708,
764            3.563_269_185_823_832_3,
765            4.561_856_630_604_048,
766            5.561_826_276_936_932,
767        ];
768        for (actual, expected) in actual.iter().zip(expected) {
769            assert!((actual - expected).abs() < 1e-12, "{actual} != {expected}");
770        }
771    }
772
773    #[test]
774    fn reset_clears_transient_state() {
775        let mut ss = SuperSmoother::new(5);
776        ss.update(100.0);
777        ss.update(110.0);
778        ss.reset();
779        let mut fresh = SuperSmoother::new(5);
780        assert_eq!(ss.update(50.0), fresh.update(50.0));
781    }
782}
783
784#[cfg(test)]
785mod kama_smoother_tests {
786    use super::Kama;
787
788    /// `Kama` reuses `KamaEngine`'s already golden-validated formula (see
789    /// `tests/golden_reference_moving_averages.rs`, `kama5_last =
790    /// 14.554043488814198` for `KAMA(period=5, fast_period=2, slow_period=30)` over the shared
791    /// `CLOSES` series `[10, 11, 12, 11, 13, 14, 13, 15, 16, 15]`) — this is not a new formula
792    /// derivation, just a check that the `Bar`-decoupled scalar-stream version computes the exact
793    /// same sequence as the already-confirmed `Indicator` version, referencing that existing
794    /// golden fixture rather than re-deriving it (no circularity: the fixture value predates and
795    /// is independent of this struct).
796    #[test]
797    fn matches_already_confirmed_kama_engine_golden_value() {
798        const CLOSES: [f64; 10] = [10.0, 11.0, 12.0, 11.0, 13.0, 14.0, 13.0, 15.0, 16.0, 15.0];
799        let mut kama = Kama::new(5, 2, 30);
800        let mut last = None;
801        for &c in &CLOSES {
802            if let Some(value) = kama.update(c) {
803                last = Some(value);
804            }
805        }
806        let last = last.expect("kama produced no output");
807        assert!(
808            (last - 14.554_043_488_814_198).abs() < 1e-9,
809            "{last} != 14.554043488814198"
810        );
811    }
812
813    #[test]
814    fn warmup_returns_none_until_period_plus_one_samples() {
815        let mut kama = Kama::new(3, 2, 30);
816        assert_eq!(kama.update(1.0), None);
817        assert_eq!(kama.update(2.0), None);
818        assert_eq!(kama.update(3.0), None);
819        assert!(kama.update(4.0).is_some());
820    }
821}
822
823#[cfg(test)]
824mod chain_tests {
825    use super::*;
826
827    #[test]
828    fn test_chain_warmup_is_sum_of_stage_warmups() {
829        let chain =
830            SmootherChain::new(vec![SmootherKind::Sma.build(3), SmootherKind::Rma.build(4)]);
831        assert_eq!(chain.warmup_period(), 3 + 4);
832    }
833
834    #[test]
835    fn test_chain_none_until_every_stage_warm() {
836        let mut chain =
837            SmootherChain::new(vec![SmootherKind::Sma.build(2), SmootherKind::Sma.build(2)]);
838        assert_eq!(chain.update(1.0), None); // stage 1 still cold
839        assert_eq!(chain.update(2.0), None); // stage 1 warm (sma=1.5), stage 2 gets its 1st input
840                                             // stage 1 sma(2,3)=2.5; stage 2 now has both its inputs (1.5, 2.5) -> warm.
841        let value = chain.update(3.0).unwrap();
842        assert!((value - 2.0).abs() < 1e-9);
843        let value = chain.update(4.0).unwrap();
844        assert!((value - 3.0).abs() < 1e-9);
845    }
846
847    #[test]
848    fn test_chain_reset_clears_every_stage() {
849        let mut chain = SmootherChain::new(vec![SmootherKind::Sma.build(2)]);
850        chain.update(1.0);
851        assert!(chain.update(2.0).is_some());
852        chain.reset();
853        assert_eq!(chain.update(5.0), None, "reset stage must re-enter warmup");
854    }
855
856    #[test]
857    fn test_smoother_kind_build_matches_direct_construction() {
858        let mut via_kind = SmootherKind::Ema.build(5);
859        let mut direct = Ema::new(5);
860        for v in [10.0, 11.0, 12.0, 9.0] {
861            assert_eq!(via_kind.update(v), Ema::update(&mut direct, v));
862        }
863    }
864}