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], ...lowandhighbound the bucket's price band. The daemon's data plane serves flat[price, buy, sell]triplets, so todaylow == high == price; a coarser host may serve real bands, and a scan that reads both edges works either way.buyandsellare the aggressor-side volumes at that level.- The number of tuples varies per bar with the bar's range;
max_cellsis 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):
| 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:
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 |
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_FUTURESA 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 infinalize(). 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;
vpPocwithvpPocVolumesays how dominant the level is. - Read delta for pressure.
vpDeltasummarizes net aggressor flow per bar. Sustained positive delta is buy-side control; feed it toCumfor cumulative delta or toRsifor delta-RSI (TA library). - Size the cap honestly.
max_cellsis 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.