Skip to main content

wickra_core/indicators/
donchian_stop.rs

1//! Donchian Channel Stop (Turtle).
2
3use std::collections::VecDeque;
4
5use crate::error::{Error, Result};
6use crate::ohlcv::Candle;
7use crate::traits::Indicator;
8
9/// Donchian Channel Stop output: the long-side and short-side trailing stops.
10#[derive(Debug, Clone, Copy, PartialEq)]
11pub struct DonchianStopOutput {
12    /// Long-position stop: the lowest low over the lookback.
13    pub stop_long: f64,
14    /// Short-position stop: the highest high over the lookback.
15    pub stop_short: f64,
16}
17
18/// Donchian Channel Stop — the original Turtle-trader exit rule. A long is
19/// trailed at the lowest low of the last `period` bars; a short at the highest
20/// high. There is no ATR, no multiplier, and no flip-bit — the two levels are
21/// always emitted and the caller selects whichever side matches the position.
22///
23/// ```text
24/// stop_long  = min(low,  over period bars)
25/// stop_short = max(high, over period bars)
26/// ```
27///
28/// Richard Dennis' original Turtle System used a 20-bar entry channel and a
29/// 10-bar exit channel — feed this indicator the exit window. The first
30/// `period` candles are warmup; on the bar that fills the window it begins
31/// emitting both stops.
32///
33/// # Example
34///
35/// ```
36/// use wickra_core::{Candle, Indicator, DonchianStop};
37///
38/// let mut indicator = DonchianStop::new(10).unwrap();
39/// let mut last = None;
40/// for i in 0..40 {
41///     let base = 100.0 + f64::from(i);
42///     let candle =
43///         Candle::new(base, base + 2.0, base - 2.0, base + 1.0, 10.0, i64::from(i)).unwrap();
44///     last = indicator.update(candle);
45/// }
46/// assert!(last.is_some());
47/// ```
48#[derive(Debug, Clone)]
49pub struct DonchianStop {
50    period: usize,
51    highs: VecDeque<f64>,
52    lows: VecDeque<f64>,
53}
54
55impl DonchianStop {
56    /// Construct a Donchian Channel Stop with an explicit lookback.
57    ///
58    /// # Errors
59    /// Returns [`Error::PeriodZero`] if `period == 0`.
60    pub fn new(period: usize) -> Result<Self> {
61        if period == 0 {
62            return Err(Error::PeriodZero);
63        }
64        Ok(Self {
65            period,
66            highs: VecDeque::with_capacity(period),
67            lows: VecDeque::with_capacity(period),
68        })
69    }
70
71    /// The Turtle-system exit window: a `10`-bar lookback.
72    pub fn classic() -> Self {
73        Self::new(10).expect("classic Donchian Stop period is valid")
74    }
75
76    /// Configured lookback.
77    pub const fn period(&self) -> usize {
78        self.period
79    }
80}
81
82impl Indicator for DonchianStop {
83    type Input = Candle;
84    type Output = DonchianStopOutput;
85
86    fn update(&mut self, candle: Candle) -> Option<DonchianStopOutput> {
87        if self.highs.len() == self.period {
88            self.highs.pop_front();
89            self.lows.pop_front();
90        }
91        self.highs.push_back(candle.high);
92        self.lows.push_back(candle.low);
93        if self.highs.len() < self.period {
94            return None;
95        }
96        let stop_short = self.highs.iter().copied().fold(f64::NEG_INFINITY, f64::max);
97        let stop_long = self.lows.iter().copied().fold(f64::INFINITY, f64::min);
98        Some(DonchianStopOutput {
99            stop_long,
100            stop_short,
101        })
102    }
103
104    fn reset(&mut self) {
105        self.highs.clear();
106        self.lows.clear();
107    }
108
109    fn warmup_period(&self) -> usize {
110        self.period
111    }
112
113    fn is_ready(&self) -> bool {
114        self.highs.len() == self.period
115    }
116
117    fn name(&self) -> &'static str {
118        "DonchianStop"
119    }
120}
121
122#[cfg(test)]
123mod tests {
124    use super::*;
125    use crate::traits::BatchExt;
126    use approx::assert_relative_eq;
127
128    fn c(high: f64, low: f64, close: f64, ts: i64) -> Candle {
129        Candle::new(f64::midpoint(high, low), high, low, close, 1.0, ts).unwrap()
130    }
131
132    #[test]
133    fn rejects_zero_period() {
134        assert!(DonchianStop::new(0).is_err());
135    }
136
137    #[test]
138    fn accessors_and_metadata() {
139        let s = DonchianStop::classic();
140        assert_eq!(s.period(), 10);
141        assert_eq!(s.warmup_period(), 10);
142        assert_eq!(s.name(), "DonchianStop");
143    }
144
145    #[test]
146    fn first_emission_matches_warmup() {
147        let candles: Vec<Candle> = (0..10)
148            .map(|i| {
149                let base = 100.0 + i as f64;
150                c(base + 1.0, base - 1.0, base, i)
151            })
152            .collect();
153        let mut s = DonchianStop::new(5).unwrap();
154        let out = s.batch(&candles);
155        for (i, v) in out.iter().enumerate().take(4) {
156            assert!(v.is_none(), "index {i} must be None during warmup");
157        }
158        assert!(out[4].is_some());
159    }
160
161    #[test]
162    fn reference_values_uptrend_window() {
163        // Highs 0..5 = 1..6; lowest low = 0, highest high = 5.
164        let candles: Vec<Candle> = (0..5)
165            .map(|i| {
166                let base = i as f64 + 0.5;
167                c(base + 0.5, base - 0.5, base, i)
168            })
169            .collect();
170        let mut s = DonchianStop::new(5).unwrap();
171        let out = s.batch(&candles);
172        let v = out[4].expect("ready at index 4");
173        assert_relative_eq!(v.stop_short, 5.0, epsilon = 1e-12);
174        assert_relative_eq!(v.stop_long, 0.0, epsilon = 1e-12);
175    }
176
177    #[test]
178    fn constant_series_holds_both_stops() {
179        let candles: Vec<Candle> = (0..30).map(|i| c(11.0, 9.0, 10.0, i)).collect();
180        let mut s = DonchianStop::new(5).unwrap();
181        for v in s.batch(&candles).into_iter().flatten() {
182            assert_relative_eq!(v.stop_short, 11.0, epsilon = 1e-12);
183            assert_relative_eq!(v.stop_long, 9.0, epsilon = 1e-12);
184        }
185    }
186
187    #[test]
188    fn reset_clears_state() {
189        let candles: Vec<Candle> = (0..30)
190            .map(|i| {
191                let base = 100.0 + i as f64;
192                c(base + 1.0, base - 1.0, base, i)
193            })
194            .collect();
195        let mut s = DonchianStop::classic();
196        s.batch(&candles);
197        assert!(s.is_ready());
198        s.reset();
199        assert!(!s.is_ready());
200        assert_eq!(s.update(candles[0]), None);
201    }
202
203    #[test]
204    fn batch_equals_streaming() {
205        let candles: Vec<Candle> = (0..80)
206            .map(|i| {
207                let mid = 100.0 + (i as f64 * 0.3).sin() * 8.0;
208                c(mid + 1.5, mid - 1.5, mid + 0.5, i)
209            })
210            .collect();
211        let mut a = DonchianStop::classic();
212        let mut b = DonchianStop::classic();
213        assert_eq!(
214            a.batch(&candles),
215            candles.iter().map(|x| b.update(*x)).collect::<Vec<_>>()
216        );
217    }
218}