Skip to main content

wickra_core/indicators/
projection_bands.rs

1//! Projection Bands (Mel Widner) — a high/low linear-regression projection
2//! envelope.
3
4use std::collections::VecDeque;
5
6use crate::error::{Error, Result};
7use crate::ohlcv::Candle;
8use crate::traits::Indicator;
9
10/// Projection Bands output.
11#[derive(Debug, Clone, Copy, PartialEq)]
12pub struct ProjectionBandsOutput {
13    /// Upper band: the maximum forward-projected high in the window.
14    pub upper: f64,
15    /// Middle line: the midpoint of the upper and lower bands.
16    pub middle: f64,
17    /// Lower band: the minimum forward-projected low in the window.
18    pub lower: f64,
19}
20
21/// Projection Bands: forward-projected high/low envelope.
22///
23/// Mel Widner ("Projection Bands and the Projection Oscillator", *Technical
24/// Analysis of Stocks & Commodities*, May 1995) fits a separate linear
25/// regression to the highs and to the lows over the last `period` bars, then
26/// slides every bar's high and low forward to the current bar along its own
27/// slope. The upper band is the maximum of the projected highs, the lower band
28/// the minimum of the projected lows:
29///
30/// ```text
31/// slope_h = OLS slope of (x, high) over the window
32/// slope_l = OLS slope of (x, low)  over the window
33/// // bar i (0 = oldest, period-1 = newest) is (period-1-i) bars in the past
34/// upper   = max over i of [ high_i + slope_h · (period-1-i) ]
35/// lower   = min over i of [ low_i  + slope_l · (period-1-i) ]
36/// middle  = (upper + lower) / 2
37/// ```
38///
39/// Unlike [`LinRegChannel`](crate::LinRegChannel) and
40/// [`StandardErrorBands`](crate::StandardErrorBands) — which wrap a single
41/// close-regression endpoint by a dispersion statistic — Projection Bands are
42/// built from the *extremes*: the envelope adapts to the trend's slope yet
43/// always contains every projected high and low, so by construction price never
44/// pierces the bands within the window. A flat slope reduces the bands to the
45/// rolling highest-high / lowest-low (a Donchian channel); a steep slope tilts
46/// the whole envelope with the trend.
47///
48/// # Example
49///
50/// ```
51/// use wickra_core::{Candle, Indicator, ProjectionBands};
52///
53/// let mut indicator = ProjectionBands::new(14).unwrap();
54/// let mut last = None;
55/// for i in 0..30 {
56///     let base = 100.0 + f64::from(i);
57///     let candle =
58///         Candle::new(base, base + 2.0, base - 2.0, base + 1.0, 10.0, i64::from(i)).unwrap();
59///     last = indicator.update(candle);
60/// }
61/// assert!(last.is_some());
62/// ```
63#[derive(Debug, Clone)]
64pub struct ProjectionBands {
65    period: usize,
66    highs: VecDeque<f64>,
67    lows: VecDeque<f64>,
68    sum_x: f64,
69    sum_xx: f64,
70}
71
72impl ProjectionBands {
73    /// Construct new Projection Bands.
74    ///
75    /// # Errors
76    /// Returns [`Error::InvalidPeriod`] if `period < 2` (a regression slope
77    /// needs at least two points).
78    pub fn new(period: usize) -> Result<Self> {
79        if period < 2 {
80            return Err(Error::InvalidPeriod {
81                message: "projection bands need period >= 2",
82            });
83        }
84        let n = period as f64;
85        Ok(Self {
86            period,
87            highs: VecDeque::with_capacity(period),
88            lows: VecDeque::with_capacity(period),
89            sum_x: n * (n - 1.0) / 2.0,
90            sum_xx: (n - 1.0) * n * (2.0 * n - 1.0) / 6.0,
91        })
92    }
93
94    /// Configured period.
95    pub const fn period(&self) -> usize {
96        self.period
97    }
98
99    /// OLS slope of `(0..period, values)` over the live window.
100    fn slope(&self, values: &VecDeque<f64>) -> f64 {
101        let n = self.period as f64;
102        let mut sum_y = 0.0;
103        let mut sum_xy = 0.0;
104        for (i, &y) in values.iter().enumerate() {
105            sum_y += y;
106            sum_xy += (i as f64) * y;
107        }
108        let denom = n * self.sum_xx - self.sum_x * self.sum_x;
109        (n * sum_xy - self.sum_x * sum_y) / denom
110    }
111}
112
113impl Indicator for ProjectionBands {
114    type Input = Candle;
115    type Output = ProjectionBandsOutput;
116
117    fn update(&mut self, candle: Candle) -> Option<ProjectionBandsOutput> {
118        if self.highs.len() == self.period {
119            self.highs.pop_front();
120            self.lows.pop_front();
121        }
122        self.highs.push_back(candle.high);
123        self.lows.push_back(candle.low);
124        if self.highs.len() < self.period {
125            return None;
126        }
127
128        let slope_h = self.slope(&self.highs);
129        let slope_l = self.slope(&self.lows);
130        let last = (self.period - 1) as f64;
131
132        let mut upper = f64::NEG_INFINITY;
133        let mut lower = f64::INFINITY;
134        for (i, (&high, &low)) in self.highs.iter().zip(self.lows.iter()).enumerate() {
135            let forward = last - (i as f64);
136            let projected_high = high + slope_h * forward;
137            let projected_low = low + slope_l * forward;
138            if projected_high > upper {
139                upper = projected_high;
140            }
141            if projected_low < lower {
142                lower = projected_low;
143            }
144        }
145
146        Some(ProjectionBandsOutput {
147            upper,
148            middle: f64::midpoint(upper, lower),
149            lower,
150        })
151    }
152
153    fn reset(&mut self) {
154        self.highs.clear();
155        self.lows.clear();
156    }
157
158    fn warmup_period(&self) -> usize {
159        self.period
160    }
161
162    fn is_ready(&self) -> bool {
163        self.highs.len() == self.period
164    }
165
166    fn name(&self) -> &'static str {
167        "ProjectionBands"
168    }
169}
170
171#[cfg(test)]
172mod tests {
173    use super::*;
174    use approx::assert_relative_eq;
175
176    fn candle(high: f64, low: f64, close: f64, ts: i64) -> Candle {
177        Candle::new(low, high, low, close, 10.0, ts).unwrap()
178    }
179
180    #[test]
181    fn rejects_period_below_two() {
182        assert!(matches!(
183            ProjectionBands::new(0),
184            Err(Error::InvalidPeriod { .. })
185        ));
186        assert!(matches!(
187            ProjectionBands::new(1),
188            Err(Error::InvalidPeriod { .. })
189        ));
190        assert!(ProjectionBands::new(2).is_ok());
191    }
192
193    #[test]
194    fn accessors_and_metadata() {
195        let pb = ProjectionBands::new(14).unwrap();
196        assert_eq!(pb.period(), 14);
197        assert_eq!(pb.warmup_period(), 14);
198        assert_eq!(pb.name(), "ProjectionBands");
199        assert!(!pb.is_ready());
200    }
201
202    #[test]
203    fn warms_up_then_emits() {
204        let mut pb = ProjectionBands::new(3).unwrap();
205        assert!(pb.update(candle(10.0, 8.0, 9.0, 0)).is_none());
206        assert!(pb.update(candle(12.0, 9.0, 11.0, 1)).is_none());
207        assert!(pb.update(candle(11.0, 10.0, 11.0, 2)).is_some());
208        assert!(pb.is_ready());
209    }
210
211    #[test]
212    fn known_projection() {
213        // highs 10,12,11 -> slope_h = 0.5; projected = 11, 12.5, 11 -> upper 12.5
214        // lows   8, 9,10 -> slope_l = 1.0; projected = 10, 10,  10 -> lower 10
215        let mut pb = ProjectionBands::new(3).unwrap();
216        pb.update(candle(10.0, 8.0, 9.0, 0));
217        pb.update(candle(12.0, 9.0, 11.0, 1));
218        let out = pb.update(candle(11.0, 10.0, 11.0, 2)).unwrap();
219        assert_relative_eq!(out.upper, 12.5, epsilon = 1e-9);
220        assert_relative_eq!(out.lower, 10.0, epsilon = 1e-9);
221        assert_relative_eq!(out.middle, 11.25, epsilon = 1e-9);
222    }
223
224    #[test]
225    fn perfect_trend_pins_bands_to_current_extremes() {
226        // High_i and Low_i both rise by exactly 1 per bar: every projected high
227        // collapses onto the current high, every projected low onto the current
228        // low.
229        let mut pb = ProjectionBands::new(5).unwrap();
230        let mut last = None;
231        for i in 0..10 {
232            let high = 100.0 + f64::from(i);
233            let low = 95.0 + f64::from(i);
234            last = pb.update(candle(high, low, high, i64::from(i)));
235        }
236        let out = last.unwrap();
237        assert_relative_eq!(out.upper, 109.0, epsilon = 1e-9);
238        assert_relative_eq!(out.lower, 104.0, epsilon = 1e-9);
239        assert_relative_eq!(out.middle, 106.5, epsilon = 1e-9);
240    }
241
242    #[test]
243    fn reset_clears_state() {
244        let mut pb = ProjectionBands::new(3).unwrap();
245        pb.update(candle(10.0, 8.0, 9.0, 0));
246        pb.update(candle(12.0, 9.0, 11.0, 1));
247        pb.update(candle(11.0, 10.0, 11.0, 2));
248        assert!(pb.is_ready());
249        pb.reset();
250        assert!(!pb.is_ready());
251        assert!(pb.update(candle(10.0, 8.0, 9.0, 3)).is_none());
252    }
253}