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::ohlcv::Candle;
8use crate::traits::Indicator;
9
10/// Output of [`KaseDevStop`]: the active trailing-stop level and the trend
11/// direction it protects.
12#[derive(Debug, Clone, Copy, PartialEq)]
13pub struct KaseDevStopOutput {
14    /// The `DevStop` level — below price in an uptrend, above price in a downtrend.
15    pub value: f64,
16    /// Trend direction: `+1.0` long (stop below price), `-1.0` short.
17    pub direction: f64,
18}
19
20/// Sample standard deviation from a running `(sum, sum_of_squares, count)`.
21fn sample_stddev(sum: f64, sum_sq: f64, count: usize) -> f64 {
22    let n = count as f64;
23    let mean = sum / n;
24    (((sum_sq - n * mean * mean) / (n - 1.0)).max(0.0)).sqrt()
25}
26
27/// Kase `DevStop` — Cynthia Kase's volatility stop, built on the **standard
28/// deviation of the two-bar true range** rather than a single-bar ATR.
29///
30/// ```text
31/// DTR_t = max(high_t, high_{t−1}) − min(low_t, low_{t−1})   (two-bar range)
32/// band  = mean(DTR, period) + dev · stddev(DTR, period)
33/// long  stop = ratchet_up(  highest_high_since_flip − band )
34/// short stop = ratchet_down( lowest_low_since_flip  + band )
35/// ```
36///
37/// Kase observed that range expansion is better captured by a two-bar range than
38/// a one-bar one, and that subtracting a *standard-deviation* band (not a fixed
39/// ATR multiple) adapts the stop to changing volatility. The stop trails the
40/// extreme reached since the last reversal — ratcheting only in the trend's favour
41/// — and flips sides when price closes through it. `dev` selects which `DevStop`
42/// line to follow (`1`, `2` or `3` standard deviations are Kase's warning lines).
43///
44/// The first bar seeds the prior candle; the next `period` two-bar ranges seed the
45/// mean and standard deviation, so the first stop lands after `period + 1` inputs.
46/// Each `update` is O(1).
47///
48/// # Example
49///
50/// ```
51/// use wickra_core::{Candle, Indicator, KaseDevStop};
52///
53/// let mut indicator = KaseDevStop::new(30, 1.0).unwrap();
54/// let mut last = None;
55/// for i in 0..80 {
56///     let base = 100.0 + f64::from(i);
57///     let c = Candle::new(base, base + 2.0, base - 2.0, base + 1.0, 1_000.0, 0).unwrap();
58///     last = indicator.update(c);
59/// }
60/// assert!(last.is_some());
61/// ```
62#[derive(Debug, Clone)]
63pub struct KaseDevStop {
64    period: usize,
65    dev: f64,
66    prev: Option<Candle>,
67    window: VecDeque<f64>,
68    sum: f64,
69    sum_sq: f64,
70    direction: f64,
71    extreme: f64,
72    stop: f64,
73    last: Option<KaseDevStopOutput>,
74}
75
76impl KaseDevStop {
77    /// Construct a Kase `DevStop` with the given lookback `period` and
78    /// standard-deviation multiplier `dev`.
79    ///
80    /// # Errors
81    ///
82    /// Returns [`Error::InvalidPeriod`] if `period < 2` (a standard deviation
83    /// needs at least two samples) and [`Error::NonPositiveMultiplier`] if `dev`
84    /// is not finite and positive.
85    pub fn new(period: usize, dev: f64) -> Result<Self> {
86        if period < 2 {
87            return Err(Error::InvalidPeriod {
88                message: "Kase DevStop period must be >= 2",
89            });
90        }
91        if !dev.is_finite() || dev <= 0.0 {
92            return Err(Error::NonPositiveMultiplier);
93        }
94        Ok(Self {
95            period,
96            dev,
97            prev: None,
98            window: VecDeque::with_capacity(period),
99            sum: 0.0,
100            sum_sq: 0.0,
101            direction: 0.0,
102            extreme: 0.0,
103            stop: 0.0,
104            last: None,
105        })
106    }
107
108    /// Configured `(period, dev)`.
109    pub const fn params(&self) -> (usize, f64) {
110        (self.period, self.dev)
111    }
112
113    /// Current value if available.
114    pub const fn value(&self) -> Option<KaseDevStopOutput> {
115        self.last
116    }
117}
118
119impl Indicator for KaseDevStop {
120    type Input = Candle;
121    type Output = KaseDevStopOutput;
122
123    fn update(&mut self, candle: Candle) -> Option<KaseDevStopOutput> {
124        let Some(prev) = self.prev else {
125            self.prev = Some(candle);
126            return None;
127        };
128        let dtr = candle.high.max(prev.high) - candle.low.min(prev.low);
129        self.prev = Some(candle);
130
131        if self.window.len() == self.period {
132            let old = self.window.pop_front().expect("non-empty");
133            self.sum -= old;
134            self.sum_sq -= old * old;
135        }
136        self.window.push_back(dtr);
137        self.sum += dtr;
138        self.sum_sq += dtr * dtr;
139        if self.window.len() < self.period {
140            return None;
141        }
142        let mean = self.sum / self.period as f64;
143        let band = mean + self.dev * sample_stddev(self.sum, self.sum_sq, self.period);
144
145        if self.direction == 0.0 {
146            // Seed the trend as long off the first fully-warmed bar.
147            self.direction = 1.0;
148            self.extreme = candle.high;
149            self.stop = candle.high - band;
150        } else if self.direction > 0.0 {
151            self.extreme = self.extreme.max(candle.high);
152            let raw = self.extreme - band;
153            self.stop = self.stop.max(raw);
154            if candle.close < self.stop {
155                self.direction = -1.0;
156                self.extreme = candle.low;
157                self.stop = candle.low + band;
158            }
159        } else {
160            self.extreme = self.extreme.min(candle.low);
161            let raw = self.extreme + band;
162            self.stop = self.stop.min(raw);
163            if candle.close > self.stop {
164                self.direction = 1.0;
165                self.extreme = candle.high;
166                self.stop = candle.high - band;
167            }
168        }
169
170        let out = KaseDevStopOutput {
171            value: self.stop,
172            direction: self.direction,
173        };
174        self.last = Some(out);
175        Some(out)
176    }
177
178    fn reset(&mut self) {
179        self.prev = None;
180        self.window.clear();
181        self.sum = 0.0;
182        self.sum_sq = 0.0;
183        self.direction = 0.0;
184        self.extreme = 0.0;
185        self.stop = 0.0;
186        self.last = None;
187    }
188
189    fn warmup_period(&self) -> usize {
190        self.period + 1
191    }
192
193    fn is_ready(&self) -> bool {
194        self.last.is_some()
195    }
196
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}