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        Ok(Self {
71            smma: Smma::new(period)?,
72            period,
73            direction: 0.0,
74            stop: 0.0,
75            last: None,
76        })
77    }
78
79    /// Configured SMMA period.
80    pub const fn period(&self) -> usize {
81        self.period
82    }
83
84    /// Current value if available.
85    pub const fn value(&self) -> Option<ModifiedMaStopOutput> {
86        self.last
87    }
88}
89
90impl Indicator for ModifiedMaStop {
91    type Input = Candle;
92    type Output = ModifiedMaStopOutput;
93
94    fn update(&mut self, candle: Candle) -> Option<ModifiedMaStopOutput> {
95        let ma = self.smma.update(candle.close)?;
96        let close = candle.close;
97
98        if self.direction == 0.0 {
99            self.direction = if close >= ma { 1.0 } else { -1.0 };
100            self.stop = ma;
101        } else if self.direction > 0.0 {
102            self.stop = self.stop.max(ma);
103            if close < self.stop {
104                self.direction = -1.0;
105                self.stop = ma;
106            }
107        } else {
108            self.stop = self.stop.min(ma);
109            if close > self.stop {
110                self.direction = 1.0;
111                self.stop = ma;
112            }
113        }
114
115        let out = ModifiedMaStopOutput {
116            value: self.stop,
117            direction: self.direction,
118        };
119        self.last = Some(out);
120        Some(out)
121    }
122
123    fn reset(&mut self) {
124        self.smma.reset();
125        self.direction = 0.0;
126        self.stop = 0.0;
127        self.last = None;
128    }
129
130    fn warmup_period(&self) -> usize {
131        self.period
132    }
133
134    fn is_ready(&self) -> bool {
135        self.last.is_some()
136    }
137
138    fn name(&self) -> &'static str {
139        "ModifiedMaStop"
140    }
141}
142
143#[cfg(test)]
144mod tests {
145    use super::*;
146    use crate::traits::BatchExt;
147
148    fn c(close: f64) -> Candle {
149        Candle::new_unchecked(close, close + 1.0, close - 1.0, close, 1_000.0, 0)
150    }
151
152    #[test]
153    fn rejects_zero_period() {
154        assert!(matches!(ModifiedMaStop::new(0), Err(Error::PeriodZero)));
155    }
156
157    #[test]
158    fn accessors_and_metadata() {
159        let m = ModifiedMaStop::new(14).unwrap();
160        assert_eq!(m.period(), 14);
161        assert_eq!(m.warmup_period(), 14);
162        assert_eq!(m.name(), "ModifiedMaStop");
163        assert!(!m.is_ready());
164        assert_eq!(m.value(), None);
165    }
166
167    #[test]
168    fn first_emission_at_warmup_period() {
169        let mut m = ModifiedMaStop::new(5).unwrap();
170        let candles: Vec<Candle> = (0..12).map(|i| c(100.0 + f64::from(i))).collect();
171        let out = m.batch(&candles);
172        for v in out.iter().take(4) {
173            assert!(v.is_none());
174        }
175        assert!(out[4].is_some());
176    }
177
178    #[test]
179    fn uptrend_keeps_stop_below_price() {
180        let mut m = ModifiedMaStop::new(5).unwrap();
181        let candles: Vec<Candle> = (0..60).map(|i| c(100.0 + 2.0 * f64::from(i))).collect();
182        for (o, candle) in m.batch(&candles).into_iter().zip(candles.iter()) {
183            if let Some(o) = o {
184                assert_eq!(o.direction, 1.0);
185                assert!(o.value < candle.close);
186            }
187        }
188    }
189
190    #[test]
191    fn long_stop_ratchets_up() {
192        let mut m = ModifiedMaStop::new(5).unwrap();
193        let candles: Vec<Candle> = (0..60).map(|i| c(100.0 + 2.0 * f64::from(i))).collect();
194        let mut prev = f64::NEG_INFINITY;
195        for o in m.batch(&candles).into_iter().flatten() {
196            assert_eq!(o.direction, 1.0, "pure uptrend stays long");
197            assert!(o.value >= prev, "long stop must not fall");
198            prev = o.value;
199        }
200    }
201
202    #[test]
203    fn flips_on_reversal() {
204        let mut candles: Vec<Candle> = (0..40).map(|i| c(100.0 + f64::from(i))).collect();
205        candles.extend((0..40).map(|i| c(140.0 - f64::from(i))));
206        let mut m = ModifiedMaStop::new(5).unwrap();
207        let dirs: Vec<f64> = m
208            .batch(&candles)
209            .into_iter()
210            .flatten()
211            .map(|o| o.direction)
212            .collect();
213        assert!(dirs.iter().any(|&d| d > 0.0));
214        assert!(dirs.iter().any(|&d| d < 0.0));
215    }
216
217    #[test]
218    fn reset_clears_state() {
219        let mut m = ModifiedMaStop::new(5).unwrap();
220        m.batch(&(0..40).map(|i| c(100.0 + f64::from(i))).collect::<Vec<_>>());
221        assert!(m.is_ready());
222        m.reset();
223        assert!(!m.is_ready());
224        assert_eq!(m.value(), None);
225        assert_eq!(m.update(c(100.0)), None);
226    }
227
228    #[test]
229    fn batch_equals_streaming() {
230        let candles: Vec<Candle> = (0..120)
231            .map(|i| c(100.0 + (f64::from(i) * 0.25).sin() * 9.0))
232            .collect();
233        let batch = ModifiedMaStop::new(14).unwrap().batch(&candles);
234        let mut b = ModifiedMaStop::new(14).unwrap();
235        let streamed: Vec<_> = candles.iter().map(|c| b.update(*c)).collect();
236        assert_eq!(batch, streamed);
237    }
238}