Skip to main content

wickra_core/indicators/
vpin.rs

1//! VPIN — Volume-Synchronised Probability of Informed Trading.
2
3use std::collections::VecDeque;
4
5use crate::indicators::rolling_moments::RollingSum;
6use crate::microstructure::{Side, Trade};
7use crate::traits::Indicator;
8use crate::{Error, Result};
9
10/// VPIN — the Volume-Synchronised Probability of Informed Trading
11/// (Easley, López de Prado & O'Hara, 2012).
12///
13/// Trades are bucketed into equal-volume buckets of size `bucket_volume`. For
14/// each completed bucket the order-flow imbalance is the absolute difference
15/// between buy and sell volume; VPIN is that imbalance averaged over the last
16/// `num_buckets` buckets and normalised by the bucket size:
17///
18/// ```text
19/// VPIN = ( Σ |Vᴮ_τ − Vˢ_τ| ) / (num_buckets · bucket_volume)
20/// ```
21///
22/// The aggressor [`Side`] of each [`Trade`] classifies its volume directly (no
23/// bulk-volume classification needed). A single trade may span several buckets;
24/// its volume is split across bucket boundaries. The result lies in `[0, 1]`:
25/// values near `1` signal a strongly one-sided, likely-informed flow (a toxic
26/// regime), values near `0` a balanced two-sided flow.
27///
28/// `Input = Trade`. Because bucket completion is driven by cumulative volume,
29/// readiness is data-dependent; [`warmup_period`](Indicator::warmup_period)
30/// reports `num_buckets` as the minimum number of trades (one per bucket) and
31/// [`is_ready`](Indicator::is_ready) reflects the true bucket count.
32///
33/// # Example
34///
35/// ```
36/// use wickra_core::{Indicator, Side, Trade, Vpin};
37///
38/// let mut vpin = Vpin::new(10.0, 2).unwrap();
39/// // Two buckets of pure buying => imbalance == bucket size => VPIN 1.
40/// let mut last = None;
41/// for _ in 0..4 {
42///     last = vpin.update(Trade::new(100.0, 5.0, Side::Buy, 0).unwrap());
43/// }
44/// assert_eq!(last, Some(1.0));
45/// ```
46#[derive(Debug, Clone)]
47pub struct Vpin {
48    bucket_volume: f64,
49    num_buckets: usize,
50    cur_buy: f64,
51    cur_sell: f64,
52    cur_total: f64,
53    window: VecDeque<f64>,
54    sum_imbalance: RollingSum,
55}
56
57impl Vpin {
58    /// Construct a new VPIN estimator.
59    ///
60    /// # Errors
61    /// Returns [`Error::PeriodZero`] if `num_buckets == 0`, or
62    /// [`Error::InvalidParameter`] if `bucket_volume` is not finite and
63    /// positive.
64    pub fn new(bucket_volume: f64, num_buckets: usize) -> Result<Self> {
65        if num_buckets == 0 {
66            return Err(Error::PeriodZero);
67        }
68        if num_buckets > crate::error::MAX_PERIOD {
69            return Err(Error::InvalidPeriod {
70                message: crate::error::PERIOD_ABOVE_MAX,
71            });
72        }
73        if !bucket_volume.is_finite() || bucket_volume <= 0.0 {
74            return Err(Error::InvalidParameter {
75                message: "VPIN bucket_volume must be finite and positive",
76            });
77        }
78        Ok(Self {
79            bucket_volume,
80            num_buckets,
81            cur_buy: 0.0,
82            cur_sell: 0.0,
83            cur_total: 0.0,
84            window: VecDeque::with_capacity(num_buckets),
85            sum_imbalance: RollingSum::new(),
86        })
87    }
88
89    /// Configured `(bucket_volume, num_buckets)`.
90    pub const fn params(&self) -> (f64, usize) {
91        (self.bucket_volume, self.num_buckets)
92    }
93
94    fn close_bucket(&mut self) {
95        let imbalance = (self.cur_buy - self.cur_sell).abs();
96        if self.window.len() == self.num_buckets {
97            let old = self.window.pop_front().expect("window is non-empty");
98            self.sum_imbalance.evict(old);
99        }
100        self.window.push_back(imbalance);
101        self.sum_imbalance.push(imbalance);
102        if self.sum_imbalance.needs_reseed(self.num_buckets) {
103            self.sum_imbalance.reseed(self.window.iter().copied());
104        }
105        self.cur_buy = 0.0;
106        self.cur_sell = 0.0;
107        self.cur_total = 0.0;
108    }
109}
110
111impl Indicator for Vpin {
112    type Input = Trade;
113    type Output = f64;
114
115    #[inline]
116    fn update(&mut self, trade: Trade) -> Option<f64> {
117        let mut remaining = trade.size;
118        let buy = trade.side == Side::Buy;
119
120        // Bound the work at what can still be observed. A trade is one-sided,
121        // so every complete bucket it fills carries an imbalance of exactly
122        // `bucket_volume`, and the window keeps only the last `num_buckets`:
123        // closing more than that pushes values identical to the ones it
124        // evicts. Dropping them is exact rather than approximate, because the
125        // remainder that decides where the next bucket boundary falls is kept.
126        //
127        // Without the bound the loop does not merely do useless work. Once the
128        // size is large enough that `bucket_volume` falls below one ULP of it
129        // -- 1.4e277 against a bucket of 8 -- `remaining -= take` leaves
130        // `remaining` unchanged, so `while remaining > 0.0` never terminates.
131        // A single malformed trade hung the caller forever. Found by
132        // fuzz/fuzz_targets/indicator_update_trade.rs.
133        let capacity = self.bucket_volume - self.cur_total;
134        let window_full = self.num_buckets as f64 * self.bucket_volume;
135        if remaining.is_infinite() {
136            // Infinitely one-sided: it fills the window and leaves nothing over.
137            remaining = capacity + window_full;
138        } else if remaining > capacity {
139            let beyond = remaining - capacity;
140            if beyond / self.bucket_volume > self.num_buckets as f64 {
141                remaining = capacity + window_full + beyond % self.bucket_volume;
142            }
143        }
144
145        // Distribute the trade's volume across one or more buckets.
146        while remaining > 0.0 {
147            let capacity = self.bucket_volume - self.cur_total;
148            let take = remaining.min(capacity);
149            if buy {
150                self.cur_buy += take;
151            } else {
152                self.cur_sell += take;
153            }
154            self.cur_total += take;
155            remaining -= take;
156            if self.cur_total >= self.bucket_volume {
157                self.close_bucket();
158            }
159        }
160        if self.window.len() < self.num_buckets {
161            return None;
162        }
163        Some(self.sum_imbalance.value() / (self.num_buckets as f64 * self.bucket_volume))
164    }
165
166    fn reset(&mut self) {
167        self.cur_buy = 0.0;
168        self.cur_sell = 0.0;
169        self.cur_total = 0.0;
170        self.window.clear();
171        self.sum_imbalance.reset();
172    }
173
174    #[inline]
175    fn warmup_period(&self) -> usize {
176        // Buckets close on cumulative volume, so one large trade can fill
177        // every bucket at once and no input count guarantees readiness. The
178        // honest lower bound is one; `is_ready` is what callers should test.
179        1
180    }
181
182    #[inline]
183    fn is_ready(&self) -> bool {
184        self.window.len() == self.num_buckets
185    }
186
187    #[inline]
188    fn name(&self) -> &'static str {
189        "Vpin"
190    }
191}
192
193#[cfg(test)]
194mod tests {
195    use super::*;
196    use crate::traits::BatchExt;
197    use approx::assert_relative_eq;
198
199    fn trade(size: f64, side: Side) -> Trade {
200        Trade::new(100.0, size, side, 0).unwrap()
201    }
202
203    #[test]
204    fn rejects_bad_params() {
205        assert!(matches!(Vpin::new(10.0, 0), Err(Error::PeriodZero)));
206        assert!(matches!(
207            Vpin::new(0.0, 5),
208            Err(Error::InvalidParameter { .. })
209        ));
210        assert!(matches!(
211            Vpin::new(f64::NAN, 5),
212            Err(Error::InvalidParameter { .. })
213        ));
214    }
215
216    #[test]
217    fn accessors_and_metadata() {
218        let vpin = Vpin::new(10.0, 50).unwrap();
219        assert_eq!(vpin.params(), (10.0, 50));
220        assert_eq!(vpin.warmup_period(), 1);
221        assert_eq!(vpin.name(), "Vpin");
222        assert!(!vpin.is_ready());
223    }
224
225    #[test]
226    fn one_sided_flow_is_one() {
227        // Every bucket is pure buying => |buy - sell| == bucket size => VPIN 1.
228        let mut vpin = Vpin::new(10.0, 2).unwrap();
229        let mut last = None;
230        for _ in 0..4 {
231            last = vpin.update(trade(5.0, Side::Buy));
232        }
233        assert_relative_eq!(last.unwrap(), 1.0, epsilon = 1e-12);
234        assert!(vpin.is_ready());
235    }
236
237    #[test]
238    fn balanced_flow_is_zero() {
239        // Each bucket holds equal buy and sell volume => imbalance 0 => VPIN 0.
240        let mut vpin = Vpin::new(10.0, 2).unwrap();
241        let mut last = None;
242        for _ in 0..4 {
243            vpin.update(trade(5.0, Side::Buy));
244            last = vpin.update(trade(5.0, Side::Sell));
245        }
246        assert_relative_eq!(last.unwrap(), 0.0, epsilon = 1e-12);
247    }
248
249    #[test]
250    fn large_trade_spans_multiple_buckets() {
251        // A single 25-unit buy fills 2 full buckets (size 10) plus 5 into a
252        // third. Two buckets close => both pure buy => imbalance 10 each.
253        let mut vpin = Vpin::new(10.0, 2).unwrap();
254        let out = vpin.update(trade(25.0, Side::Buy));
255        // After 2 closed buckets the window is full: VPIN = (10+10)/(2*10) = 1.
256        assert_relative_eq!(out.unwrap(), 1.0, epsilon = 1e-12);
257    }
258
259    #[test]
260    fn output_within_bounds() {
261        let mut vpin = Vpin::new(7.0, 4).unwrap();
262        for i in 0..200 {
263            let side = if i % 3 == 0 { Side::Sell } else { Side::Buy };
264            if let Some(v) = vpin.update(trade(1.0 + f64::from(i % 5), side)) {
265                assert!((0.0..=1.0).contains(&v), "out of bounds: {v}");
266            }
267        }
268    }
269
270    #[test]
271    fn zero_size_trade_is_noop() {
272        let mut vpin = Vpin::new(10.0, 1).unwrap();
273        assert_eq!(vpin.update(trade(0.0, Side::Buy)), None);
274        // A full bucket of buying then closes it: VPIN 1.
275        let out = vpin.update(trade(10.0, Side::Buy));
276        assert_relative_eq!(out.unwrap(), 1.0, epsilon = 1e-12);
277    }
278
279    #[test]
280    fn reset_clears_state() {
281        let mut vpin = Vpin::new(10.0, 2).unwrap();
282        for _ in 0..4 {
283            vpin.update(trade(5.0, Side::Buy));
284        }
285        assert!(vpin.is_ready());
286        vpin.reset();
287        assert!(!vpin.is_ready());
288        assert_eq!(vpin.update(trade(5.0, Side::Buy)), None);
289    }
290
291    #[test]
292    fn batch_equals_streaming() {
293        let trades: Vec<Trade> = (0..120)
294            .map(|i| {
295                let side = if i % 2 == 0 { Side::Buy } else { Side::Sell };
296                trade(1.0 + f64::from(i % 4), side)
297            })
298            .collect();
299        let batch = Vpin::new(8.0, 5).unwrap().batch(&trades);
300        let mut b = Vpin::new(8.0, 5).unwrap();
301        let streamed: Vec<_> = trades.iter().map(|t| b.update(*t)).collect();
302        assert_eq!(batch, streamed);
303    }
304
305    // The exact input libFuzzer found: a size so large that subtracting a
306    // bucket from it is below one ULP, so the distribution loop could never
307    // make progress and `update` never returned. Any assertion here is
308    // secondary to the test completing at all.
309    #[test]
310    fn enormous_size_terminates() {
311        let mut vpin = Vpin::new(8.0, 5).unwrap();
312        let value = vpin.update(trade(1.397_926_697_262_895_6e277, Side::Buy));
313        assert_eq!(
314            value,
315            Some(1.0),
316            "a one-sided flood is maximally imbalanced"
317        );
318    }
319
320    // `Trade::new` rejects a non-finite size, so this one is only reachable
321    // through `new_unchecked` -- which is what the fuzz harness uses, and what
322    // a binding that has already validated upstream may use too. The finite
323    // case above needs no such help: 1.4e277 passes the validating constructor
324    // unchanged, so the hang was reachable through the ordinary API.
325    #[test]
326    fn infinite_size_terminates() {
327        let mut vpin = Vpin::new(8.0, 5).unwrap();
328        let flood = Trade::new_unchecked(100.0, f64::INFINITY, Side::Buy, 0);
329        assert_eq!(vpin.update(flood), Some(1.0));
330    }
331
332    // Bounding the loop must not move the next bucket boundary. A size well
333    // past the window still leaves a remainder, and the bucket after it has to
334    // start where it would have without the bound.
335    #[test]
336    fn bounding_preserves_the_remainder() {
337        let mut bounded = Vpin::new(8.0, 5).unwrap();
338        bounded.update(trade(1000.5, Side::Buy));
339
340        // The same volume delivered as many small trades, which never triggers
341        // the bound, must leave the estimator in the same state.
342        let mut unbounded = Vpin::new(8.0, 5).unwrap();
343        for _ in 0..2001 {
344            unbounded.update(trade(0.5, Side::Buy));
345        }
346        assert_eq!(bounded.cur_total, unbounded.cur_total);
347        assert_eq!(
348            bounded.update(trade(1.0, Side::Sell)),
349            unbounded.update(trade(1.0, Side::Sell))
350        );
351    }
352
353    #[test]
354    fn a_size_below_the_bound_is_untouched() {
355        // 6 buckets' worth against a 5-bucket window: right at the edge, and
356        // the bound must not engage where the loop still terminates on its own.
357        let mut vpin = Vpin::new(8.0, 5).unwrap();
358        assert_eq!(vpin.update(trade(48.0, Side::Buy)), Some(1.0));
359        assert_eq!(vpin.cur_total, 0.0);
360    }
361}