Skip to main content

wickra_core/indicators/
modified_ma_stop.rs

1//! Modified-MA Stop — a trailing stop riding the Modified Moving Average (SMMA).
2
3use crate::error::{Error, Result};
4use crate::indicators::smma::Smma;
5use crate::ohlcv::Candle;
6use crate::traits::Indicator;
7
8/// Output of [`ModifiedMaStop`]: the active stop level and the trend direction.
9#[derive(Debug, Clone, Copy, PartialEq)]
10pub struct ModifiedMaStopOutput {
11    /// The stop level (a directionally-ratcheted Modified Moving Average).
12    pub value: f64,
13    /// Trend direction: `+1.0` long (stop below price), `-1.0` short.
14    pub direction: f64,
15}
16
17/// Modified-MA Stop — a trailing stop whose line is the **Modified Moving
18/// Average** (SMMA / Wilder's RMA) of price, allowed to move only in the trend's
19/// favour.
20///
21/// ```text
22/// ma = SMMA(close, period)                 (Modified Moving Average)
23/// long:  stop = max(prev_stop, ma);  flip short when close < stop
24/// short: stop = min(prev_stop, ma);  flip long  when close > stop
25/// ```
26///
27/// The Modified Moving Average (also called the smoothed or running moving
28/// average) is the slow, low-lag average Wilder used throughout his systems. Using
29/// it directly as a trailing line — but **ratcheting** so the long stop never
30/// falls and the short stop never rises — turns the smooth average into a stop
31/// that hugs price in a trend and flips when price decisively crosses it. Because
32/// the SMMA lags, the stop gives trends room while still exiting clean reversals.
33///
34/// The first stop lands once the SMMA is ready (`period` inputs). Each `update` is
35/// O(1).
36///
37/// # Example
38///
39/// ```
40/// use wickra_core::{Candle, Indicator, ModifiedMaStop};
41///
42/// let mut indicator = ModifiedMaStop::new(14).unwrap();
43/// let mut last = None;
44/// for i in 0..60 {
45///     let base = 100.0 + f64::from(i);
46///     let c = Candle::new(base, base + 1.0, base - 1.0, base + 0.5, 1_000.0, 0).unwrap();
47///     last = indicator.update(c);
48/// }
49/// assert!(last.is_some());
50/// ```
51#[derive(Debug, Clone)]
52pub struct ModifiedMaStop {
53    smma: Smma,
54    period: usize,
55    direction: f64,
56    stop: f64,
57    last: Option<ModifiedMaStopOutput>,
58}
59
60impl ModifiedMaStop {
61    /// Construct a Modified-MA stop with the given SMMA `period`.
62    ///
63    /// # Errors
64    ///
65    /// Returns [`Error::PeriodZero`] if `period == 0`.
66    pub fn new(period: usize) -> Result<Self> {
67        if period == 0 {
68            return Err(Error::PeriodZero);
69        }
70        if period > crate::error::MAX_PERIOD {
71            return Err(Error::InvalidPeriod {
72                message: crate::error::PERIOD_ABOVE_MAX,
73            });
74        }
75        Ok(Self {
76            smma: Smma::new(period)?,
77            period,
78            direction: 0.0,
79            stop: 0.0,
80            last: None,
81        })
82    }
83
84    /// Configured SMMA period.
85    pub const fn period(&self) -> usize {
86        self.period
87    }
88
89    /// Current value if available.
90    pub const fn value(&self) -> Option<ModifiedMaStopOutput> {
91        self.last
92    }
93}
94
95impl Indicator for ModifiedMaStop {
96    type Input = Candle;
97    type Output = ModifiedMaStopOutput;
98
99    #[inline]
100    fn update(&mut self, candle: Candle) -> Option<ModifiedMaStopOutput> {
101        let ma = self.smma.update(candle.close)?;
102        let close = candle.close;
103
104        if self.direction == 0.0 {
105            self.direction = if close >= ma { 1.0 } else { -1.0 };
106            self.stop = ma;
107        } else if self.direction > 0.0 {
108            self.stop = self.stop.max(ma);
109            if close < self.stop {
110                self.direction = -1.0;
111                self.stop = ma;
112            }
113        } else {
114            self.stop = self.stop.min(ma);
115            if close > self.stop {
116                self.direction = 1.0;
117                self.stop = ma;
118            }
119        }
120
121        let out = ModifiedMaStopOutput {
122            value: self.stop,
123            direction: self.direction,
124        };
125        self.last = Some(out);
126        Some(out)
127    }
128
129    fn reset(&mut self) {
130        self.smma.reset();
131        self.direction = 0.0;
132        self.stop = 0.0;
133        self.last = None;
134    }
135
136    #[inline]
137    fn warmup_period(&self) -> usize {
138        self.period
139    }
140
141    #[inline]
142    fn is_ready(&self) -> bool {
143        self.last.is_some()
144    }
145
146    #[inline]
147    fn name(&self) -> &'static str {
148        "ModifiedMaStop"
149    }
150}
151
152#[cfg(test)]
153mod tests {
154    use super::*;
155    use crate::traits::BatchExt;
156
157    fn c(close: f64) -> Candle {
158        Candle::new_unchecked(close, close + 1.0, close - 1.0, close, 1_000.0, 0)
159    }
160
161    #[test]
162    fn rejects_zero_period() {
163        assert!(matches!(ModifiedMaStop::new(0), Err(Error::PeriodZero)));
164    }
165
166    #[test]
167    fn accessors_and_metadata() {
168        let m = ModifiedMaStop::new(14).unwrap();
169        assert_eq!(m.period(), 14);
170        assert_eq!(m.warmup_period(), 14);
171        assert_eq!(m.name(), "ModifiedMaStop");
172        assert!(!m.is_ready());
173        assert_eq!(m.value(), None);
174    }
175
176    #[test]
177    fn first_emission_at_warmup_period() {
178        let mut m = ModifiedMaStop::new(5).unwrap();
179        let candles: Vec<Candle> = (0..12).map(|i| c(100.0 + f64::from(i))).collect();
180        let out = m.batch(&candles);
181        for v in out.iter().take(4) {
182            assert!(v.is_none());
183        }
184        assert!(out[4].is_some());
185    }
186
187    #[test]
188    fn uptrend_keeps_stop_below_price() {
189        let mut m = ModifiedMaStop::new(5).unwrap();
190        let candles: Vec<Candle> = (0..60).map(|i| c(100.0 + 2.0 * f64::from(i))).collect();
191        for (o, candle) in m.batch(&candles).into_iter().zip(candles.iter()) {
192            if let Some(o) = o {
193                assert_eq!(o.direction, 1.0);
194                assert!(o.value < candle.close);
195            }
196        }
197    }
198
199    #[test]
200    fn long_stop_ratchets_up() {
201        let mut m = ModifiedMaStop::new(5).unwrap();
202        let candles: Vec<Candle> = (0..60).map(|i| c(100.0 + 2.0 * f64::from(i))).collect();
203        let mut prev = f64::NEG_INFINITY;
204        for o in m.batch(&candles).into_iter().flatten() {
205            assert_eq!(o.direction, 1.0, "pure uptrend stays long");
206            assert!(o.value >= prev, "long stop must not fall");
207            prev = o.value;
208        }
209    }
210
211    #[test]
212    fn flips_on_reversal() {
213        let mut candles: Vec<Candle> = (0..40).map(|i| c(100.0 + f64::from(i))).collect();
214        candles.extend((0..40).map(|i| c(140.0 - f64::from(i))));
215        let mut m = ModifiedMaStop::new(5).unwrap();
216        let dirs: Vec<f64> = m
217            .batch(&candles)
218            .into_iter()
219            .flatten()
220            .map(|o| o.direction)
221            .collect();
222        assert!(dirs.iter().any(|&d| d > 0.0));
223        assert!(dirs.iter().any(|&d| d < 0.0));
224    }
225
226    #[test]
227    fn reset_clears_state() {
228        let mut m = ModifiedMaStop::new(5).unwrap();
229        m.batch(&(0..40).map(|i| c(100.0 + f64::from(i))).collect::<Vec<_>>());
230        assert!(m.is_ready());
231        m.reset();
232        assert!(!m.is_ready());
233        assert_eq!(m.value(), None);
234        assert_eq!(m.update(c(100.0)), None);
235    }
236
237    #[test]
238    fn batch_equals_streaming() {
239        let candles: Vec<Candle> = (0..120)
240            .map(|i| c(100.0 + (f64::from(i) * 0.25).sin() * 9.0))
241            .collect();
242        let batch = ModifiedMaStop::new(14).unwrap().batch(&candles);
243        let mut b = ModifiedMaStop::new(14).unwrap();
244        let streamed: Vec<_> = candles.iter().map(|c| b.update(*c)).collect();
245        assert_eq!(batch, streamed);
246    }
247}