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…

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:

[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:

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):

AccessorReturns
in_profile_cells(): i32the 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): i32copies 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: i32the f64 count to preallocate (max_cells times 4)

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

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.

kScriptIndicatorReturns
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
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.

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:

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:

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).

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).
  • 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.