Skip to main content

wickra_core/indicators/
atr_ratchet.rs

1//! ATR Ratchet (Kaufman) — a trailing stop that creeps toward price each bar.
2
3use crate::error::{Error, Result};
4use crate::indicators::atr::Atr;
5use crate::ohlcv::Candle;
6use crate::traits::Indicator;
7
8/// Output of [`AtrRatchet`]: the active stop level and the trend direction.
9#[derive(Debug, Clone, Copy, PartialEq)]
10pub struct AtrRatchetOutput {
11    /// The ratchet stop level — below price when long, above price when short.
12    pub value: f64,
13    /// Trend direction: `+1.0` long, `-1.0` short.
14    pub direction: f64,
15}
16
17/// ATR Ratchet — Perry Kaufman's time-based volatility stop that tightens by a
18/// fixed fraction of ATR **every bar**, whether or not price moves.
19///
20/// ```text
21/// on entry (long):   stop = close − start_mult · ATR
22/// each later bar:     stop = stop + increment · ATR    (ratchets toward price)
23/// flip to short when  close < stop, reseeding stop = close + start_mult · ATR
24/// ```
25///
26/// Most trailing stops only move when price makes a new extreme. Kaufman's ratchet
27/// instead advances the stop a little each bar — `increment · ATR` — so a trade
28/// that stalls is squeezed out over time even in a flat market. The initial
29/// distance (`start_mult · ATR`) gives the position room to breathe; the per-bar
30/// `increment` controls how aggressively the leash shortens. When price closes
31/// through the stop the system reverses and reseeds at the full initial distance.
32///
33/// The first stop lands once ATR is ready (`atr_period` inputs). Each `update` is
34/// O(1).
35///
36/// # Example
37///
38/// ```
39/// use wickra_core::{Candle, Indicator, AtrRatchet};
40///
41/// let mut indicator = AtrRatchet::new(14, 4.0, 0.1).unwrap();
42/// let mut last = None;
43/// for i in 0..60 {
44///     let base = 100.0 + f64::from(i);
45///     let c = Candle::new(base, base + 2.0, base - 2.0, base + 1.0, 1_000.0, 0).unwrap();
46///     last = indicator.update(c);
47/// }
48/// assert!(last.is_some());
49/// ```
50#[derive(Debug, Clone)]
51pub struct AtrRatchet {
52    atr: Atr,
53    atr_period: usize,
54    start_mult: f64,
55    increment: f64,
56    direction: f64,
57    stop: f64,
58    last: Option<AtrRatchetOutput>,
59}
60
61impl AtrRatchet {
62    /// Construct an ATR Ratchet stop.
63    ///
64    /// # Errors
65    ///
66    /// Returns [`Error::PeriodZero`] if `atr_period == 0` and
67    /// [`Error::NonPositiveMultiplier`] if `start_mult` or `increment` is not
68    /// finite and positive.
69    pub fn new(atr_period: usize, start_mult: f64, increment: f64) -> Result<Self> {
70        if !start_mult.is_finite()
71            || start_mult <= 0.0
72            || !increment.is_finite()
73            || increment <= 0.0
74        {
75            return Err(Error::NonPositiveMultiplier);
76        }
77        Ok(Self {
78            atr: Atr::new(atr_period)?,
79            atr_period,
80            start_mult,
81            increment,
82            direction: 0.0,
83            stop: 0.0,
84            last: None,
85        })
86    }
87
88    /// Configured `(atr_period, start_mult, increment)`.
89    pub const fn params(&self) -> (usize, f64, f64) {
90        (self.atr_period, self.start_mult, self.increment)
91    }
92
93    /// Current value if available.
94    pub const fn value(&self) -> Option<AtrRatchetOutput> {
95        self.last
96    }
97}
98
99impl Indicator for AtrRatchet {
100    type Input = Candle;
101    type Output = AtrRatchetOutput;
102
103    #[inline]
104    fn update(&mut self, candle: Candle) -> Option<AtrRatchetOutput> {
105        let atr = self.atr.update(candle)?;
106        let close = candle.close;
107
108        if self.direction == 0.0 {
109            self.direction = 1.0;
110            self.stop = close - self.start_mult * atr;
111        } else if self.direction > 0.0 {
112            self.stop += self.increment * atr;
113            if close < self.stop {
114                self.direction = -1.0;
115                self.stop = close + self.start_mult * atr;
116            }
117        } else {
118            self.stop -= self.increment * atr;
119            if close > self.stop {
120                self.direction = 1.0;
121                self.stop = close - self.start_mult * atr;
122            }
123        }
124
125        let out = AtrRatchetOutput {
126            value: self.stop,
127            direction: self.direction,
128        };
129        self.last = Some(out);
130        Some(out)
131    }
132
133    fn reset(&mut self) {
134        self.atr.reset();
135        self.direction = 0.0;
136        self.stop = 0.0;
137        self.last = None;
138    }
139
140    #[inline]
141    fn warmup_period(&self) -> usize {
142        self.atr_period
143    }
144
145    #[inline]
146    fn is_ready(&self) -> bool {
147        self.last.is_some()
148    }
149
150    #[inline]
151    fn name(&self) -> &'static str {
152        "AtrRatchet"
153    }
154}
155
156#[cfg(test)]
157mod tests {
158    use super::*;
159    use crate::traits::BatchExt;
160
161    fn c(high: f64, low: f64, close: f64) -> Candle {
162        Candle::new_unchecked(f64::midpoint(high, low), high, low, close, 1_000.0, 0)
163    }
164
165    #[test]
166    fn rejects_invalid_params() {
167        assert!(matches!(
168            AtrRatchet::new(0, 4.0, 0.1),
169            Err(Error::PeriodZero)
170        ));
171        assert!(matches!(
172            AtrRatchet::new(14, 0.0, 0.1),
173            Err(Error::NonPositiveMultiplier)
174        ));
175        assert!(matches!(
176            AtrRatchet::new(14, 4.0, 0.0),
177            Err(Error::NonPositiveMultiplier)
178        ));
179        assert!(matches!(
180            AtrRatchet::new(14, 4.0, f64::NAN),
181            Err(Error::NonPositiveMultiplier)
182        ));
183    }
184
185    #[test]
186    fn accessors_and_metadata() {
187        let r = AtrRatchet::new(14, 4.0, 0.1).unwrap();
188        assert_eq!(r.params(), (14, 4.0, 0.1));
189        assert_eq!(r.warmup_period(), 14);
190        assert_eq!(r.name(), "AtrRatchet");
191        assert!(!r.is_ready());
192        assert_eq!(r.value(), None);
193    }
194
195    #[test]
196    fn first_emission_at_warmup_period() {
197        let mut r = AtrRatchet::new(5, 4.0, 0.1).unwrap();
198        let candles: Vec<Candle> = (0..12)
199            .map(|i| {
200                let base = 100.0 + f64::from(i);
201                c(base + 1.0, base - 1.0, base)
202            })
203            .collect();
204        let out = r.batch(&candles);
205        for v in out.iter().take(4) {
206            assert!(v.is_none());
207        }
208        assert!(out[4].is_some());
209    }
210
211    #[test]
212    fn uptrend_keeps_stop_below_price() {
213        let mut r = AtrRatchet::new(5, 4.0, 0.05).unwrap();
214        let candles: Vec<Candle> = (0..60)
215            .map(|i| {
216                let base = 100.0 + 2.0 * f64::from(i);
217                c(base + 1.0, base - 1.0, base + 0.5)
218            })
219            .collect();
220        for (o, candle) in r.batch(&candles).into_iter().zip(candles.iter()) {
221            if let Some(o) = o {
222                assert_eq!(o.direction, 1.0);
223                assert!(o.value < candle.close);
224            }
225        }
226    }
227
228    #[test]
229    fn stall_eventually_triggers_flip() {
230        // A long trend then a long flat stretch: the ratchet creeps up each bar
231        // and eventually overtakes the flat close, flipping to short.
232        let mut r = AtrRatchet::new(5, 2.0, 0.5).unwrap();
233        let mut candles: Vec<Candle> = (0..20)
234            .map(|i| {
235                let base = 100.0 + f64::from(i);
236                c(base + 1.0, base - 1.0, base + 0.5)
237            })
238            .collect();
239        // Flat stretch at the last price.
240        candles.extend((0..40).map(|_| c(120.6, 118.6, 119.5)));
241        let dirs: Vec<f64> = r
242            .batch(&candles)
243            .into_iter()
244            .flatten()
245            .map(|o| o.direction)
246            .collect();
247        assert!(
248            dirs.iter().any(|&d| d < 0.0),
249            "the ratchet should eventually flip short"
250        );
251    }
252
253    #[test]
254    fn reset_clears_state() {
255        let mut r = AtrRatchet::new(5, 4.0, 0.1).unwrap();
256        let candles: Vec<Candle> = (0..40)
257            .map(|i| {
258                let base = 100.0 + f64::from(i);
259                c(base + 1.0, base - 1.0, base + 0.5)
260            })
261            .collect();
262        r.batch(&candles);
263        assert!(r.is_ready());
264        r.reset();
265        assert!(!r.is_ready());
266        assert_eq!(r.value(), None);
267        assert_eq!(r.update(candles[0]), None);
268    }
269
270    #[test]
271    fn batch_equals_streaming() {
272        let candles: Vec<Candle> = (0..120)
273            .map(|i| {
274                let base = 100.0 + (f64::from(i) * 0.25).sin() * 9.0;
275                c(base + 2.0, base - 1.5, base + 0.5)
276            })
277            .collect();
278        let batch = AtrRatchet::new(14, 4.0, 0.1).unwrap().batch(&candles);
279        let mut b = AtrRatchet::new(14, 4.0, 0.1).unwrap();
280        let streamed: Vec<_> = candles.iter().map(|c| b.update(*c)).collect();
281        assert_eq!(batch, streamed);
282    }
283}