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::ohlcv::Candle;
7use crate::traits::Indicator;
8
9/// Parkinson Volatility — a high-low realised-volatility estimator.
10///
11/// Michael Parkinson (1980) noted that the extreme range of a bar carries
12/// more variance information than the closing price alone: a wide bar that
13/// closes near its open is far more "volatile" than a narrow bar that
14/// happens to close at the same level. The estimator is
15///
16/// ```text
17/// sigma² = (1 / (4n · ln 2)) · Σ_{i=1..n} (ln(H_i / L_i))²
18/// sigma  = √sigma²
19/// out    = sigma · √trading_periods · 100
20/// ```
21///
22/// The output is annualised to a percent in the same style as
23/// [`HistoricalVolatility`](crate::HistoricalVolatility) — `trading_periods`
24/// of `252` for daily bars, `52` for weekly, `12` for monthly. Pass
25/// `trading_periods = 1` for the raw per-bar `sigma · 100` figure.
26///
27/// Under a driftless Geometric-Brownian-Motion assumption, Parkinson's
28/// estimator has roughly `1/5` the variance of the close-to-close
29/// estimator — i.e. five close-to-close samples give the same statistical
30/// efficiency as one Parkinson sample.
31///
32/// # Example
33///
34/// ```
35/// use wickra_core::{Candle, Indicator, ParkinsonVolatility};
36///
37/// let mut indicator = ParkinsonVolatility::new(20, 252).unwrap();
38/// let mut last = None;
39/// for i in 0..40 {
40///     let base = 100.0 + f64::from(i);
41///     let candle = Candle::new(base, base + 2.0, base - 2.0, base + 1.0, 1.0, i64::from(i))
42///         .unwrap();
43///     last = indicator.update(candle);
44/// }
45/// assert!(last.is_some());
46/// ```
47#[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
56/// `1 / (4 · ln 2)` — the Parkinson normalisation constant, evaluated once at
57/// `const` to keep the per-update path branch-free.
58const PARKINSON_FACTOR: f64 = 0.360_673_760_222_241_2;
59
60impl ParkinsonVolatility {
61    /// Construct a Parkinson Volatility estimator.
62    ///
63    /// `period` is the rolling window of bars; `trading_periods` is the
64    /// annualisation factor (`252` daily, `52` weekly, `12` monthly, or
65    /// `1` for raw per-bar volatility).
66    ///
67    /// # Errors
68    ///
69    /// Returns [`Error::PeriodZero`] if either parameter is `0`.
70    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    /// Configured `(period, trading_periods)`.
84    pub const fn periods(&self) -> (usize, usize) {
85        (self.period, self.trading_periods)
86    }
87
88    /// Current value if available.
89    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        // `Candle::new` already guarantees finite, positive `high` and `low`
100        // with `high >= low`, so the log ratio is always well-defined and
101        // non-negative.
102        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        // H == L every bar -> ln(H/L) = 0 -> sigma = 0.
178        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        // Every bar has the same H/L ratio -> every (ln H/L)² is the same
188        // constant -> the rolling sum is `n * k` and the variance simplifies
189        // to `factor * k`. The output is `sqrt(factor * k) * 100` (with
190        // trading_periods = 1).
191        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        // Same candles run through (period, 1) and (period, 252) -> the
220        // 252-version is `sqrt(252)` times the raw version, bar-for-bar.
221        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}