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    #[inline]
86    fn update(&mut self, input: f64) -> Option<MaEnvelopeOutput> {
87        let middle = self.sma.update(input)?;
88        Some(MaEnvelopeOutput {
89            upper: middle * (1.0 + self.percent),
90            middle,
91            lower: middle * (1.0 - self.percent),
92        })
93    }
94
95    fn reset(&mut self) {
96        self.sma.reset();
97    }
98
99    #[inline]
100    fn warmup_period(&self) -> usize {
101        self.sma.warmup_period()
102    }
103
104    #[inline]
105    fn is_ready(&self) -> bool {
106        self.sma.is_ready()
107    }
108
109    #[inline]
110    fn name(&self) -> &'static str {
111        "MaEnvelope"
112    }
113}
114
115#[cfg(test)]
116mod tests {
117    use super::*;
118    use crate::traits::BatchExt;
119    use approx::assert_relative_eq;
120
121    #[test]
122    fn rejects_zero_period() {
123        assert!(matches!(MaEnvelope::new(0, 0.025), Err(Error::PeriodZero)));
124    }
125
126    #[test]
127    fn rejects_non_positive_percent() {
128        assert!(matches!(
129            MaEnvelope::new(20, 0.0),
130            Err(Error::NonPositiveMultiplier)
131        ));
132        assert!(matches!(
133            MaEnvelope::new(20, -0.1),
134            Err(Error::NonPositiveMultiplier)
135        ));
136        assert!(matches!(
137            MaEnvelope::new(20, f64::NAN),
138            Err(Error::NonPositiveMultiplier)
139        ));
140    }
141
142    #[test]
143    fn accessors_and_metadata() {
144        let env = MaEnvelope::new(20, 0.025).unwrap();
145        assert_eq!(env.period(), 20);
146        assert_relative_eq!(env.percent(), 0.025, epsilon = 1e-12);
147        assert_eq!(env.warmup_period(), 20);
148        assert_eq!(env.name(), "MaEnvelope");
149        assert!(!env.is_ready());
150    }
151
152    #[test]
153    fn constant_series_yields_flat_envelope() {
154        let mut env = MaEnvelope::new(5, 0.01).unwrap();
155        let last = env
156            .batch(&[100.0_f64; 20])
157            .into_iter()
158            .flatten()
159            .last()
160            .unwrap();
161        assert_relative_eq!(last.middle, 100.0, epsilon = 1e-12);
162        assert_relative_eq!(last.upper, 101.0, epsilon = 1e-12);
163        assert_relative_eq!(last.lower, 99.0, epsilon = 1e-12);
164    }
165
166    #[test]
167    fn warmup_returns_none() {
168        let mut env = MaEnvelope::new(5, 0.05).unwrap();
169        for v in [1.0, 2.0, 3.0, 4.0] {
170            assert!(env.update(v).is_none());
171        }
172        assert!(env.update(5.0).is_some());
173    }
174
175    #[test]
176    fn upper_above_middle_above_lower() {
177        let prices: Vec<f64> = (1..=80)
178            .map(|i| 100.0 + (f64::from(i) * 0.2).sin() * 5.0)
179            .collect();
180        let mut env = MaEnvelope::new(20, 0.025).unwrap();
181        for o in env.batch(&prices).into_iter().flatten() {
182            assert!(o.upper >= o.middle);
183            assert!(o.middle >= o.lower);
184        }
185    }
186
187    #[test]
188    fn batch_equals_streaming() {
189        let prices: Vec<f64> = (1..=50).map(|i| f64::from(i) * 0.7 + 100.0).collect();
190        let mut a = MaEnvelope::new(10, 0.03).unwrap();
191        let mut b = MaEnvelope::new(10, 0.03).unwrap();
192        assert_eq!(
193            a.batch(&prices),
194            prices.iter().map(|p| b.update(*p)).collect::<Vec<_>>()
195        );
196    }
197
198    #[test]
199    fn reset_clears_state() {
200        let mut env = MaEnvelope::new(5, 0.02).unwrap();
201        env.batch(&[1.0, 2.0, 3.0, 4.0, 5.0]);
202        assert!(env.is_ready());
203        env.reset();
204        assert!(!env.is_ready());
205        assert_eq!(env.update(1.0), None);
206    }
207
208    /// Reference value: SMA over [10, 20, 30] is 20; with percent = 0.10 the
209    /// upper band is 22 and the lower band is 18.
210    #[test]
211    fn reference_values() {
212        let mut env = MaEnvelope::new(3, 0.10).unwrap();
213        let out = env.batch(&[10.0, 20.0, 30.0]);
214        assert!(out[0].is_none() && out[1].is_none());
215        let v = out[2].unwrap();
216        assert_relative_eq!(v.middle, 20.0, epsilon = 1e-12);
217        assert_relative_eq!(v.upper, 22.0, epsilon = 1e-12);
218        assert_relative_eq!(v.lower, 18.0, epsilon = 1e-12);
219    }
220}