wickra_core/indicators/
parkinson.rs1use std::collections::VecDeque;
4
5use crate::error::{Error, Result};
6use crate::ohlcv::Candle;
7use crate::traits::Indicator;
8
9#[derive(Debug, Clone)]
48pub struct ParkinsonVolatility {
49 period: usize,
50 trading_periods: usize,
51 window: VecDeque<f64>,
52 sum_sq: f64,
53 last: Option<f64>,
54}
55
56const PARKINSON_FACTOR: f64 = 0.360_673_760_222_241_2;
59
60impl ParkinsonVolatility {
61 pub fn new(period: usize, trading_periods: usize) -> Result<Self> {
71 if period == 0 || trading_periods == 0 {
72 return Err(Error::PeriodZero);
73 }
74 Ok(Self {
75 period,
76 trading_periods,
77 window: VecDeque::with_capacity(period),
78 sum_sq: 0.0,
79 last: None,
80 })
81 }
82
83 pub const fn periods(&self) -> (usize, usize) {
85 (self.period, self.trading_periods)
86 }
87
88 pub const fn value(&self) -> Option<f64> {
90 self.last
91 }
92}
93
94impl Indicator for ParkinsonVolatility {
95 type Input = Candle;
96 type Output = f64;
97
98 fn update(&mut self, candle: Candle) -> Option<f64> {
99 let log_hl = (candle.high / candle.low).ln();
103 let sample = log_hl * log_hl;
104
105 if self.window.len() == self.period {
106 let old = self.window.pop_front().expect("window is non-empty");
107 self.sum_sq -= old;
108 }
109 self.window.push_back(sample);
110 self.sum_sq += sample;
111
112 if self.window.len() < self.period {
113 return None;
114 }
115
116 let n = self.period as f64;
117 let variance = (PARKINSON_FACTOR * self.sum_sq / n).max(0.0);
118 let sigma = variance.sqrt();
119 let out = sigma * (self.trading_periods as f64).sqrt() * 100.0;
120 self.last = Some(out);
121 Some(out)
122 }
123
124 fn reset(&mut self) {
125 self.window.clear();
126 self.sum_sq = 0.0;
127 self.last = None;
128 }
129
130 fn warmup_period(&self) -> usize {
131 self.period
132 }
133
134 fn is_ready(&self) -> bool {
135 self.last.is_some()
136 }
137
138 fn name(&self) -> &'static str {
139 "ParkinsonVolatility"
140 }
141}
142
143#[cfg(test)]
144mod tests {
145 use super::*;
146 use crate::traits::BatchExt;
147 use approx::assert_relative_eq;
148
149 fn candle(h: f64, l: f64, c: f64, ts: i64) -> Candle {
150 Candle::new(f64::midpoint(h, l), h, l, c, 1.0, ts).unwrap()
151 }
152
153 #[test]
154 fn rejects_zero_period() {
155 assert!(matches!(
156 ParkinsonVolatility::new(0, 252),
157 Err(Error::PeriodZero)
158 ));
159 assert!(matches!(
160 ParkinsonVolatility::new(20, 0),
161 Err(Error::PeriodZero)
162 ));
163 }
164
165 #[test]
166 fn accessors_and_metadata() {
167 let pv = ParkinsonVolatility::new(20, 252).unwrap();
168 assert_eq!(pv.periods(), (20, 252));
169 assert_eq!(pv.value(), None);
170 assert_eq!(pv.warmup_period(), 20);
171 assert_eq!(pv.name(), "ParkinsonVolatility");
172 assert!(!pv.is_ready());
173 }
174
175 #[test]
176 fn zero_range_yields_zero() {
177 let candles: Vec<Candle> = (0..30).map(|i| candle(10.0, 10.0, 10.0, i)).collect();
179 let mut pv = ParkinsonVolatility::new(14, 1).unwrap();
180 for v in pv.batch(&candles).into_iter().flatten() {
181 assert_relative_eq!(v, 0.0, epsilon = 1e-12);
182 }
183 }
184
185 #[test]
186 fn constant_range_yields_constant_sigma() {
187 let candles: Vec<Candle> = (0..30).map(|i| candle(11.0, 9.0, 10.0, i)).collect();
192 let mut pv = ParkinsonVolatility::new(10, 1).unwrap();
193 let out = pv.batch(&candles);
194
195 let k = (11.0_f64 / 9.0_f64).ln().powi(2);
196 let expected = (PARKINSON_FACTOR * k).sqrt() * 100.0;
197 for v in out.iter().skip(9).flatten() {
198 assert_relative_eq!(*v, expected, epsilon = 1e-9);
199 }
200 }
201
202 #[test]
203 fn output_is_non_negative() {
204 let mut pv = ParkinsonVolatility::new(14, 252).unwrap();
205 let candles: Vec<Candle> = (0..200)
206 .map(|i| {
207 let base = 100.0 + (f64::from(i) * 0.3).sin() * 12.0;
208 let half = 0.5 + (f64::from(i) * 0.13).cos().abs() * 1.5;
209 candle(base + half, base - half, base, i64::from(i))
210 })
211 .collect();
212 for v in pv.batch(&candles).into_iter().flatten() {
213 assert!(v >= 0.0, "Parkinson volatility must be non-negative: {v}");
214 }
215 }
216
217 #[test]
218 fn annualisation_scales_by_sqrt_trading_periods() {
219 let candles: Vec<Candle> = (0..40)
222 .map(|i| {
223 let base = 100.0 + (f64::from(i) * 0.3).sin() * 5.0;
224 let half = 1.0 + (f64::from(i) * 0.2).cos().abs();
225 candle(base + half, base - half, base, i64::from(i))
226 })
227 .collect();
228 let raw = ParkinsonVolatility::new(10, 1).unwrap().batch(&candles);
229 let annual = ParkinsonVolatility::new(10, 252).unwrap().batch(&candles);
230 let scale = (252.0_f64).sqrt();
231 for (r, a) in raw.iter().zip(annual.iter()) {
232 assert_eq!(r.is_some(), a.is_some(), "warmup mismatch");
233 if let (Some(r), Some(a)) = (r, a) {
234 assert_relative_eq!(*a, r * scale, epsilon = 1e-9);
235 }
236 }
237 }
238
239 #[test]
240 fn first_emission_at_warmup_period() {
241 let candles: Vec<Candle> = (0..20).map(|i| candle(11.0, 9.0, 10.0, i)).collect();
242 let mut pv = ParkinsonVolatility::new(5, 1).unwrap();
243 let out = pv.batch(&candles);
244 for v in out.iter().take(4) {
245 assert!(v.is_none());
246 }
247 assert!(out[4].is_some());
248 }
249
250 #[test]
251 fn batch_equals_streaming() {
252 let candles: Vec<Candle> = (0..80)
253 .map(|i| {
254 let base = 100.0 + (f64::from(i) * 0.25).sin() * 6.0;
255 let half = 1.0 + (f64::from(i) * 0.15).cos().abs();
256 candle(base + half, base - half, base, i64::from(i))
257 })
258 .collect();
259 let batch = ParkinsonVolatility::new(14, 252).unwrap().batch(&candles);
260 let mut streamer = ParkinsonVolatility::new(14, 252).unwrap();
261 let streamed: Vec<_> = candles.iter().map(|c| streamer.update(*c)).collect();
262 assert_eq!(batch, streamed);
263 }
264
265 #[test]
266 fn reset_clears_state() {
267 let candles: Vec<Candle> = (0..30).map(|i| candle(11.0, 9.0, 10.0, i)).collect();
268 let mut pv = ParkinsonVolatility::new(14, 252).unwrap();
269 pv.batch(&candles);
270 assert!(pv.is_ready());
271 pv.reset();
272 assert!(!pv.is_ready());
273 assert_eq!(pv.value(), None);
274 assert_eq!(pv.update(candles[0]), None);
275 }
276}