Skip to main content

finance_solution/stocks/ta/
donchian.rs

1//! # Donchian channels
2//!
3//! ```text
4//! upper[i]  = max(high[i-N+1 ..= i])
5//! lower[i]  = min(low [i-N+1 ..= i])
6//! middle[i] = (upper + lower) / 2
7//! ```
8//!
9//! Classic breakout envelope (Turtle-style). Warm-up: first `N-1` bars are `None`.
10//!
11//! ---
12//!
13//! ## Trading perspective
14//!
15//! | Event | Habit |
16//! |-------|-------|
17//! | Close > upper | Upside breakout screen |
18//! | Close < lower | Downside breakout screen |
19//! | Channel width | Range expansion / compression |
20//!
21//! ---
22//!
23//! ## Engineering
24//!
25//! [`DonchianParams`] → [`donchian`] / [`DonchianState`] → [`donchian_solution`].
26//!
27//! ## Word problem
28//!
29//! > Highs 11,12,13,14 and lows 9,10,11,12 with N=3. Upper on last bar?
30//!
31//! Expect: **14** (max of last three highs 12,13,14).
32//!
33//! ```
34//! use finance_solution::stocks::ta::{donchian, DonchianParams};
35//! let h = [11.0, 12.0, 13.0, 14.0];
36//! let l = [9.0, 10.0, 11.0, 12.0];
37//! let s = donchian(&h, &l, DonchianParams::new(3)).unwrap();
38//! assert!((s.upper[3].unwrap() - 14.0).abs() < 1e-12);
39//! assert!((s.lower[3].unwrap() - 10.0).abs() < 1e-12); // min(lows 10,11,12)
40//! ```
41
42use crate::stocks::ta::common::{opt_cell, require_same_len, validate_series};
43use crate::stocks::ta::ring::RingF64;
44use crate::util::error::{require_finite, FinanceError, FinanceResult};
45use crate::util::primitives::PeriodLength;
46use crate::{columns_with_strings, print_table_locale_opt};
47
48/// Donchian lookback.
49#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
50pub struct DonchianParams {
51    pub period: usize,
52}
53
54impl DonchianParams {
55    pub const fn new(period: usize) -> Self {
56        Self { period }
57    }
58
59    /// Common 20-bar channel.
60    pub const fn period_20() -> Self {
61        Self { period: 20 }
62    }
63
64    pub const fn period_55() -> Self {
65        Self { period: 55 }
66    }
67}
68
69#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
70pub struct ValidatedDonchian {
71    params: DonchianParams,
72}
73
74impl ValidatedDonchian {
75    pub fn new(params: DonchianParams) -> FinanceResult<Self> {
76        PeriodLength::new(params.period)?;
77        Ok(Self { params })
78    }
79
80    pub fn params(self) -> DonchianParams {
81        self.params
82    }
83
84    pub fn compute(self, high: &[f64], low: &[f64]) -> FinanceResult<DonchianSeries> {
85        donchian_validated(high, low, self)
86    }
87}
88
89#[derive(Clone, Debug, PartialEq)]
90pub struct DonchianSeries {
91    pub upper: Vec<Option<f64>>,
92    pub lower: Vec<Option<f64>>,
93    pub middle: Vec<Option<f64>>,
94    pub params: DonchianParams,
95}
96
97impl DonchianSeries {
98    pub fn last(&self) -> Option<(f64, f64, f64)> {
99        let n = self.upper.len();
100        if n == 0 {
101            return None;
102        }
103        Some((self.upper[n - 1]?, self.middle[n - 1]?, self.lower[n - 1]?))
104    }
105}
106
107#[derive(Clone, Copy, Debug, PartialEq)]
108pub struct DonchianBarOutput {
109    pub upper: f64,
110    pub middle: f64,
111    pub lower: f64,
112}
113
114/// Incremental Donchian.
115#[derive(Clone, Debug)]
116pub struct DonchianState {
117    params: DonchianParams,
118    high: RingF64,
119    low: RingF64,
120    last: Option<DonchianBarOutput>,
121}
122
123impl DonchianState {
124    pub fn new(params: DonchianParams) -> FinanceResult<Self> {
125        let _ = ValidatedDonchian::new(params)?;
126        Ok(Self {
127            params,
128            high: RingF64::with_capacity(params.period),
129            low: RingF64::with_capacity(params.period),
130            last: None,
131        })
132    }
133
134    pub fn from_history(params: DonchianParams, high: &[f64], low: &[f64]) -> FinanceResult<Self> {
135        let mut s = Self::new(params)?;
136        let _ = s.push_bars(high, low)?;
137        Ok(s)
138    }
139
140    pub fn params(&self) -> DonchianParams {
141        self.params
142    }
143
144    pub fn reset(&mut self) {
145        self.high.clear();
146        self.low.clear();
147        self.last = None;
148    }
149
150    pub fn push(&mut self, high: f64, low: f64) -> FinanceResult<Option<DonchianBarOutput>> {
151        require_finite("high", high)?;
152        require_finite("low", low)?;
153        if high < low {
154            return Err(FinanceError::InvalidCashflow {
155                message: "high must be >= low for each bar",
156            });
157        }
158        self.high.push(high);
159        self.low.push(low);
160        if !self.high.is_full() {
161            self.last = None;
162            return Ok(None);
163        }
164        let u = self.high.max().unwrap();
165        let l = self.low.min().unwrap();
166        let out = DonchianBarOutput {
167            upper: u,
168            middle: 0.5 * (u + l),
169            lower: l,
170        };
171        self.last = Some(out);
172        Ok(Some(out))
173    }
174
175    pub fn push_bars(
176        &mut self,
177        high: &[f64],
178        low: &[f64],
179    ) -> FinanceResult<Vec<Option<DonchianBarOutput>>> {
180        validate_series("high", high)?;
181        validate_series("low", low)?;
182        require_same_len(high, low, "high/low")?;
183        let mut out = Vec::with_capacity(high.len());
184        for i in 0..high.len() {
185            out.push(self.push(high[i], low[i])?);
186        }
187        Ok(out)
188    }
189
190    pub fn last(&self) -> Option<DonchianBarOutput> {
191        self.last
192    }
193}
194
195#[derive(Clone, Debug)]
196pub struct DonchianSolution {
197    series: DonchianSeries,
198    close: Option<Vec<f64>>,
199    formula: String,
200}
201
202impl DonchianSolution {
203    pub fn series(&self) -> &DonchianSeries {
204        &self.series
205    }
206    pub fn formula(&self) -> &str {
207        &self.formula
208    }
209
210    pub fn print_table(&self) {
211        self.print_table_locale_opt(None, None);
212    }
213
214    pub fn print_table_locale(&self, locale: &num_format::Locale, precision: usize) {
215        self.print_table_locale_opt(Some(locale), Some(precision));
216    }
217
218    fn print_table_locale_opt(
219        &self,
220        locale: Option<&num_format::Locale>,
221        precision: Option<usize>,
222    ) {
223        let columns = columns_with_strings(&[
224            ("period", "i", true),
225            ("upper", "f", true),
226            ("middle", "f", true),
227            ("lower", "f", true),
228        ]);
229        let n = self.series.upper.len();
230        let data = (0..n)
231            .map(|i| {
232                vec![
233                    i.to_string(),
234                    opt_cell(self.series.upper[i]),
235                    opt_cell(self.series.middle[i]),
236                    opt_cell(self.series.lower[i]),
237                ]
238            })
239            .collect();
240        let _ = &self.close;
241        print_table_locale_opt(&columns, data, locale, precision);
242    }
243}
244
245pub fn donchian(
246    high: &[f64],
247    low: &[f64],
248    params: DonchianParams,
249) -> FinanceResult<DonchianSeries> {
250    ValidatedDonchian::new(params)?.compute(high, low)
251}
252
253pub fn donchian_solution(
254    high: &[f64],
255    low: &[f64],
256    params: DonchianParams,
257) -> FinanceResult<DonchianSolution> {
258    let series = donchian(high, low, params)?;
259    Ok(DonchianSolution {
260        series,
261        close: None,
262        formula: format!(
263            "Donchian({}): upper=max(high,N), lower=min(low,N), mid=(u+l)/2",
264            params.period
265        ),
266    })
267}
268
269fn donchian_validated(
270    high: &[f64],
271    low: &[f64],
272    eng: ValidatedDonchian,
273) -> FinanceResult<DonchianSeries> {
274    validate_series("high", high)?;
275    validate_series("low", low)?;
276    require_same_len(high, low, "high/low")?;
277    for i in 0..high.len() {
278        if high[i] < low[i] {
279            return Err(FinanceError::InvalidCashflow {
280                message: "high must be >= low for each bar",
281            });
282        }
283    }
284    let mut st = DonchianState::new(eng.params)?;
285    let bars = st.push_bars(high, low)?;
286    let n = high.len();
287    let mut upper = vec![None; n];
288    let mut lower = vec![None; n];
289    let mut middle = vec![None; n];
290    for (i, b) in bars.into_iter().enumerate() {
291        if let Some(o) = b {
292            upper[i] = Some(o.upper);
293            lower[i] = Some(o.lower);
294            middle[i] = Some(o.middle);
295        }
296    }
297    Ok(DonchianSeries {
298        upper,
299        lower,
300        middle,
301        params: eng.params,
302    })
303}
304
305#[cfg(test)]
306mod tests {
307    use super::*;
308
309    #[test]
310    fn basic_window() {
311        let h = [11.0, 12.0, 13.0, 14.0];
312        let l = [9.0, 10.0, 11.0, 12.0];
313        let s = donchian(&h, &l, DonchianParams::new(3)).unwrap();
314        assert!(s.upper[1].is_none());
315        assert!((s.upper[3].unwrap() - 14.0).abs() < 1e-12);
316        assert!((s.lower[3].unwrap() - 10.0).abs() < 1e-12); // min of lows 10,11,12
317        assert!((s.middle[3].unwrap() - 12.0).abs() < 1e-12);
318    }
319
320    #[test]
321    fn state_parity() {
322        let n = 40usize;
323        let h: Vec<_> = (0..n).map(|i| 100.0 + (i % 7) as f64).collect();
324        let l: Vec<_> = (0..n).map(|i| 90.0 + (i % 7) as f64).collect();
325        let batch = donchian(&h, &l, DonchianParams::period_20()).unwrap();
326        let st = DonchianState::from_history(DonchianParams::period_20(), &h, &l).unwrap();
327        let (u, m, lo) = batch.last().unwrap();
328        let o = st.last().unwrap();
329        assert!((u - o.upper).abs() < 1e-12);
330        assert!((m - o.middle).abs() < 1e-12);
331        assert!((lo - o.lower).abs() < 1e-12);
332    }
333
334    #[test]
335    fn period_one_is_raw_hl() {
336        let h = [5.0, 7.0];
337        let l = [4.0, 6.0];
338        let s = donchian(&h, &l, DonchianParams::new(1)).unwrap();
339        assert_eq!(s.upper[0], Some(5.0));
340        assert_eq!(s.lower[0], Some(4.0));
341        assert_eq!(s.middle[0], Some(4.5));
342        assert_eq!(s.upper[1], Some(7.0));
343    }
344
345    #[test]
346    fn high_lt_low_err() {
347        assert!(donchian(&[1.0, 0.0], &[0.0, 1.0], DonchianParams::new(2)).is_err());
348    }
349
350    #[test]
351    fn reset_clears() {
352        let h = [10.0; 25];
353        let l = [9.0; 25];
354        let mut st = DonchianState::from_history(DonchianParams::period_20(), &h, &l).unwrap();
355        assert!(st.last().is_some());
356        st.reset();
357        assert!(st.last().is_none());
358    }
359}