vike
Tutorials

A VPIN study over a recorded tape

Fold a recorded trade tape into cumulative volume delta, a footprint grid and two flavours of VPIN, and understand the one classification decision all three inherit.

vike-orderflow is a pure trade-aggregation core: no I/O, no rendering, no threading. You hand it vike_model::TradeTicks and it hands back order-flow primitives. This tutorial takes a recorded tape off the history store and builds three studies from it — CVD, a footprint, and VPIN — in the order the crate itself puts them.

Get the tape

HistStore::scan_trades(venue, symbol, range) returns the executed trades for that series in the range, timestamp-ascending. That is the whole input. Recording one is record a feed, then replay it; note there that Binance serves a trade tape and a conflating depth lane but no quotes and no lossless book, which matters for the mid-price variant below.

The one decision everything inherits

crates/vike-orderflow/src/classify.rs calls itself "the one load-bearing semantic of the whole crate", and it is one line of logic: is_buyer_maker is the Binance aggregate-trades convention — the buyer was the resting maker, sitting on a bid, so the aggressor was the seller. is_buyer_maker == false classifies Buy (an ask was lifted); true classifies Sell (a bid was hit). signed(t) then routes the trade's full size to the aggressor's side, and delta = buy_vol − sell_vol everywhere downstream.

If your tape's flag means something else, every number on this page is sign-flipped. Check it once, at the source.

Cumulative volume delta

The smallest study, and the one to sanity-check the tape with. CvdAccumulator::push(&trade) folds one trade and value() reads the running sum; CvdAccumulator::from_trades(&trades) is the batch twin over a slice. Non-positive sizes are skipped rather than folded, in this and every other builder.

Fold the whole slice both ways. They must agree bit for bit — that is the crate's own gate discipline, described in its header as streaming-versus-batch parity compared on f64::to_bits, where the batch path is a genuinely separate slice computation rather than push in disguise. If your own aggregation disagrees with from_trades, the bug is yours.

A footprint

FootprintBuilder::new(tick_size) asserts a positive tick size, push accumulates each trade into its price bucket, and close_bar() returns a FootprintBar — a bar index plus ascending PriceBin cells, each carrying buy_vol and sell_vol at one price. FootprintBuilder::from_bars(&bars, tick_size) is the batch twin over trades already grouped per bar.

To group them, use the bar builders in the same crate: TickBarBuilder, VolumeBarBuilder and DollarBarBuilder each emit an OrderflowBar carrying o/h/l/c, buy_vol, sell_vol, delta, trade_count and first/last timestamps. The volume and dollar builders split a trade proportionally across a threshold boundary — by volume and by cumulative price·size respectively — which is the same convention VPIN uses below, so the two agree about where a bucket ends.

The bucketing itself is owned by profile.rs, not re-derived: the footprint reuses that module's PriceBin, its price-to-bucket rounding and its grid-to-bins transform. The same module builds a VolumeProfile with its point of control and a 70% value area, if what you want is volume-at-price rather than per-bar cells.

VPIN, twice

crates/vike-orderflow/src/vpin.rs ships two estimators, and the difference is what they need from the tape.

Vpin chops the stream into fixed-volume buckets of bucket_volume; a trade that overflows the open bucket spills its remainder into the next. Each completed bucket scores an order-flow toxicity imbalance |Vbuy − Vsell| / Vbucket in [0, 1], and VPIN is the rolling mean of the last window completed buckets — DEFAULT_VPIN_WINDOW is 50 — maintained as an O(1) rolling sum, never a window re-scan on the hot path.

let mut v = Vpin::new(bucket_volume);          // aggressor-classified, window = 50
for t in &trades {
    for imbalance in v.push(t, None) {         // one value per bucket this trade COMPLETED
        // ... a big trade can complete several
    }
}
let toxicity = v.committed();                  // None until the first bucket completes

committed() reads completed buckets only. forming_bucket() and interim() are pure reads that fold the open bucket's partial imbalance in as the newest window sample — evicting the oldest when the window is full — without mutating state, which is the convention the bar builders use too.

For a feed with no real aggressor flag, construct with has_aggressor = false and pass the prevailing mid to push: price >= mid classifies Buy, otherwise Sell. A missing mid falls back to the aggressor flag, so no trade is ever dropped.

BvcVpin is the canonical Easley, López de Prado and O'Hara (2012) VPIN, and it needs only price and volume. A bucket's directional split comes from its price change rather than trade-level tags: buy_frac = Φ((P_i − P_{i−1}) / σ_ΔP), with Φ the standard-normal CDF over libm's erf and σ_ΔP the population standard deviation of bucket price changes over the last window buckets. The bucket imbalance is then |2·buy_frac − 1|. directional_bias() gives the signed version — above zero is buy-toxic, below is sell-toxic — so a caller can route the magnitude onto a per-side gate.

BvcVpin needs one more bucket than you expect

value() is None until window bucket imbalances exist, and that takes window + 1 bucket closes: the first close only sets the P_{i−1} reference, because a price change needs two. A study that stops early and reads None has not found a degenerate tape — it has not finished warming up.

Its degenerate guards are worth knowing before you tune a bucket size. A non-positive or non-finite bucket_volume and a window below 1 panic at construction. When σ_ΔP is undefined — during warm-up, or on a flat tape — the estimator falls back per bucket to the tick rule, which classifies a bucket wholly by the sign of ΔP, so a flat tape yields 0.0 rather than a NaN from Φ(0/0) and a one-way tape yields 1.0. VolumeClassifier::TickRule selects that behaviour outright.

The module documents a default for the window and none for the bucket size, and that is honest rather than an omission: bucket volume is a property of the instrument's own tape, and the guards above tell you what the extremes do.

Next: VPIN, CVD and footprint for the reference pages, buy/sell classification for the semantic in full, and in the GUI for where each study renders.

On this page