Skip to main content

wickra_core/indicators/
kase_devstop.rs

1//! Kase `DevStop` — a volatility trailing stop on the standard deviation of the
2//! two-bar true range.
3
4use std::collections::VecDeque;
5
6use crate::error::{Error, Result};
7use crate::indicators::rolling_moments::ShiftedMoments;
8use crate::ohlcv::Candle;
9use crate::traits::Indicator;
10
11/// Output of [`KaseDevStop`]: the active trailing-stop level and the trend
12/// direction it protects.
13#[derive(Debug, Clone, Copy, PartialEq)]
14pub struct KaseDevStopOutput {
15    /// The `DevStop` level — below price in an uptrend, above price in a downtrend.
16    pub value: f64,
17    /// Trend direction: `+1.0` long (stop below price), `-1.0` short.
18    pub direction: f64,
19}
20
21/// Kase `DevStop` — Cynthia Kase's volatility stop, built on the **standard
22/// deviation of the two-bar true range** rather than a single-bar ATR.
23///
24/// ```text
25/// DTR_t = max(high_t, high_{t−1}) − min(low_t, low_{t−1})   (two-bar range)
26/// band  = mean(DTR, period) + dev · stddev(DTR, period)
27/// long  stop = ratchet_up(  highest_high_since_flip − band )
28/// short stop = ratchet_down( lowest_low_since_flip  + band )
29/// ```
30///
31/// Kase observed that range expansion is better captured by a two-bar range than
32/// a one-bar one, and that subtracting a *standard-deviation* band (not a fixed
33/// ATR multiple) adapts the stop to changing volatility. The stop trails the
34/// extreme reached since the last reversal — ratcheting only in the trend's favour
35/// — and flips sides when price closes through it. `dev` selects which `DevStop`
36/// line to follow (`1`, `2` or `3` standard deviations are Kase's warning lines).
37///
38/// The first bar seeds the prior candle; the next `period` two-bar ranges seed the
39/// mean and standard deviation, so the first stop lands after `period + 1` inputs.
40/// Each `update` is O(1).
41///
42/// # Example
43///
44/// ```
45/// use wickra_core::{Candle, Indicator, KaseDevStop};
46///
47/// let mut indicator = KaseDevStop::new(30, 1.0).unwrap();
48/// let mut last = None;
49/// for i in 0..80 {
50///     let base = 100.0 + f64::from(i);
51///     let c = Candle::new(base, base + 2.0, base - 2.0, base + 1.0, 1_000.0, 0).unwrap();
52///     last = indicator.update(c);
53/// }
54/// assert!(last.is_some());
55/// ```
56#[derive(Debug, Clone)]
57pub struct KaseDevStop {
58    period: usize,
59    dev: f64,
60    prev: Option<Candle>,
61    window: VecDeque<f64>,
62    moments: ShiftedMoments,
63    direction: f64,
64    extreme: f64,
65    stop: f64,
66    last: Option<KaseDevStopOutput>,
67}
68
69impl KaseDevStop {
70    /// Construct a Kase `DevStop` with the given lookback `period` and
71    /// standard-deviation multiplier `dev`.
72    ///
73    /// # Errors
74    ///
75    /// Returns [`Error::InvalidPeriod`] if `period < 2` (a standard deviation
76    /// needs at least two samples) and [`Error::NonPositiveMultiplier`] if `dev`
77    /// is not finite and positive.
78    pub fn new(period: usize, dev: f64) -> Result<Self> {
79        if period < 2 {
80            return Err(Error::InvalidPeriod {
81                message: "Kase DevStop period must be >= 2",
82            });
83        }
84        if period > crate::error::MAX_PERIOD {
85            return Err(Error::InvalidPeriod {
86                message: crate::error::PERIOD_ABOVE_MAX,
87            });
88        }
89        if !dev.is_finite() || dev <= 0.0 {
90            return Err(Error::NonPositiveMultiplier);
91        }
92        Ok(Self {
93            period,
94            dev,
95            prev: None,
96            window: VecDeque::with_capacity(period),
97            moments: ShiftedMoments::new(),
98            direction: 0.0,
99            extreme: 0.0,
100            stop: 0.0,
101            last: None,
102        })
103    }
104
105    /// Configured `(period, dev)`.
106    pub const fn params(&self) -> (usize, f64) {
107        (self.period, self.dev)
108    }
109
110    /// Current value if available.
111    pub const fn value(&self) -> Option<KaseDevStopOutput> {
112        self.last
113    }
114}
115
116impl Indicator for KaseDevStop {
117    type Input = Candle;
118    type Output = KaseDevStopOutput;
119
120    fn update(&mut self, candle: Candle) -> Option<KaseDevStopOutput> {
121        let Some(prev) = self.prev else {
122            self.prev = Some(candle);
123            return None;
124        };
125        let dtr = candle.high.max(prev.high) - candle.low.min(prev.low);
126        self.prev = Some(candle);
127
128        if self.window.len() == self.period {
129            let old = self.window.pop_front().expect("non-empty");
130            self.moments.evict(old);
131        }
132        self.window.push_back(dtr);
133        self.moments.push(dtr);
134        if self.moments.needs_reseed(self.period) {
135            self.moments.reseed(self.window.iter().copied());
136        }
137        if self.window.len() < self.period {
138            return None;
139        }
140        let mean = self.moments.mean(self.period);
141        let band = mean + self.dev * self.moments.sample_variance(self.period).sqrt();
142
143        if self.direction == 0.0 {
144            // Seed the trend as long off the first fully-warmed bar.
145            self.direction = 1.0;
146            self.extreme = candle.high;
147            self.stop = candle.high - band;
148        } else if self.direction > 0.0 {
149            self.extreme = self.extreme.max(candle.high);
150            let raw = self.extreme - band;
151            self.stop = self.stop.max(raw);
152            if candle.close < self.stop {
153                self.direction = -1.0;
154                self.extreme = candle.low;
155                self.stop = candle.low + band;
156            }
157        } else {
158            self.extreme = self.extreme.min(candle.low);
159            let raw = self.extreme + band;
160            self.stop = self.stop.min(raw);
161            if candle.close > self.stop {
162                self.direction = 1.0;
163                self.extreme = candle.high;
164                self.stop = candle.high - band;
165            }
166        }
167
168        let out = KaseDevStopOutput {
169            value: self.stop,
170            direction: self.direction,
171        };
172        self.last = Some(out);
173        Some(out)
174    }
175
176    fn reset(&mut self) {
177        self.prev = None;
178        self.window.clear();
179        self.moments.reset();
180        self.direction = 0.0;
181        self.extreme = 0.0;
182        self.stop = 0.0;
183        self.last = None;
184    }
185
186    #[inline]
187    fn warmup_period(&self) -> usize {
188        self.period + 1
189    }
190
191    #[inline]
192    fn is_ready(&self) -> bool {
193        self.last.is_some()
194    }
195
196    #[inline]
197    fn name(&self) -> &'static str {
198        "KaseDevStop"
199    }
200}
201
202#[cfg(test)]
203mod tests {
204    use super::*;
205    use crate::traits::BatchExt;
206
207    fn c(high: f64, low: f64, close: f64) -> Candle {
208        Candle::new_unchecked(f64::midpoint(high, low), high, low, close, 1_000.0, 0)
209    }
210
211    #[test]
212    fn rejects_invalid_params() {
213        assert!(matches!(
214            KaseDevStop::new(1, 1.0),
215            Err(Error::InvalidPeriod { .. })
216        ));
217        assert!(matches!(
218            KaseDevStop::new(30, 0.0),
219            Err(Error::NonPositiveMultiplier)
220        ));
221        assert!(matches!(
222            KaseDevStop::new(30, -1.0),
223            Err(Error::NonPositiveMultiplier)
224        ));
225    }
226
227    #[test]
228    fn accessors_and_metadata() {
229        let k = KaseDevStop::new(30, 1.0).unwrap();
230        assert_eq!(k.params(), (30, 1.0));
231        assert_eq!(k.warmup_period(), 31);
232        assert_eq!(k.name(), "KaseDevStop");
233        assert!(!k.is_ready());
234        assert_eq!(k.value(), None);
235    }
236
237    #[test]
238    fn first_emission_at_warmup_period() {
239        let mut k = KaseDevStop::new(3, 1.0).unwrap();
240        let candles: Vec<Candle> = (0..8)
241            .map(|i| {
242                let base = 100.0 + f64::from(i);
243                c(base + 1.0, base - 1.0, base)
244            })
245            .collect();
246        let out = k.batch(&candles);
247        let warmup = k.warmup_period(); // 4
248        assert_eq!(warmup, 4);
249        for v in out.iter().take(warmup - 1) {
250            assert!(v.is_none());
251        }
252        assert!(out[warmup - 1].is_some());
253    }
254
255    #[test]
256    fn uptrend_keeps_stop_below_price() {
257        let mut k = KaseDevStop::new(5, 1.0).unwrap();
258        let candles: Vec<Candle> = (0..60)
259            .map(|i| {
260                let base = 100.0 + 2.0 * f64::from(i);
261                c(base + 1.0, base - 1.0, base + 0.5)
262            })
263            .collect();
264        for (o, candle) in k.batch(&candles).into_iter().zip(candles.iter()) {
265            if let Some(o) = o {
266                assert_eq!(o.direction, 1.0, "pure uptrend stays long");
267                assert!(o.value < candle.close, "stop below price");
268            }
269        }
270    }
271
272    #[test]
273    fn stop_ratchets_up_in_uptrend() {
274        let mut k = KaseDevStop::new(5, 1.0).unwrap();
275        let candles: Vec<Candle> = (0..60)
276            .map(|i| {
277                let base = 100.0 + 2.0 * f64::from(i);
278                c(base + 1.0, base - 1.0, base + 0.5)
279            })
280            .collect();
281        let mut prev = f64::NEG_INFINITY;
282        for o in k.batch(&candles).into_iter().flatten() {
283            assert!(o.value >= prev, "long stop must not fall");
284            prev = o.value;
285        }
286    }
287
288    #[test]
289    fn flips_on_reversal() {
290        let mut candles: Vec<Candle> = (0..40)
291            .map(|i| {
292                let base = 100.0 + f64::from(i);
293                c(base + 1.0, base - 1.0, base + 0.5)
294            })
295            .collect();
296        candles.extend((0..40).map(|i| {
297            let base = 140.0 - f64::from(i);
298            c(base + 1.0, base - 1.0, base - 0.5)
299        }));
300        let mut k = KaseDevStop::new(5, 1.0).unwrap();
301        let dirs: Vec<f64> = k
302            .batch(&candles)
303            .into_iter()
304            .flatten()
305            .map(|o| o.direction)
306            .collect();
307        assert!(dirs.iter().any(|&d| d > 0.0));
308        assert!(dirs.iter().any(|&d| d < 0.0));
309    }
310
311    #[test]
312    fn reset_clears_state() {
313        let mut k = KaseDevStop::new(5, 1.0).unwrap();
314        let candles: Vec<Candle> = (0..40)
315            .map(|i| {
316                let base = 100.0 + f64::from(i);
317                c(base + 1.0, base - 1.0, base + 0.5)
318            })
319            .collect();
320        k.batch(&candles);
321        assert!(k.is_ready());
322        k.reset();
323        assert!(!k.is_ready());
324        assert_eq!(k.value(), None);
325        assert_eq!(k.update(candles[0]), None);
326    }
327
328    #[test]
329    fn batch_equals_streaming() {
330        let candles: Vec<Candle> = (0..120)
331            .map(|i| {
332                let base = 100.0 + (f64::from(i) * 0.25).sin() * 9.0;
333                c(base + 2.0, base - 1.5, base + 0.5)
334            })
335            .collect();
336        let batch = KaseDevStop::new(20, 2.0).unwrap().batch(&candles);
337        let mut b = KaseDevStop::new(20, 2.0).unwrap();
338        let streamed: Vec<_> = candles.iter().map(|c| b.update(*c)).collect();
339        assert_eq!(batch, streamed);
340    }
341}