wickra_core/indicators/
vpin.rs1use 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#[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 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 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 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 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 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 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 let mut vpin = Vpin::new(10.0, 2).unwrap();
228 let out = vpin.update(trade(25.0, Side::Buy));
229 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 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}