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        if period > crate::error::MAX_PERIOD {
85            return Err(Error::InvalidPeriod {
86                message: crate::error::PERIOD_ABOVE_MAX,
87            });
88        }
89        let n = period as f64;
90        Ok(Self {
91            period,
92            highs: VecDeque::with_capacity(period),
93            lows: VecDeque::with_capacity(period),
94            sum_x: n * (n - 1.0) / 2.0,
95            sum_xx: (n - 1.0) * n * (2.0 * n - 1.0) / 6.0,
96        })
97    }
98
99    /// Configured period.
100    pub const fn period(&self) -> usize {
101        self.period
102    }
103
104    /// OLS slope of `(0..period, values)` over the live window.
105    ///
106    /// Computed about the window mean rather than from raw power sums: the
107    /// slope is invariant under a shift of `y`, and the shifted form keeps
108    /// every term on the scale of the deviation inside the window instead of
109    /// the price level.
110    fn slope(&self, values: &VecDeque<f64>) -> f64 {
111        let n = self.period as f64;
112        let mean_y = values.iter().sum::<f64>() / n;
113        let mut sum_y = 0.0;
114        let mut sum_xy = 0.0;
115        for (i, &y) in values.iter().enumerate() {
116            let d = y - mean_y;
117            sum_y += d;
118            sum_xy += (i as f64) * d;
119        }
120        let denom = n * self.sum_xx - self.sum_x * self.sum_x;
121        (n * sum_xy - self.sum_x * sum_y) / denom
122    }
123}
124
125impl Indicator for ProjectionBands {
126    type Input = Candle;
127    type Output = ProjectionBandsOutput;
128
129    #[inline]
130    fn update(&mut self, candle: Candle) -> Option<ProjectionBandsOutput> {
131        if self.highs.len() == self.period {
132            self.highs.pop_front();
133            self.lows.pop_front();
134        }
135        self.highs.push_back(candle.high);
136        self.lows.push_back(candle.low);
137        if self.highs.len() < self.period {
138            return None;
139        }
140
141        let slope_h = self.slope(&self.highs);
142        let slope_l = self.slope(&self.lows);
143        let last = (self.period - 1) as f64;
144
145        let mut upper = f64::NEG_INFINITY;
146        let mut lower = f64::INFINITY;
147        for (i, (&high, &low)) in self.highs.iter().zip(self.lows.iter()).enumerate() {
148            let forward = last - (i as f64);
149            let projected_high = high + slope_h * forward;
150            let projected_low = low + slope_l * forward;
151            if projected_high > upper {
152                upper = projected_high;
153            }
154            if projected_low < lower {
155                lower = projected_low;
156            }
157        }
158
159        Some(ProjectionBandsOutput {
160            upper,
161            middle: f64::midpoint(upper, lower),
162            lower,
163        })
164    }
165
166    fn reset(&mut self) {
167        self.highs.clear();
168        self.lows.clear();
169    }
170
171    #[inline]
172    fn warmup_period(&self) -> usize {
173        self.period
174    }
175
176    #[inline]
177    fn is_ready(&self) -> bool {
178        self.highs.len() == self.period
179    }
180
181    #[inline]
182    fn name(&self) -> &'static str {
183        "ProjectionBands"
184    }
185}
186
187#[cfg(test)]
188mod tests {
189    use super::*;
190    use approx::assert_relative_eq;
191
192    fn candle(high: f64, low: f64, close: f64, ts: i64) -> Candle {
193        Candle::new(low, high, low, close, 10.0, ts).unwrap()
194    }
195
196    #[test]
197    fn rejects_period_below_two() {
198        assert!(matches!(
199            ProjectionBands::new(0),
200            Err(Error::InvalidPeriod { .. })
201        ));
202        assert!(matches!(
203            ProjectionBands::new(1),
204            Err(Error::InvalidPeriod { .. })
205        ));
206        assert!(ProjectionBands::new(2).is_ok());
207    }
208
209    #[test]
210    fn accessors_and_metadata() {
211        let pb = ProjectionBands::new(14).unwrap();
212        assert_eq!(pb.period(), 14);
213        assert_eq!(pb.warmup_period(), 14);
214        assert_eq!(pb.name(), "ProjectionBands");
215        assert!(!pb.is_ready());
216    }
217
218    #[test]
219    fn warms_up_then_emits() {
220        let mut pb = ProjectionBands::new(3).unwrap();
221        assert!(pb.update(candle(10.0, 8.0, 9.0, 0)).is_none());
222        assert!(pb.update(candle(12.0, 9.0, 11.0, 1)).is_none());
223        assert!(pb.update(candle(11.0, 10.0, 11.0, 2)).is_some());
224        assert!(pb.is_ready());
225    }
226
227    #[test]
228    fn known_projection() {
229        // highs 10,12,11 -> slope_h = 0.5; projected = 11, 12.5, 11 -> upper 12.5
230        // lows   8, 9,10 -> slope_l = 1.0; projected = 10, 10,  10 -> lower 10
231        let mut pb = ProjectionBands::new(3).unwrap();
232        pb.update(candle(10.0, 8.0, 9.0, 0));
233        pb.update(candle(12.0, 9.0, 11.0, 1));
234        let out = pb.update(candle(11.0, 10.0, 11.0, 2)).unwrap();
235        assert_relative_eq!(out.upper, 12.5, epsilon = 1e-9);
236        assert_relative_eq!(out.lower, 10.0, epsilon = 1e-9);
237        assert_relative_eq!(out.middle, 11.25, epsilon = 1e-9);
238    }
239
240    #[test]
241    fn perfect_trend_pins_bands_to_current_extremes() {
242        // High_i and Low_i both rise by exactly 1 per bar: every projected high
243        // collapses onto the current high, every projected low onto the current
244        // low.
245        let mut pb = ProjectionBands::new(5).unwrap();
246        let mut last = None;
247        for i in 0..10 {
248            let high = 100.0 + f64::from(i);
249            let low = 95.0 + f64::from(i);
250            last = pb.update(candle(high, low, high, i64::from(i)));
251        }
252        let out = last.unwrap();
253        assert_relative_eq!(out.upper, 109.0, epsilon = 1e-9);
254        assert_relative_eq!(out.lower, 104.0, epsilon = 1e-9);
255        assert_relative_eq!(out.middle, 106.5, epsilon = 1e-9);
256    }
257
258    #[test]
259    fn reset_clears_state() {
260        let mut pb = ProjectionBands::new(3).unwrap();
261        pb.update(candle(10.0, 8.0, 9.0, 0));
262        pb.update(candle(12.0, 9.0, 11.0, 1));
263        pb.update(candle(11.0, 10.0, 11.0, 2));
264        assert!(pb.is_ready());
265        pb.reset();
266        assert!(!pb.is_ready());
267        assert!(pb.update(candle(10.0, 8.0, 9.0, 3)).is_none());
268    }
269}