---
title: "Order flow"
description: "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…"
order: 55
section: "functions"
---

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

# Order flow

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

| Source | What one bar carries | Notes |
| --- | --- | --- |
| `trades.volume` with a `side` | the bar's aggressive buy volume, or its sell volume, as one number | declared twice, `side: "BUY"` and `side: "SELL"`; `missing: "zero"` reads 0 on a bar without prints; `currency: "USD"` reads dollars instead of coins |
| `volume_profile.cells` | one `[low, high, buy, sell]` tuple per price level | real 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.cells` | one `[price, size, side]` tuple per level | `side` 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 `side` | the bar's forced liquidations on one side, in USD | the 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:

```typescript
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:

| Accessor | Returns |
| --- | --- |
| `in_profile_cells(): i32`, `in_book_cells(): i32` | the 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](../core-concepts/data-sources.md#reading-a-block).

## Every export

| Export | Signature | What it gives |
| --- | --- | --- |
| `delta` | `delta(buy: f64, sell: f64): f64` | `buy - sell`; NaN when a side is not finite |
| `deltaPct` | `deltaPct(buy: f64, sell: f64): f64` | `(buy - sell) / (buy + sell) * 100`, -100..100; NaN when the sum is 0 |
| `Cvd` | `update(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 |
| `VolumeProfile` | `constructor(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 |
| `BookImbalance` | `constructor(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 |
| `Absorption` | `constructor(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 |
| `LiquidationBurst` | `constructor(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.

```typescript sample=fn-orderflow-cvd
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);
}
```

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](../cookbook/volume-profile-value-area.md)
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.

```typescript sample=fn-orderflow-profile
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);
}
```

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

| Function | Returns |
| --- | --- |
| `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:

| | `VolumeProfile` | Hand scan |
| --- | --- | --- |
| Rows | `rows` equal-width rows across the span you pass to `begin()` | the chart's native buckets, one tick each |
| Point of control | the heaviest row's centre, the lower row on a tie | the heaviest bucket's midprice, the lower bucket on a tie |
| Value area | `vah()` and `val()` at `valueAreaPct` | none |
| Totals | `total()`, buy and sell per row | `vpBuy`, `vpSell`, `vpDelta`, `vpTotal` |
| Range | the span you passed | `vpPriceLow` 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.

```typescript sample=fn-vp-accessors
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](../presentation/price-canvases.md)). 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](../presentation/cards-frames-panels.md#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](../cookbook/volume-profile-value-area.md)
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](../core-concepts/data-sources.md#the-worked-footprint-example);
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).

```typescript sample=fn-orderflow-book
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);
}
```

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.

| | `BookImbalance` | Depth window scan |
| --- | --- | --- |
| Levels kept | the `levels` best on each side | every level within `depthPct` of the mid |
| Window | a count, the same whatever the price | a price band that scales with the mid |
| Reads | sizes, ratio, imbalance, touch, spread | sums, 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):

| Scan | Returns |
| --- | --- |
| `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:

| Category | What the scans give you |
| --- | --- |
| Volume analysis | bid and ask size summed within the depth window, and their imbalance |
| Order size analysis | the largest and smallest resting orders on each side, to spot size |
| Market depth | how 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.

```typescript sample=fn-orderbook-depth
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);
}
```

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](../cookbook/absorption.md) and
[Liquidation bursts](../cookbook/liquidation-bursts.md) 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.

```typescript sample=fn-orderflow-detectors
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));
}
```

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](../core-concepts/data-sources.md#celled-sources)). When
the market has no profile data, the run stops with a toast instead of
drawing zeros: "Volume profile data is unavailable for '<title>' (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](alerts.md)).

## 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](ta-library.md)).
- **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.
