use std::collections::VecDeque;
use crate::error::{Error, Result};
use crate::ohlcv::Candle;
use crate::traits::Indicator;
#[derive(Debug, Clone, Copy, PartialEq)]
pub struct KaseDevStopOutput {
pub value: f64,
pub direction: f64,
}
fn sample_stddev(sum: f64, sum_sq: f64, count: usize) -> f64 {
let n = count as f64;
let mean = sum / n;
(((sum_sq - n * mean * mean) / (n - 1.0)).max(0.0)).sqrt()
}
#[derive(Debug, Clone)]
pub struct KaseDevStop {
period: usize,
dev: f64,
prev: Option<Candle>,
window: VecDeque<f64>,
sum: f64,
sum_sq: f64,
direction: f64,
extreme: f64,
stop: f64,
last: Option<KaseDevStopOutput>,
}
impl KaseDevStop {
pub fn new(period: usize, dev: f64) -> Result<Self> {
if period < 2 {
return Err(Error::InvalidPeriod {
message: "Kase DevStop period must be >= 2",
});
}
if !dev.is_finite() || dev <= 0.0 {
return Err(Error::NonPositiveMultiplier);
}
Ok(Self {
period,
dev,
prev: None,
window: VecDeque::with_capacity(period),
sum: 0.0,
sum_sq: 0.0,
direction: 0.0,
extreme: 0.0,
stop: 0.0,
last: None,
})
}
pub const fn params(&self) -> (usize, f64) {
(self.period, self.dev)
}
pub const fn value(&self) -> Option<KaseDevStopOutput> {
self.last
}
}
impl Indicator for KaseDevStop {
type Input = Candle;
type Output = KaseDevStopOutput;
fn update(&mut self, candle: Candle) -> Option<KaseDevStopOutput> {
let Some(prev) = self.prev else {
self.prev = Some(candle);
return None;
};
let dtr = candle.high.max(prev.high) - candle.low.min(prev.low);
self.prev = Some(candle);
if self.window.len() == self.period {
let old = self.window.pop_front().expect("non-empty");
self.sum -= old;
self.sum_sq -= old * old;
}
self.window.push_back(dtr);
self.sum += dtr;
self.sum_sq += dtr * dtr;
if self.window.len() < self.period {
return None;
}
let mean = self.sum / self.period as f64;
let band = mean + self.dev * sample_stddev(self.sum, self.sum_sq, self.period);
if self.direction == 0.0 {
self.direction = 1.0;
self.extreme = candle.high;
self.stop = candle.high - band;
} else if self.direction > 0.0 {
self.extreme = self.extreme.max(candle.high);
let raw = self.extreme - band;
self.stop = self.stop.max(raw);
if candle.close < self.stop {
self.direction = -1.0;
self.extreme = candle.low;
self.stop = candle.low + band;
}
} else {
self.extreme = self.extreme.min(candle.low);
let raw = self.extreme + band;
self.stop = self.stop.min(raw);
if candle.close > self.stop {
self.direction = 1.0;
self.extreme = candle.high;
self.stop = candle.high - band;
}
}
let out = KaseDevStopOutput {
value: self.stop,
direction: self.direction,
};
self.last = Some(out);
Some(out)
}
fn reset(&mut self) {
self.prev = None;
self.window.clear();
self.sum = 0.0;
self.sum_sq = 0.0;
self.direction = 0.0;
self.extreme = 0.0;
self.stop = 0.0;
self.last = None;
}
fn warmup_period(&self) -> usize {
self.period + 1
}
fn is_ready(&self) -> bool {
self.last.is_some()
}
fn name(&self) -> &'static str {
"KaseDevStop"
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::traits::BatchExt;
fn c(high: f64, low: f64, close: f64) -> Candle {
Candle::new_unchecked(f64::midpoint(high, low), high, low, close, 1_000.0, 0)
}
#[test]
fn rejects_invalid_params() {
assert!(matches!(
KaseDevStop::new(1, 1.0),
Err(Error::InvalidPeriod { .. })
));
assert!(matches!(
KaseDevStop::new(30, 0.0),
Err(Error::NonPositiveMultiplier)
));
assert!(matches!(
KaseDevStop::new(30, -1.0),
Err(Error::NonPositiveMultiplier)
));
}
#[test]
fn accessors_and_metadata() {
let k = KaseDevStop::new(30, 1.0).unwrap();
assert_eq!(k.params(), (30, 1.0));
assert_eq!(k.warmup_period(), 31);
assert_eq!(k.name(), "KaseDevStop");
assert!(!k.is_ready());
assert_eq!(k.value(), None);
}
#[test]
fn first_emission_at_warmup_period() {
let mut k = KaseDevStop::new(3, 1.0).unwrap();
let candles: Vec<Candle> = (0..8)
.map(|i| {
let base = 100.0 + f64::from(i);
c(base + 1.0, base - 1.0, base)
})
.collect();
let out = k.batch(&candles);
let warmup = k.warmup_period(); assert_eq!(warmup, 4);
for v in out.iter().take(warmup - 1) {
assert!(v.is_none());
}
assert!(out[warmup - 1].is_some());
}
#[test]
fn uptrend_keeps_stop_below_price() {
let mut k = KaseDevStop::new(5, 1.0).unwrap();
let candles: Vec<Candle> = (0..60)
.map(|i| {
let base = 100.0 + 2.0 * f64::from(i);
c(base + 1.0, base - 1.0, base + 0.5)
})
.collect();
for (o, candle) in k.batch(&candles).into_iter().zip(candles.iter()) {
if let Some(o) = o {
assert_eq!(o.direction, 1.0, "pure uptrend stays long");
assert!(o.value < candle.close, "stop below price");
}
}
}
#[test]
fn stop_ratchets_up_in_uptrend() {
let mut k = KaseDevStop::new(5, 1.0).unwrap();
let candles: Vec<Candle> = (0..60)
.map(|i| {
let base = 100.0 + 2.0 * f64::from(i);
c(base + 1.0, base - 1.0, base + 0.5)
})
.collect();
let mut prev = f64::NEG_INFINITY;
for o in k.batch(&candles).into_iter().flatten() {
assert!(o.value >= prev, "long stop must not fall");
prev = o.value;
}
}
#[test]
fn flips_on_reversal() {
let mut candles: Vec<Candle> = (0..40)
.map(|i| {
let base = 100.0 + f64::from(i);
c(base + 1.0, base - 1.0, base + 0.5)
})
.collect();
candles.extend((0..40).map(|i| {
let base = 140.0 - f64::from(i);
c(base + 1.0, base - 1.0, base - 0.5)
}));
let mut k = KaseDevStop::new(5, 1.0).unwrap();
let dirs: Vec<f64> = k
.batch(&candles)
.into_iter()
.flatten()
.map(|o| o.direction)
.collect();
assert!(dirs.iter().any(|&d| d > 0.0));
assert!(dirs.iter().any(|&d| d < 0.0));
}
#[test]
fn reset_clears_state() {
let mut k = KaseDevStop::new(5, 1.0).unwrap();
let candles: Vec<Candle> = (0..40)
.map(|i| {
let base = 100.0 + f64::from(i);
c(base + 1.0, base - 1.0, base + 0.5)
})
.collect();
k.batch(&candles);
assert!(k.is_ready());
k.reset();
assert!(!k.is_ready());
assert_eq!(k.value(), None);
assert_eq!(k.update(candles[0]), None);
}
#[test]
fn batch_equals_streaming() {
let candles: Vec<Candle> = (0..120)
.map(|i| {
let base = 100.0 + (f64::from(i) * 0.25).sin() * 9.0;
c(base + 2.0, base - 1.5, base + 0.5)
})
.collect();
let batch = KaseDevStop::new(20, 2.0).unwrap().batch(&candles);
let mut b = KaseDevStop::new(20, 2.0).unwrap();
let streamed: Vec<_> = candles.iter().map(|c| b.update(*c)).collect();
assert_eq!(batch, streamed);
}
}