Skip to main content

wickra_core/indicators/
garman_klass.rs

1//! Garman-Klass Volatility (OHLC 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/// Garman-Klass Volatility — an OHLC realised-volatility estimator.
11///
12/// Garman & Klass (1980) extended Parkinson's high-low estimator by adding
13/// an open-to-close term, removing some of the bias introduced when the
14/// closing price drifts within the bar. The per-bar sample is
15///
16/// ```text
17/// s_t = 0.5 · (ln(H_t / L_t))² − (2·ln 2 − 1) · (ln(C_t / O_t))²
18/// ```
19///
20/// and the indicator returns the annualised square root of the rolling
21/// mean of `s_t`:
22///
23/// ```text
24/// out = sqrt(max(mean(s_t over `period`), 0)) · sqrt(trading_periods) · 100
25/// ```
26///
27/// Garman & Klass showed the estimator is ~7.4× more statistically efficient
28/// than the close-to-close estimator under driftless Geometric Brownian
29/// Motion (Parkinson sits at ~5.0×). It is still biased when there is
30/// significant overnight drift between bars — use the Yang-Zhang estimator
31/// when the dataset has meaningful close-to-open gaps.
32///
33/// The per-bar sample `s_t` can be slightly negative when the bar's range
34/// is small relative to its open-to-close move; this matches the original
35/// paper's algebra and is handled by clamping the rolling mean to zero
36/// before taking the square root.
37///
38/// # Example
39///
40/// ```
41/// use wickra_core::{Candle, GarmanKlassVolatility, Indicator};
42///
43/// let mut indicator = GarmanKlassVolatility::new(20, 252).unwrap();
44/// let mut last = None;
45/// for i in 0..40 {
46///     let base = 100.0 + f64::from(i);
47///     let candle = Candle::new(base, base + 2.0, base - 2.0, base + 0.5, 1.0, i64::from(i))
48///         .unwrap();
49///     last = indicator.update(candle);
50/// }
51/// assert!(last.is_some());
52/// ```
53#[derive(Debug, Clone)]
54pub struct GarmanKlassVolatility {
55    period: usize,
56    trading_periods: usize,
57    window: VecDeque<f64>,
58    sum: RollingSum,
59    last: Option<f64>,
60}
61
62/// `2 · ln 2 − 1` — the Garman-Klass open-to-close weight.
63const GK_OC_COEFF: f64 = 0.386_294_361_119_890_6;
64
65impl GarmanKlassVolatility {
66    /// Construct a Garman-Klass Volatility estimator.
67    ///
68    /// `period` is the rolling window of bars; `trading_periods` is the
69    /// annualisation factor (`252` daily, `52` weekly, `12` monthly, or
70    /// `1` for raw per-bar volatility).
71    ///
72    /// # Errors
73    ///
74    /// Returns [`Error::PeriodZero`] if either parameter is `0`.
75    pub fn new(period: usize, trading_periods: usize) -> Result<Self> {
76        if period == 0 || trading_periods == 0 {
77            return Err(Error::PeriodZero);
78        }
79        Ok(Self {
80            period,
81            trading_periods,
82            window: VecDeque::with_capacity(period),
83            sum: RollingSum::new(),
84            last: None,
85        })
86    }
87
88    /// Configured `(period, trading_periods)`.
89    pub const fn periods(&self) -> (usize, usize) {
90        (self.period, self.trading_periods)
91    }
92
93    /// Current value if available.
94    pub const fn value(&self) -> Option<f64> {
95        self.last
96    }
97}
98
99impl Indicator for GarmanKlassVolatility {
100    type Input = Candle;
101    type Output = f64;
102
103    #[inline]
104    fn update(&mut self, candle: Candle) -> Option<f64> {
105        // `Candle::new` enforces finite, positive OHLC with `high >= max(open,
106        // low, close)` and `low <= min(open, high, close)`, so every log
107        // ratio below is well-defined and `ln(H/L) >= 0`.
108        let log_hl = (candle.high / candle.low).ln();
109        let log_co = (candle.close / candle.open).ln();
110        let sample = 0.5 * log_hl * log_hl - GK_OC_COEFF * log_co * log_co;
111
112        if self.window.len() == self.period {
113            let old = self.window.pop_front().expect("window is non-empty");
114            self.sum.evict(old);
115        }
116        self.window.push_back(sample);
117        self.sum.push(sample);
118        if self.sum.needs_reseed(self.period) {
119            self.sum.reseed(self.window.iter().copied());
120        }
121
122        if self.window.len() < self.period {
123            return None;
124        }
125
126        let n = self.period as f64;
127        // Rolling mean. Garman-Klass samples can be marginally negative on
128        // narrow-range bars with large O-to-C moves; the rolling mean is
129        // theoretically `>= 0` but a clamp absorbs FP cancellation and the
130        // pathological all-negative case.
131        let variance = (self.sum.value() / n).max(0.0);
132        let sigma = variance.sqrt();
133        let out = sigma * (self.trading_periods as f64).sqrt() * 100.0;
134        self.last = Some(out);
135        Some(out)
136    }
137
138    fn reset(&mut self) {
139        self.window.clear();
140        self.sum.reset();
141        self.last = None;
142    }
143
144    #[inline]
145    fn warmup_period(&self) -> usize {
146        self.period
147    }
148
149    #[inline]
150    fn is_ready(&self) -> bool {
151        self.last.is_some()
152    }
153
154    #[inline]
155    fn name(&self) -> &'static str {
156        "GarmanKlassVolatility"
157    }
158}
159
160#[cfg(test)]
161mod tests {
162    use super::*;
163    use crate::traits::BatchExt;
164    use approx::assert_relative_eq;
165
166    fn candle(o: f64, h: f64, l: f64, c: f64, ts: i64) -> Candle {
167        Candle::new(o, h, l, c, 1.0, ts).unwrap()
168    }
169
170    #[test]
171    fn rejects_zero_period() {
172        assert!(matches!(
173            GarmanKlassVolatility::new(0, 252),
174            Err(Error::PeriodZero)
175        ));
176        assert!(matches!(
177            GarmanKlassVolatility::new(20, 0),
178            Err(Error::PeriodZero)
179        ));
180    }
181
182    #[test]
183    fn accessors_and_metadata() {
184        let gk = GarmanKlassVolatility::new(20, 252).unwrap();
185        assert_eq!(gk.periods(), (20, 252));
186        assert_eq!(gk.value(), None);
187        assert_eq!(gk.warmup_period(), 20);
188        assert_eq!(gk.name(), "GarmanKlassVolatility");
189        assert!(!gk.is_ready());
190    }
191
192    #[test]
193    fn zero_movement_yields_zero() {
194        // O == H == L == C -> both log terms are zero -> sigma is zero.
195        let candles: Vec<Candle> = (0..30).map(|i| candle(10.0, 10.0, 10.0, 10.0, i)).collect();
196        let mut gk = GarmanKlassVolatility::new(14, 1).unwrap();
197        for v in gk.batch(&candles).into_iter().flatten() {
198            assert_relative_eq!(v, 0.0, epsilon = 1e-12);
199        }
200    }
201
202    #[test]
203    fn constant_bar_shape_yields_constant_sigma() {
204        // Every bar has identical O/H/L/C ratios -> per-bar sample is a
205        // constant `k`, so the rolling mean is `k` and the output is
206        // `sqrt(k) * 100` (trading_periods = 1).
207        let candles: Vec<Candle> = (0..30).map(|i| candle(10.0, 11.0, 9.0, 10.2, i)).collect();
208        let log_hl = (11.0_f64 / 9.0_f64).ln();
209        let log_co = (10.2_f64 / 10.0_f64).ln();
210        let k = 0.5 * log_hl * log_hl - GK_OC_COEFF * log_co * log_co;
211        let expected = k.max(0.0).sqrt() * 100.0;
212
213        let mut gk = GarmanKlassVolatility::new(10, 1).unwrap();
214        let out = gk.batch(&candles);
215        for v in out.iter().skip(9).flatten() {
216            assert_relative_eq!(*v, expected, epsilon = 1e-9);
217        }
218    }
219
220    #[test]
221    fn output_is_non_negative() {
222        let mut gk = GarmanKlassVolatility::new(14, 252).unwrap();
223        let candles: Vec<Candle> = (0..200)
224            .map(|i| {
225                let base = 100.0 + (f64::from(i) * 0.3).sin() * 12.0;
226                let half = 0.5 + (f64::from(i) * 0.13).cos().abs() * 1.5;
227                let open = base - 0.1;
228                let close = base + 0.2;
229                candle(open, base + half, base - half, close, i64::from(i))
230            })
231            .collect();
232        for v in gk.batch(&candles).into_iter().flatten() {
233            assert!(v >= 0.0, "Garman-Klass must be non-negative: {v}");
234        }
235    }
236
237    #[test]
238    fn annualisation_scales_by_sqrt_trading_periods() {
239        let candles: Vec<Candle> = (0..40)
240            .map(|i| {
241                let base = 100.0 + (f64::from(i) * 0.3).sin() * 5.0;
242                let half = 1.0 + (f64::from(i) * 0.2).cos().abs();
243                candle(base, base + half, base - half, base + 0.3, i64::from(i))
244            })
245            .collect();
246        let raw = GarmanKlassVolatility::new(10, 1).unwrap().batch(&candles);
247        let annual = GarmanKlassVolatility::new(10, 252).unwrap().batch(&candles);
248        let scale = (252.0_f64).sqrt();
249        for (r, a) in raw.iter().zip(annual.iter()) {
250            assert_eq!(r.is_some(), a.is_some(), "warmup mismatch");
251            if let (Some(r), Some(a)) = (r, a) {
252                assert_relative_eq!(*a, r * scale, epsilon = 1e-9);
253            }
254        }
255    }
256
257    #[test]
258    fn first_emission_at_warmup_period() {
259        let candles: Vec<Candle> = (0..20).map(|i| candle(10.0, 11.0, 9.0, 10.2, i)).collect();
260        let mut gk = GarmanKlassVolatility::new(5, 1).unwrap();
261        let out = gk.batch(&candles);
262        for v in out.iter().take(4) {
263            assert!(v.is_none());
264        }
265        assert!(out[4].is_some());
266    }
267
268    #[test]
269    fn batch_equals_streaming() {
270        let candles: Vec<Candle> = (0..80)
271            .map(|i| {
272                let base = 100.0 + (f64::from(i) * 0.25).sin() * 6.0;
273                let half = 1.0 + (f64::from(i) * 0.15).cos().abs();
274                candle(base, base + half, base - half, base + 0.5, i64::from(i))
275            })
276            .collect();
277        let batch = GarmanKlassVolatility::new(14, 252).unwrap().batch(&candles);
278        let mut streamer = GarmanKlassVolatility::new(14, 252).unwrap();
279        let streamed: Vec<_> = candles.iter().map(|c| streamer.update(*c)).collect();
280        assert_eq!(batch, streamed);
281    }
282
283    #[test]
284    fn reset_clears_state() {
285        let candles: Vec<Candle> = (0..30).map(|i| candle(10.0, 11.0, 9.0, 10.2, i)).collect();
286        let mut gk = GarmanKlassVolatility::new(14, 252).unwrap();
287        gk.batch(&candles);
288        assert!(gk.is_ready());
289        gk.reset();
290        assert!(!gk.is_ready());
291        assert_eq!(gk.value(), None);
292        assert_eq!(gk.update(candles[0]), None);
293    }
294}