---
title: "Volume profile"
description: "The volume_profile source carries a variable number of price-level buckets per bar, each one a low, high, buy, sell tuple, so \"did buyers or sellers do the…"
order: 38
section: "functions"
---

<!-- source: docs/indicators/functions/volume-profile.md; generated by packages/cli/scripts/gen-indicator-docs.ts, do not edit -->

# Volume profile

The `volume_profile` source carries a variable number of price-level
buckets per bar, each one a low, high, buy, sell tuple, so "did
buyers or sellers do the volume, and at which prices" is answerable inside
one bar instead of only as a per-bar total. Because the bucket count
changes from bar to bar, the source has no scalar fields: it is a celled
input, read as a block of `f64` cells in `state()`. kScript (legacy)
wrapped that block in nine `vp*` accessor functions; in Indicators each
accessor is a short scan over the tuples, shown below and compiled in one
module.

## Data structure

Each bar's block is a run of tuples, four `f64` cells per price level:

```text
[low, high, buy, sell], [low, high, buy, sell], ...
```

- `low` and `high` bound the bucket's price band. The daemon's data plane
  serves flat `[price, buy, sell]` triplets, so today `low == high ==
  price`; a coarser host may serve real bands, and a scan that reads both
  edges works either way.
- `buy` and `sell` are the aggressor-side volumes at that level.
- The number of tuples varies per bar with the bar's range; `max_cells`
  is the cap you declare, counted in tuples, and a bar whose block
  exceeds it refuses the whole evaluation by name
  (`wrun_cell_block_too_large`) rather than truncating.

There is no bar timestamp in the block: the `time` source carries it when
a scan needs one.

## Opening the source

Declare the celled input with its cap. It cannot be the primary input (a
celled class has no clock of its own), so a scalar input comes first and
sets the grid the blocks join row for row:

```typescript
input("close", ohlcv.close);
input("profile", volume_profile.cells, { max_cells: 512 });
```

Options: `max_cells` (required), an optional `symbol` + `exchange` pin
pair (both or neither, honored on your machine), and `description`.
Declaring a celled input derives a sheet on the second runtime contract
(`abi_version: "wrun-2"`); the build does that for you.

**Not in Indicators yet.** kScript's `ticksPerBar` (merge every N price
levels into one bucket) and `currency` (quote bucket volume in USD instead
of coins) knobs have no declaration form: the block arrives at the
venue's native bucket width in base-asset units. Coarsen it in your own
scan (sum tuples whose `low` falls in the same wider band) and convert to
notional with `volume * price` where a dollar figure is wanted.

The block reaches `state()` through three generated accessors (the
scalar `in_profile()` does not exist; a celled input's slot in the scalar
block holds `NaN`):

| Accessor | Returns |
| --- | --- |
| `in_profile_cells(): i32` | the number of `f64` cells this bar (tuples times 4); `0` for a present, empty block; `-1` when the bar carries no block |
| `in_profile_read(ptr: i32): i32` | copies the block into the module's memory at `ptr`; the bytes written, `0` for an empty block, `-1` for a missing one |
| `in_profile_capacity: i32` | the `f64` count to preallocate (`max_cells` times 4) |

Reserve the buffer once at module start and pass its address:

```typescript
const cells = new StaticArray<f64>(in_profile_capacity);
const n = in_profile_cells();
if (n > 0) in_profile_read(i32(changetype<usize>(cells)));
```

Both calls trap by name outside `state()`; read the block there and keep
what `finalize()` needs in module state.

## Accessor functions

Each kScript accessor as a function over the block, `cells` being the
buffer above and `n` the cell count for the bar. All nine take the same
two arguments, so a module scans once and reads as many as it likes.

| kScript | Indicator | Returns |
| --- | --- | --- |
| `vpBuy(vpa)` | `vpBuy(cells, n)` | total buy volume across the bar's buckets |
| `vpSell(vpa)` | `vpSell(cells, n)` | total sell volume |
| `vpDelta(vpa)` | `vpDelta(cells, n)` | `vpBuy - vpSell`; positive is buy dominant |
| `vpTotal(vpa)` | `vpTotal(cells, n)` | combined buy + sell volume |
| `vpPoc(vpa)` | `vpPoc(cells, n)` | the point of control: the midprice of the highest-volume bucket, `NaN` if no buckets |
| `vpPocVolume(vpa)` | `vpPocVolume(cells, n)` | combined volume at the point-of-control bucket |
| `vpBucketCount(vpa)` | `vpBucketCount(n)` | the number of buckets this bar |
| `vpPriceHigh(vpa)` | `vpPriceHigh(cells, n)` | the highest `high` across buckets, `NaN` if empty |
| `vpPriceLow(vpa)` | `vpPriceLow(cells, n)` | the lowest `low` across buckets, `NaN` if empty |

```typescript
function vpBuy(cells: StaticArray<f64>, n: i32): f64 {
  let total = 0.0;
  for (let i = 0; i + 3 < n; i += 4) total += cells[i + 2];
  return total;
}

function vpSell(cells: StaticArray<f64>, n: i32): f64 {
  let total = 0.0;
  for (let i = 0; i + 3 < n; i += 4) total += cells[i + 3];
  return total;
}

function vpDelta(cells: StaticArray<f64>, n: i32): f64 {
  return vpBuy(cells, n) - vpSell(cells, n);
}

function vpTotal(cells: StaticArray<f64>, n: i32): f64 {
  return vpBuy(cells, n) + vpSell(cells, n);
}

// The index of the bucket with the most combined volume, or -1 for an empty block.
function vpPocIndex(cells: StaticArray<f64>, n: i32): i32 {
  let best = -1;
  let bestVolume = -1.0;
  for (let i = 0; i + 3 < n; i += 4) {
    const volume = cells[i + 2] + cells[i + 3];
    if (volume > bestVolume) {
      bestVolume = volume;
      best = i;
    }
  }
  return best;
}

function vpPoc(cells: StaticArray<f64>, n: i32): f64 {
  const i = vpPocIndex(cells, n);
  return i < 0 ? NaN : (cells[i] + cells[i + 1]) / 2.0;
}

function vpPocVolume(cells: StaticArray<f64>, n: i32): f64 {
  const i = vpPocIndex(cells, n);
  return i < 0 ? NaN : cells[i + 2] + cells[i + 3];
}

function vpBucketCount(n: i32): f64 {
  return n < 0 ? 0.0 : f64(n / 4);
}

function vpPriceHigh(cells: StaticArray<f64>, n: i32): f64 {
  let top = NaN;
  for (let i = 0; i + 3 < n; i += 4) {
    if (isNaN(top) || cells[i + 1] > top) top = cells[i + 1];
  }
  return top;
}

function vpPriceLow(cells: StaticArray<f64>, n: i32): f64 {
  let bottom = NaN;
  for (let i = 0; i + 3 < n; i += 4) {
    if (isNaN(bottom) || cells[i] < bottom) bottom = cells[i];
  }
  return bottom;
}
```

The `i + 3 < n` guard walks whole tuples only, so a block that is not a
multiple of four (it never is, but the guard costs nothing) cannot read
past the last cell.

## Every accessor in one module

Nine outputs, one scan per bar. The delta draws as a histogram tinted by
sign, the point of control as a line on the price pane, and the rest in
a lower pane. A bar with no block (`n < 0`) abstains; a bar with an
empty block (`n == 0`) is a real observation of zero volume and writes
zeros and `NaN` prices.

```typescript
import { histogram, input, line, lower, none, ohlcv, output, overlay, volume_profile } from "./sdk/declare";
import { in_close, in_profile_capacity, in_profile_cells, in_profile_read } from "./gen/inputs";
import {
  emitRow,
  out_bucket_count,
  out_delta,
  out_delta_sign,
  out_poc,
  out_poc_volume,
  out_price_high,
  out_price_low,
  out_total_buy,
  out_total_sell,
  out_total_volume,
} from "./gen/outputs";

input("close", ohlcv.close);
input("profile", volume_profile.cells, { max_cells: 512 });
output("poc", line, overlay, { color: "#ff9800", width: 2, description: "Point of control" });
output("price_high", line, overlay, { color: "#94a3b8", width: 1, description: "Top of the profile" });
output("price_low", line, overlay, { color: "#94a3b8", width: 1, description: "Bottom of the profile" });
output("total_buy", line, lower, { color: "#26a69a", width: 2, description: "Buy volume summed over the profile" });
output("total_sell", line, lower, { color: "#ef5350", width: 2, description: "Sell volume summed over the profile" });
output("delta", histogram, lower, { color_by: "delta_sign", colors: ["#ef5350", "#26a69a"], description: "Net buy minus sell volume" });
output("delta_sign", none, lower, { description: "0 sell dominant, 1 buy dominant: the delta palette index" });
output("total_volume", line, lower, { color: "#9e9e9e", width: 1, description: "Combined volume" });
output("poc_volume", line, lower, { color: "#ff9800", width: 1, description: "Volume at the point of control" });
output("bucket_count", line, lower, { color: "#64748b", width: 1, description: "Price levels this bar" });

function vpBuy(cells: StaticArray<f64>, n: i32): f64 {
  let total = 0.0;
  for (let i = 0; i + 3 < n; i += 4) total += cells[i + 2];
  return total;
}

function vpSell(cells: StaticArray<f64>, n: i32): f64 {
  let total = 0.0;
  for (let i = 0; i + 3 < n; i += 4) total += cells[i + 3];
  return total;
}

function vpDelta(cells: StaticArray<f64>, n: i32): f64 {
  return vpBuy(cells, n) - vpSell(cells, n);
}

function vpTotal(cells: StaticArray<f64>, n: i32): f64 {
  return vpBuy(cells, n) + vpSell(cells, n);
}

// The index of the bucket with the most combined volume, or -1 for an empty block.
function vpPocIndex(cells: StaticArray<f64>, n: i32): i32 {
  let best = -1;
  let bestVolume = -1.0;
  for (let i = 0; i + 3 < n; i += 4) {
    const volume = cells[i + 2] + cells[i + 3];
    if (volume > bestVolume) {
      bestVolume = volume;
      best = i;
    }
  }
  return best;
}

function vpPoc(cells: StaticArray<f64>, n: i32): f64 {
  const i = vpPocIndex(cells, n);
  return i < 0 ? NaN : (cells[i] + cells[i + 1]) / 2.0;
}

function vpPocVolume(cells: StaticArray<f64>, n: i32): f64 {
  const i = vpPocIndex(cells, n);
  return i < 0 ? NaN : cells[i + 2] + cells[i + 3];
}

function vpBucketCount(n: i32): f64 {
  return n < 0 ? 0.0 : f64(n / 4);
}

function vpPriceHigh(cells: StaticArray<f64>, n: i32): f64 {
  let top = NaN;
  for (let i = 0; i + 3 < n; i += 4) {
    if (isNaN(top) || cells[i + 1] > top) top = cells[i + 1];
  }
  return top;
}

function vpPriceLow(cells: StaticArray<f64>, n: i32): f64 {
  let bottom = NaN;
  for (let i = 0; i + 3 < n; i += 4) {
    if (isNaN(bottom) || cells[i] < bottom) bottom = cells[i];
  }
  return bottom;
}

const cells = new StaticArray<f64>(in_profile_capacity);
let n: i32 = -1;

export function init(): void {}

export function state(): i32 {
  in_close(); // the scalar block still carries every scalar input
  n = in_profile_cells();
  if (n < 0) return 0; // no block on this bar: abstain
  if (n > 0) in_profile_read(i32(changetype<usize>(cells)));
  return 1;
}

export function finalize(): void {
  const delta = vpDelta(cells, n);
  out_poc(vpPoc(cells, n));
  out_price_high(vpPriceHigh(cells, n));
  out_price_low(vpPriceLow(cells, n));
  out_total_buy(vpBuy(cells, n));
  out_total_sell(vpSell(cells, n));
  out_delta(delta);
  out_delta_sign(delta >= 0.0 ? 1.0 : 0.0);
  out_total_volume(vpTotal(cells, n));
  out_poc_volume(vpPocVolume(cells, n));
  out_bucket_count(vpBucketCount(n));
  emitRow();
}

export function reset(): void {
  n = -1;
}
```

The scans run in `finalize()` over the buffer `state()` filled: the
accessors trap outside `state()`, the buffer does not, and a module that
reads the block once and computes many things from it keeps the two
phases in their lanes.

## Iterating the buckets yourself

kScript read a raw bucket with `vpa[0][i + 1]` and handed a per-bucket
callback to `plotBatches` to draw one marker or pie per price level. The
raw read ports directly: bucket `i` is `cells[4 * i]` through `cells[4 *
i + 3]`, and `n / 4` is the count.

**Not in Indicators yet.** A variable number of marks per bar
(`plotBatches`, `plotPie`) has no renderer: every output is one number
per bar and every renderer draws one thing per bar. The nearest forms
are a fixed set of outputs (the point of control as a line, the top and
bottom of the profile as two more, a `render.shape` at the level you
care about) and, for the per-level picture itself, the chart's own
footprint view beside the Indicator.

## The worked footprint

The `vp-buy-share-codefirst` template is the footprint loop end to end: a
celled input, the buy share of each bar's profile as a numeric output,
and a per-bar text renderer fed from a string slot. The whole file:

```typescript
import { input, line, lower, ohlcv, output, render, string, volume_profile } from "./sdk/declare";
import { in_profile_capacity, in_profile_cells, in_profile_read } from "./gen/inputs";
import { emitRow, out_buy_share } from "./gen/outputs";
import { sb_clear, sb_f64, sb_text, str_summary_sb } from "./gen/strings";

input("close", ohlcv.close);
input("profile", volume_profile.cells, { max_cells: 512 });
output("buy_share", line, lower);
string("summary", { max_bytes: 64 });
render.text("flow", { y: "buy_share", text: "summary" });

const cells = new StaticArray<f64>(in_profile_capacity); let share: f64 = NaN;

export function init(): void {}
export function state(): i32 {
  const n = in_profile_cells(); if (n <= 0 || in_profile_read(i32(changetype<usize>(cells))) < 0) return 0;
  let buy = 0.0; let sell = 0.0;
  for (let i = 0; i + 3 < n; i += 4) { buy += cells[i + 2]; sell += cells[i + 3]; }
  share = buy + sell > 0.0 ? (100.0 * buy) / (buy + sell) : NaN; return isNaN(share) ? 0 : 1;
}
export function finalize(): void { out_buy_share(share); sb_clear(); sb_text("buy "); sb_f64(share, 1); sb_text("%"); str_summary_sb(); emitRow(); }
export function reset(): void { share = NaN; }
```

Scaffold it, install it, and read a live value on real profile rows:

```bash
om wrun create @you/vp-flow ./vp-flow --template vp-buy-share-codefirst
om wrun install ./vp-flow --replace
om metric get --metric wrun/@you/vp-flow/buy_share --symbol BTCUSDT --exchange BINANCE_FUTURES
```

A bar whose profile splits 60/40 to the buy side computes `buy_share =
60` and renders the text `buy 60.0%` at that bar.

## Where it runs

The chart lane serves `volume_profile` for the markets the venue serves
profiles for (a market without them reports "Volume profile data is
unavailable" by name). On your machine, alerts, `om metric get`, `om
metric series`, and chart previews evaluate celled packages like any
other; backtests and screens refuse them by name
(`wrun_celled_metric_unsupported`) because their replay and fan-out
paths carry no cell blocks yet. Cell alignment is an exact join: a
primary bar with no profile observation gets a present empty block
(`n == 0`), never a carried-forward one, so volume is never counted
twice ([Data sources](../core-concepts/data-sources.md)).

## Practices

- **Scan once.** Read the block in `state()`, keep the buffer, compute
  every accessor from it in `finalize()`. Nine scans over 512 tuples is
  still cheap, but one is cheaper.
- **Watch the point of control.** The price with the most traded volume
  often acts as a magnet; `vpPoc` with `vpPocVolume` says how dominant the
  level is.
- **Read delta for pressure.** `vpDelta` summarizes net aggressor flow per
  bar. Sustained positive delta is buy-side control; feed it to `Cum` for
  cumulative delta or to `Rsi` for delta-RSI
  ([TA library](ta-library.md)).
- **Size the cap honestly.** `max_cells` is a contract: a low cap refuses
  wide bars, a high cap reserves memory you never use. 512 tuples covers
  the majors at native bucket width.
