wickra_core/indicators/
atr_trailing_stop.rs1use crate::error::{Error, Result};
4use crate::indicators::atr::Atr;
5use crate::ohlcv::Candle;
6use crate::traits::Indicator;
7
8#[derive(Debug, Clone)]
43pub struct AtrTrailingStop {
44 atr: Atr,
45 multiplier: f64,
46 atr_period: usize,
47 prev_close: Option<f64>,
48 prev_stop: Option<f64>,
49}
50
51impl AtrTrailingStop {
52 pub fn new(atr_period: usize, multiplier: f64) -> Result<Self> {
59 if !multiplier.is_finite() || multiplier <= 0.0 {
60 return Err(Error::NonPositiveMultiplier);
61 }
62 Ok(Self {
63 atr: Atr::new(atr_period)?,
64 multiplier,
65 atr_period,
66 prev_close: None,
67 prev_stop: None,
68 })
69 }
70
71 pub fn classic() -> Self {
73 Self::new(14, 3.0).expect("classic ATR Trailing Stop params are valid")
74 }
75
76 pub const fn params(&self) -> (usize, f64) {
78 (self.atr_period, self.multiplier)
79 }
80}
81
82impl Indicator for AtrTrailingStop {
83 type Input = Candle;
84 type Output = f64;
85
86 #[inline]
87 fn update(&mut self, candle: Candle) -> Option<f64> {
88 let atr = self.atr.update(candle)?;
89 let loss = self.multiplier * atr;
90 let close = candle.close;
91
92 let stop = match (self.prev_stop, self.prev_close) {
93 (Some(prev_stop), Some(prev_close)) => {
94 if close > prev_stop && prev_close > prev_stop {
95 (close - loss).max(prev_stop)
97 } else if close < prev_stop && prev_close < prev_stop {
98 (close + loss).min(prev_stop)
100 } else if close > prev_stop {
101 close - loss
103 } else {
104 close + loss
106 }
107 }
108 _ => close - loss,
110 };
111
112 self.prev_close = Some(close);
113 self.prev_stop = Some(stop);
114 Some(stop)
115 }
116
117 fn reset(&mut self) {
118 self.atr.reset();
119 self.prev_close = None;
120 self.prev_stop = None;
121 }
122
123 #[inline]
124 fn warmup_period(&self) -> usize {
125 self.atr_period
126 }
127
128 #[inline]
129 fn is_ready(&self) -> bool {
130 self.prev_stop.is_some()
131 }
132
133 #[inline]
134 fn name(&self) -> &'static str {
135 "AtrTrailingStop"
136 }
137}
138
139#[cfg(test)]
140mod tests {
141 use super::*;
142 use crate::traits::BatchExt;
143 use approx::assert_relative_eq;
144
145 fn c(high: f64, low: f64, close: f64, ts: i64) -> Candle {
146 Candle::new(f64::midpoint(high, low), high, low, close, 1.0, ts).unwrap()
147 }
148
149 #[test]
150 fn reference_values_flat_market() {
151 let candles: Vec<Candle> = (0..20).map(|i| c(11.0, 9.0, 10.0, i)).collect();
154 let mut ts = AtrTrailingStop::new(5, 3.0).unwrap();
155 for v in ts.batch(&candles).into_iter().flatten() {
156 assert_relative_eq!(v, 4.0, epsilon = 1e-12);
157 }
158 }
159
160 #[test]
161 fn uptrend_stop_ratchets_up_and_stays_below_price() {
162 let candles: Vec<Candle> = (0..50)
163 .map(|i| {
164 let base = 100.0 + i as f64;
165 c(base + 1.0, base - 1.0, base, i)
166 })
167 .collect();
168 let mut ts = AtrTrailingStop::new(14, 3.0).unwrap();
169 let emitted: Vec<(f64, f64)> = ts
170 .batch(&candles)
171 .into_iter()
172 .zip(candles.iter())
173 .filter_map(|(o, c)| o.map(|v| (v, c.close)))
174 .collect();
175 for w in emitted.windows(2) {
176 assert!(
177 w[1].0 >= w[0].0 - 1e-9,
178 "stop must not loosen in an uptrend"
179 );
180 }
181 for &(stop, close) in &emitted {
182 assert!(stop < close, "uptrend stop should sit below the close");
183 }
184 }
185
186 #[test]
187 fn stop_flips_to_the_other_side_when_price_reverses() {
188 let mut candles: Vec<Candle> = (0..40)
189 .map(|i| {
190 let base = 100.0 + i as f64;
191 c(base + 1.0, base - 1.0, base, i)
192 })
193 .collect();
194 candles.extend((0..40).map(|i| {
196 let base = 140.0 - 3.0 * i as f64;
197 c(base + 1.0, base - 1.0, base, 40 + i)
198 }));
199 let mut ts = AtrTrailingStop::new(14, 3.0).unwrap();
200 let paired: Vec<(f64, f64)> = ts
201 .batch(&candles)
202 .into_iter()
203 .zip(candles.iter())
204 .filter_map(|(o, c)| o.map(|v| (v, c.close)))
205 .collect();
206 assert!(
207 paired.iter().any(|&(stop, close)| stop < close),
208 "expected a long stretch with the stop below price"
209 );
210 assert!(
211 paired.iter().any(|&(stop, close)| stop > close),
212 "expected the stop to flip above price after the reversal"
213 );
214 }
215
216 #[test]
217 fn first_emission_matches_warmup_period() {
218 let candles: Vec<Candle> = (0..20)
219 .map(|i| {
220 let base = 100.0 + i as f64;
221 c(base + 1.0, base - 1.0, base, i)
222 })
223 .collect();
224 let mut ts = AtrTrailingStop::new(8, 3.0).unwrap();
225 let out = ts.batch(&candles);
226 assert_eq!(ts.warmup_period(), 8);
227 for (i, v) in out.iter().enumerate().take(7) {
228 assert!(v.is_none(), "index {i} must be None during warmup");
229 }
230 assert!(out[7].is_some(), "first value lands at warmup_period - 1");
231 }
232
233 #[test]
234 fn rejects_invalid_params() {
235 assert!(AtrTrailingStop::new(0, 3.0).is_err());
236 assert!(AtrTrailingStop::new(14, 0.0).is_err());
237 assert!(AtrTrailingStop::new(14, -1.0).is_err());
238 assert!(AtrTrailingStop::new(14, f64::NAN).is_err());
239 }
240
241 #[test]
244 fn accessors_and_metadata() {
245 let s = AtrTrailingStop::classic();
246 let (atr_p, mult) = s.params();
247 assert_eq!(atr_p, 14);
248 assert!((mult - 3.0).abs() < 1e-12);
249 assert_eq!(s.name(), "AtrTrailingStop");
250 }
251
252 #[test]
253 fn reset_clears_state() {
254 let candles: Vec<Candle> = (0..40)
255 .map(|i| {
256 let base = 100.0 + i as f64;
257 c(base + 1.0, base - 1.0, base, i)
258 })
259 .collect();
260 let mut ts = AtrTrailingStop::classic();
261 ts.batch(&candles);
262 assert!(ts.is_ready());
263 ts.reset();
264 assert!(!ts.is_ready());
265 assert_eq!(ts.update(candles[0]), None);
266 }
267
268 #[test]
269 fn batch_equals_streaming() {
270 let candles: Vec<Candle> = (0..80)
271 .map(|i| {
272 let mid = 100.0 + (i as f64 * 0.3).sin() * 8.0;
273 c(mid + 1.5, mid - 1.5, mid + 0.5, i)
274 })
275 .collect();
276 let mut a = AtrTrailingStop::classic();
277 let mut b = AtrTrailingStop::classic();
278 assert_eq!(
279 a.batch(&candles),
280 candles.iter().map(|x| b.update(*x)).collect::<Vec<_>>()
281 );
282 }
283}