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::microstructure::{Side, Trade};
6use crate::traits::Indicator;
7use crate::{Error, Result};
8
9/// VPIN — the Volume-Synchronised Probability of Informed Trading
10/// (Easley, López de Prado & O'Hara, 2012).
11///
12/// Trades are bucketed into equal-volume buckets of size `bucket_volume`. For
13/// each completed bucket the order-flow imbalance is the absolute difference
14/// between buy and sell volume; VPIN is that imbalance averaged over the last
15/// `num_buckets` buckets and normalised by the bucket size:
16///
17/// ```text
18/// VPIN = ( Σ |Vᴮ_τ − Vˢ_τ| ) / (num_buckets · bucket_volume)
19/// ```
20///
21/// The aggressor [`Side`] of each [`Trade`] classifies its volume directly (no
22/// bulk-volume classification needed). A single trade may span several buckets;
23/// its volume is split across bucket boundaries. The result lies in `[0, 1]`:
24/// values near `1` signal a strongly one-sided, likely-informed flow (a toxic
25/// regime), values near `0` a balanced two-sided flow.
26///
27/// `Input = Trade`. Because bucket completion is driven by cumulative volume,
28/// readiness is data-dependent; [`warmup_period`](Indicator::warmup_period)
29/// reports `num_buckets` as the minimum number of trades (one per bucket) and
30/// [`is_ready`](Indicator::is_ready) reflects the true bucket count.
31///
32/// # Example
33///
34/// ```
35/// use wickra_core::{Indicator, Side, Trade, Vpin};
36///
37/// let mut vpin = Vpin::new(10.0, 2).unwrap();
38/// // Two buckets of pure buying => imbalance == bucket size => VPIN 1.
39/// let mut last = None;
40/// for _ in 0..4 {
41///     last = vpin.update(Trade::new(100.0, 5.0, Side::Buy, 0).unwrap());
42/// }
43/// assert_eq!(last, Some(1.0));
44/// ```
45#[derive(Debug, Clone)]
46pub struct Vpin {
47    bucket_volume: f64,
48    num_buckets: usize,
49    cur_buy: f64,
50    cur_sell: f64,
51    cur_total: f64,
52    window: VecDeque<f64>,
53    sum_imbalance: f64,
54}
55
56impl Vpin {
57    /// Construct a new VPIN estimator.
58    ///
59    /// # Errors
60    /// Returns [`Error::PeriodZero`] if `num_buckets == 0`, or
61    /// [`Error::InvalidParameter`] if `bucket_volume` is not finite and
62    /// positive.
63    pub fn new(bucket_volume: f64, num_buckets: usize) -> Result<Self> {
64        if num_buckets == 0 {
65            return Err(Error::PeriodZero);
66        }
67        if !bucket_volume.is_finite() || bucket_volume <= 0.0 {
68            return Err(Error::InvalidParameter {
69                message: "VPIN bucket_volume must be finite and positive",
70            });
71        }
72        Ok(Self {
73            bucket_volume,
74            num_buckets,
75            cur_buy: 0.0,
76            cur_sell: 0.0,
77            cur_total: 0.0,
78            window: VecDeque::with_capacity(num_buckets),
79            sum_imbalance: 0.0,
80        })
81    }
82
83    /// Configured `(bucket_volume, num_buckets)`.
84    pub const fn params(&self) -> (f64, usize) {
85        (self.bucket_volume, self.num_buckets)
86    }
87
88    fn close_bucket(&mut self) {
89        let imbalance = (self.cur_buy - self.cur_sell).abs();
90        if self.window.len() == self.num_buckets {
91            let old = self.window.pop_front().expect("window is non-empty");
92            self.sum_imbalance -= old;
93        }
94        self.window.push_back(imbalance);
95        self.sum_imbalance += imbalance;
96        self.cur_buy = 0.0;
97        self.cur_sell = 0.0;
98        self.cur_total = 0.0;
99    }
100}
101
102impl Indicator for Vpin {
103    type Input = Trade;
104    type Output = f64;
105
106    fn update(&mut self, trade: Trade) -> Option<f64> {
107        let mut remaining = trade.size;
108        let buy = trade.side == Side::Buy;
109        // Distribute the trade's volume across one or more buckets.
110        while remaining > 0.0 {
111            let capacity = self.bucket_volume - self.cur_total;
112            let take = remaining.min(capacity);
113            if buy {
114                self.cur_buy += take;
115            } else {
116                self.cur_sell += take;
117            }
118            self.cur_total += take;
119            remaining -= take;
120            if self.cur_total >= self.bucket_volume {
121                self.close_bucket();
122            }
123        }
124        if self.window.len() < self.num_buckets {
125            return None;
126        }
127        Some(self.sum_imbalance / (self.num_buckets as f64 * self.bucket_volume))
128    }
129
130    fn reset(&mut self) {
131        self.cur_buy = 0.0;
132        self.cur_sell = 0.0;
133        self.cur_total = 0.0;
134        self.window.clear();
135        self.sum_imbalance = 0.0;
136    }
137
138    fn warmup_period(&self) -> usize {
139        self.num_buckets
140    }
141
142    fn is_ready(&self) -> bool {
143        self.window.len() == self.num_buckets
144    }
145
146    fn name(&self) -> &'static str {
147        "Vpin"
148    }
149}
150
151#[cfg(test)]
152mod tests {
153    use super::*;
154    use crate::traits::BatchExt;
155    use approx::assert_relative_eq;
156
157    fn trade(size: f64, side: Side) -> Trade {
158        Trade::new(100.0, size, side, 0).unwrap()
159    }
160
161    #[test]
162    fn rejects_bad_params() {
163        assert!(matches!(Vpin::new(10.0, 0), Err(Error::PeriodZero)));
164        assert!(matches!(
165            Vpin::new(0.0, 5),
166            Err(Error::InvalidParameter { .. })
167        ));
168        assert!(matches!(
169            Vpin::new(f64::NAN, 5),
170            Err(Error::InvalidParameter { .. })
171        ));
172    }
173
174    #[test]
175    fn accessors_and_metadata() {
176        let vpin = Vpin::new(10.0, 50).unwrap();
177        assert_eq!(vpin.params(), (10.0, 50));
178        assert_eq!(vpin.warmup_period(), 50);
179        assert_eq!(vpin.name(), "Vpin");
180        assert!(!vpin.is_ready());
181    }
182
183    #[test]
184    fn one_sided_flow_is_one() {
185        // Every bucket is pure buying => |buy - sell| == bucket size => VPIN 1.
186        let mut vpin = Vpin::new(10.0, 2).unwrap();
187        let mut last = None;
188        for _ in 0..4 {
189            last = vpin.update(trade(5.0, Side::Buy));
190        }
191        assert_relative_eq!(last.unwrap(), 1.0, epsilon = 1e-12);
192        assert!(vpin.is_ready());
193    }
194
195    #[test]
196    fn balanced_flow_is_zero() {
197        // Each bucket holds equal buy and sell volume => imbalance 0 => VPIN 0.
198        let mut vpin = Vpin::new(10.0, 2).unwrap();
199        let mut last = None;
200        for _ in 0..4 {
201            vpin.update(trade(5.0, Side::Buy));
202            last = vpin.update(trade(5.0, Side::Sell));
203        }
204        assert_relative_eq!(last.unwrap(), 0.0, epsilon = 1e-12);
205    }
206
207    #[test]
208    fn large_trade_spans_multiple_buckets() {
209        // A single 25-unit buy fills 2 full buckets (size 10) plus 5 into a
210        // third. Two buckets close => both pure buy => imbalance 10 each.
211        let mut vpin = Vpin::new(10.0, 2).unwrap();
212        let out = vpin.update(trade(25.0, Side::Buy));
213        // After 2 closed buckets the window is full: VPIN = (10+10)/(2*10) = 1.
214        assert_relative_eq!(out.unwrap(), 1.0, epsilon = 1e-12);
215    }
216
217    #[test]
218    fn output_within_bounds() {
219        let mut vpin = Vpin::new(7.0, 4).unwrap();
220        for i in 0..200 {
221            let side = if i % 3 == 0 { Side::Sell } else { Side::Buy };
222            if let Some(v) = vpin.update(trade(1.0 + f64::from(i % 5), side)) {
223                assert!((0.0..=1.0).contains(&v), "out of bounds: {v}");
224            }
225        }
226    }
227
228    #[test]
229    fn zero_size_trade_is_noop() {
230        let mut vpin = Vpin::new(10.0, 1).unwrap();
231        assert_eq!(vpin.update(trade(0.0, Side::Buy)), None);
232        // A full bucket of buying then closes it: VPIN 1.
233        let out = vpin.update(trade(10.0, Side::Buy));
234        assert_relative_eq!(out.unwrap(), 1.0, epsilon = 1e-12);
235    }
236
237    #[test]
238    fn reset_clears_state() {
239        let mut vpin = Vpin::new(10.0, 2).unwrap();
240        for _ in 0..4 {
241            vpin.update(trade(5.0, Side::Buy));
242        }
243        assert!(vpin.is_ready());
244        vpin.reset();
245        assert!(!vpin.is_ready());
246        assert_eq!(vpin.update(trade(5.0, Side::Buy)), None);
247    }
248
249    #[test]
250    fn batch_equals_streaming() {
251        let trades: Vec<Trade> = (0..120)
252            .map(|i| {
253                let side = if i % 2 == 0 { Side::Buy } else { Side::Sell };
254                trade(1.0 + f64::from(i % 4), side)
255            })
256            .collect();
257        let batch = Vpin::new(8.0, 5).unwrap().batch(&trades);
258        let mut b = Vpin::new(8.0, 5).unwrap();
259        let streamed: Vec<_> = trades.iter().map(|t| b.update(*t)).collect();
260        assert_eq!(batch, streamed);
261    }
262}