Skip to main content

wickra_core/indicators/
parkinson.rs

1//! Parkinson Volatility (high-low estimator).
2
3use std::collections::VecDeque;
4
5use crate::error::{Error, Result};
6use crate::indicators::rolling_moments::RollingSum;
7use crate::ohlcv::Candle;
8use crate::traits::Indicator;
9
10/// Parkinson Volatility — a high-low realised-volatility estimator.
11///
12/// Michael Parkinson (1980) noted that the extreme range of a bar carries
13/// more variance information than the closing price alone: a wide bar that
14/// closes near its open is far more "volatile" than a narrow bar that
15/// happens to close at the same level. The estimator is
16///
17/// ```text
18/// sigma² = (1 / (4n · ln 2)) · Σ_{i=1..n} (ln(H_i / L_i))²
19/// sigma  = √sigma²
20/// out    = sigma · √trading_periods · 100
21/// ```
22///
23/// The output is annualised to a percent in the same style as
24/// [`HistoricalVolatility`](crate::HistoricalVolatility) — `trading_periods`
25/// of `252` for daily bars, `52` for weekly, `12` for monthly. Pass
26/// `trading_periods = 1` for the raw per-bar `sigma · 100` figure.
27///
28/// Under a driftless Geometric-Brownian-Motion assumption, Parkinson's
29/// estimator has roughly `1/5` the variance of the close-to-close
30/// estimator — i.e. five close-to-close samples give the same statistical
31/// efficiency as one Parkinson sample.
32///
33/// # Example
34///
35/// ```
36/// use wickra_core::{Candle, Indicator, ParkinsonVolatility};
37///
38/// let mut indicator = ParkinsonVolatility::new(20, 252).unwrap();
39/// let mut last = None;
40/// for i in 0..40 {
41///     let base = 100.0 + f64::from(i);
42///     let candle = Candle::new(base, base + 2.0, base - 2.0, base + 1.0, 1.0, i64::from(i))
43///         .unwrap();
44///     last = indicator.update(candle);
45/// }
46/// assert!(last.is_some());
47/// ```
48#[derive(Debug, Clone)]
49pub struct ParkinsonVolatility {
50    period: usize,
51    trading_periods: usize,
52    window: VecDeque<f64>,
53    sum_sq: RollingSum,
54    last: Option<f64>,
55}
56
57/// `1 / (4 · ln 2)` — the Parkinson normalisation constant, evaluated once at
58/// `const` to keep the per-update path branch-free.
59const PARKINSON_FACTOR: f64 = 0.360_673_760_222_241_2;
60
61impl ParkinsonVolatility {
62    /// Construct a Parkinson Volatility estimator.
63    ///
64    /// `period` is the rolling window of bars; `trading_periods` is the
65    /// annualisation factor (`252` daily, `52` weekly, `12` monthly, or
66    /// `1` for raw per-bar volatility).
67    ///
68    /// # Errors
69    ///
70    /// Returns [`Error::PeriodZero`] if either parameter is `0`.
71    pub fn new(period: usize, trading_periods: usize) -> Result<Self> {
72        if period == 0 || trading_periods == 0 {
73            return Err(Error::PeriodZero);
74        }
75        Ok(Self {
76            period,
77            trading_periods,
78            window: VecDeque::with_capacity(period),
79            sum_sq: RollingSum::new(),
80            last: None,
81        })
82    }
83
84    /// Configured `(period, trading_periods)`.
85    pub const fn periods(&self) -> (usize, usize) {
86        (self.period, self.trading_periods)
87    }
88
89    /// Current value if available.
90    pub const fn value(&self) -> Option<f64> {
91        self.last
92    }
93}
94
95impl Indicator for ParkinsonVolatility {
96    type Input = Candle;
97    type Output = f64;
98
99    #[inline]
100    fn update(&mut self, candle: Candle) -> Option<f64> {
101        // `Candle::new` already guarantees finite, positive `high` and `low`
102        // with `high >= low`, so the log ratio is always well-defined and
103        // non-negative.
104        let log_hl = (candle.high / candle.low).ln();
105        let sample = log_hl * log_hl;
106
107        if self.window.len() == self.period {
108            let old = self.window.pop_front().expect("window is non-empty");
109            self.sum_sq.evict(old);
110        }
111        self.window.push_back(sample);
112        self.sum_sq.push(sample);
113        if self.sum_sq.needs_reseed(self.period) {
114            self.sum_sq.reseed(self.window.iter().copied());
115        }
116
117        if self.window.len() < self.period {
118            return None;
119        }
120
121        let n = self.period as f64;
122        let variance = (PARKINSON_FACTOR * self.sum_sq.value() / n).max(0.0);
123        let sigma = variance.sqrt();
124        let out = sigma * (self.trading_periods as f64).sqrt() * 100.0;
125        self.last = Some(out);
126        Some(out)
127    }
128
129    fn reset(&mut self) {
130        self.window.clear();
131        self.sum_sq.reset();
132        self.last = None;
133    }
134
135    #[inline]
136    fn warmup_period(&self) -> usize {
137        self.period
138    }
139
140    #[inline]
141    fn is_ready(&self) -> bool {
142        self.last.is_some()
143    }
144
145    #[inline]
146    fn name(&self) -> &'static str {
147        "ParkinsonVolatility"
148    }
149}
150
151#[cfg(test)]
152mod tests {
153    use super::*;
154    use crate::traits::BatchExt;
155    use approx::assert_relative_eq;
156
157    fn candle(h: f64, l: f64, c: f64, ts: i64) -> Candle {
158        Candle::new(f64::midpoint(h, l), h, l, c, 1.0, ts).unwrap()
159    }
160
161    #[test]
162    fn rejects_zero_period() {
163        assert!(matches!(
164            ParkinsonVolatility::new(0, 252),
165            Err(Error::PeriodZero)
166        ));
167        assert!(matches!(
168            ParkinsonVolatility::new(20, 0),
169            Err(Error::PeriodZero)
170        ));
171    }
172
173    #[test]
174    fn accessors_and_metadata() {
175        let pv = ParkinsonVolatility::new(20, 252).unwrap();
176        assert_eq!(pv.periods(), (20, 252));
177        assert_eq!(pv.value(), None);
178        assert_eq!(pv.warmup_period(), 20);
179        assert_eq!(pv.name(), "ParkinsonVolatility");
180        assert!(!pv.is_ready());
181    }
182
183    #[test]
184    fn zero_range_yields_zero() {
185        // H == L every bar -> ln(H/L) = 0 -> sigma = 0.
186        let candles: Vec<Candle> = (0..30).map(|i| candle(10.0, 10.0, 10.0, i)).collect();
187        let mut pv = ParkinsonVolatility::new(14, 1).unwrap();
188        for v in pv.batch(&candles).into_iter().flatten() {
189            assert_relative_eq!(v, 0.0, epsilon = 1e-12);
190        }
191    }
192
193    #[test]
194    fn constant_range_yields_constant_sigma() {
195        // Every bar has the same H/L ratio -> every (ln H/L)² is the same
196        // constant -> the rolling sum is `n * k` and the variance simplifies
197        // to `factor * k`. The output is `sqrt(factor * k) * 100` (with
198        // trading_periods = 1).
199        let candles: Vec<Candle> = (0..30).map(|i| candle(11.0, 9.0, 10.0, i)).collect();
200        let mut pv = ParkinsonVolatility::new(10, 1).unwrap();
201        let out = pv.batch(&candles);
202
203        let k = (11.0_f64 / 9.0_f64).ln().powi(2);
204        let expected = (PARKINSON_FACTOR * k).sqrt() * 100.0;
205        for v in out.iter().skip(9).flatten() {
206            assert_relative_eq!(*v, expected, epsilon = 1e-9);
207        }
208    }
209
210    #[test]
211    fn output_is_non_negative() {
212        let mut pv = ParkinsonVolatility::new(14, 252).unwrap();
213        let candles: Vec<Candle> = (0..200)
214            .map(|i| {
215                let base = 100.0 + (f64::from(i) * 0.3).sin() * 12.0;
216                let half = 0.5 + (f64::from(i) * 0.13).cos().abs() * 1.5;
217                candle(base + half, base - half, base, i64::from(i))
218            })
219            .collect();
220        for v in pv.batch(&candles).into_iter().flatten() {
221            assert!(v >= 0.0, "Parkinson volatility must be non-negative: {v}");
222        }
223    }
224
225    #[test]
226    fn annualisation_scales_by_sqrt_trading_periods() {
227        // Same candles run through (period, 1) and (period, 252) -> the
228        // 252-version is `sqrt(252)` times the raw version, bar-for-bar.
229        let candles: Vec<Candle> = (0..40)
230            .map(|i| {
231                let base = 100.0 + (f64::from(i) * 0.3).sin() * 5.0;
232                let half = 1.0 + (f64::from(i) * 0.2).cos().abs();
233                candle(base + half, base - half, base, i64::from(i))
234            })
235            .collect();
236        let raw = ParkinsonVolatility::new(10, 1).unwrap().batch(&candles);
237        let annual = ParkinsonVolatility::new(10, 252).unwrap().batch(&candles);
238        let scale = (252.0_f64).sqrt();
239        for (r, a) in raw.iter().zip(annual.iter()) {
240            assert_eq!(r.is_some(), a.is_some(), "warmup mismatch");
241            if let (Some(r), Some(a)) = (r, a) {
242                assert_relative_eq!(*a, r * scale, epsilon = 1e-9);
243            }
244        }
245    }
246
247    #[test]
248    fn first_emission_at_warmup_period() {
249        let candles: Vec<Candle> = (0..20).map(|i| candle(11.0, 9.0, 10.0, i)).collect();
250        let mut pv = ParkinsonVolatility::new(5, 1).unwrap();
251        let out = pv.batch(&candles);
252        for v in out.iter().take(4) {
253            assert!(v.is_none());
254        }
255        assert!(out[4].is_some());
256    }
257
258    #[test]
259    fn batch_equals_streaming() {
260        let candles: Vec<Candle> = (0..80)
261            .map(|i| {
262                let base = 100.0 + (f64::from(i) * 0.25).sin() * 6.0;
263                let half = 1.0 + (f64::from(i) * 0.15).cos().abs();
264                candle(base + half, base - half, base, i64::from(i))
265            })
266            .collect();
267        let batch = ParkinsonVolatility::new(14, 252).unwrap().batch(&candles);
268        let mut streamer = ParkinsonVolatility::new(14, 252).unwrap();
269        let streamed: Vec<_> = candles.iter().map(|c| streamer.update(*c)).collect();
270        assert_eq!(batch, streamed);
271    }
272
273    #[test]
274    fn reset_clears_state() {
275        let candles: Vec<Candle> = (0..30).map(|i| candle(11.0, 9.0, 10.0, i)).collect();
276        let mut pv = ParkinsonVolatility::new(14, 252).unwrap();
277        pv.batch(&candles);
278        assert!(pv.is_ready());
279        pv.reset();
280        assert!(!pv.is_ready());
281        assert_eq!(pv.value(), None);
282        assert_eq!(pv.update(candles[0]), None);
283    }
284}