---
title: "Volume profile and value area"
description: "The last 96 bars' volume profile docked on the price axis, the point of control row amber, the value area rows sky, the rest slate. The rows never go finer…"
order: 98
section: "cookbook"
---

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

# Volume profile and value area

![Volume profile docked on the price axis with POC lines and heatmap](/wrun/images/volume-profile-value-area.png)

The last 96 bars' volume profile docked on the price axis, the point of control row amber, the value area rows sky, the rest slate. The rows never go finer than the market's own price levels, each level's volume is spread over the rows it covers, and a level the candle never touched is ignored, so the profile reads as a profile everywhere. POC, VAH and VAL are drawn as lines across the window they were measured on, through to the axis (POC solid amber, VAH and VAL dashed sky), each with a price tag at the axis, and the legend reads all three ("POC 84,798 VAH 85,987 VAL 83,288"), so a level the window measured below or above the candles in view still has a readout. Below the chart, a heatmap of volume by UTC hour for the last seven days, newest day on top, each cell the hour's percentile rank among the grid's hours, so the heavy hours jump out and the quiet ones fade.

The parts are a celled `volume_profile.cells` input read as a block in `onBar()` ([Volume profile](../functions/order-flow-kit.md#volume-profile)), a frame feeding `plot.levels` docked on the price axis and a second frame feeding `panel.heatmap` below the chart ([Cards, frames and panels](../presentation/cards-frames-panels.md)), line and label handles for the three levels and their tags ([Drawing objects](../presentation/drawing-objects.md)), and a legend entry that reads them ([Legend](../presentation/legend.md)). This is also the `volume-profile-value-area` template: the **Volume Profile and Value Area** card under **Beyond the time axis** in the editor's starter list, and it compiles as written.

## The wrun indicator

```typescript sample=volume-profile-value-area
// Volume Profile and Value Area: the last `window` bars' volume profile docked on the price axis with the point of
// control and the value area lit, POC / VAH / VAL as lines on price with price tags at the axis, and an hour-by-day
// volume heatmap below the chart. The trader sees where volume was accepted and where price sits against it.

section("Profile"); // the dialog's first section: the docked profile's settings
param.int("window", 96, { min: 10, max: 500, label: "Window in bars", description: "Bars in the rolling profile" });
param.number("value_area", 70, { min: 50, max: 95, label: "Value area in percent", description: "Value area as a percent of the window's volume" });
param.int("rows", 64, { min: 16, max: 128, label: "Profile rows", description: "Price rows in the docked profile" });
section("Heatmap"); // the second section: the panel under the chart
param.int("heat_days", 7, { min: 2, max: 14, label: "Heatmap days", description: "Days shown in the volume heatmap" });
input("close", ohlcv.close); // the primary input: the chart's own candles define the grid
input("profile", volume_profile.cells, { max_cells: 8192, description: "This bar's volume by price level" }); // [low, high, buy, sell] per level; measured on Binance Futures BTCUSDT: about 280 levels per 15m bar, 860 per 1h bar
output("poc", none, overlay, { description: "Point of control of the rolling profile" }); // data-only: the live level is drawn as a line across its window
output("vah", none, overlay, { description: "Value area high" });
output("val", none, overlay, { description: "Value area low" });
string("tag", { max_bytes: 40 }); // one bounded slot the three price tags are written through, one at a time
string("readout", { max_bytes: 64 }); // the three levels in one line of words, for the legend
handles.line({ color: "#38bdf8", width: 1, lineStyle: "dashed", extend: "right" }); // POC, VAH and VAL across the window they were measured on, through to the axis
handles.label({ anchor: "right", align: "right", size: 11, color: "#e2e8f0" }); // price tags: x in pixels from the right edge, y in price
const profile_rows = frame("profile_rows", { max_bytes: 8192 }); // the docked profile: one row per price bin
const heat_rows = frame("heat_rows", { max_bytes: 16384 }); // the heatmap: one cell per hour and day
plot.levels({ name: "profile", frame: profile_rows, dock: "right", width_frac: 0.2, labels: false, color: "#38bdf8" });
panel.heatmap({ name: "volume_heat", title: "Volume by hour, percentile rank", x: "category", place: "below", frame: heat_rows });
render.legend("level_readout", { text: "readout", color: "#38bdf8" }); // POC, VAH and VAL in the legend: readable when a level sits off the price pane

const MAX_WINDOW = 500; const BINS = 48; const MAX_ROWS = 128; const MAX_DAYS = 14; const HOURS = 24; // ring sizes for the largest settings
const barLo = new StaticArray<f64>(MAX_WINDOW); const barHi = new StaticArray<f64>(MAX_WINDOW); const barT = new StaticArray<f64>(MAX_WINDOW); // each ring bar's price span and open time
const barBins = new StaticArray<f64>(MAX_WINDOW * BINS); // each ring bar's volume in BINS equal bins across its span
const rowVol = new StaticArray<f64>(MAX_ROWS); // the window profile: volume per price row
const heat = new StaticArray<f64>(MAX_DAYS * HOURS); const heatBars = new StaticArray<i32>(MAX_DAYS * HOURS); // volume and bar count per day and hour
const jsonBytes = new StaticArray<u8>(16384); let jsonUsed = 0; // the frame JSON, appended as UTF-8 bytes
const tags: LabelHandle[] = [draw.label(0), draw.label(1), draw.label(2)]; const levelLines: LineHandle[] = [draw.line(3), draw.line(4), draw.line(5)]; // POC, VAH, VAL price tags and the same three levels as lines; handle objects allocate once, ids one space across kinds
let window = 96; let valueArea = 0.7; let rows = 64; let heatDays = 7; // the params, read in onStart()
let head = -1; let count = 0; // the ring: head is the newest bar, count the bars in use
let dayBase: i64 = -1; // the day number (days since the epoch) of the heatmap's oldest row
let t: f64 = NaN; let poc: f64 = NaN; let vah: f64 = NaN; let val: f64 = NaN; // this bar's open time and the window's three levels
let levelHeight: f64 = Infinity; // the market's price level height, the smallest [low, high] span seen: the row grid never goes finer
let rowCount = 64; // rows in use this bar: the setting, or fewer on a market whose levels are coarser than the setting would ask
let probLike = true; // every close so far sits in 0..1 on a 0.001 grid: a prediction market, whose prices read as percent
let winLo: f64 = NaN; let rowHeight: f64 = NaN; let pocRow = 0; let lowRow = 0; let highRow = 0; // the window profile's geometry

// ── JSON bytes: append text and numbers without allocating; one string is made per frame write ──
function jbByte(b: u32): void { if (jsonUsed < jsonBytes.length) { jsonBytes[jsonUsed] = <u8>b; jsonUsed += 1; } }
function jbText(s: String): void {
  const n = String.UTF8.byteLength(s);
  if (jsonUsed + n <= jsonBytes.length) jsonUsed += i32(String.UTF8.encodeUnsafe(changetype<usize>(s), s.length, changetype<usize>(jsonBytes) + jsonUsed));
}
function jbUint(v: u64): void { if (v >= 10) jbUint(v / 10); jbByte(0x30 + <u32>(v % 10)); }
function jbNum(v: f64, decimals: i32): void { // a fixed-point number, or null when not finite
  if (!isFinite(v)) { jbText("null"); return; }
  let x = v; if (x < 0.0) { jbByte(0x2d); x = -x; }
  let scale = 1.0; for (let i = 0; i < decimals; i += 1) scale *= 10.0;
  const scaled = Math.round(x * scale); const whole = Math.floor(scaled / scale); const frac = scaled - whole * scale;
  jbUint(<u64>whole);
  if (decimals > 0) { jbByte(0x2e); let f = <u64>frac; let digits = 1; let probe = f; while (probe >= 10) { probe /= 10; digits += 1; } for (let i = digits; i < decimals; i += 1) jbByte(0x30); jbUint(f); }
}
function jbFlush(slot: i32): void { writeFrame(slot, String.UTF8.decodeUnsafe(changetype<usize>(jsonBytes), jsonUsed)); jsonUsed = 0; }

// ── Text helpers for the price tags ──
function sbGrouped(n: i64): void { // 84210 -> 84,210
  if (n >= 1000) { sbGrouped(n / 1000); sb_text(","); const r = n % 1000; if (r < 100) sb_text("0"); if (r < 10) sb_text("0"); sb_int(r); } else sb_int(n);
}
function sbPrice(v: f64): void { // decimals follow the price's size: 84,210 / 2,431.55 / 12.345 / 0.13797; a prediction market's price reads as percent
  if (probLike) { sb_f64(v * 100.0, 1); sb_text("%"); } else if (v >= 1000.0) sbGrouped(i64(Math.round(v))); else if (v >= 100.0) sb_f64(v, 2); else if (v >= 1.0) sb_f64(v, 3); else sb_f64(v, 5);
}
function priceDecimals(step: f64): i32 { // enough decimals that the rounded row prices stay evenly spaced (the docked grid is contiguous only within 0.1% of its step)
  const d = i32(Math.ceil(Math.log(2000.0 / step) / Math.LN10)); return d < 2 ? 2 : d > 9 ? 9 : d;
}

// ── The heatmap: volume per UTC hour and day, the last heat_days days ──
function shiftHeat(by: i32): void { // a new day arrived: drop the oldest rows
  if (by >= heatDays) { for (let i = 0; i < MAX_DAYS * HOURS; i += 1) { heat[i] = 0.0; heatBars[i] = 0; } return; }
  for (let r = 0; r < heatDays; r += 1) for (let h = 0; h < HOURS; h += 1) {
    const from = r + by; heat[r * HOURS + h] = from < heatDays ? heat[from * HOURS + h] : 0.0; heatBars[r * HOURS + h] = from < heatDays ? heatBars[from * HOURS + h] : 0;
  }
}
function foldHeat(t: f64, volume: f64): void {
  if (isNaN(t) || isNaN(volume)) return;
  const day = i64(Math.floor(t / 86400.0)); const hour = i32(Math.floor((t - f64(day) * 86400.0) / 3600.0));
  if (dayBase < 0) dayBase = day - i64(heatDays - 1); // the first bar lands in the newest row
  if (day < dayBase) return;
  const newest = dayBase + i64(heatDays - 1);
  if (day > newest) { const by = i32(day - newest); shiftHeat(by); dayBase += i64(by); }
  const at = i32(day - dayBase) * HOURS + hour; heat[at] += volume; heatBars[at] += 1;
}
let civilMonth = 0; let civilDay = 0;
function civilFromDays(days: i64): void { // days since 1970-01-01 -> month and day of month (proleptic Gregorian)
  const z = days + 719468; const era = (z >= 0 ? z : z - 146096) / 146097; const doe = z - era * 146097;
  const yoe = (doe - doe / 1460 + doe / 36524 - doe / 146096) / 365; const doy = doe - (365 * yoe + yoe / 4 - yoe / 100);
  const mp = (5 * doy + 2) / 153; civilDay = i32(doy - (153 * mp + 2) / 5 + 1); civilMonth = i32(mp < 10 ? mp + 3 : mp - 9);
}
function jbTwoDigits(n: i32): void { if (n < 10) jbByte(0x30); jbUint(<u64>n); }
function percentile(at: i32, filled: i32): f64 { // the share of filled hours at or below this hour's volume, 0 to 100
  let below = 0; const v = heat[at];
  for (let i = 0; i < heatDays * HOURS; i += 1) if (heatBars[i] > 0 && heat[i] <= v) below += 1;
  return Math.round((100.0 * f64(below)) / f64(filled));
}
function writeHeat(): void { // [hour, day, percentile] triplets: hour on x, day on y (newest day first); the engine colours from the lowest to the highest value
  let filled = 0;
  for (let i = 0; i < heatDays * HOURS; i += 1) if (heatBars[i] > 0) filled += 1;
  if (filled == 0) return;
  let first = true;
  jbText("{\"rows\":[");
  for (let r = heatDays - 1; r >= 0; r -= 1) {
    civilFromDays(dayBase + i64(r));
    for (let h = 0; h < HOURS; h += 1) {
      const at = r * HOURS + h; if (heatBars[at] == 0) continue;
      if (!first) jbText(","); first = false;
      jbText("[\""); jbTwoDigits(h); jbText("\",\""); jbTwoDigits(civilMonth); jbText("-"); jbTwoDigits(civilDay); jbText("\","); jbNum(percentile(at, filled), 0); jbText("]");
    }
  }
  jbText("]}"); jbFlush(FRAME_HEAT_ROWS);
}

// ── The rolling profile ──
function spread(a: f64, b: f64, volume: f64, gridLo: f64, width: f64, base: i32, cells: i32, into: StaticArray<f64>): void { // [a, b]'s volume over the grid cells it overlaps, in proportion
  if (volume <= 0.0 || !(b >= a)) return;
  let first = i32(Math.floor((a - gridLo) / width)); let last = i32(Math.floor((b - gridLo) / width));
  if (first < 0) first = 0; if (last > cells - 1) last = cells - 1; if (first > cells - 1) first = cells - 1; if (last < 0) last = 0;
  if (first >= last || b - a <= 0.0) { into[base + first] += volume; return; } // one cell, or a level thinner than the grid
  for (let k = first; k <= last; k += 1) {
    const cellLo = gridLo + f64(k) * width; const cellHi = cellLo + width;
    const overlap = Math.min(b, cellHi) - Math.max(a, cellLo); if (overlap > 0.0) into[base + k] += (volume * overlap) / (b - a);
  }
}
function foldBar(candleLow: f64, candleHigh: f64): void { // this bar's cells into BINS bins across the bar's own price span, appended to the ring
  head = (head + 1) % window; if (count < window) count += 1; barT[head] = t;
  const n = in_profile_cells(); const base = head * BINS;
  for (let b = 0; b < BINS; b += 1) barBins[base + b] = 0.0;
  barLo[head] = NaN; barHi[head] = NaN;
  if (n < 4) return; // an empty block: the bar counts, its volume is unknown
  const cells = in_profile_view(); // this bar's cells in place: the first n values of the build's own buffer
  const typical = cells[(n / 8) * 4 + 1] - cells[(n / 8) * 4]; // the middle level's span: the market's level width (one stray level never sets it)
  if (typical > 0.0 && typical < levelHeight) levelHeight = typical;
  const tolerance = isFinite(levelHeight) ? levelHeight : 0.0; // a level may straddle the candle's edge by one step
  let lo = candleLow; let hi = candleHigh; // the candle's own span; the levels are read against it
  if (!(hi >= lo)) { lo = Infinity; hi = -Infinity; for (let i = 0; i + 3 < n; i += 4) { if (cells[i] < lo) lo = cells[i]; if (cells[i + 1] > hi) hi = cells[i + 1]; } } // no candle span: the cells' own
  if (!(hi >= lo)) return;
  if (hi == lo) hi = lo + Math.abs(lo) * 1e-9 + 1e-9;
  const binWidth = (hi - lo) / f64(BINS);
  for (let i = 0; i + 3 < n; i += 4) {
    const centre = (cells[i] + cells[i + 1]) * 0.5;
    if (centre < lo - tolerance || centre > hi + tolerance) continue; // a level the candle never touched: a stray print, ignored
    spread(cells[i], cells[i + 1], cells[i + 2] + cells[i + 3], lo, binWidth, base, BINS, barBins); // the level's volume over the bins it covers
  }
  barLo[head] = lo; barHi[head] = hi;
}
function buildProfile(): bool { // the ring's bars folded into `rows` price rows across the window's span; POC and value area
  let lo = Infinity; let hi = -Infinity;
  for (let k = 0; k < count; k += 1) { const i = (head + window - k) % window; if (isNaN(barLo[i])) continue; if (barLo[i] < lo) lo = barLo[i]; if (barHi[i] > hi) hi = barHi[i]; }
  if (!(hi > lo)) return false;
  rowHeight = (hi - lo) / f64(rows); // the setting's row height, or the market's level height when that is coarser (a thin-tick market)
  if (isFinite(levelHeight) && levelHeight > rowHeight) rowHeight = levelHeight;
  winLo = Math.floor(lo / rowHeight) * rowHeight; // rows sit on the level grid
  rowCount = i32(Math.ceil((hi - winLo) / rowHeight)); if (rowCount < 2) return false; if (rowCount > rows) rowCount = rows;
  lo = winLo;
  for (let r = 0; r < rowCount; r += 1) rowVol[r] = 0.0;
  let total = 0.0;
  for (let k = 0; k < count; k += 1) {
    const i = (head + window - k) % window; if (isNaN(barLo[i])) continue;
    const binWidth = (barHi[i] - barLo[i]) / f64(BINS); const base = i * BINS;
    for (let b = 0; b < BINS; b += 1) {
      const v = barBins[base + b]; if (v <= 0.0) continue;
      spread(barLo[i] + f64(b) * binWidth, barLo[i] + f64(b + 1) * binWidth, v, lo, rowHeight, 0, rowCount, rowVol); total += v; // the bin's volume over the rows it covers
    }
  }
  if (total <= 0.0) return false;
  pocRow = 0; for (let r = 1; r < rowCount; r += 1) if (rowVol[r] > rowVol[pocRow]) pocRow = r;
  lowRow = pocRow; highRow = pocRow; let inside = rowVol[pocRow]; // the value area grows from the POC toward the heavier neighbour
  while (inside < valueArea * total && (lowRow > 0 || highRow < rowCount - 1)) {
    const up = highRow < rowCount - 1 ? rowVol[highRow + 1] : -1.0; const down = lowRow > 0 ? rowVol[lowRow - 1] : -1.0;
    if (up >= down) { highRow += 1; inside += up; } else { lowRow -= 1; inside += down; }
  }
  poc = lo + (f64(pocRow) + 0.5) * rowHeight; vah = lo + f64(highRow + 1) * rowHeight; val = lo + f64(lowRow) * rowHeight;
  return true;
}
function writeProfile(): void { // prices strictly increasing (row centres), a value per row (0 where empty, so the grid stays whole), a colour per row
  const decimals = priceDecimals(rowHeight);
  jbText("{\"prices\":[");
  for (let r = 0; r < rowCount; r += 1) { if (r > 0) jbText(","); jbNum(winLo + (f64(r) + 0.5) * rowHeight, decimals); }
  jbText("],\"values\":[");
  for (let r = 0; r < rowCount; r += 1) { if (r > 0) jbText(","); jbNum(rowVol[r] > 0.0 ? rowVol[r] : 0.0, 2); }
  jbText("],\"colors\":[");
  for (let r = 0; r < rowCount; r += 1) { if (r > 0) jbText(","); jbText(r == pocRow ? "\"#f8c000\"" : r >= lowRow && r <= highRow ? "\"#38bdf8\"" : "\"#94a3b8\""); }
  jbText("]}"); jbFlush(FRAME_PROFILE_ROWS);
}
function drawLevel(which: i32, name: String, price: f64, ink: i32, from: f64): void { // the level as a line from the window's first bar to the live bar (extended to the axis) and its price tag
  levelLines[which].set(from, price, t, price).color(ink).width(which == 0 ? 2.0 : 1.0).style(which == 0 ? LineStyle.Solid : LineStyle.Dashed).extend(Extend.Right);
  sb_clear(); sb_text(name); sb_text(" "); sbPrice(price);
  tags[which].set(which == 0 ? 6.0 : 96.0, price).text(str_tag_sb).color(ink).fill(rgba(15, 23, 42, 235)); // the POC tag at the axis, VAH and VAL a step inward so close levels never overlap
}

// onStart() runs once before the first bar: read the params.
function onStart(): void { window = i32(p_window()); valueArea = p_value_area() / 100.0; rows = i32(p_rows()); heatDays = i32(p_heat_days()); memory.grow(1); } // headroom for the frame strings: the sandbox forbids growth after onStart()
// onBar() runs once per bar: fold the bar into the heatmap and the ring, rebuild the window profile; then the three levels as
// numbers on every bar, and the docked profile, the heatmap, the level lines and the tags on the live bar only.
function onBar(): void {
  t = bar.time(); const close = bar.close(); foldHeat(t, bar.volume()); foldBar(bar.low(), bar.high()); // the bar's open time, volume and span, from the chart's own candle: a level outside the span is ignored, so one bad level never stretches the profile
  if (!isNaN(close) && (close < 0.0 || close > 1.0 || Math.abs(close * 1000.0 - Math.round(close * 1000.0)) > 1e-6)) probLike = false;
  if (count < 2 || !buildProfile()) return; // no profile yet: nothing is written on this bar
  out_poc(poc); out_vah(vah); out_val(val);
  if (bar.isLast()) {
    writeProfile(); writeHeat(); sb_clear(); sb_text("POC "); sbPrice(poc); sb_text(" VAH "); sbPrice(vah); sb_text(" VAL "); sbPrice(val); str_readout_sb(); // the legend's readout
    const from = barT[(head + window - (count - 1)) % window]; // the oldest bar in the window
    drawLevel(0, "POC", poc, rgba(248, 192, 0, 255), from); drawLevel(1, "VAH", vah, rgba(56, 189, 248, 255), from); drawLevel(2, "VAL", val, rgba(56, 189, 248, 255), from);
  }
}
```

## How it works

**One block per bar.** `input("profile", volume_profile.cells, { max_cells: 8192 })` delivers each bar's volume by price level as `[low, high, buy, sell]` tuples (about 280 levels per 15m bar and 860 per 1h bar on Binance Futures BTCUSDT). The rolling profile folds the last `window` (96) bars' blocks into `rows` (64) price bins between the window's low and high; a level outside the bar's own high-to-low span is ignored, so a stray print never stretches the profile, and each level's volume is spread over the bins it covers.

**POC and the value area.** The bin with the most volume is the point of control; the value area grows from it, bin by bin toward the heavier neighbour, until it holds `value_area` (70) percent of the window's volume. `poc`, `vah` and `val` are data-only outputs, one value per bar; on the live bar each is drawn as a line handle across the window it was measured on, extended right to the axis, with a price tag written through the one bounded slot to a right-anchored label. The price axis scales to the candles in view, not to the window the levels are measured on, so after a run-up VAL can sit below the pane with its line and tag; the same three prices are written into a second slot, `readout`, on the live bar, and `render.legend("level_readout", { text: "readout" })` prints that line of words in the legend, in sky. On a prediction market the tags and the readout read as percent, like the axis.

**Two frames, two views.** `frame("profile_rows")` carries one row per bin and feeds `plot.levels`, the docked profile (the POC row amber, value-area rows sky, the rest slate); `frame("heat_rows")` carries one cell per hour and day for the last `heat_days` (7) days and feeds `panel.heatmap`, each cell the hour's percentile rank among the grid's hours (100 is the busiest).

## Where it runs

Markets with a volume profile lane: crypto (Binance Futures, Binance spot, Hyperliquid) and Polymarket markets, where the profile shows the odds the crowd traded most. The heatmap reads best on charts finer than 1h; on 1h each cell is one bar.

## When data is missing

Stocks and FX have no profile lane: the run stops with the toast `Volume profile data is unavailable for '<title>' (the volume_profile source lane answered empty or was declined), so the indicator cannot compute.`, where `<title>` is the tab's title. A bar without a profile block counts toward the window with no volume; the first bar draws nothing (the profile needs two bars); hours with no bars stay empty in the heatmap.

## Customize it

- **A session profile.** Reset the fold at a UTC boundary (the [Session map](session-map.md) window test) instead of rolling the last `window` bars.
- **Finer rows.** `rows` up to 128; the rows never go finer than the market's own price levels.
- **A wider value area.** `value_area` up to 95 percent.

## Run it

1. In the editor's Explorer, press the **Templates** icon ("Browse starter templates") and pick **Volume Profile and Value Area** under **Beyond the time axis**.
2. Press **Run** on a crypto chart finer than 1h, such as BTCUSDT on Binance Futures at 15m: the profile docks on the price axis, the three levels draw with their tags, and the heatmap appears below the chart.
3. At the editor's Console prompt, type `poc`, `vah` and `val` to read the three levels on the newest bar.

## Concepts used

- [Volume profile](../functions/order-flow-kit.md#volume-profile) for the celled profile input and the block layout
- [Cards, frames and panels](../presentation/cards-frames-panels.md) for frames, `plot.levels` and `panel.heatmap`
- [Drawing objects](../presentation/drawing-objects.md) for line and label handles extended to the axis
