vike
Order flow

Volume profile

Volume at price on the tick grid, the point of control, and the 70% value area — with the tie rules that make the same trades produce the same profile every time.

crates/vike-orderflow/src/profile.rs is the "volume-at-price profile with POC + 70% value area". It also owns the price bucketing the whole crate uses — the footprint module borrows it rather than re-deriving it — so this page is where a price becomes a bin.

The shape

pub struct PriceBin { pub price: f64, pub buy_vol: f64, pub sell_vol: f64 }

pub struct VolumeProfile {
    pub tick_size: f64,
    pub bins: Vec<PriceBin>,      // ascending by price
    pub poc: f64,                 // the bin price with the largest buy + sell
    pub value_area: (f64, f64),   // (low bin price, high bin price)
}

Bucketing

bucket(price, tick_size) is "the one rounding site for all price bucketing in this crate": (price / tick_size).round(), an integer bucket index. grid_to_cells turns the sorted grid back into ascending PriceBins whose price is bucket × tick_size — a grid price, not the price of any particular trade. Buy and sell volume per bin come from the classification module's signed split, so a bin's buy_vol is aggressive buying at that grid price.

VolumeProfileBuilder::new(tick_size) asserts a positive tick size. push skips a zero-size trade and adds the split to the trade's bucket; finish reduces a copy of the grid, so it can be read repeatedly while trades keep arriving — the crate's forming convention in this module's shape. from_trades is the batch twin, described in place as "separate path: collect then reduce", and the module's parity test asserts finish() on a streamed builder equals it.

The reduction

build is the shared reducer, used by the builder and by VolumeProfile::from_footprints. Given ascending bins:

  1. Total is Σ(buy + sell) over the bins.
  2. POC is the bin with the largest buy + sell. Ties go to the lower price — the bins are ascending and the first maximum is kept.
  3. Value area is a greedy one-bucket expansion from the POC. The target is 0.70 × total. While the accumulated volume is below the target, the reducer looks at the next bin above and the next bin below the current range and adds whichever holds more volume; a tie goes down, to the lower price. When only one direction remains, it takes that direction; when neither remains, it stops. The result is (bins[lo].price, bins[hi].price), and the POC is always inside it.
  4. Empty input — no bins, or a from_footprints range that is empty or out of range — produces poc = 0.0, value_area = (0.0, 0.0) and no bins.

The module's own worked example, from its tests: four trades at tick 1.0 — 5 bought at 100, 10 sold at 101, 3 bought at 102, 1 bought at 99. Total 19, so the target is 13.3. The POC is 101 (volume 10); adding 100 (volume 5) reaches 15 ≥ 13.3, so the value area is (100, 101).

The tie rules are part of the contract

Both ties — POC and value-area expansion — resolve toward the lower price. That is what makes the profile a deterministic function of the trades; two paths that reduce the same grid produce the same POC and the same value area bit for bit, which the streaming ↔ batch and footprint-merge tests rely on.

From footprints

VolumeProfile::from_footprints(footprints, tick_size, lo, hi) merges "the cells of footprints[lo..=hi] (clamped to range) into one volume-at-price profile with POC + 70% value area". It re-buckets each cell's price through the same bucket, so "tick_size must match the footprints' bucketing". The tests pin that merging all of a chart's footprints equals the direct builder over all of its trades, and that a sub-range excludes exactly the bars outside it.

That is the form the chart uses: crates/vike-chart/src/chart/price_render.rs's paint_volume_profile reaches it through ChartState::visible_profile_shared, so the profile a pane shows is the profile of the bars in view, computed from the same cells the footprint pane draws. Where it renders is the in the GUI page.

On this page