Skip to main content

wickra_core/indicators/
bipower_variation.rs

1//! Realized Bipower Variation — a jump-robust quadratic-variation estimator.
2
3use std::collections::VecDeque;
4
5use crate::error::{Error, Result};
6use crate::indicators::rolling_moments::RollingSum;
7use crate::traits::Indicator;
8
9/// Realized Bipower Variation — the sum of *adjacent* absolute log-return
10/// products over the trailing `period` returns, scaled to estimate integrated
11/// variance.
12///
13/// ```text
14/// r_t = ln(price_t / price_{t−1})
15/// BV  = (π / 2) · Σ |r_t| · |r_{t−1}|   over the window
16/// ```
17///
18/// Bipower variation (Barndorff-Nielsen & Shephard 2004) estimates the same
19/// integrated variance as [`RealizedVolatility`](crate::RealizedVolatility)'s
20/// `Σ r²`, but by multiplying *neighbouring* absolute returns rather than
21/// squaring a single one. A price jump inflates exactly one return; because that
22/// return appears in a product with its (ordinary) neighbour rather than squared,
23/// its contribution stays bounded — so `BV` is **robust to jumps** while realized
24/// variance is not. The constant `π / 2 = μ₁⁻²` (with `μ₁ = E|Z| = √(2/π)` for a
25/// standard normal) debiases the product of two half-normal magnitudes back to a
26/// variance scale.
27///
28/// The output is on the **variance** scale (the jump-robust counterpart of
29/// realized *variance*, not volatility); take its square root for a volatility,
30/// and compare `RV − BV` to isolate the jump contribution. A window of `period`
31/// returns contributes `period − 1` adjacent products; each `update` is O(1) via
32/// a running sum.
33///
34/// Non-finite and non-positive prices are ignored (the log return would be
35/// undefined): the tick is dropped, state is left untouched, and the last value
36/// is returned.
37///
38/// # Example
39///
40/// ```
41/// use wickra_core::{BipowerVariation, Indicator};
42///
43/// let mut indicator = BipowerVariation::new(20).unwrap();
44/// let mut last = None;
45/// for i in 0..80 {
46///     last = indicator.update(100.0 + (f64::from(i) * 0.3).sin() * 5.0);
47/// }
48/// assert!(last.is_some());
49/// ```
50#[derive(Debug, Clone)]
51pub struct BipowerVariation {
52    period: usize,
53    prev_price: Option<f64>,
54    /// Rolling window of the last `period` log returns.
55    window: VecDeque<f64>,
56    /// Running sum of adjacent absolute-return products inside the window.
57    sum_adjacent: RollingSum,
58    last: Option<f64>,
59}
60
61impl BipowerVariation {
62    /// Construct a new bipower-variation indicator.
63    ///
64    /// `period` is the number of log returns in the rolling window; the estimate
65    /// uses the `period − 1` adjacent products between them.
66    ///
67    /// # Errors
68    /// Returns [`Error::PeriodZero`] if `period == 0`, or
69    /// [`Error::InvalidPeriod`] if `period == 1` (an adjacent product needs at
70    /// least two returns).
71    pub fn new(period: usize) -> Result<Self> {
72        if period == 0 {
73            return Err(Error::PeriodZero);
74        }
75        if period > crate::error::MAX_PERIOD {
76            return Err(Error::InvalidPeriod {
77                message: crate::error::PERIOD_ABOVE_MAX,
78            });
79        }
80        if period < 2 {
81            return Err(Error::InvalidPeriod {
82                message: "bipower variation period must be >= 2",
83            });
84        }
85        Ok(Self {
86            period,
87            prev_price: None,
88            window: VecDeque::with_capacity(period),
89            sum_adjacent: RollingSum::new(),
90            last: None,
91        })
92    }
93
94    /// Configured period.
95    pub const fn period(&self) -> usize {
96        self.period
97    }
98}
99
100/// `μ₁⁻² = π / 2`, the debiasing constant for a product of half-normal returns.
101const MU1_INV_SQ: f64 = std::f64::consts::FRAC_PI_2;
102
103impl Indicator for BipowerVariation {
104    type Input = f64;
105    type Output = f64;
106
107    #[inline]
108    fn update(&mut self, input: f64) -> Option<f64> {
109        // Non-finite / non-positive prices are skipped: `ln(input / prev)` is
110        // undefined, so the tick must not enter the return window.
111        if !input.is_finite() || input <= 0.0 {
112            return None;
113        }
114        let Some(prev) = self.prev_price else {
115            self.prev_price = Some(input);
116            return None;
117        };
118        self.prev_price = Some(input);
119        // `prev` came from `self.prev_price`, gated by the guard above, so it is
120        // finite and positive — the log return is always well-defined.
121        let r = (input / prev).ln();
122        // The incoming return forms a product with the current last return.
123        if let Some(&back) = self.window.back() {
124            self.sum_adjacent.push(back.abs() * r.abs());
125        }
126        self.window.push_back(r);
127        if self.window.len() > self.period {
128            let first = self.window.pop_front().expect("window is non-empty");
129            // The product between the dropped return and the new front leaves.
130            let second = *self.window.front().expect("window still has >= 1 element");
131            self.sum_adjacent.evict(first.abs() * second.abs());
132            if self.sum_adjacent.needs_reseed(self.period) {
133                self.sum_adjacent.reseed(
134                    self.window
135                        .iter()
136                        .zip(self.window.iter().skip(1))
137                        .map(|(a, b)| a.abs() * b.abs()),
138                );
139            }
140        }
141        if self.window.len() < self.period {
142            return None;
143        }
144        // Products are non-negative; the rolling subtraction can leave a tiny
145        // negative residual when returns are ~0, so clamp before scaling.
146        let bv = MU1_INV_SQ * self.sum_adjacent.value().max(0.0);
147        self.last = Some(bv);
148        Some(bv)
149    }
150
151    fn reset(&mut self) {
152        self.prev_price = None;
153        self.window.clear();
154        self.sum_adjacent.reset();
155        self.last = None;
156    }
157
158    #[inline]
159    fn warmup_period(&self) -> usize {
160        // The first log return needs a previous price, then the window fills.
161        self.period + 1
162    }
163
164    #[inline]
165    fn is_ready(&self) -> bool {
166        self.last.is_some()
167    }
168
169    #[inline]
170    fn name(&self) -> &'static str {
171        "BipowerVariation"
172    }
173}
174
175#[cfg(test)]
176mod tests {
177    use super::*;
178    use crate::traits::BatchExt;
179    use approx::assert_relative_eq;
180
181    #[test]
182    fn rejects_zero_period() {
183        assert!(matches!(BipowerVariation::new(0), Err(Error::PeriodZero)));
184    }
185
186    #[test]
187    fn rejects_period_one() {
188        assert!(matches!(
189            BipowerVariation::new(1),
190            Err(Error::InvalidPeriod { .. })
191        ));
192    }
193
194    #[test]
195    fn accessors_and_metadata() {
196        let bv = BipowerVariation::new(20).unwrap();
197        assert_eq!(bv.period(), 20);
198        assert_eq!(bv.warmup_period(), 21);
199        assert_eq!(bv.name(), "BipowerVariation");
200        assert!(!bv.is_ready());
201    }
202
203    #[test]
204    fn first_emission_at_warmup_period() {
205        let mut bv = BipowerVariation::new(5).unwrap();
206        let out = bv.batch(&(1..=20).map(f64::from).collect::<Vec<_>>());
207        for v in out.iter().take(5) {
208            assert!(v.is_none());
209        }
210        assert!(out[5].is_some());
211    }
212
213    #[test]
214    fn known_value() {
215        // period = 2: one adjacent product. r1 = ln(1.1), r2 = ln(0.9).
216        // BV = (π/2)·|r1|·|r2|.
217        let mut bv = BipowerVariation::new(2).unwrap();
218        let out = bv.batch(&[100.0, 110.0, 99.0]);
219        assert!(out[1].is_none());
220        let r1 = (110.0_f64 / 100.0).ln();
221        let r2 = (99.0_f64 / 110.0).ln();
222        let expected = std::f64::consts::FRAC_PI_2 * r1.abs() * r2.abs();
223        assert_relative_eq!(out[2].unwrap(), expected, epsilon = 1e-12);
224    }
225
226    #[test]
227    fn rolling_window_drops_oldest_product() {
228        // period = 2, four prices -> two emissions, each a single product.
229        let mut bv = BipowerVariation::new(2).unwrap();
230        let out = bv.batch(&[100.0, 110.0, 99.0, 105.0]);
231        let r2 = (99.0_f64 / 110.0).ln();
232        let r3 = (105.0_f64 / 99.0).ln();
233        let expected = std::f64::consts::FRAC_PI_2 * r2.abs() * r3.abs();
234        assert_relative_eq!(out[3].unwrap(), expected, epsilon = 1e-12);
235    }
236
237    #[test]
238    fn constant_series_yields_zero() {
239        let mut bv = BipowerVariation::new(10).unwrap();
240        for v in bv.batch(&[100.0; 40]).into_iter().flatten() {
241            assert_relative_eq!(v, 0.0, epsilon = 1e-12);
242        }
243    }
244
245    #[test]
246    fn output_is_non_negative() {
247        let mut bv = BipowerVariation::new(20).unwrap();
248        let prices: Vec<f64> = (1..=200)
249            .map(|i| 100.0 + (f64::from(i) * 0.3).sin() * 12.0)
250            .collect();
251        for v in bv.batch(&prices).into_iter().flatten() {
252            assert!(v >= 0.0, "bipower variation must be non-negative, got {v}");
253        }
254    }
255
256    #[test]
257    fn ignores_non_finite_input() {
258        let mut bv = BipowerVariation::new(5).unwrap();
259        let out = bv.batch(&(1..=20).map(f64::from).collect::<Vec<_>>());
260        let last = *out.last().unwrap();
261        assert!(last.is_some());
262        assert_eq!(bv.update(f64::NAN), None);
263        assert_eq!(bv.update(f64::INFINITY), None);
264    }
265
266    #[test]
267    fn skips_non_positive_prices() {
268        let mut bv = BipowerVariation::new(5).unwrap();
269        let warmup = bv.batch(&(1..=20).map(f64::from).collect::<Vec<_>>());
270        warmup.last().copied().flatten().expect("warmed up");
271        assert_eq!(bv.update(-5.0), None);
272        assert_eq!(bv.update(0.0), None);
273        // State untouched: a clone advanced by the same real tick agrees.
274        let mut control = bv.clone();
275        let after = bv.update(21.0).expect("ready");
276        assert_eq!(control.update(21.0).expect("ready"), after);
277    }
278
279    #[test]
280    fn reset_clears_state() {
281        let mut bv = BipowerVariation::new(5).unwrap();
282        bv.batch(&(1..=20).map(f64::from).collect::<Vec<_>>());
283        assert!(bv.is_ready());
284        bv.reset();
285        assert!(!bv.is_ready());
286        assert_eq!(bv.update(1.0), None);
287    }
288
289    #[test]
290    fn batch_equals_streaming() {
291        let prices: Vec<f64> = (1..=120)
292            .map(|i| 100.0 + (f64::from(i) * 0.25).sin() * 9.0)
293            .collect();
294        let batch = BipowerVariation::new(20).unwrap().batch(&prices);
295        let mut b = BipowerVariation::new(20).unwrap();
296        let streamed: Vec<_> = prices.iter().map(|p| b.update(*p)).collect();
297        assert_eq!(batch, streamed);
298    }
299}