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