wickra-core 0.1.3

Core streaming-first technical indicators engine for the Wickra library
//! Donchian Channels.

use std::collections::VecDeque;

use crate::error::{Error, Result};
use crate::ohlcv::Candle;
use crate::traits::Indicator;

/// Donchian Channels output.
#[derive(Debug, Clone, Copy, PartialEq)]
pub struct DonchianOutput {
    /// Highest high over the lookback.
    pub upper: f64,
    /// Average of upper and lower.
    pub middle: f64,
    /// Lowest low over the lookback.
    pub lower: f64,
}

/// Donchian Channels: rolling highest high / lowest low envelopes.
#[derive(Debug, Clone)]
pub struct Donchian {
    period: usize,
    candles: VecDeque<Candle>,
}

impl Donchian {
    /// # Errors
    /// Returns [`Error::PeriodZero`] if `period == 0`.
    pub fn new(period: usize) -> Result<Self> {
        if period == 0 {
            return Err(Error::PeriodZero);
        }
        Ok(Self {
            period,
            candles: VecDeque::with_capacity(period),
        })
    }

    /// Configured period.
    pub const fn period(&self) -> usize {
        self.period
    }
}

impl Indicator for Donchian {
    type Input = Candle;
    type Output = DonchianOutput;

    fn update(&mut self, candle: Candle) -> Option<DonchianOutput> {
        if self.candles.len() == self.period {
            self.candles.pop_front();
        }
        self.candles.push_back(candle);
        if self.candles.len() < self.period {
            return None;
        }
        let upper = self
            .candles
            .iter()
            .map(|c| c.high)
            .fold(f64::NEG_INFINITY, f64::max);
        let lower = self
            .candles
            .iter()
            .map(|c| c.low)
            .fold(f64::INFINITY, f64::min);
        Some(DonchianOutput {
            upper,
            middle: (upper + lower) / 2.0,
            lower,
        })
    }

    fn reset(&mut self) {
        self.candles.clear();
    }

    fn warmup_period(&self) -> usize {
        self.period
    }

    fn is_ready(&self) -> bool {
        self.candles.len() == self.period
    }

    fn name(&self) -> &'static str {
        "DonchianChannels"
    }
}

#[cfg(test)]
mod tests {
    use super::*;
    use crate::traits::BatchExt;
    use approx::assert_relative_eq;

    fn c(h: f64, l: f64, cl: f64) -> Candle {
        Candle::new(cl, h, l, cl, 1.0, 0).unwrap()
    }

    #[test]
    fn flat_market_yields_equal_bands() {
        let candles: Vec<Candle> = (0..20).map(|_| c(11.0, 9.0, 10.0)).collect();
        let mut d = Donchian::new(5).unwrap();
        let last = d.batch(&candles).into_iter().flatten().last().unwrap();
        assert_relative_eq!(last.upper, 11.0, epsilon = 1e-12);
        assert_relative_eq!(last.lower, 9.0, epsilon = 1e-12);
        assert_relative_eq!(last.middle, 10.0, epsilon = 1e-12);
    }

    #[test]
    fn batch_equals_streaming() {
        let candles: Vec<Candle> = (0..40)
            .map(|i| c(f64::from(i) + 2.0, f64::from(i), f64::from(i) + 1.0))
            .collect();
        let mut a = Donchian::new(10).unwrap();
        let mut b = Donchian::new(10).unwrap();
        assert_eq!(
            a.batch(&candles),
            candles.iter().map(|x| b.update(*x)).collect::<Vec<_>>()
        );
    }

    #[test]
    fn upper_above_middle_above_lower() {
        let candles: Vec<Candle> = (0..50)
            .map(|i| c(f64::from(i) + 1.0, f64::from(i) - 1.0, f64::from(i)))
            .collect();
        let mut d = Donchian::new(10).unwrap();
        for o in d.batch(&candles).into_iter().flatten() {
            assert!(o.upper >= o.middle);
            assert!(o.middle >= o.lower);
        }
    }

    #[test]
    fn rejects_zero_period() {
        assert!(Donchian::new(0).is_err());
    }
}