VPIN
Volume-synchronized probability of informed trading — fixed-volume buckets, the per-bucket imbalance, the rolling window, and the aggressor-free bulk-volume-classification twin with its side-bias read.
crates/vike-orderflow/src/vpin.rs carries two estimators of the same quantity. Vpin is the
aggressor-classified form: it reads each trade's side through the
classification module, "the crate's one load-bearing
semantic". BvcVpin is the canonical Easley, López de Prado and O'Hara form, which needs no
aggressor flag at all — price and volume only. Both are streaming, pure, and gated
streaming-against-batch like every builder in the crate.
The mechanism
The trade stream is chopped into fixed-volume buckets of bucket_volume. A trade that
overflows the open bucket spills its remainder into the next — the same splitting convention as
bars::VolumeBarBuilder on the flow bars page. Each completed bucket
scores an order-flow toxicity imbalance:
|Vbuy − Vsell| / Vbucket ∈ [0, 1]VPIN is the rolling mean of the last window completed buckets — DEFAULT_VPIN_WINDOW is 50 —
"maintained as an O(1) rolling sum (add-newest/subtract-evicted — never a window re-scan on the
hot path)".
The module's hand-computed case, pinned in its tests: with a bucket volume of 4, two buys of 2 fill the first bucket at an imbalance of 1.0; a sell of 1, a buy of 1 and a sell of 2 fill the second at |1 − 3| / 4 = 0.5; VPIN reads (1.0 + 0.5) / 2 = 0.75.
Vpin — the aggressor-classified form
impl Vpin {
pub fn new(bucket_volume: f64) -> Self; // aggressor-classified, window 50
pub fn with_params(bucket_volume: f64, window: usize, has_aggressor: bool) -> Self;
pub fn push(&mut self, t: &TradeTick, mid: Option<f64>) -> Vec<f64>;
pub fn committed(&self) -> Option<f64>;
pub fn forming_bucket(&self) -> Option<VpinBucket>;
pub fn interim(&self) -> Option<f64>;
pub fn completed_buckets(&self) -> usize;
pub fn bucket_imbalances(trades: &[TradeTick], bucket_volume: f64) -> Vec<f64>;
pub fn from_trades(trades: &[TradeTick], bucket_volume: f64, window: usize) -> Option<f64>;
}Construction asserts a finite, positive bucket_volume and a window of at least 1. push
folds one trade and "returns the imbalances of every bucket this trade COMPLETED (usually empty;
≥ 2 when one big trade spans several buckets)"; a trade of non-positive size is skipped.
Classification. With has_aggressor — the default — each trade routes by classify. A feed
without a real aggressor flag constructs with has_aggressor = false and passes the prevailing
mid to push: price >= mid is a buy, else a sell — the quote-midpoint test. "A missing mid
falls back to the aggressor flag so no trade is ever dropped."
Forming versus committed. The convention mirrors bars: committed() reads completed
buckets only and is None until the first bucket completes. forming_bucket() and interim()
are pure reads that fold the open bucket's partial imbalance, |b − s| / filled, in "as the NEWEST
window sample (evicting the oldest when the window is full), without mutating state".
interim() falls back to committed() when nothing is forming, and is None only before any
volume has arrived.
Batch twins. bucket_imbalances is the full completed-bucket series with the trailing
partial bucket dropped; from_trades is the last ≤ window entries of that series under "a
naive-fold mean — a fresh sum, NOT the streaming rolling sum". The parity tests state what the
two forms owe each other: on exact-dyadic sizes streaming and batch are bit-identical even past
eviction; on non-dyadic sizes the per-bucket imbalances stay bit-identical (same fold order) and
the rolling-sum mean may differ from the fresh fold only at rounding noise.
BvcVpin — the aggressor-free form
The header cites the paper: Easley, López de Prado & O'Hara (2012), "Flow Toxicity and Liquidity in a High-Frequency World", Review of Financial Studies 25(5):1457–1493 — "the paper that defines VPIN and Bulk Volume Classification (BVC)".
- Buckets. Equal-volume buckets of
bucket_volume(VBS), with the same spill convention. Each bucket's close price is the price of the trade that filled it. - BVC. A bucket's directional split comes from its price change, not from trade-level tags:
buy_frac = Φ((P_i − P_{i−1}) / σ_ΔP), where Φ is the standard-normal CDF (norm_cdf, alibmerfmirroringvike-options) and σ_ΔP is the population standard deviation of bucket price changes over the lastwindowbuckets. ThenV_buy = VBS·buy_frac,V_sell = VBS·(1 − buy_frac), and the bucket imbalance is|2·buy_frac − 1|. - The value.
value()is the rolling mean of the lastwindowimbalances — andNoneuntilwindowof them exist. The first bucket close only sets theP_{i−1}reference: "a price CHANGE needs two closes, so N imbalances need N+1 closes". The tests pin the boundary — a window of 2 is ready on the third close. - The tick-rule option.
VolumeClassifier::TickRuleclassifies each bucket wholly by the sign of ΔP: up → all buy, down → all sell, flat → balanced. It is "the degenerate limit BVC itself falls back to when σ_ΔP is undefined". - Degenerate guards. A non-positive or non-finite
bucket_volumeand awindowbelow 1 panic at construction; a non-positive-size trade is skipped; σ_ΔP ≤ 0, or fewer than two ΔP samples (the warm-up), falls back per bucket to the tick rule — "so a flat tape yields0.0(no NaN fromΦ(0/0)) and a one-way tape yields1.0".
The worked series from the tests: closes 100, 101, 100. The first is the reference; the second
gives ΔP = +1 with one sample in the σ window, so the tick rule fires and the imbalance is 1.0;
the third gives ΔP = −1 against σ = 1, buy_frac = Φ(−1), and an imbalance of
|2·Φ(−1) − 1| = erf(1/√2) = 0.6826894921370859.
A side as well as a magnitude
value() is deliberately side-less. directional_bias() is the signed rolling mean of
(2·buy_frac − 1) ∈ [−1, 1]: "> 0 buy-toxic → ask side, < 0 sell-toxic → bid side — so a
caller can route the magnitude onto a per-side gate". It shares value()'s readiness gate.
Feeding it
on_trade(price, size) buckets internally and returns the imbalance of every bucket the trade
completed (empty for the very first bucket); on_trade_tick is the TradeTick convenience, with
the aggressor flag "deliberately unused — BVC needs none". A caller that has already formed
equal-volume buckets feeds their close prices to on_bucket(close) instead — the two must not be
interleaved on one instance. The batch twins are imbalances_from_closes ("a genuinely separate
slice computation (NOT on_bucket in disguise)") and from_bucket_closes; the σ fold order is
shared verbatim between the streaming and batch paths, so the per-bucket imbalance series is
bit-identical across them.
In the chart
The chart engine's study registry (crates/vike-chart/src/studies.rs) builds its vpin entry —
"VPIN (order-flow toxicity)" — on the aggressor-classified Vpin: bucket volume 1000 by default,
window 50 (2 to 1000), an aggressor-flag switch whose 0 selects quote-midpoint classification,
and reference bands at 0.2, 0.5 and 0.8. It is the one study that needs trades only and is never
book-gated. What that registry can reach in the shipped app is on the
in the GUI page; a walkthrough over a recorded tape is the
VPIN tutorial.
A level, not an alarm
Neither estimator emits a signal. A threshold that adapts to its own history is what the crate's streaming quantile exists for — its header names "a VPIN alarm level tracking its own p95" as the intended use.
Order-to-trade ratio
Quoting intensity inferred from consecutive book snapshots — the add/update/cancel proxy over the visible top-N, the per-window formula, and the event-anchored windows it is reported in.
Streaming quantiles
The P² single-quantile estimator — five markers, constant space, an O(1) update per observation, exact below five samples, and gated against a full-sort oracle and the published paper example.