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