wickra_core/indicators/
modified_ma_stop.rs1use crate::error::{Error, Result};
4use crate::indicators::smma::Smma;
5use crate::ohlcv::Candle;
6use crate::traits::Indicator;
7
8#[derive(Debug, Clone, Copy, PartialEq)]
10pub struct ModifiedMaStopOutput {
11 pub value: f64,
13 pub direction: f64,
15}
16
17#[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 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 pub const fn period(&self) -> usize {
86 self.period
87 }
88
89 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}