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 #[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 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 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}