wickra_core/indicators/
kase_devstop.rs1use 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#[derive(Debug, Clone, Copy, PartialEq)]
14pub struct KaseDevStopOutput {
15 pub value: f64,
17 pub direction: f64,
19}
20
21#[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 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 pub const fn params(&self) -> (usize, f64) {
107 (self.period, self.dev)
108 }
109
110 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 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(); 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}