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_ | 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. 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_cellsis 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 declaredmissing: "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+exchangepin or anintervalpin is refused ("the browser lane serves market pins on secondary ohlcv inputs only (typed feeds, cells and time follow the chart's own market)").descriptionis the only other option the profile takes. - The book declaration requires
block_sizeand acceptsmax_depth, but the chart reads neither: it serves the same snapshots its own order book lane fetches for the chart's market. Sizemax_cellsfor the deepest book the market shows, not frommax_depth. - The profile arrives at the market's own bucket width, one tick per
bucket, in coins, unless the input says otherwise.
ticks_per_barmerges 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), andcurrency: "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 aparam.intwhosemin..maxlies 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()is0) 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 thanmax_cellsreads 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 onvolume_profile.cells. - Neither block carries the bar's timestamp: the
timesource does when a scan needs one.
Reading the cells
Declare a celled input behind a scalar one, with its cap:
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_, in_ | 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_, in_book_view(): Static | the bar's cells in place: one buffer the build owns, filled before onBar() runs and never copied; only the first in_ 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
| 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(, begin(low, high), add(low, high, buy, sell), end(, 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(, 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(, update(high, low, close, buy, sell): i32, delta(), norm(), range(), reset() | +1 buying absorbed, -1 selling absorbed, 0 otherwise: the absorption recipe's test |
Liquidation | constructor(, 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);
}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 == highland 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
valueAreaPctpercent of the total.vah()is the area's top row's upper edge andval()its bottom row's lower edge, both row EDGES. - Buy and sell are kept per row:
rowBuy(i),rowSell(i)and their sumrowVolume(i);rowLow(i)is the row's lower edge andtotal()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);
}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 |
vp | combined volume at the point-of-control bucket |
vp | the number of buckets this bar |
vp | the highest high across buckets, NaN if empty |
vp | 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.
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);
}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.
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 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));
}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 '
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;
vpPocwithvpPocVolumesays how dominant the level is. - Read delta for pressure.
vpDeltasummarizes net aggressor flow per bar. Sustained positive delta is buy-side control; feed it toCvdorCumfor cumulative delta, or toRsifor 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_bidandmax_askjumping is size arriving; pair them withmin_bid/min_askto 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.