Skip to main content

wickra_core/indicators/
ma_envelope.rs

1//! Moving Average Envelope.
2
3use crate::error::{Error, Result};
4use crate::indicators::sma::Sma;
5use crate::traits::Indicator;
6
7/// Moving Average Envelope output: SMA middle line wrapped by a fixed-percent
8/// envelope on either side.
9#[derive(Debug, Clone, Copy, PartialEq)]
10pub struct MaEnvelopeOutput {
11    /// Upper envelope: `middle · (1 + percent)`.
12    pub upper: f64,
13    /// Middle band: SMA over the window.
14    pub middle: f64,
15    /// Lower envelope: `middle · (1 − percent)`.
16    pub lower: f64,
17}
18
19/// Moving Average Envelope: an SMA centerline with constant-percent bands on
20/// each side.
21///
22/// ```text
23/// middle = SMA(period)
24/// upper  = middle · (1 + percent)
25/// lower  = middle · (1 − percent)
26/// ```
27///
28/// The envelope is a fixed multiplicative offset around the moving average,
29/// so the band width scales with price rather than with realised volatility
30/// (contrast Bollinger Bands, whose width is `2·k·σ`, or Keltner Channels,
31/// whose width is `2·k·ATR`). It is the oldest band-style overlay still in
32/// regular use; chart vendors typically default to `period = 20`,
33/// `percent = 0.025` (2.5 %).
34///
35/// # Example
36///
37/// ```
38/// use wickra_core::{Indicator, MaEnvelope};
39///
40/// let mut indicator = MaEnvelope::new(20, 0.025).unwrap();
41/// let mut last = None;
42/// for i in 0..40 {
43///     last = indicator.update(100.0 + f64::from(i));
44/// }
45/// assert!(last.is_some());
46/// ```
47#[derive(Debug, Clone)]
48pub struct MaEnvelope {
49    sma: Sma,
50    percent: f64,
51}
52
53impl MaEnvelope {
54    /// Construct a new Moving Average Envelope.
55    ///
56    /// # Errors
57    /// Returns [`Error::PeriodZero`] if `period == 0` and
58    /// [`Error::NonPositiveMultiplier`] if `percent` is not strictly positive
59    /// and finite.
60    pub fn new(period: usize, percent: f64) -> Result<Self> {
61        if !percent.is_finite() || percent <= 0.0 {
62            return Err(Error::NonPositiveMultiplier);
63        }
64        Ok(Self {
65            sma: Sma::new(period)?,
66            percent,
67        })
68    }
69
70    /// Configured period.
71    pub const fn period(&self) -> usize {
72        self.sma.period()
73    }
74
75    /// Configured envelope percent (e.g. `0.025` for ±2.5 %).
76    pub const fn percent(&self) -> f64 {
77        self.percent
78    }
79}
80
81impl Indicator for MaEnvelope {
82    type Input = f64;
83    type Output = MaEnvelopeOutput;
84
85    fn update(&mut self, input: f64) -> Option<MaEnvelopeOutput> {
86        let middle = self.sma.update(input)?;
87        Some(MaEnvelopeOutput {
88            upper: middle * (1.0 + self.percent),
89            middle,
90            lower: middle * (1.0 - self.percent),
91        })
92    }
93
94    fn reset(&mut self) {
95        self.sma.reset();
96    }
97
98    fn warmup_period(&self) -> usize {
99        self.sma.warmup_period()
100    }
101
102    fn is_ready(&self) -> bool {
103        self.sma.is_ready()
104    }
105
106    fn name(&self) -> &'static str {
107        "MaEnvelope"
108    }
109}
110
111#[cfg(test)]
112mod tests {
113    use super::*;
114    use crate::traits::BatchExt;
115    use approx::assert_relative_eq;
116
117    #[test]
118    fn rejects_zero_period() {
119        assert!(matches!(MaEnvelope::new(0, 0.025), Err(Error::PeriodZero)));
120    }
121
122    #[test]
123    fn rejects_non_positive_percent() {
124        assert!(matches!(
125            MaEnvelope::new(20, 0.0),
126            Err(Error::NonPositiveMultiplier)
127        ));
128        assert!(matches!(
129            MaEnvelope::new(20, -0.1),
130            Err(Error::NonPositiveMultiplier)
131        ));
132        assert!(matches!(
133            MaEnvelope::new(20, f64::NAN),
134            Err(Error::NonPositiveMultiplier)
135        ));
136    }
137
138    #[test]
139    fn accessors_and_metadata() {
140        let env = MaEnvelope::new(20, 0.025).unwrap();
141        assert_eq!(env.period(), 20);
142        assert_relative_eq!(env.percent(), 0.025, epsilon = 1e-12);
143        assert_eq!(env.warmup_period(), 20);
144        assert_eq!(env.name(), "MaEnvelope");
145        assert!(!env.is_ready());
146    }
147
148    #[test]
149    fn constant_series_yields_flat_envelope() {
150        let mut env = MaEnvelope::new(5, 0.01).unwrap();
151        let last = env
152            .batch(&[100.0_f64; 20])
153            .into_iter()
154            .flatten()
155            .last()
156            .unwrap();
157        assert_relative_eq!(last.middle, 100.0, epsilon = 1e-12);
158        assert_relative_eq!(last.upper, 101.0, epsilon = 1e-12);
159        assert_relative_eq!(last.lower, 99.0, epsilon = 1e-12);
160    }
161
162    #[test]
163    fn warmup_returns_none() {
164        let mut env = MaEnvelope::new(5, 0.05).unwrap();
165        for v in [1.0, 2.0, 3.0, 4.0] {
166            assert!(env.update(v).is_none());
167        }
168        assert!(env.update(5.0).is_some());
169    }
170
171    #[test]
172    fn upper_above_middle_above_lower() {
173        let prices: Vec<f64> = (1..=80)
174            .map(|i| 100.0 + (f64::from(i) * 0.2).sin() * 5.0)
175            .collect();
176        let mut env = MaEnvelope::new(20, 0.025).unwrap();
177        for o in env.batch(&prices).into_iter().flatten() {
178            assert!(o.upper >= o.middle);
179            assert!(o.middle >= o.lower);
180        }
181    }
182
183    #[test]
184    fn batch_equals_streaming() {
185        let prices: Vec<f64> = (1..=50).map(|i| f64::from(i) * 0.7 + 100.0).collect();
186        let mut a = MaEnvelope::new(10, 0.03).unwrap();
187        let mut b = MaEnvelope::new(10, 0.03).unwrap();
188        assert_eq!(
189            a.batch(&prices),
190            prices.iter().map(|p| b.update(*p)).collect::<Vec<_>>()
191        );
192    }
193
194    #[test]
195    fn reset_clears_state() {
196        let mut env = MaEnvelope::new(5, 0.02).unwrap();
197        env.batch(&[1.0, 2.0, 3.0, 4.0, 5.0]);
198        assert!(env.is_ready());
199        env.reset();
200        assert!(!env.is_ready());
201        assert_eq!(env.update(1.0), None);
202    }
203
204    /// Reference value: SMA over [10, 20, 30] is 20; with percent = 0.10 the
205    /// upper band is 22 and the lower band is 18.
206    #[test]
207    fn reference_values() {
208        let mut env = MaEnvelope::new(3, 0.10).unwrap();
209        let out = env.batch(&[10.0, 20.0, 30.0]);
210        assert!(out[0].is_none() && out[1].is_none());
211        let v = out[2].unwrap();
212        assert_relative_eq!(v.middle, 20.0, epsilon = 1e-12);
213        assert_relative_eq!(v.upper, 22.0, epsilon = 1e-12);
214        assert_relative_eq!(v.lower, 18.0, epsilon = 1e-12);
215    }
216}