wickra_core/indicators/
vpin.rs1use std::collections::VecDeque;
4
5use crate::microstructure::{Side, Trade};
6use crate::traits::Indicator;
7use crate::{Error, Result};
8
9#[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 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 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 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 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 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 let mut vpin = Vpin::new(10.0, 2).unwrap();
212 let out = vpin.update(trade(25.0, Side::Buy));
213 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 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}