Skip to main content

quantwave_core/indicators/
donchian.rs

1use crate::indicators::metadata::{IndicatorMetadata, ParamDef};
2use crate::traits::Next;
3use std::collections::VecDeque;
4
5/// Donchian Channels
6/// Upper Band: Highest high over the last N periods.
7/// Lower Band: Lowest low over the last N periods.
8/// Middle Band: (Upper + Lower) / 2
9#[derive(Debug, Clone)]
10pub struct DonchianChannels {
11    period: usize,
12    highs: VecDeque<f64>,
13    lows: VecDeque<f64>,
14}
15
16impl DonchianChannels {
17    pub fn new(period: usize) -> Self {
18        Self {
19            period,
20            highs: VecDeque::with_capacity(period),
21            lows: VecDeque::with_capacity(period),
22        }
23    }
24}
25
26impl Next<(f64, f64)> for DonchianChannels {
27    type Output = (f64, f64, f64);
28
29    fn next(&mut self, (high, low): (f64, f64)) -> Self::Output {
30        self.highs.push_back(high);
31        self.lows.push_back(low);
32
33        if self.highs.len() > self.period {
34            self.highs.pop_front();
35            self.lows.pop_front();
36        }
37
38        let mut max_high = f64::MIN;
39        let mut min_low = f64::MAX;
40
41        for &h in self.highs.iter() {
42            if h > max_high {
43                max_high = h;
44            }
45        }
46
47        for &l in self.lows.iter() {
48            if l < min_low {
49                min_low = l;
50            }
51        }
52
53        let middle = (max_high + min_low) / 2.0;
54
55        (max_high, middle, min_low)
56    }
57}
58
59#[cfg(test)]
60mod tests {
61    use super::*;
62    use proptest::prelude::*;
63    use serde::Deserialize;
64    use std::fs;
65    use std::path::Path;
66
67    #[derive(Debug, Deserialize)]
68    struct DonchianCase {
69        highs: Vec<f64>,
70        lows: Vec<f64>,
71        expected_middle: Vec<f64>,
72    }
73
74    #[test]
75    fn test_donchian_gold_standard() {
76        let manifest_dir = std::env::var("CARGO_MANIFEST_DIR").unwrap();
77        let manifest_path = Path::new(&manifest_dir);
78        let path = manifest_path.join("tests/gold_standard/donchian_5.json");
79        let path = if path.exists() {
80            path
81        } else {
82            manifest_path
83                .parent()
84                .unwrap()
85                .join("tests/gold_standard/donchian_5.json")
86        };
87        let content = fs::read_to_string(path).unwrap();
88        let case: DonchianCase = serde_json::from_str(&content).unwrap();
89
90        let mut dc = DonchianChannels::new(5);
91        for i in 0..case.highs.len() {
92            let (_, middle, _) = dc.next((case.highs[i], case.lows[i]));
93            approx::assert_relative_eq!(middle, case.expected_middle[i]);
94        }
95    }
96
97    #[test]
98    fn test_donchian_basic() {
99        let mut dc = DonchianChannels::new(3);
100
101        // bar 1: H=10, L=8 -> U=10, M=9, L=8
102        let (u1, m1, l1) = dc.next((10.0, 8.0));
103        assert_eq!(u1, 10.0);
104        assert_eq!(m1, 9.0);
105        assert_eq!(l1, 8.0);
106
107        // bar 2: H=12, L=7 -> U=12, M=9.5, L=7
108        let (u2, m2, l2) = dc.next((12.0, 7.0));
109        assert_eq!(u2, 12.0);
110        assert_eq!(m2, 9.5);
111        assert_eq!(l2, 7.0);
112
113        // bar 3: H=11, L=9 -> U=12, M=9.5, L=7
114        let (u3, m3, l3) = dc.next((11.0, 9.0));
115        assert_eq!(u3, 12.0);
116        assert_eq!(m3, 9.5);
117        assert_eq!(l3, 7.0);
118
119        // bar 4: H=13, L=10 -> U=13, M=10, L=7 (bar 1 is out)
120        let (u4, m4, l4) = dc.next((13.0, 10.0));
121        assert_eq!(u4, 13.0);
122        assert_eq!(m4, 10.0);
123        assert_eq!(l4, 7.0);
124    }
125
126    fn donchian_batch(data: Vec<(f64, f64)>, period: usize) -> Vec<f64> {
127        let mut dc = DonchianChannels::new(period);
128        // We'll just return the middle band for parity check to simplify,
129        // or we could change the parity helper to handle tuples.
130        data.into_iter().map(|x| dc.next(x).1).collect()
131    }
132
133    proptest! {
134        #[test]
135        fn test_donchian_parity(highs in prop::collection::vec(0.0..1000.0, 1..100), lows in prop::collection::vec(0.0..1000.0, 1..100)) {
136            let len = highs.len().min(lows.len());
137            let highs: Vec<f64> = highs[..len].to_vec();
138            let lows: Vec<f64> = lows[..len].to_vec();
139            let mut input = Vec::with_capacity(len);
140            for i in 0..len {
141                let h = highs[i];
142                let l = lows[i].min(h); // Ensure low <= high
143                input.push((h, l));
144            }
145
146            let period = 5;
147            let mut dc = DonchianChannels::new(period);
148            let mut streaming_results = Vec::with_capacity(len);
149            for &val in &input {
150                streaming_results.push(dc.next(val).1);
151            }
152
153            let batch_results = donchian_batch(input, period);
154
155            for (s, b) in streaming_results.iter().zip(batch_results.iter()) {
156                assert_eq!(s, b);
157            }
158        }
159    }
160}
161
162pub const DONCHIAN_METADATA: IndicatorMetadata = IndicatorMetadata {
163    name: "Donchian Channels",
164    description: "Donchian Channels are volatility indicators formed by taking the highest high and the lowest low of the last N periods.",
165    params: &[ParamDef {
166        name: "period",
167        default: "20",
168        description: "Channel period",
169    }],
170    formula_source: "https://www.investopedia.com/terms/d/donchianchannels.asp",
171    formula_latex: r#"
172\[
173UC = \max(H_{t-n \dots t}) \\ LC = \min(L_{t-n \dots t})
174\]
175"#,
176    gold_standard_file: "donchian.json",
177    category: "Classic",
178};