Order flow

View as MarkdownOpen the editor

The chart serves order flow a candle alone cannot show: the bar's aggressive buy and sell volume as two sided inputs, the bar's volume profile as volume_profile cells, a book snapshot as book cells and the bar's forced liquidations per side.

This page is the arithmetic on top. The ./sdk/orderflow module ships delta and deltaPct for a bar, a Cvd you restart where you choose, a VolumeProfile with its point of control and value area, a BookImbalance, and the two detectors the cookbook recipes use, Absorption and LiquidationBurst. The same scans written by hand over the profile and book cells follow each class, with the readers those cells come through; the classes are those scans with the edge rules pinned.

What the chart serves

SourceWhat one bar carriesNotes
trades.volume with a sidethe bar's aggressive buy volume, or its sell volume, as one numberdeclared twice, side: "BUY" and side: "SELL"; missing: "zero" reads 0 on a bar without prints; currency: "USD" reads dollars instead of coins
volume_profile.cellsone [low, high, buy, sell] tuple per price levelreal band edges, in ascending price order, a level with a non-finite member skipped; buy and sell are the aggressor volumes at that level in coins (base-asset units); the count varies with the bar's range
book.cellsone [price, size, side] tuple per levelside is +1 for a bid and -1 for an ask, size the absolute resting quantity; the bids as the snapshot lists them, then the asks from the highest price down; up to 500 levels a side, so a full book is up to 1,000 tuples
liquidations.liquidations with a sidethe bar's forced liquidations on one side, in USDthe detector sample reads long liquidations from the SELL side and short ones from the BUY side, both missing: "zero"

The two celled sources share these rules:

  • max_cells is required and counts tuples. A bar whose block exceeds it refuses the whole run rather than truncating ("max_cells is 512, so the evaluation is refused (a block is never truncated)"), except on a profile declared missing: "empty" (below), where that bar reads an empty block and the run goes on. A profile has one tuple per bucket the bar crossed: BTCUSDT's bucket is $5, so 512 refuses any bar wider than about $2,560, while 8192 covers the majors' profiles at native bucket width; 1000 holds a full book. A low cap refuses wide bars, a high cap reserves memory you never use (8192 profile tuples are 256 KB).
  • A celled input 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. Declaring one derives the sheet on the second runtime contract (abi_version: "wrun-2"); Run does that for you.
  • Both follow the chart's own market and interval: a symbol + exchange pin or an interval pin is refused ("the browser lane serves market pins on secondary ohlcv inputs only (typed feeds, cells and time follow the chart's own market)"). description is the only other option the profile takes.
  • The book declaration requires block_size and accepts max_depth, but the chart reads neither: it serves the same snapshots its own order book lane fetches for the chart's market. Size max_cells for the deepest book the market shows, not from max_depth.
  • The profile arrives at the market's own bucket width, one tick per bucket, in coins, unless the input says otherwise. ticks_per_bar merges that many of the market's own buckets into one, a whole number from 1 to 500 (left out, or 1, the market's own width), and currency: "USD" quotes bucket volume in dollars instead of coins ("Coin", the default, keeps coins): input("profile", volume_profile.cells, { max_cells: 8192, ticks_per_bar: 5, currency: "USD" }). Any other value stops the build with the option's name.
  • The bucket size can be a setting: ticks_per_bar: "@node_ticks" names a param.int whose min..max lies inside 1..500, the chart asks for the profile at the setting's value and fetches again when the user changes it: param.int("node_ticks", 10, { min: 1, max: 50 }); input("profile", volume_profile.cells, { max_cells: 8192, ticks_per_bar: "@node_ticks" }).
  • The profile is required: a market without one stops the run with a message naming it. missing: "empty" makes it optional, so on a market that serves no profile, or a stretch without one, every bar reads an empty block (in_profile_cells() is 0) and the rest of the indicator draws as usual: input("profile", volume_profile.cells, { max_cells: 8192, missing: "empty" }). A bar whose profile has more buckets than max_cells reads the same empty block on an optional profile, never a truncated one, so a wide bar blanks that bar's profile parts instead of stopping the run. Scans over an empty block find no level, so code that reads the profile blanks those outputs by itself. Only "empty", and only on volume_profile.cells.
  • Neither block carries the bar's timestamp: the time source does when a scan needs one.

Reading the cells

Declare a celled input behind a scalar one, with its cap:

wrun
input("close", ohlcv.close);
input("profile", volume_profile.cells, { max_cells: 8192 });
input("book", book.cells, { max_cells: 1000, block_size: 10 });

A celled input has no scalar reader (in_profile() does not exist). It reaches onBar() through a generated pair, four cells per profile tuple and three per book tuple:

AccessorReturns
in_profile_cells(): i32, in_book_cells(): i32the number of f64 cells this bar (tuples times the tuple width); 0 for a present, empty block; -1 when the bar carries no block
in_profile_view(): StaticArray<f64>, in_book_view(): StaticArray<f64>the bar's cells in place: one buffer the build owns, filled before onBar() runs and never copied; only the first in_<name>_cells() values belong to this bar

Read the count, then the block, where you use them. The view is not cleared between bars: an absent or empty bar leaves the previous block in it, so bound every scan by the count. Profile tuple i is cells[4 * i] through cells[4 * i + 3] and n / 4 is the count; a loop guarded by i + 3 < n (i + 2 < n over the book) walks whole tuples only, so a block that is not a multiple of the tuple width (it never is, but the guard costs nothing) cannot read past the last cell. The copying reader and the capacity constants are on Data sources.

Every export

ExportSignatureWhat it gives
deltadelta(buy: f64, sell: f64): f64buy - sell; NaN when a side is not finite
deltaPctdeltaPct(buy: f64, sell: f64): f64(buy - sell) / (buy + sell) * 100, -100..100; NaN when the sum is 0
Cvdupdate(buy, sell): f64, value(): f64, delta(): f64, reset()the running sum of buy - sell since construction or the last reset(); a non-finite side counts as 0 on its bar
VolumeProfileconstructor(rows: i32), begin(low, high), add(low, high, buy, sell), end(valueAreaPct: f64 = 70.0): bool, poc(), vah(), val(), total(), rowCount(), rowLow(i), rowVolume(i), rowBuy(i), rowSell(i), pocRow(), vahRow(), valRow(), reset()bands spread over rows equal-width rows across [low, high), buy and sell kept per row; the POC row, its centre, and the value area's edges
BookImbalanceconstructor(levels: i32), begin(), add(price, size, side: i32), end(), bidSize(), askSize(), ratio(), imbalance(), bestBid(), bestAsk(), spread(), reset()the levels highest bids and lowest asks kept whatever order the cells arrive in; their sizes summed, bid / (bid + ask), (bid - ask) / (bid + ask), the touch and the spread
Absorptionconstructor(deltaWindow: i32, atrLength: i32, deltaSpike: f64, maxRange: f64), update(high, low, close, buy, sell): i32, delta(), norm(), range(), reset()+1 buying absorbed, -1 selling absorbed, 0 otherwise: the absorption recipe's test
LiquidationBurstconstructor(window: i32, burstZ: f64), update(longLiq, shortLiq): i32, longZ(), shortZ(), reset()-1 a long burst, +1 a short burst, 0 neither: the liquidation-bursts recipe's test

Every class allocates in its constructor and never per bar, NaN means "nothing" (not warm yet, no such row, no level on that side), and reset() restores the freshly constructed state. Counts below 1 (rows, levels, windows) are clamped to 1. Construct the objects in onStart() (a settings change rebuilds them there); an abort at module start loses its message.

Delta and CVD

delta(buy, sell) is the bar's buy - sell and deltaPct the same as a percent of the bar's sided volume. Cvd keeps the running sum: every update(buy, sell) adds the bar's delta and returns the sum, value() reads it again, delta() reads the last bar's own delta. The sum has no anchor of its own: you decide where it restarts by calling reset(). The sample below restarts it on the first bar of each UTC day, gated by a data-only new_day flag computed from bar.time() (the bar's open in UTC seconds), so the flag is on the chart too and an alert can read it.

input("close", ohlcv.close);
input("buy", trades.volume, { side: "BUY", missing: "zero" });
input("sell", trades.volume, { side: "SELL", missing: "zero" });
output("cvd", line, lower, { color: "#2563eb", width: 2, description: "Cumulative volume delta, restarted each UTC day" });
output("bar_delta", line, lower, { color: "#94a3b8", description: "This bar's buy minus sell volume" });
output("new_day", none, lower, { description: "1 on the first bar of a UTC day (the reset gate)" });

let cvd = new Cvd();
let prevDay: f64 = NaN;

function onStart(): void {
  cvd = new Cvd();
}

function onBar(): void {
  const day = Math.floor(bar.time() / 86400.0);
  const newDay = !isNaN(prevDay) && day != prevDay ? 1.0 : 0.0;
  prevDay = day;
  if (newDay == 1.0) cvd.reset();
  const buy = in_buy();
  const sell = in_sell();
  const barDelta = delta(buy, sell);
  const sum = cvd.update(buy, sell);
  out_cvd(sum);
  out_bar_delta(barDelta);
  out_new_day(newDay);
}
BTCUSDT perpetual on Binance, 1 hour bars, Aug 10 to Aug 18, 2026Real output from OpenMarket's engine

The first bar of the history is not a "new day" (there is no previous day to compare), so the sum starts there and restarts at every day boundary after it. Read new_day in the Console to check the gate before you alert on the line.

Volume profile

A VolumeProfile is built from bands. begin(low, high) opens a profile over [low, high) split into the constructor's rows equal-width rows and clears them; add(low, high, buy, sell) folds one band; end(valueAreaPct) closes the profile and answers false when nothing landed. Call add() per cell across as many bars as the profile should cover: a bar profile is begin, the bar's cells, end; a rolling profile is one begin, cells from every bar, end when you read it.

The frozen rules, the ones the Volume profile and value area recipe uses:

  • A band is spread over the rows it overlaps in proportion to the overlap. A band whose two edges fall in the same row, a band thinner than a row and a point band with low == high land whole in that row.
  • A band entirely outside the span lands whole in the nearest edge row; a band crossing the span's edge keeps the part inside.
  • The POC is the heaviest row; the lower row wins a tie. poc() is that row's centre.
  • The value area starts at the POC and grows toward the heavier neighbour (up wins a tie) until it holds valueAreaPct percent of the total. vah() is the area's top row's upper edge and val() its bottom row's lower edge, both row EDGES.
  • Buy and sell are kept per row: rowBuy(i), rowSell(i) and their sum rowVolume(i); rowLow(i) is the row's lower edge and total() what every row holds.

The sample reads the bar's volume_profile cells with the generated in_profile_cells() / in_profile_view() pair, loops the four-cell tuples into add(), and draws the three levels as lines on price.

param("rows", 24, { min: 2, max: 128, description: "Price rows across the bar's range" });
param("value_area", 70, { min: 50, max: 95, description: "Value area as a percent of the bar's volume" });
input("close", ohlcv.close);
input("profile", volume_profile.cells, { max_cells: 4096, description: "This bar's volume by price level" });
output("poc", line, overlay, { color: "#f59e0b", width: 2, description: "Point of control: the heaviest row's centre" });
output("vah", line, overlay, { color: "#38bdf8", description: "Value area high: the area's upper edge" });
output("val", line, overlay, { color: "#38bdf8", description: "Value area low: the area's lower edge" });

let profile = new VolumeProfile(24);
let valueArea: f64 = 70.0;

function onStart(): void {
  profile = new VolumeProfile(i32(p_rows()));
  valueArea = p_value_area();
}

function onBar(): void {
  profile.begin(bar.low(), bar.high()); // the bar's own range; a flat bar keeps the profile closed
  const n = in_profile_cells();
  if (n >= 4) {
    const cells = in_profile_view();
    for (let i = 0; i + 3 < n; i += 4) {
      profile.add(cells[i], cells[i + 1], cells[i + 2], cells[i + 3]);
    }
  }
  const ok = profile.end(valueArea);
  out_poc(ok ? profile.poc() : NaN);
  out_vah(ok ? profile.vah() : NaN);
  out_val(ok ? profile.val() : NaN);
}
BTCUSDT perpetual on Binance, 1 hour bars, Aug 10 to Aug 18, 2026Real output from OpenMarket's engine

end() answers false on a bar with no bands or a flat range and the three outputs are written NaN there, so the lines simply skip it. A clamp such bands into the edge rows on purpose, or skip them before add() when you want the recipe's stray-print rule.

Profile scans by hand

The class re-bins the bar's bands into rows you choose. When the native buckets are what you want (the heaviest level as the chart serves it, the bar's sided totals, the range the profile spans), read the tuples directly. Each read is a function over the block, cells being the view 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.

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

How the two readings differ:

VolumeProfileHand scan
Rowsrows equal-width rows across the span you pass to begin()the chart's native buckets, one tick each
Point of controlthe heaviest row's centre, the lower row on a tiethe heaviest bucket's midprice, the lower bucket on a tie
Value areavah() and val() at valueAreaPctnone
Totalstotal(), buy and sell per rowvpBuy, vpSell, vpDelta, vpTotal
Rangethe span you passedvpPriceLow and vpPriceHigh from the buckets

The module below writes nine outputs from 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.

input("close", ohlcv.close);
input("profile", volume_profile.cells, { max_cells: 8192 });
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;
}

function onBar(): void {
  const n = in_profile_cells();
  if (n < 0) return; // no block on this bar: abstain
  const cells = in_profile_view();
  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));
}

The scans run in onBar() over the view the build filled before it: a module that reads the count once and computes many things from one block pays for the block once.

One mark per price level

Every output is one number per bar and every renderer draws one thing per bar, so the per-level picture is not a loop of marks. It is a declaration over the same cells: plot.footprint({ name, cells: "profile" }) draws one footprint column per bar from the buckets, plot.heatmap({ name, cells: "profile", value: "delta" }) paints them as a time by price heatmap, and plot.profile({ name, cells: "profile", span: "session" }) sums them into a volume profile per session, range or visible window (Price canvases). A profile the module computes itself (a scan that weights or filters the buckets) goes to a plot.levels frame docked on the price axis (Docked profiles); 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) draws the levels you name. The Volume profile and value area recipe draws its levels that way.

The worked footprint

The vp-buy-share-codefirst starter is the footprint loop end to end: a celled input, the buy share of each bar's profile as a numeric output, and a text renderer fed from a string slot on the newest bar. The whole file and how to run it are on Data sources; in the editor it is the VP Buy Share card under Order flow in the template picker (the Templates icon beside New indicator). After a run on a market with profile data, type last 5 buy_share at the Console prompt to read the newest values.

Book imbalance

A BookImbalance reads one snapshot per bar: begin(), one add(price, size, side) per level with side exactly as the cell encodes it (+1 bid, -1 ask; any other value is ignored, so is a non-finite price or size), then end(). It keeps the levels highest bids and the levels lowest asks, each side in a small array sorted best price first, so the result is the same whatever order the levels arrive in. That matters: the chart hands the asks over from the highest price down, so the first ask tuple is the worst ask, never the best; bestAsk() is the lowest ask price whatever the order. After end(), bidSize() and askSize() are the kept sizes summed, ratio() is bid / (bid + ask) and imbalance() (bid - ask) / (bid + ask) (both NaN when both sums are 0), bestBid(), bestAsk() and spread() read the touch (NaN when a side is empty).

param("levels", 10, { min: 1, max: 500, description: "Levels kept per side, counted from the touch" });
param("smooth", 21, { min: 1, max: 200, description: "EMA window over the imbalance" });
input("close", ohlcv.close);
input("book", book.cells, { max_cells: 1000, block_size: 10 });
output("imbalance", line, lower, { color: "#94a3b8", description: "(bids - asks) / (bids + asks) over the kept levels, -1..1" });
output("smoothed", line, lower, { color: "#2563eb", width: 2, description: "The imbalance smoothed" });
output("spread", none, lower, { description: "Best ask minus best bid" });

let imbalanceOf = new BookImbalance(10);
let ema = new Ema(21);

function onStart(): void {
  imbalanceOf = new BookImbalance(i32(p_levels()));
  ema = new Ema(i32(p_smooth()));
}

function onBar(): void {
  const n = in_book_cells();
  if (n <= 0) return; // no snapshot this bar, or an empty one: nothing to measure
  const cells = in_book_view();
  imbalanceOf.begin();
  for (let i = 0; i + 2 < n; i += 3) {
    imbalanceOf.add(cells[i], cells[i + 1], i32(cells[i + 2]));
  }
  imbalanceOf.end();
  const imbalance = imbalanceOf.imbalance();
  const spread = imbalanceOf.spread();
  const smoothed = isNaN(imbalance) ? NaN : ema.update(imbalance);
  out_imbalance(imbalance);
  out_smoothed(smoothed);
  out_spread(spread);
}
BTCUSDT perpetual on Binance, 1 hour bars, Aug 10 to Aug 18, 2026Real output from OpenMarket's engine

The Ema from ./sdk/ta is fed once per bar, in order; on a bar without a snapshot onBar() returns early, so the average is left untouched and the three outputs stay NaN. Size max_cells for the deepest book the market shows (a snapshot over the cap refuses the run); the cap is in tuples.

Depth window scans by hand

The class keeps a count of levels from the touch. A depth window keeps a price band instead: every scan takes a depthPct (10 in the module below) and only levels within that percentage of the mid price count. The mid is the average of the best bid (the highest bid price) and the best ask (the lowest ask price), read from prices rather than positions so any level order works, and a level is inside the window when abs(price - mid) <= mid * depthPct / 100. Smaller percentages (1 to 5) focus on top-of-book activity; larger ones (10 to 20) read overall depth.

BookImbalanceDepth window scan
Levels keptthe levels best on each sideevery level within depthPct of the mid
Windowa count, the same whatever the pricea price band that scales with the mid
Readssizes, ratio, imbalance, touch, spreadsums, the largest and smallest order, imbalance

Three scans parameterized by side cover six measures. The sums answer 0 on an empty window, maxSide and minSide answer NaN when no level of that side sits inside it, and all three answer NaN when a side of the book is missing (no mid):

ScanReturns
sumSide(cells, n, 1.0, depthPct)total bid size within the window
sumSide(cells, n, -1.0, depthPct)total ask size within the window
maxSide(cells, n, 1.0, depthPct)the largest single bid within the window
maxSide(cells, n, -1.0, depthPct)the largest single ask
minSide(cells, n, 1.0, depthPct)the smallest bid within the window
minSide(cells, n, -1.0, depthPct)the smallest ask

What the six measures serve:

CategoryWhat the scans give you
Volume analysisbid and ask size summed within the depth window, and their imbalance
Order size analysisthe largest and smallest resting orders on each side, to spot size
Market depthhow far liquidity reaches and where it clusters, for support and resistance reads

The module adds the imbalance the sums are usually combined into, (bids - asks) / (bids + asks), smoothed with the shipped Ema so the per-snapshot noise reads as pressure. depth_pct is a param, so the window is a setting the chart user can change.

param("depth_pct", 10, { min: 0.1, max: 50, description: "Depth window as a percent of the mid price" });
param("smooth", 21, { min: 1, max: 200, description: "EMA window over the imbalance" });
input("close", ohlcv.close);
input("book", book.cells, { max_cells: 1000, block_size: 10 });
output("bid_volume", line, lower, { color: "#26a69a", width: 2, description: "Bid size within the window" });
output("ask_volume", line, lower, { color: "#ef5350", width: 2, description: "Ask size within the window" });
output("max_bid", line, lower, { color: "#0f766e", width: 1, description: "Largest resting bid within the window" });
output("max_ask", line, lower, { color: "#be123c", width: 1, description: "Largest resting ask within the window" });
output("min_bid", line, lower, { color: "#5eead4", width: 1, description: "Smallest resting bid within the window" });
output("min_ask", line, lower, { color: "#fda4af", width: 1, description: "Smallest resting ask within the window" });
output("imbalance", line, lower, { color: "#94a3b8", width: 1, description: "(bids - asks) / (bids + asks), -1..1" });
output("imbalance_ema", line, lower, { color: "#2563eb", width: 2, description: "Smoothed imbalance" });

// The average of the best bid (the highest bid price) and the best ask (the lowest ask price),
// or NaN when either side is missing. Reads prices, not positions, so any level order works.
function bookMid(cells: StaticArray<f64>, n: i32): f64 {
  let bestBid = NaN;
  let bestAsk = NaN;
  for (let i = 0; i + 2 < n; i += 3) {
    const price = cells[i];
    if (cells[i + 2] > 0.0) {
      if (isNaN(bestBid) || price > bestBid) bestBid = price;
    } else if (isNaN(bestAsk) || price < bestAsk) bestAsk = price;
  }
  return isNaN(bestBid) || isNaN(bestAsk) ? NaN : (bestBid + bestAsk) / 2.0;
}

// True when a level sits within depthPct percent of the mid.
function inWindow(price: f64, mid: f64, depthPct: f64): bool {
  return Math.abs(price - mid) <= (mid * depthPct) / 100.0;
}

function sumSide(cells: StaticArray<f64>, n: i32, side: f64, depthPct: f64): f64 {
  const mid = bookMid(cells, n);
  if (isNaN(mid)) return NaN;
  let total = 0.0;
  for (let i = 0; i + 2 < n; i += 3) {
    if (cells[i + 2] == side && inWindow(cells[i], mid, depthPct)) total += cells[i + 1];
  }
  return total;
}

function maxSide(cells: StaticArray<f64>, n: i32, side: f64, depthPct: f64): f64 {
  const mid = bookMid(cells, n);
  if (isNaN(mid)) return NaN;
  let best = NaN;
  for (let i = 0; i + 2 < n; i += 3) {
    if (cells[i + 2] == side && inWindow(cells[i], mid, depthPct)) {
      if (isNaN(best) || cells[i + 1] > best) best = cells[i + 1];
    }
  }
  return best;
}

function minSide(cells: StaticArray<f64>, n: i32, side: f64, depthPct: f64): f64 {
  const mid = bookMid(cells, n);
  if (isNaN(mid)) return NaN;
  let best = NaN;
  for (let i = 0; i + 2 < n; i += 3) {
    if (cells[i + 2] == side && inWindow(cells[i], mid, depthPct)) {
      if (isNaN(best) || cells[i + 1] < best) best = cells[i + 1];
    }
  }
  return best;
}

let depthPct: f64 = 10.0;
let ema = new Ema(21);

function onStart(): void {
  depthPct = p_depth_pct();
  ema = new Ema(i32(p_smooth()));
}

function onBar(): void {
  const n = in_book_cells();
  if (n <= 0) return; // no snapshot, or an empty one: nothing to measure
  const cells = in_book_view();
  const bids = sumSide(cells, n, 1.0, depthPct);
  const asks = sumSide(cells, n, -1.0, depthPct);
  const imbalance = bids + asks > 0.0 ? (bids - asks) / (bids + asks) : NaN;
  const smoothed = isNaN(imbalance) ? NaN : ema.update(imbalance);
  out_bid_volume(sumSide(cells, n, 1.0, depthPct));
  out_ask_volume(sumSide(cells, n, -1.0, depthPct));
  out_max_bid(maxSide(cells, n, 1.0, depthPct));
  out_max_ask(maxSide(cells, n, -1.0, depthPct));
  out_min_bid(minSide(cells, n, 1.0, depthPct));
  out_min_ask(minSide(cells, n, -1.0, depthPct));
  out_imbalance(imbalance);
  out_imbalance_ema(smoothed);
}
BTCUSDT perpetual on Binance, 1 hour bars, Aug 10 to Aug 18, 2026Real output from OpenMarket's engine

The Ema is fed in onBar() once per bar, in order, after the early return, so a bar without a snapshot never touches it.

Absorption and liquidation bursts

Both detectors are the cookbook recipes' tests, verbatim, so an indicator built on them flags exactly the bars Absorption and Liquidation bursts flag.

Absorption(deltaWindow, atrLength, deltaSpike, maxRange) answers, per update(high, low, close, buy, sell): delta = buy - sell; norm = the Sma(deltaWindow) of abs(delta); range = the Atr(atrLength); heavy = norm > 0 and abs(delta) >= deltaSpike * norm; stuck = range > 0 and high - low <= maxRange * range; heavy and stuck give +1 when delta >= 0 (buying absorbed) and -1 otherwise (selling absorbed), else 0. delta(), norm() and range() read the bar's three numbers. The answer is 0 while the average or the ATR is warming up.

LiquidationBurst(window, burstZ) answers, per update(longLiq, shortLiq), side by side: mean = Sma(window), dev = Stdev(window); burst = value > 0 and dev > 0 and value >= mean + burstZ * dev. A long burst answers -1, a short burst +1; when both sides burst the larger value wins, long on a tie; else 0. longZ() and shortZ() read (value - mean) / dev, NaN while not warm or when dev is 0.

The sample draws absorbed bars as dots (above the candle when buying was absorbed, below it when selling was) and writes both answers as data-only outputs an alert can read.

param("delta_spike", 2.0, { min: 1.0, max: 6.0, description: "Heavy when abs(delta) is this many times its rolling mean" });
param("delta_window", 48, { min: 10, max: 400, description: "Bars the mean of abs(delta) is taken over" });
param("max_range", 0.8, { min: 0.2, max: 2.0, description: "Stuck when the bar's range is under this many ATRs" });
param("atr_length", 14, { min: 2, max: 100, description: "The ATR's Wilder length" });
param("burst_z", 3.0, { min: 1.0, max: 8.0, description: "A burst sits this many deviations above the window's mean" });
param("window", 96, { min: 10, max: 500, description: "Bars the liquidation mean and deviation are taken over" });
input("close", ohlcv.close);
input("buy", trades.volume, { side: "BUY", missing: "zero" });
input("sell", trades.volume, { side: "SELL", missing: "zero" });
input("long_liq", liquidations.liquidations, { side: "SELL", missing: "zero" });
input("short_liq", liquidations.liquidations, { side: "BUY", missing: "zero" });
output("absorbed_buying", shape, overlay, { color: "#f86800", description: "A dot above a candle where heavy buying went nowhere" });
output("absorbed_selling", shape, overlay, { color: "#f8c000", description: "A dot below a candle where heavy selling went nowhere" });
output("absorption", none, overlay, { description: "+1 buying absorbed, -1 selling absorbed, 0 none" });
output("burst", none, overlay, { description: "-1 a long burst, +1 a short burst, 0 none" });

let absorption = new Absorption(48, 14, 2.0, 0.8);
let bursts = new LiquidationBurst(96, 3.0);

function onStart(): void {
  absorption = new Absorption(i32(p_delta_window()), i32(p_atr_length()), p_delta_spike(), p_max_range());
  bursts = new LiquidationBurst(i32(p_window()), p_burst_z());
}

function onBar(): void {
  const close = bar.close();
  const high = bar.high();
  const low = bar.low();
  const absorbed = absorption.update(high, low, close, in_buy(), in_sell());
  const burst = bursts.update(in_long_liq(), in_short_liq());
  const range = absorption.range();
  const dotAbove = absorbed == 1 ? high + range * 0.4 : NaN;
  const dotBelow = absorbed == -1 ? low - range * 0.4 : NaN;
  out_absorbed_buying(dotAbove);
  out_absorbed_selling(dotBelow);
  out_absorption(f64(absorbed));
  out_burst(f64(burst));
}
BTCUSDT perpetual on Binance, 1 hour bars, Aug 10 to Aug 18, 2026Real output from OpenMarket's engine

A shape output written NaN draws nothing on that bar, so the dots appear only where the detector fired. The recipes add a box around the candle, a money tag on a burst and a notice when the market serves no sided prints or no liquidations; those are drawing objects on top of the same two answers.

Where it runs

The chart serves volume_profile and book over the loaded history and pushes the forming bar's profile and book live; a book module refreshes its newest bar at most about once a second. Cell alignment is an exact join: a primary bar with no observation gets a present empty block (n == 0), never a carried-forward one, so volume is never counted twice and a stale book never masquerades as a fresh read (Data sources). When the market has no profile data, the run stops with a toast instead of drawing zeros: "Volume profile data is unavailable for '' (the volume_profile source lane answered empty or was declined), so the indicator cannot compute." An alert on the indicator runs in OpenMarket's cloud; when it cannot evaluate a data source, saving the alert says so ("This Indicator reads a data source alerts cannot evaluate yet.", Alerts).

Practices

  • Scan once. Read the count and the view once per bar and compute every measure from them. Nine scans over a bar's few hundred tuples is still cheap, but one is cheaper.
  • Track 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 Cvd or Cum for cumulative delta, or to Rsi for delta-RSI (TA library).
  • Smooth the imbalance. A large bid-over-ask imbalance often precedes an upward move and the reverse a downward one; a single snapshot is noisy, so smooth it before alerting on it.
  • Watch large orders. max_bid and max_ask jumping is size arriving; pair them with min_bid / min_ask to tell a thin book from a deep one.

From Pine

Nothing on this page maps one to one. Pine has no sided trades, volume profile bands, book levels or liquidations as script inputs, so there is no ta.* call to translate: the delta, the profile and the imbalance are computed from cells the chart serves to the indicator, and the detectors are recipes of this chart. Where Pine offers a volume profile, it is a chart-level drawing, not a series a script can read.