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        if period > crate::error::MAX_PERIOD {
65            return Err(Error::InvalidPeriod {
66                message: crate::error::PERIOD_ABOVE_MAX,
67            });
68        }
69        Ok(Self {
70            period,
71            highs: VecDeque::with_capacity(period),
72            lows: VecDeque::with_capacity(period),
73        })
74    }
75
76    /// The Turtle-system exit window: a `10`-bar lookback.
77    pub fn classic() -> Self {
78        Self::new(10).expect("classic Donchian Stop period is valid")
79    }
80
81    /// Configured lookback.
82    pub const fn period(&self) -> usize {
83        self.period
84    }
85}
86
87impl Indicator for DonchianStop {
88    type Input = Candle;
89    type Output = DonchianStopOutput;
90
91    #[inline]
92    fn update(&mut self, candle: Candle) -> Option<DonchianStopOutput> {
93        if self.highs.len() == self.period {
94            self.highs.pop_front();
95            self.lows.pop_front();
96        }
97        self.highs.push_back(candle.high);
98        self.lows.push_back(candle.low);
99        if self.highs.len() < self.period {
100            return None;
101        }
102        let stop_short = self.highs.iter().copied().fold(f64::NEG_INFINITY, f64::max);
103        let stop_long = self.lows.iter().copied().fold(f64::INFINITY, f64::min);
104        Some(DonchianStopOutput {
105            stop_long,
106            stop_short,
107        })
108    }
109
110    fn reset(&mut self) {
111        self.highs.clear();
112        self.lows.clear();
113    }
114
115    #[inline]
116    fn warmup_period(&self) -> usize {
117        self.period
118    }
119
120    #[inline]
121    fn is_ready(&self) -> bool {
122        self.highs.len() == self.period
123    }
124
125    #[inline]
126    fn name(&self) -> &'static str {
127        "DonchianStop"
128    }
129}
130
131#[cfg(test)]
132mod tests {
133    use super::*;
134    use crate::traits::BatchExt;
135    use approx::assert_relative_eq;
136
137    fn c(high: f64, low: f64, close: f64, ts: i64) -> Candle {
138        Candle::new(f64::midpoint(high, low), high, low, close, 1.0, ts).unwrap()
139    }
140
141    #[test]
142    fn rejects_zero_period() {
143        assert!(DonchianStop::new(0).is_err());
144    }
145
146    #[test]
147    fn accessors_and_metadata() {
148        let s = DonchianStop::classic();
149        assert_eq!(s.period(), 10);
150        assert_eq!(s.warmup_period(), 10);
151        assert_eq!(s.name(), "DonchianStop");
152    }
153
154    #[test]
155    fn first_emission_matches_warmup() {
156        let candles: Vec<Candle> = (0..10)
157            .map(|i| {
158                let base = 100.0 + i as f64;
159                c(base + 1.0, base - 1.0, base, i)
160            })
161            .collect();
162        let mut s = DonchianStop::new(5).unwrap();
163        let out = s.batch(&candles);
164        for (i, v) in out.iter().enumerate().take(4) {
165            assert!(v.is_none(), "index {i} must be None during warmup");
166        }
167        assert!(out[4].is_some());
168    }
169
170    #[test]
171    fn reference_values_uptrend_window() {
172        // Highs 0..5 = 1..6; lowest low = 0, highest high = 5.
173        let candles: Vec<Candle> = (0..5)
174            .map(|i| {
175                let base = i as f64 + 0.5;
176                c(base + 0.5, base - 0.5, base, i)
177            })
178            .collect();
179        let mut s = DonchianStop::new(5).unwrap();
180        let out = s.batch(&candles);
181        let v = out[4].expect("ready at index 4");
182        assert_relative_eq!(v.stop_short, 5.0, epsilon = 1e-12);
183        assert_relative_eq!(v.stop_long, 0.0, epsilon = 1e-12);
184    }
185
186    #[test]
187    fn constant_series_holds_both_stops() {
188        let candles: Vec<Candle> = (0..30).map(|i| c(11.0, 9.0, 10.0, i)).collect();
189        let mut s = DonchianStop::new(5).unwrap();
190        for v in s.batch(&candles).into_iter().flatten() {
191            assert_relative_eq!(v.stop_short, 11.0, epsilon = 1e-12);
192            assert_relative_eq!(v.stop_long, 9.0, epsilon = 1e-12);
193        }
194    }
195
196    #[test]
197    fn reset_clears_state() {
198        let candles: Vec<Candle> = (0..30)
199            .map(|i| {
200                let base = 100.0 + i as f64;
201                c(base + 1.0, base - 1.0, base, i)
202            })
203            .collect();
204        let mut s = DonchianStop::classic();
205        s.batch(&candles);
206        assert!(s.is_ready());
207        s.reset();
208        assert!(!s.is_ready());
209        assert_eq!(s.update(candles[0]), None);
210    }
211
212    #[test]
213    fn batch_equals_streaming() {
214        let candles: Vec<Candle> = (0..80)
215            .map(|i| {
216                let mid = 100.0 + (i as f64 * 0.3).sin() * 8.0;
217                c(mid + 1.5, mid - 1.5, mid + 0.5, i)
218            })
219            .collect();
220        let mut a = DonchianStop::classic();
221        let mut b = DonchianStop::classic();
222        assert_eq!(
223            a.batch(&candles),
224            candles.iter().map(|x| b.update(*x)).collect::<Vec<_>>()
225        );
226    }
227}