Price canvases
Five declarations draw a canvas on the price chart from cells the chart
already serves or from a frame the module writes: plot.heatmap (a
time by price heatmap), plot.footprint (one footprint column per bar),
plot.tpo (letter blocks per period and price), plot.profile (a volume
profile anchored to a session, a range, the developing session or the
visible window) and plot.matrix (a strike by expiry table docked on the
price axis). A place: "side" panel is not a canvas: on a chart it mounts
below like every panel, and its right-side home is a window (Side
placement and windows).
Like plot.levels, each is an object literal the build reads and removes
before the file compiles; the module declares the canvas and the chart
draws it from the rows it already holds, so a footprint costs no output
and no transport of its own.
What the canvases share
- Names. Every canvas has a
name, unique across outputs, string slots, frames, levels, panels, renderers, drawings, heatmaps, profiles and matrices. A canvas is a child of the indicator: it shows and hides with the indicator's eye, and its legend X removes the indicator. - Cells. A heatmap, footprint, TPO or profile reads a declared celled
input through
cells:bookorvolume_profilefor a heatmap,volume_profilefor a footprint or a profile,volume_profileorintrabarfor a TPO (absent on a TPO means the chart's own candles). Acellsword that names an input of another class is refused by name ("heatmap 'h' reads cells from a book or volume_profile input; 'm1' is intrabar"). - Colours. Every colour word takes
"#rrggbb","#rrggbbaa"or a theme token (theme.up,theme.down,theme.text,theme.muted,theme.bg,theme.grid,theme.accent); a token follows the chart's theme and re-resolves on a theme switch without a rerun. - Type.
font_familyis"ui","mono","serif"or"rounded"(system fonts only, nothing downloads);font_weightis"normal","medium"or"bold";font_sizeis 6..64 px. - Numbers.
formatis one ofprice,%,si,int,0,0.0,0.00,0.000,usd,auto;decimals(0..8),signedandunit(0..8 characters) refine it and are refused without it ("decimals needs format").priceprints at the chart's price digits. - Settings. Any style key except a name or a reference (
cells,grid,price_low,price_step,start,end,frame) takes"@<param>", so aparam.colororparam.choiceon the Style page repaints the canvas without a rerun of your code (The Style page). - Caps. At most 4 heatmaps and 4 matrices per indicator; footprints, TPOs and profiles share one cap of 4. The numbers are on Limits.
- Opt-in. Every word has a default that keeps the chart's own look for that canvas; declare only what you want changed.
Heatmaps
plot.heatmap(options) paints one coloured cell per bar and price row.
The cells come from a celled input (cells) or from a grid of outputs
the module writes (grid); exactly one of the two is declared.
input("close", ohlcv.close);
input("depth", book.cells, { max_cells: 1000, block_size: 10 });
// The book as a heatmap behind the candles: size per level, the 98th percentile as the top of the scale.
plot.heatmap({ name: "liquidity", cells: "depth", value: "size", palette: ["theme.bg", "#2563eb", "#22d3ee", "#facc15"], opacity: 0.85, tooltip: "{{value:si}} at {{price}}" });| Word | Value | What it does |
|---|---|---|
cells | a book or volume_profile input | the rows to paint: book levels, or profile buckets |
value | book "size" (default), "bid", "ask", "signed"; profile "total" (default), "buy", "sell", "delta" | the number a cell carries; signed and delta make a diverging scale |
grid | an out.grid name | the module's own grid instead of cells (below) |
price_low, price_step | output names (price_step may be a number > 0) | with grid: the bottom edge of row 0 on each bar, and every row's height in price; both required, both refused beside cells |
row_height | number > 0 | with book cells: the row height in price (default the book's own grouping); refused on other cells |
palette | 2..8 colours, low to high | default ["theme.bg", "#2563eb", "#22d3ee", "#facc15"], or ["theme.down", "theme.bg", "theme.up"] when center applies |
min, max | numbers, min below max | the ends of the scale (default from the data, below) |
center | number | the midpoint of a diverging scale; default 0 under value: "signed" or "delta", else absent; refused outside min..max when both are declared |
auto_quantile | 0.5..1 (default 0.98) | the quantile that sets an automatic end of the scale; refused beside both min and max; the viewer's Sensitivity row moves it |
scale | "linear" (default), "sqrt", "log" | how a value maps onto the palette; "log" is refused on a diverging scale |
floor | number >= 0 (default 0) | a cell whose distance from the centre is at or under it draws nothing (so does an empty cell) |
opacity | 0..1 (default 0.85) | multiplies every cell colour's own alpha |
behind_candles | boolean (default true) | under the candles; false paints over them |
cell_gap | 0..4 px (default 0) | a gap between neighbouring cells |
labels | boolean (default false) | print each cell's value where the cell is tall and wide enough |
font_size, font_weight, font_family, text_color | 6..64 (10), the weight word, the family word ("ui"), a colour (theme.text) | the label type |
format, decimals, signed, unit | the number words (default "auto") | how a label and the readout print a value |
tooltip | a template of at most 200 characters | a hover readout over the cell; absent means no readout |
label | 1..40 characters (default the name in words) | the readout's title |
The automatic scale is read over every cell of the full run: a
one-sided scale runs from 0 (for size, bid, ask, total, buy and
sell) or from the low quantile to the high quantile; a diverging scale
runs the same distance either side of center. Live ticks keep the run's
scale, so colours do not shift between runs. The viewer moves the
automatic end with the heatmap's Sensitivity row on the Style page
(Subtle to Vivid, starting at your auto_quantile), with no rerun
(The Style page).
The tooltip template reads {{value}} and {{value:<format>}} (the
cell's number), {{price}} (the row's middle), {{price_low}},
{{price_high}}, {{time}} and {{label}}; an unknown placeholder stays
as written. A heatmap draws beside the chart's own book or options
heatmap without touching it.
A book heatmap on a slow chart reads better in coarser rows. This one regroups the book into rows of 25 price units, maps size through a square-root scale so a few large resting orders do not wash out the rest, leaves a one-pixel gap between cells and blanks the thinnest levels:
input("close", ohlcv.close);
input("depth", book.cells, { max_cells: 1000, block_size: 10 });
plot.heatmap({ name: "depth_rows", cells: "depth", value: "size", row_height: 25, scale: "sqrt", cell_gap: 1, floor: 2, opacity: 0.9 });A grid the module computes
out.grid(name, { rows }) declares rows (2..128) data-only outputs
named <name>_0 to <name>_<rows - 1> and generates one writer,
out_<name>(row: i32, value: f64), for onBar(). A row left unwritten
is an empty cell (NaN); a row outside 0..rows - 1 aborts the run by
name. The heatmap then names the grid and two price outputs: the bottom
edge of row 0 on each bar and the row height.
param("rows", 64, { min: 2, max: 128 });
input("close", ohlcv.close);
output("low_edge", none, overlay, { description: "The bottom of row 0: 32 rows under the close" });
output("step", none, overlay, { description: "The row height in price" });
out.grid("heat", { rows: 64 });
plot.heatmap({ name: "liq", grid: "heat", price_low: "low_edge", price_step: "step", center: 0, floor: 0.5 });
function onBar(): void {
const step = bar.close() * 0.0025;
out_step(step);
out_low_edge(bar.close() - 32.0 * step);
// Row 32 sits on the close; rows above it read positive, rows below negative, fading with distance.
for (let row = 0; row < 64; row++) out_heat(row, f64(row - 32) * bar.volume() / 32.0);
}The grid's outputs count toward the 256 outputs a file may declare, and the rows must be contiguous in declaration order (the build lays them out for you). The Liquidation Heat recipe is a complete grid heatmap, and OI Liquidation Heat builds the same grid from open interest; Book Heat paints the order book.
Footprints
plot.footprint(options) draws one footprint column per chart bar from
the bar's [low, high, buy, sell] buckets: the module only declares it.
input("close", ohlcv.close);
input("profile", volume_profile.cells, { max_cells: 8192 });
plot.footprint({ name: "fp", cells: "profile", mode: "split_bar", imbalance: true, imbalance_ratio: 3, stacked_imbalances: 3 });| Word | Value | What it does |
|---|---|---|
cells | a volume_profile input | required; a book input is refused by name |
mode | "cluster", "split_bar" (default), "stacked_bar", "ladder", "imbalance_ | the column's drawing |
buy_color, sell_color | colours | default the chart's profile pair |
row_height | number > 0 or "auto" (default) | the row height in price; "auto" is the input's bucket width times the footprint's row table (Row height) |
labels | boolean (default true) | print the cell numbers |
hide_zero | boolean (default false) | leave zero cells blank |
text_color | colour | the cell ink (default the chart's footprint ink) |
imbalance, imbalance_ratio, imbalance_, imbalance_, stacked_ | boolean (false), 1.1..20 (3), colours (theme.up, theme.down), 0..10 (3) | mark buy and sell imbalances at the ratio, and stacks of them |
poc, poc_color, poc_width | boolean (true), colour (theme.text), 1..10 px (1) | the point of control per column |
value_area, vah, val, vah_color, val_color | 0.5..0.95 (0.7), booleans (true), colours (theme.text) | the value area and its edges |
cell_metric | "volume", "delta" (default), "trades" | what a cluster cell reads |
volume_color | colour (default "#4a90e2") | the volume cell colour |
grading | "candle" (default), "session", "visible" | the range the cell shading is scaled over |
font_family, font_weight | the family and weight words (default the chart's indicator font) | the type |
cell_hover | boolean (default true) | the chart's cell readout on hover |
A cluster footprint reads volume per row rather than delta. This one shades every cell against the session's largest cell instead of its own candle's, in one teal, leaves zero cells blank in rows of 10 price units, and turns the chart's hover readout off so the numbers printed in the cells are the only readout:
input("close", ohlcv.close);
input("profile", volume_profile.cells, { max_cells: 8192 });
plot.footprint({ name: "clusters", cells: "profile", mode: "cluster", cell_metric: "volume", volume_color: "#2dd4bf", grading: "session", hide_zero: true, row_height: 10, cell_hover: false });Volume Footprint is the complete recipe.
TPO letters
plot.tpo(options) prints a letter for every letter interval of the
period in which a price row traded (profile buckets) or lay inside the
bar's range (finer bars, or the chart's own candles when cells is
absent).
input("close", ohlcv.close);
input("m5", intrabar.cells, { interval: "5m", max_cells: 288 });
plot.tpo({ name: "tpo", cells: "m5", period: "day", letter_minutes: 30, initial_balance: true, single_prints: true });| Word | Value | What it does |
|---|---|---|
cells | a volume_profile or intrabar input, or absent | the bars or buckets the letters are built from |
period | "day" (default), "week", "month" | one profile per period |
letter_minutes | 1..240 (default 30) | the minutes one letter stands for |
row_height | number > 0 or "auto" (default) | the row height in price; "auto" comes from the market, and a period past 512 rows is coarsened (Row height) |
display | "letters", "blocks", "both" (default) | letters, blocks, or both |
palette | 2..26 colours | the colours across the letters (default the chart's TPO scheme) |
color_mode | "period" (default), "count", "volume", "delta" | what colours a block; "volume" and "delta" need volume_profile cells |
value_area, poc, vah, val, poc_color, vah_color, val_color | 0.5..0.95 (0.7), booleans (true), colours (theme.text, theme.muted, theme.muted) | the value area and its edges |
outside_ | 0..1 (default 0.3) | how far rows outside the value area fade |
initial_balance, ib_color | boolean (true), colour (theme.text) | the initial balance bracket |
single_prints, single_, poor_extremes, poor_ | booleans (false), colours ("#cc0033", "#ffd966") | single prints and poor highs and lows |
counts | boolean (default false) | the TPO count per row |
volume_profile | boolean (default false) | a volume profile beside the letters; needs volume_profile cells |
font_family, font_weight, cell_hover | as a footprint | the type and the hover readout |
A day profile coloured by what each row traded, with the structure marks a profile reader looks for: poor highs and lows in amber, single prints in red, the TPO count printed on every row and a volume profile beside the letters:
input("close", ohlcv.close);
input("profile", volume_profile.cells, { max_cells: 8192 });
plot.tpo({ name: "day_tpo", cells: "profile", period: "day", color_mode: "volume", poor_extremes: true, poor_extremes_color: "#f59e0b", single_prints: true, single_prints_color: "#ef4444", counts: true, volume_profile: true });A letter must be a whole multiple of the feeding bars' interval: 30-minute letters over 1h candles cannot be placed, and the legend chip and the Console say so ("tpo 't': 30-minute letters need bars of 30 minutes or a whole divisor; the bars here are 1h (declare an intrabar input with interval "5m" and name it in cells)"). TPO Letters is the complete recipe.
Profiles anchored in time
plot.profile(options) draws a volume profile over a span of time from
volume_profile cells: one per session, one over a range two outputs
mark, the developing session, or the visible window.
input("close", ohlcv.close);
input("profile", volume_profile.cells, { max_cells: 8192 });
plot.profile({ name: "session_vp", cells: "profile", span: "session", session: "1d", value_area_shade: true, background: true });| Word | Value | What it does |
|---|---|---|
cells | a volume_profile input | required |
span | "session" (default), "range", "developing", "visible" | what one profile covers |
session | "auto" (default), "1h", "2h", "4h", "6h", "12h", "1d", "1w", "us", "eu", "asia" | the session length under "session" and "developing"; refused on "range" and "visible"; "auto" follows the chart interval |
start, end | output names, epoch seconds read on the last ready row | the range under span: "range" (start required there, end defaults to the newest bar; both refused elsewhere); a run whose start is not a number, or whose end is not after it, mounts no profile and the legend chip says why |
mode | "stacked_bar" (default), "split_bar", "outline", "delta" | the bars' drawing |
buy_color, sell_color | colours | default the chart's profile pair |
dock | "left", "right" | which edge of the span the bars grow from (default left, right on "visible") |
width_frac | 0.05..1 | the bars' share of the span (default 0.7), or of the pane under "developing" and "visible" (default 0.2) |
row_height | number > 0 or "auto" (default) | the row height in price; "auto" is the input's bucket width times the profile's row table (Row height) |
value_area, value_, outside_, outside_ | 0.5..0.95 (0.7), boolean (true), colours (the base colours at half strength) | the value area and how rows outside it paint |
poc, poc_color, poc_width, vah, val, vah_color, val_color | booleans (true), colours (theme.text), 1..10 px (1) | the point of control and the value area edges |
level_labels | boolean (default false) | price tags on the three levels |
naked | boolean (default false) | keep a level drawn until price trades through it |
extend | "none", "right" | extend the levels to the right (default right on "developing", none otherwise) |
behind_candles | boolean (default false) | under the candles; needs span: "session" and a filled mode |
background, background_, background_ | boolean (false), colour (theme.text), 0..1 (0.08) | a box behind each session; needs span: "session" |
font_family, font_weight, cell_hover | as a footprint | the type and the hover readout |
Session Volume Profile is the
complete recipe. A profile the module computes itself (open interest by
strike, a liquidation map) is a plot.levels whose frame carries spans:
const oiFrame = frame("oi_spans", { max_bytes: 32768 });
plot.levels({ name: "oi", frame: oiFrame, dock: "left", span: "time", width_frac: 0.4 });With span: "time" the frame is { "spans": [...] }, one entry per
profile, each drawn inside its own time span (with span: "pane", the
default, the frame keeps the { prices, values, colors? } shape docked on
the pane edge):
{
"spans": [
{
"start": 1727740800,
"end": 1727827200,
"prices": [61000, 61500, 62000],
"values": [120.5, 310.2, 88.0]
},
{
"start": 1727827200,
"end": 1727913600,
"prices": [61500, 62000, 62500],
"values": [90.0, null, 140.0],
"colors": ["theme.up", "theme.muted", "theme.up"]
}
]
}A frame holds at most 64 spans, each start before its end in epoch
seconds, 1..512 strictly ordered prices with as many values (null for a
gap) and optional colours, and at most 4096 rows across every span. A
frame whose shape does not match the declared span refuses the run with
wrun_frame_invalid. Every other plot.levels word applies
(Docked profiles);
Session OI Levels is the recipe.
Row height
A footprint, a TPO and a profile draw one row per band of price,
row_height tall. A number is used as written. "auto", the default,
takes its unit from the first of these the chart knows for the market,
never from the decimals the closes print:
- The bucket width of the
volume_profileinput named incells. A footprint and a profile always read one, so their auto stops here. - The block size the chart serves for the market (its TPO catalog, else its volume profile catalog), or, with no catalog, the venue's instrument tick when the chart holds one (CME futures).
- The median candle range divided by 4, snapped up to 1, 2 or 5 x 10^k.
- Otherwise 1.
Rungs 1 and 2 are multiplied by the chart's row table, the figures the chart's own footprint, TPO and session profile use; the range unit (rung 3) already scales with the interval, so it is used as is.
| Chart interval | 1m | 5m | 15m | 30m | 1h | 4h | 1d | 1w |
|---|---|---|---|---|---|---|---|---|
| Footprint | 2 | 5 | 8 | 10 | 15 | 25 | 50 | 250 |
| TPO | 5 | 10 | 15 | 20 | 25 | 30 | 50 | 100 |
| Profile | 5 | 10 | 15 | 20 | 25 | 50 | 100 | 200 |
A week TPO multiplies its figure by 1.5 and a month TPO by 2, rounded down (22 and 30 at 15m).
512 rows per period
512 rows is the most a TPO period (a day, a week or a month) holds before
the chart steps in. Past that it doubles the row height and folds again
until every period fits, at most 6 times, whether the height came from
"auto" or from a number.
The TPO still draws, and every run says so with a warning row in the
editor's Console and the warning on the indicator's legend chip, naming
the declaration and both heights:
tpo 'tpo': row_height auto coarsened from 1.5 to 12 (512 rows per day is the chart's limit)tpo 'tpo': row_height 1 coarsened to 8 (512 rows per day is the chart's limit)
The cap depends on how far the market's price moves, so only the running chart applies it; the build never refuses a row height for it. A number so fine that one bar alone spans more than 4,096 rows is still refused by name, and rows thinner than 3 px on screen merge as you zoom out (Limits).
BTC at 15m
TPO Letters declares no row_height and no
cells. On Binance's BTCUSDC perpetual at 15m the chart serves a 5-dollar
TPO block and the TPO figure at 15m is 15, so a row is 5 x 15 = 75
dollars, the row the chart's own TPO draws there. A day holds about 25 to
90 rows, each tall enough for its letters (a letter needs a row about 8 px
tall).
The price tick times the same figure would make 0.1 x 15 = 1.5-dollar
rows: every row under a pixel at the default zoom, no letter printed, and
48 times the rows to fold and paint (22,946 over 16 days against 476).
That is why "auto" reads what the venue serves and never the decimals
the closes print.
Strike matrices
plot.matrix(options) docks a table of numbers on the price axis: one
row per price, one column per expiry (or whatever the columns are), each
cell read from a frame the module writes on the live bar. It needs
abi_version: "wrun-4", which a frame declaration stamps for you.
const board = frame("board", { max_bytes: 65536 });
plot.matrix({ name: "gex", frame: board, dock: "right", column_width: 56, price_column: true, palette: ["theme.down", "theme.bg", "theme.up"], center: 0, format: "si", tooltip: "{{column}} {{value:si}} at {{price}}" });| Word | Value | What it does |
|---|---|---|
frame | a declared frame | the snapshot below; required |
dock | "right" (default), "left", "side" | against the price axis, or in the strip beside the chart |
columns | 1..12 names of 1..16 characters | the column labels (default the frame's cols, else "1", "2", ...); a frame with another column count refuses the run |
column_width | 24..160 px (default 56) | one column's width |
row_max_px | 8..64 px (default 24) | the tallest a row may grow |
price_column, header | booleans (false, true) | a price column on the axis side; the sticky header row |
palette, min, max, center, auto_quantile, scale | as a heatmap (no default palette) | the fill behind a numeric cell; without a palette a cell has no fill unless it carries its own colour |
opacity | 0..1 (default 1) | multiplies every cell fill |
format, decimals, signed, unit | the number words (default "auto") | how a numeric cell prints when it carries no text |
font_size, font_weight, font_family, align, text_color | 6..64 (10), the weight word, the family word ("ui"), "left", "center", "right" ("right"), a colour (theme.text) | the cell type |
header_, header_ | colours (theme.muted, theme.bg) | the header row |
background_, background_ | colour (theme.bg), 0..1 (0.85) | the backdrop behind the table |
grid_color, grid_width, grid_style, grid_lines | colour (theme.grid), 0..10 (1), "solid", "dashed", "dotted", "all", "rows", "cols", "none" | the lines between cells |
border_color, border_width, border_style | colour (theme.grid), 0..10 (0), the line style | the frame around the table |
cell_padding | 0..12 px (default 4) | the inset inside a cell |
highlight_color | colour (default theme.accent) | the outline of the frame's highlighted row and column |
tooltip, label | a template of at most 200 characters; 1..40 characters | the hover readout and its title; the template reads {{value}}, {{value:<format>}}, {{text}}, {{price}}, {{column}} and {{label}} |
A matrix can take chrome of its own. This board docks on the left, prints its header row in the accent colour on a dark fill, frames the table with a dashed border and draws lines between rows only:
const board = frame("board", { max_bytes: 65536 });
plot.matrix({ name: "oi_board", frame: board, dock: "left", header_text_color: "theme.accent", header_background_color: "#0f172a", border_color: "theme.grid", border_width: 1, border_style: "dashed", grid_lines: "rows" });The frame is a strict object:
{
"prices": [60000, 62000, 64000, 66000],
"cells": [
[1.2e6, -4.1e5, null],
[2.5e6, 8.0e5, "n/a"],
[
{ "value": -3.3e6, "text_color": "theme.down", "font_weight": "bold" },
1.1e6,
2.0e5
],
[4.0e5, 1.5e5, 9.0e4]
],
"cols": ["27JUN", "25JUL", "26SEP"],
"title": "GEX by expiry",
"highlight": { "price": 64000, "col": 0 },
"range": { "min": -5e6, "max": 5e6 }
}prices holds 1..128 numbers in strictly rising or falling order;
cells holds exactly one row per price, every row the same width (1..12
columns, at most 1536 cells in all); cols names the columns (1..16
characters each); title (1..40 characters) prints above the table;
highlight marks a price row and a column index (0-based); range moves
the palette bounds per run. A cell is null (empty), a number (printed
through format), a string of up to 16 characters, or an object with any
of value, text, color (the fill), text_color and font_weight. A
frame that breaks any of this refuses the run with
wrun_frame_invalid: <frame>.<path>, never a truncated table.
Strike Matrix builds the board from the
live options chain (Options kit).
The strip beside the chart
A matrix joins a strip to the right of the price axis with dock: "side", its rows still on the price pane's prices. The strip takes at
most 40% of the chart's width and folds away on a chart narrower than
480 px, where its items are not drawn. Matrices sit nearest the axis, one
column each; a column the strip cannot fit at its smallest size (24 px) is
not drawn. The strip has no pointer interaction yet: no hover cards there,
and no drag to resize it.
Panels do not use the strip: a place: "side" panel mounts below the
chart like every panel, and the chart offers it a window of its own
(Side placement and windows).
Its width_px (160..480) and height_px (80..800) stay accepted with
place: "side" only, reserved for a strip that panels do not use today.
What refuses
Every misuse is refused by name when Run derives the sheet: a fifth
heatmap ("heatmaps must declare at most 4 entries"), cells beside
grid, a value word of the other cell class, out.grid rows outside
2..128 ("out.grid 'heat' rows must be between 2 and 128"), a grid heatmap
without price_step, a footprint over book cells, a TPO color_mode of
"volume" without profile cells, letter_minutes of 0, span: "range"
without start, behind_candles on a visible-window profile, a matrix
under wrun-3 ("matrices needs abi_version "wrun-4" (wrun-3 has no frame
channel)"), 13 columns, width_px on a panel placed below. A frame that
fails its shape refuses the run instead, as wrun_frame_invalid. The caps
and ranges are on Limits.