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        // Distribute the trade's volume across one or more buckets.
120        while remaining > 0.0 {
121            let capacity = self.bucket_volume - self.cur_total;
122            let take = remaining.min(capacity);
123            if buy {
124                self.cur_buy += take;
125            } else {
126                self.cur_sell += take;
127            }
128            self.cur_total += take;
129            remaining -= take;
130            if self.cur_total >= self.bucket_volume {
131                self.close_bucket();
132            }
133        }
134        if self.window.len() < self.num_buckets {
135            return None;
136        }
137        Some(self.sum_imbalance.value() / (self.num_buckets as f64 * self.bucket_volume))
138    }
139
140    fn reset(&mut self) {
141        self.cur_buy = 0.0;
142        self.cur_sell = 0.0;
143        self.cur_total = 0.0;
144        self.window.clear();
145        self.sum_imbalance.reset();
146    }
147
148    #[inline]
149    fn warmup_period(&self) -> usize {
150        // Buckets close on cumulative volume, so one large trade can fill
151        // every bucket at once and no input count guarantees readiness. The
152        // honest lower bound is one; `is_ready` is what callers should test.
153        1
154    }
155
156    #[inline]
157    fn is_ready(&self) -> bool {
158        self.window.len() == self.num_buckets
159    }
160
161    #[inline]
162    fn name(&self) -> &'static str {
163        "Vpin"
164    }
165}
166
167#[cfg(test)]
168mod tests {
169    use super::*;
170    use crate::traits::BatchExt;
171    use approx::assert_relative_eq;
172
173    fn trade(size: f64, side: Side) -> Trade {
174        Trade::new(100.0, size, side, 0).unwrap()
175    }
176
177    #[test]
178    fn rejects_bad_params() {
179        assert!(matches!(Vpin::new(10.0, 0), Err(Error::PeriodZero)));
180        assert!(matches!(
181            Vpin::new(0.0, 5),
182            Err(Error::InvalidParameter { .. })
183        ));
184        assert!(matches!(
185            Vpin::new(f64::NAN, 5),
186            Err(Error::InvalidParameter { .. })
187        ));
188    }
189
190    #[test]
191    fn accessors_and_metadata() {
192        let vpin = Vpin::new(10.0, 50).unwrap();
193        assert_eq!(vpin.params(), (10.0, 50));
194        assert_eq!(vpin.warmup_period(), 1);
195        assert_eq!(vpin.name(), "Vpin");
196        assert!(!vpin.is_ready());
197    }
198
199    #[test]
200    fn one_sided_flow_is_one() {
201        // Every bucket is pure buying => |buy - sell| == bucket size => VPIN 1.
202        let mut vpin = Vpin::new(10.0, 2).unwrap();
203        let mut last = None;
204        for _ in 0..4 {
205            last = vpin.update(trade(5.0, Side::Buy));
206        }
207        assert_relative_eq!(last.unwrap(), 1.0, epsilon = 1e-12);
208        assert!(vpin.is_ready());
209    }
210
211    #[test]
212    fn balanced_flow_is_zero() {
213        // Each bucket holds equal buy and sell volume => imbalance 0 => VPIN 0.
214        let mut vpin = Vpin::new(10.0, 2).unwrap();
215        let mut last = None;
216        for _ in 0..4 {
217            vpin.update(trade(5.0, Side::Buy));
218            last = vpin.update(trade(5.0, Side::Sell));
219        }
220        assert_relative_eq!(last.unwrap(), 0.0, epsilon = 1e-12);
221    }
222
223    #[test]
224    fn large_trade_spans_multiple_buckets() {
225        // A single 25-unit buy fills 2 full buckets (size 10) plus 5 into a
226        // third. Two buckets close => both pure buy => imbalance 10 each.
227        let mut vpin = Vpin::new(10.0, 2).unwrap();
228        let out = vpin.update(trade(25.0, Side::Buy));
229        // After 2 closed buckets the window is full: VPIN = (10+10)/(2*10) = 1.
230        assert_relative_eq!(out.unwrap(), 1.0, epsilon = 1e-12);
231    }
232
233    #[test]
234    fn output_within_bounds() {
235        let mut vpin = Vpin::new(7.0, 4).unwrap();
236        for i in 0..200 {
237            let side = if i % 3 == 0 { Side::Sell } else { Side::Buy };
238            if let Some(v) = vpin.update(trade(1.0 + f64::from(i % 5), side)) {
239                assert!((0.0..=1.0).contains(&v), "out of bounds: {v}");
240            }
241        }
242    }
243
244    #[test]
245    fn zero_size_trade_is_noop() {
246        let mut vpin = Vpin::new(10.0, 1).unwrap();
247        assert_eq!(vpin.update(trade(0.0, Side::Buy)), None);
248        // A full bucket of buying then closes it: VPIN 1.
249        let out = vpin.update(trade(10.0, Side::Buy));
250        assert_relative_eq!(out.unwrap(), 1.0, epsilon = 1e-12);
251    }
252
253    #[test]
254    fn reset_clears_state() {
255        let mut vpin = Vpin::new(10.0, 2).unwrap();
256        for _ in 0..4 {
257            vpin.update(trade(5.0, Side::Buy));
258        }
259        assert!(vpin.is_ready());
260        vpin.reset();
261        assert!(!vpin.is_ready());
262        assert_eq!(vpin.update(trade(5.0, Side::Buy)), None);
263    }
264
265    #[test]
266    fn batch_equals_streaming() {
267        let trades: Vec<Trade> = (0..120)
268            .map(|i| {
269                let side = if i % 2 == 0 { Side::Buy } else { Side::Sell };
270                trade(1.0 + f64::from(i % 4), side)
271            })
272            .collect();
273        let batch = Vpin::new(8.0, 5).unwrap().batch(&trades);
274        let mut b = Vpin::new(8.0, 5).unwrap();
275        let streamed: Vec<_> = trades.iter().map(|t| b.update(*t)).collect();
276        assert_eq!(batch, streamed);
277    }
278}