Skip to main content

mantis_ta/indicators/support_resistance/
donchian.rs

1use crate::indicators::Indicator;
2use crate::types::{Candle, DonchianOutput};
3use crate::utils::ringbuf::RingBuf;
4
5/// Donchian Channels — highest high / lowest low over a rolling window.
6///
7/// The upper band is the highest high and the lower band the lowest low
8/// over the last `period` candles (inclusive of the current one); the
9/// middle band is their midpoint. Commonly used for breakout detection
10/// and trailing-stop placement.
11///
12/// # Examples
13/// ```rust
14/// use mantis_ta::indicators::{DonchianChannels, Indicator};
15/// use mantis_ta::types::Candle;
16///
17/// let candles: Vec<Candle> = (0..10)
18///     .map(|i| {
19///         let price = 100.0 + i as f64;
20///         Candle {
21///             timestamp: i as i64,
22///             open: price,
23///             high: price + 1.0,
24///             low: price - 1.0,
25///             close: price,
26///             volume: 1_000.0,
27///         }
28///     })
29///     .collect();
30///
31/// let out = DonchianChannels::new(5).calculate(&candles);
32/// assert!(out.iter().take(4).all(|v| v.is_none()));
33/// let d = out[4].unwrap();
34/// assert!(d.upper > d.middle && d.middle > d.lower);
35/// ```
36#[derive(Debug, Clone)]
37pub struct DonchianChannels {
38    period: usize,
39    window: RingBuf<(f64, f64)>,
40}
41
42impl DonchianChannels {
43    pub fn new(period: usize) -> Self {
44        assert!(period > 0, "period must be > 0");
45        Self {
46            period,
47            window: RingBuf::new(period, (0.0, 0.0)),
48        }
49    }
50}
51
52impl Indicator for DonchianChannels {
53    type Output = DonchianOutput;
54
55    fn next(&mut self, candle: &Candle) -> Option<Self::Output> {
56        self.window.push((candle.high, candle.low));
57        if self.window.len() < self.period {
58            return None;
59        }
60
61        let mut upper = f64::MIN;
62        let mut lower = f64::MAX;
63        for &(h, l) in self.window.iter() {
64            if h > upper {
65                upper = h;
66            }
67            if l < lower {
68                lower = l;
69            }
70        }
71
72        Some(DonchianOutput {
73            upper,
74            middle: (upper + lower) / 2.0,
75            lower,
76        })
77    }
78
79    fn reset(&mut self) {
80        self.window = RingBuf::new(self.period, (0.0, 0.0));
81    }
82
83    fn warmup_period(&self) -> usize {
84        self.period
85    }
86
87    fn clone_boxed(&self) -> Box<dyn Indicator<Output = Self::Output>> {
88        Box::new(self.clone())
89    }
90}
91
92#[cfg(test)]
93mod tests {
94    use super::*;
95
96    fn candle(high: f64, low: f64) -> Candle {
97        Candle {
98            timestamp: 0,
99            open: (high + low) / 2.0,
100            high,
101            low,
102            close: (high + low) / 2.0,
103            volume: 1_000.0,
104        }
105    }
106
107    #[test]
108    fn donchian_emits_after_warmup() {
109        let mut dc = DonchianChannels::new(5);
110        let candles: Vec<Candle> = (0..8)
111            .map(|i| candle(100.0 + i as f64, 99.0 + i as f64))
112            .collect();
113
114        let outputs: Vec<_> = candles.iter().map(|c| dc.next(c)).collect();
115        let wp = dc.warmup_period();
116        assert!(outputs.iter().take(wp - 1).all(|o| o.is_none()));
117        assert!(outputs[wp - 1].is_some());
118    }
119
120    #[test]
121    fn donchian_tracks_highest_high_lowest_low() {
122        let mut dc = DonchianChannels::new(3);
123        let candles: Vec<Candle> = [(10.0, 5.0), (12.0, 6.0), (8.0, 3.0), (9.0, 4.0), (7.0, 2.0)]
124            .iter()
125            .map(|&(h, l)| candle(h, l))
126            .collect();
127
128        let outputs: Vec<_> = candles.iter().map(|c| dc.next(c)).collect();
129
130        // Window [10,12,8] high=5,6,3 low -> upper=12, lower=3
131        let out2 = outputs[2].unwrap();
132        assert!((out2.upper - 12.0).abs() < 1e-9);
133        assert!((out2.lower - 3.0).abs() < 1e-9);
134        assert!((out2.middle - 7.5).abs() < 1e-9);
135
136        // Window [12,8,9] high, [6,3,4] low -> upper=12, lower=3
137        let out3 = outputs[3].unwrap();
138        assert!((out3.upper - 12.0).abs() < 1e-9);
139        assert!((out3.lower - 3.0).abs() < 1e-9);
140
141        // Window [8,9,7] high, [3,4,2] low -> upper=9, lower=2
142        let out4 = outputs[4].unwrap();
143        assert!((out4.upper - 9.0).abs() < 1e-9);
144        assert!((out4.lower - 2.0).abs() < 1e-9);
145    }
146
147    #[test]
148    fn donchian_bands_bracket_middle() {
149        let mut dc = DonchianChannels::new(4);
150        let candles: Vec<Candle> = (0..8)
151            .map(|i| candle(100.0 + i as f64, 99.0 - i as f64))
152            .collect();
153
154        for c in &candles {
155            if let Some(out) = dc.next(c) {
156                assert!(out.upper >= out.middle);
157                assert!(out.middle >= out.lower);
158                assert!((out.middle - (out.upper + out.lower) / 2.0).abs() < 1e-9);
159            }
160        }
161    }
162
163    #[test]
164    fn donchian_flat_prices_zero_width_band() {
165        let mut dc = DonchianChannels::new(3);
166        let candles: Vec<Candle> = (0..5).map(|_| candle(50.0, 50.0)).collect();
167
168        for c in &candles {
169            if let Some(out) = dc.next(c) {
170                assert!((out.upper - 50.0).abs() < 1e-9);
171                assert!((out.lower - 50.0).abs() < 1e-9);
172                assert!((out.middle - 50.0).abs() < 1e-9);
173            }
174        }
175    }
176
177    #[test]
178    fn donchian_streaming_matches_batch() {
179        let candles: Vec<Candle> = (0..15)
180            .map(|i| candle(90.0 + i as f64 * 0.7, 89.0 + i as f64 * 0.7))
181            .collect();
182
183        let batch = DonchianChannels::new(5).calculate(&candles);
184
185        let mut streamed_dc = DonchianChannels::new(5);
186        let streamed: Vec<_> = candles.iter().map(|c| streamed_dc.next(c)).collect();
187
188        assert_eq!(streamed, batch);
189    }
190
191    #[test]
192    fn donchian_reset_clears_state() {
193        let mut dc = DonchianChannels::new(4);
194        let candles: Vec<Candle> = (0..6)
195            .map(|i| candle(100.0 + i as f64, 99.0 + i as f64))
196            .collect();
197        for c in &candles {
198            dc.next(c);
199        }
200        dc.reset();
201
202        let mut fresh = DonchianChannels::new(4);
203        for c in &candles {
204            assert_eq!(dc.next(c), fresh.next(c));
205        }
206    }
207
208    #[test]
209    #[should_panic(expected = "period must be > 0")]
210    fn donchian_rejects_zero_period() {
211        DonchianChannels::new(0);
212    }
213}