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    #[inline]
72    fn update(&mut self, candle: Candle) -> Option<f64> {
73        let bands = self.bands.update(candle)?;
74        let width = bands.upper - bands.lower;
75        if width == 0.0 {
76            return Some(50.0);
77        }
78        Some(100.0 * (candle.close - bands.lower) / width)
79    }
80
81    fn reset(&mut self) {
82        self.bands.reset();
83    }
84
85    #[inline]
86    fn warmup_period(&self) -> usize {
87        self.bands.warmup_period()
88    }
89
90    #[inline]
91    fn is_ready(&self) -> bool {
92        self.bands.is_ready()
93    }
94
95    #[inline]
96    fn name(&self) -> &'static str {
97        "ProjectionOscillator"
98    }
99}
100
101#[cfg(test)]
102mod tests {
103    use super::*;
104    use crate::error::Error;
105    use approx::assert_relative_eq;
106
107    fn candle(high: f64, low: f64, close: f64, ts: i64) -> Candle {
108        Candle::new(low, high, low, close, 10.0, ts).unwrap()
109    }
110
111    #[test]
112    fn rejects_period_below_two() {
113        assert!(matches!(
114            ProjectionOscillator::new(1),
115            Err(Error::InvalidPeriod { .. })
116        ));
117        assert!(ProjectionOscillator::new(2).is_ok());
118    }
119
120    #[test]
121    fn accessors_and_metadata() {
122        let po = ProjectionOscillator::new(14).unwrap();
123        assert_eq!(po.period(), 14);
124        assert_eq!(po.warmup_period(), 14);
125        assert_eq!(po.name(), "ProjectionOscillator");
126        assert!(!po.is_ready());
127    }
128
129    #[test]
130    fn warms_up_then_emits() {
131        let mut po = ProjectionOscillator::new(3).unwrap();
132        assert!(po.update(candle(10.0, 8.0, 9.0, 0)).is_none());
133        assert!(po.update(candle(12.0, 9.0, 11.0, 1)).is_none());
134        assert!(po.update(candle(11.0, 10.0, 11.0, 2)).is_some());
135        assert!(po.is_ready());
136    }
137
138    #[test]
139    fn known_position() {
140        // Same window as ProjectionBands::known_projection: upper 12.5, lower 10.
141        // close 11 -> 100 * (11 - 10) / (12.5 - 10) = 40.
142        let mut po = ProjectionOscillator::new(3).unwrap();
143        po.update(candle(10.0, 8.0, 9.0, 0));
144        po.update(candle(12.0, 9.0, 11.0, 1));
145        let out = po.update(candle(11.0, 10.0, 11.0, 2)).unwrap();
146        assert_relative_eq!(out, 40.0, epsilon = 1e-9);
147    }
148
149    #[test]
150    fn collapsed_bands_return_neutral() {
151        // Zero-range, perfectly trending candles: upper == lower every bar.
152        let mut po = ProjectionOscillator::new(3).unwrap();
153        let mut last = None;
154        for i in 0..6 {
155            let v = 100.0 + f64::from(i);
156            last = po.update(candle(v, v, v, i64::from(i)));
157        }
158        assert_relative_eq!(last.unwrap(), 50.0, epsilon = 1e-12);
159    }
160
161    #[test]
162    fn reset_clears_state() {
163        let mut po = ProjectionOscillator::new(3).unwrap();
164        po.update(candle(10.0, 8.0, 9.0, 0));
165        po.update(candle(12.0, 9.0, 11.0, 1));
166        po.update(candle(11.0, 10.0, 11.0, 2));
167        assert!(po.is_ready());
168        po.reset();
169        assert!(!po.is_ready());
170        assert!(po.update(candle(10.0, 8.0, 9.0, 3)).is_none());
171    }
172}