Skip to main content

wickra_core/indicators/
atr_trailing_stop.rs

1//! ATR Trailing Stop.
2
3use crate::error::{Error, Result};
4use crate::indicators::atr::Atr;
5use crate::ohlcv::Candle;
6use crate::traits::Indicator;
7
8/// ATR Trailing Stop — a stop level that trails price by a fixed ATR multiple
9/// and ratchets in the direction of the trend.
10///
11/// ```text
12/// loss = multiplier · ATR
13///
14/// stop_t = max(stop_{t−1}, close − loss)   while price holds above the stop
15///        = min(stop_{t−1}, close + loss)   while price holds below the stop
16///        = close − loss                   on a fresh break above the stop
17///        = close + loss                   on a fresh break below the stop
18/// ```
19///
20/// While price stays on one side of the stop the level only ratchets toward
21/// price — up in an uptrend, down in a downtrend — never away from it. When a
22/// close crosses the stop the level snaps to the opposite side, `loss` away
23/// from the new close, flipping the trade. This is the trailing stop used by
24/// the well-known "UT Bot"; the first ATR-ready bar seeds the stop below
25/// price (a long).
26///
27/// # Example
28///
29/// ```
30/// use wickra_core::{Candle, Indicator, AtrTrailingStop};
31///
32/// let mut indicator = AtrTrailingStop::new(14, 3.0).unwrap();
33/// let mut last = None;
34/// for i in 0..80 {
35///     let base = 100.0 + f64::from(i);
36///     let candle =
37///         Candle::new(base, base + 2.0, base - 2.0, base + 1.0, 10.0, i64::from(i)).unwrap();
38///     last = indicator.update(candle);
39/// }
40/// assert!(last.is_some());
41/// ```
42#[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    /// Construct an ATR Trailing Stop with an explicit ATR period and multiple.
53    ///
54    /// # Errors
55    /// Returns [`Error::PeriodZero`] if `atr_period == 0` and
56    /// [`Error::NonPositiveMultiplier`] if `multiplier` is not strictly
57    /// positive and finite.
58    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    /// A common configuration: `ATR(14)` with a `3.0` multiplier.
72    pub fn classic() -> Self {
73        Self::new(14, 3.0).expect("classic ATR Trailing Stop params are valid")
74    }
75
76    /// Configured `(atr_period, multiplier)`.
77    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                    // Holding above the stop — ratchet it up only.
96                    (close - loss).max(prev_stop)
97                } else if close < prev_stop && prev_close < prev_stop {
98                    // Holding below the stop — ratchet it down only.
99                    (close + loss).min(prev_stop)
100                } else if close > prev_stop {
101                    // Fresh break above — place the stop below the new close.
102                    close - loss
103                } else {
104                    // Fresh break below — place the stop above the new close.
105                    close + loss
106                }
107            }
108            // First ATR-ready bar: seed the stop below price (a long).
109            _ => 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        // Flat candles H=11, L=9, C=10 -> TR=2 -> ATR=2; loss = 3·2 = 6.
152        // Seed stop = close - loss = 10 - 6 = 4, and it holds there.
153        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        // A steep decline drags price through the trailing stop.
195        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    /// Cover the const accessor `params` (77-79) and the Indicator-impl
242    /// `name` body (130-132). `warmup_period` is exercised elsewhere.
243    #[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}