Skip to content

Volume Profile ​

The full per-bin volume distribution over a rolling window — the raw histogram that Value Area reduces to POC / VAH / VAL.

Quick reference ​

FieldValue
FamilyMarket Profile
Input typeCandle (uses high, low, volume)
Output typeVolumeProfileOutput { price_low, price_high, bins }
Output rangebins[i] >= 0; sums to the window's total volume
Default parametersVolumeProfile::classic() = 20-bar window, 50 bins
Warmup periodperiod
InterpretationWhere volume concentrated across price over the window.

Formula ​

Over the last period candles, the price domain spans the window's lowest low to its highest high, split into bin_count equal buckets of width w = (price_high - price_low) / bin_count:

bins[i] = Σ over candles of the volume attributed to bucket i

Each candle's volume is spread uniformly across the bins its [low, high] range touches (a single-print bar with low == high drops its whole volume into one bin) — the same bucketing convention as ValueArea. Where ValueArea collapses this distribution to the Point of Control and Value Area High/Low, VolumeProfile exposes the entire histogram for rendering or post-processing. See crates/wickra-core/src/indicators/volume_profile.rs.

Parameters ​

NameTypeDefaultValid rangeDescription
periodusize20 (classic)>= 1Rolling window length in candles. 0 errors with Error::PeriodZero.
bin_countusize50 (classic)>= 1Number of price buckets. 0 errors with Error::PeriodZero.

Inputs / Outputs ​

From crates/wickra-core/src/indicators/volume_profile.rs:

rust
use wickra::{Candle, Indicator, VolumeProfile, VolumeProfileOutput};
// VolumeProfile: Input = Candle, Output = VolumeProfileOutput
const _: fn(&mut VolumeProfile, Candle) -> Option<VolumeProfileOutput> =
    <VolumeProfile as Indicator>::update;

VolumeProfileOutput carries price_low: f64, price_high: f64 and bins: Vec<f64> (length bin_count, lowest bucket first).

Python update(candle) returns (price_low, price_high, bins) or None during warmup; batch(high, low, volume) returns a (n, bin_count + 2) array whose columns are [price_low, price_high, bin_0, …] (warmup rows are NaN). Node update(high, low, volume) returns { priceLow, priceHigh, bins } or null; batch returns a flat Array<number> of n * (bin_count + 2).

Warmup ​

VolumeProfile::new(period, bin_count).warmup_period() == period. No value is emitted until the rolling window holds period candles. The unit test warms_up_over_period pins this.

Edge cases ​

  • Single-print window. If every bar in the window trades at one price (price_high == price_low), the domain collapses and all volume lands in bin 0. Pinned by degenerate_single_price_window.
  • Zero-volume bars. Bars with zero volume contribute nothing to the histogram. Pinned by zero_volume_bars_are_skipped.
  • Volume conservation. The bins always sum to the window's total volume (within floating-point tolerance). Pinned by conserves_total_volume.
  • Rolling eviction. When a new candle arrives the oldest leaves the window, so the price domain and histogram only reflect the last period candles. Pinned by rolling_window_drops_oldest.
  • Reset. reset() clears the window and last output. Pinned by reset_clears_state.

Examples ​

Rust ​

rust
use wickra::{Candle, Indicator, VolumeProfile};

fn flat(o: f64, h: f64, l: f64, c: f64, v: f64) -> Candle {
    Candle::new(o, h, l, c, v, 0).unwrap()
}

fn main() {
    let mut vp = VolumeProfile::new(2, 4).unwrap();
    vp.update(flat(10.0, 10.0, 10.0, 10.0, 100.0)); // seed (single print at 10)
    let out = vp.update(flat(10.0, 14.0, 10.0, 12.0, 80.0)).unwrap();
    println!("[{}, {}] {:?}", out.price_low, out.price_high, out.bins);
}

Output:

[10, 14] [120.0, 20.0, 20.0, 20.0]

Bar 0's 100 lands in bin 0 (price 10); bar 1 spans 10‑14 over all four bins, so its 80 splits 20 per bin → [120, 20, 20, 20]. This matches the reference_distribution unit test.

Python ​

python
import numpy as np
import wickra as ta

vp = ta.VolumeProfile(2, 4)
print(vp.update((10.0, 10.0, 10.0, 10.0, 100.0, 0)))  # None (warmup)
low, high, bins = vp.update((10.0, 14.0, 10.0, 12.0, 80.0, 1))
print(low, high, bins)

Output:

None
10.0 14.0 [120.  20.  20.  20.]

Node ​

javascript
const ta = require('wickra');
const vp = new ta.VolumeProfile(2, 4);
console.log(vp.update(10, 10, 100));        // null (warmup), args: high, low, volume
const out = vp.update(14, 10, 80);
console.log(out.priceLow, out.priceHigh, out.bins);

Output:

null
10 14 [ 120, 20, 20, 20 ]

Streaming vs batch (Python) ​

python
import numpy as np, wickra as ta

high = np.array([12.0, 13.0, 14.0, 15.0])
low = np.array([9.0, 10.0, 11.0, 12.0])
volume = np.array([30.0, 40.0, 50.0, 60.0])
profile = ta.VolumeProfile(4, 8).batch(high, low, volume)   # shape (4, 10)
assert profile.shape == (4, 8 + 2)

Interpretation ​

The histogram's tallest bin is the Point of Control — the price the market most agreed on over the window — and the contiguous bins around it that hold ~70% of volume are the Value Area. Use ValueArea when you only need those summary levels; reach for VolumeProfile when you want to render the full distribution, detect multi-modal (double-distribution) sessions, or compute custom value-area percentages yourself. Low-volume "gaps" between high-volume nodes often act as fast-travel zones for price.

Common pitfalls ​

  • Reading bins without the price bounds. A bin index is only meaningful with price_low / price_high: bin i covers [price_low + i·w, price_low + (i+1)·w) where w = (price_high − price_low) / bin_count.
  • Expecting a fixed price grid. The domain is recomputed each bar from the window's low/high, so bin boundaries shift as the window rolls — bins are not comparable across timestamps by index alone.
  • Confusing it with TPO. Volume Profile weights by traded volume; TpoProfile counts time at price and ignores volume.

References ​

Market-profile volume distribution and the Point-of-Control / Value-Area concepts originate with J. Peter Steidlmayer's Market Profile work at the CBOT (1980s).

See also ​