Skip to main content

wickra_core/indicators/
projection_oscillator.rs

1//! Projection Oscillator (Mel Widner) — the close's position inside the
2//! [`ProjectionBands`](crate::ProjectionBands).
3
4use crate::error::Result;
5use crate::indicators::projection_bands::ProjectionBands;
6use crate::ohlcv::Candle;
7use crate::traits::Indicator;
8
9/// Projection Oscillator: where the close sits inside the projection bands,
10/// scaled to `0..100`.
11///
12/// The companion to [`ProjectionBands`](crate::ProjectionBands) from Mel
13/// Widner's May 1995 *Stocks & Commodities* article. It maps the close onto the
14/// `[lower, upper]` projection envelope:
15///
16/// ```text
17/// PO = 100 · (close − lower) / (upper − lower)
18/// ```
19///
20/// `PO = 0` means the close is sitting on the lower band, `PO = 100` on the
21/// upper band, and `PO = 50` at the midline. Because the bands by construction
22/// bracket every projected high and low, the close almost always falls inside
23/// them and the oscillator stays in `0..100` — readings near the extremes flag
24/// an overbought/oversold position *relative to the trend-tilted channel*
25/// rather than to a horizontal level. When the bands collapse (a zero-range
26/// window, `upper == lower`) the position is undefined and the oscillator
27/// returns the neutral `50.0`.
28///
29/// # Example
30///
31/// ```
32/// use wickra_core::{Candle, Indicator, ProjectionOscillator};
33///
34/// let mut indicator = ProjectionOscillator::new(14).unwrap();
35/// let mut last = None;
36/// for i in 0..30 {
37///     let base = 100.0 + f64::from(i);
38///     let candle =
39///         Candle::new(base, base + 2.0, base - 2.0, base + 1.0, 10.0, i64::from(i)).unwrap();
40///     last = indicator.update(candle);
41/// }
42/// assert!(last.is_some());
43/// ```
44#[derive(Debug, Clone)]
45pub struct ProjectionOscillator {
46    bands: ProjectionBands,
47}
48
49impl ProjectionOscillator {
50    /// Construct a new Projection Oscillator.
51    ///
52    /// # Errors
53    /// Returns [`Error::InvalidPeriod`](crate::Error::InvalidPeriod) if
54    /// `period < 2`.
55    pub fn new(period: usize) -> Result<Self> {
56        Ok(Self {
57            bands: ProjectionBands::new(period)?,
58        })
59    }
60
61    /// Configured period.
62    pub const fn period(&self) -> usize {
63        self.bands.period()
64    }
65}
66
67impl Indicator for ProjectionOscillator {
68    type Input = Candle;
69    type Output = f64;
70
71    fn update(&mut self, candle: Candle) -> Option<f64> {
72        let bands = self.bands.update(candle)?;
73        let width = bands.upper - bands.lower;
74        if width == 0.0 {
75            return Some(50.0);
76        }
77        Some(100.0 * (candle.close - bands.lower) / width)
78    }
79
80    fn reset(&mut self) {
81        self.bands.reset();
82    }
83
84    fn warmup_period(&self) -> usize {
85        self.bands.warmup_period()
86    }
87
88    fn is_ready(&self) -> bool {
89        self.bands.is_ready()
90    }
91
92    fn name(&self) -> &'static str {
93        "ProjectionOscillator"
94    }
95}
96
97#[cfg(test)]
98mod tests {
99    use super::*;
100    use crate::error::Error;
101    use approx::assert_relative_eq;
102
103    fn candle(high: f64, low: f64, close: f64, ts: i64) -> Candle {
104        Candle::new(low, high, low, close, 10.0, ts).unwrap()
105    }
106
107    #[test]
108    fn rejects_period_below_two() {
109        assert!(matches!(
110            ProjectionOscillator::new(1),
111            Err(Error::InvalidPeriod { .. })
112        ));
113        assert!(ProjectionOscillator::new(2).is_ok());
114    }
115
116    #[test]
117    fn accessors_and_metadata() {
118        let po = ProjectionOscillator::new(14).unwrap();
119        assert_eq!(po.period(), 14);
120        assert_eq!(po.warmup_period(), 14);
121        assert_eq!(po.name(), "ProjectionOscillator");
122        assert!(!po.is_ready());
123    }
124
125    #[test]
126    fn warms_up_then_emits() {
127        let mut po = ProjectionOscillator::new(3).unwrap();
128        assert!(po.update(candle(10.0, 8.0, 9.0, 0)).is_none());
129        assert!(po.update(candle(12.0, 9.0, 11.0, 1)).is_none());
130        assert!(po.update(candle(11.0, 10.0, 11.0, 2)).is_some());
131        assert!(po.is_ready());
132    }
133
134    #[test]
135    fn known_position() {
136        // Same window as ProjectionBands::known_projection: upper 12.5, lower 10.
137        // close 11 -> 100 * (11 - 10) / (12.5 - 10) = 40.
138        let mut po = ProjectionOscillator::new(3).unwrap();
139        po.update(candle(10.0, 8.0, 9.0, 0));
140        po.update(candle(12.0, 9.0, 11.0, 1));
141        let out = po.update(candle(11.0, 10.0, 11.0, 2)).unwrap();
142        assert_relative_eq!(out, 40.0, epsilon = 1e-9);
143    }
144
145    #[test]
146    fn collapsed_bands_return_neutral() {
147        // Zero-range, perfectly trending candles: upper == lower every bar.
148        let mut po = ProjectionOscillator::new(3).unwrap();
149        let mut last = None;
150        for i in 0..6 {
151            let v = 100.0 + f64::from(i);
152            last = po.update(candle(v, v, v, i64::from(i)));
153        }
154        assert_relative_eq!(last.unwrap(), 50.0, epsilon = 1e-12);
155    }
156
157    #[test]
158    fn reset_clears_state() {
159        let mut po = ProjectionOscillator::new(3).unwrap();
160        po.update(candle(10.0, 8.0, 9.0, 0));
161        po.update(candle(12.0, 9.0, 11.0, 1));
162        po.update(candle(11.0, 10.0, 11.0, 2));
163        assert!(po.is_ready());
164        po.reset();
165        assert!(!po.is_ready());
166        assert!(po.update(candle(10.0, 8.0, 9.0, 3)).is_none());
167    }
168}