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