wickra_core/indicators/
projection_oscillator.rs1use crate::error::Result;
5use crate::indicators::projection_bands::ProjectionBands;
6use crate::ohlcv::Candle;
7use crate::traits::Indicator;
8
9#[derive(Debug, Clone)]
45pub struct ProjectionOscillator {
46 bands: ProjectionBands,
47}
48
49impl ProjectionOscillator {
50 pub fn new(period: usize) -> Result<Self> {
56 Ok(Self {
57 bands: ProjectionBands::new(period)?,
58 })
59 }
60
61 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 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 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}