Price canvases

View as MarkdownOpen the editor

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: book or volume_profile for a heatmap, volume_profile for a footprint or a profile, volume_profile or intrabar for a TPO (absent on a TPO means the chart's own candles). A cells word 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_family is "ui", "mono", "serif" or "rounded" (system fonts only, nothing downloads); font_weight is "normal", "medium" or "bold"; font_size is 6..64 px.
  • Numbers. format is one of price, %, si, int, 0, 0.0, 0.00, 0.000, usd, auto; decimals (0..8), signed and unit (0..8 characters) refine it and are refused without it ("decimals needs format"). price prints 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 a param.color or param.choice on 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.

wrun
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}}" });
WordValueWhat it does
cellsa book or volume_profile inputthe rows to paint: book levels, or profile buckets
valuebook "size" (default), "bid", "ask", "signed"; profile "total" (default), "buy", "sell", "delta"the number a cell carries; signed and delta make a diverging scale
gridan out.grid namethe module's own grid instead of cells (below)
price_low, price_stepoutput 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_heightnumber > 0with book cells: the row height in price (default the book's own grouping); refused on other cells
palette2..8 colours, low to highdefault ["theme.bg", "#2563eb", "#22d3ee", "#facc15"], or ["theme.down", "theme.bg", "theme.up"] when center applies
min, maxnumbers, min below maxthe ends of the scale (default from the data, below)
centernumberthe midpoint of a diverging scale; default 0 under value: "signed" or "delta", else absent; refused outside min..max when both are declared
auto_quantile0.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
floornumber >= 0 (default 0)a cell whose distance from the centre is at or under it draws nothing (so does an empty cell)
opacity0..1 (default 0.85)multiplies every cell colour's own alpha
behind_candlesboolean (default true)under the candles; false paints over them
cell_gap0..4 px (default 0)a gap between neighbouring cells
labelsboolean (default false)print each cell's value where the cell is tall and wide enough
font_size, font_weight, font_family, text_color6..64 (10), the weight word, the family word ("ui"), a colour (theme.text)the label type
format, decimals, signed, unitthe number words (default "auto")how a label and the readout print a value
tooltipa template of at most 200 charactersa hover readout over the cell; absent means no readout
label1..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:

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

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

wrun
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 });
WordValueWhat it does
cellsa volume_profile inputrequired; a book input is refused by name
mode"cluster", "split_bar" (default), "stacked_bar", "ladder", "imbalance_only"the column's drawing
buy_color, sell_colorcoloursdefault the chart's profile pair
row_heightnumber > 0 or "auto" (default)the row height in price; "auto" is the input's bucket width times the footprint's row table (Row height)
labelsboolean (default true)print the cell numbers
hide_zeroboolean (default false)leave zero cells blank
text_colorcolourthe cell ink (default the chart's footprint ink)
imbalance, imbalance_ratio, imbalance_buy_color, imbalance_sell_color, stacked_imbalancesboolean (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_widthboolean (true), colour (theme.text), 1..10 px (1)the point of control per column
value_area, vah, val, vah_color, val_color0.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_colorcolour (default "#4a90e2")the volume cell colour
grading"candle" (default), "session", "visible"the range the cell shading is scaled over
font_family, font_weightthe family and weight words (default the chart's indicator font)the type
cell_hoverboolean (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:

wrun
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).

wrun
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 });
WordValueWhat it does
cellsa volume_profile or intrabar input, or absentthe bars or buckets the letters are built from
period"day" (default), "week", "month"one profile per period
letter_minutes1..240 (default 30)the minutes one letter stands for
row_heightnumber > 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
palette2..26 coloursthe 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_color0.5..0.95 (0.7), booleans (true), colours (theme.text, theme.muted, theme.muted)the value area and its edges
outside_va_opacity0..1 (default 0.3)how far rows outside the value area fade
initial_balance, ib_colorboolean (true), colour (theme.text)the initial balance bracket
single_prints, single_prints_color, poor_extremes, poor_extremes_colorbooleans (false), colours ("#cc0033", "#ffd966")single prints and poor highs and lows
countsboolean (default false)the TPO count per row
volume_profileboolean (default false)a volume profile beside the letters; needs volume_profile cells
font_family, font_weight, cell_hoveras a footprintthe 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:

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

wrun
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 });
WordValueWhat it does
cellsa volume_profile inputrequired
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, endoutput names, epoch seconds read on the last ready rowthe 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_colorcoloursdefault the chart's profile pair
dock"left", "right"which edge of the span the bars grow from (default left, right on "visible")
width_frac0.05..1the bars' share of the span (default 0.7), or of the pane under "developing" and "visible" (default 0.2)
row_heightnumber > 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_area_shade, outside_buy_color, outside_sell_color0.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_colorbooleans (true), colours (theme.text), 1..10 px (1)the point of control and the value area edges
level_labelsboolean (default false)price tags on the three levels
nakedboolean (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_candlesboolean (default false)under the candles; needs span: "session" and a filled mode
background, background_color, background_opacityboolean (false), colour (theme.text), 0..1 (0.08)a box behind each session; needs span: "session"
font_family, font_weight, cell_hoveras a footprintthe 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:

wrun
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):

json
{
  "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:

  1. The bucket width of the volume_profile input named in cells. A footprint and a profile always read one, so their auto stops here.
  2. 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).
  3. The median candle range divided by 4, snapped up to 1, 2 or 5 x 10^k.
  4. 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 interval1m5m15m30m1h4h1d1w
Footprint25810152550250
TPO5101520253050100
Profile51015202550100200

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.

wrun
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}}" });
WordValueWhat it does
framea declared framethe snapshot below; required
dock"right" (default), "left", "side"against the price axis, or in the strip beside the chart
columns1..12 names of 1..16 charactersthe column labels (default the frame's cols, else "1", "2", ...); a frame with another column count refuses the run
column_width24..160 px (default 56)one column's width
row_max_px8..64 px (default 24)the tallest a row may grow
price_column, headerbooleans (false, true)a price column on the axis side; the sticky header row
palette, min, max, center, auto_quantile, scaleas 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
opacity0..1 (default 1)multiplies every cell fill
format, decimals, signed, unitthe number words (default "auto")how a numeric cell prints when it carries no text
font_size, font_weight, font_family, align, text_color6..64 (10), the weight word, the family word ("ui"), "left", "center", "right" ("right"), a colour (theme.text)the cell type
header_text_color, header_background_colorcolours (theme.muted, theme.bg)the header row
background_color, background_opacitycolour (theme.bg), 0..1 (0.85)the backdrop behind the table
grid_color, grid_width, grid_style, grid_linescolour (theme.grid), 0..10 (1), "solid", "dashed", "dotted", "all", "rows", "cols", "none"the lines between cells
border_color, border_width, border_stylecolour (theme.grid), 0..10 (0), the line stylethe frame around the table
cell_padding0..12 px (default 4)the inset inside a cell
highlight_colorcolour (default theme.accent)the outline of the frame's highlighted row and column
tooltip, labela template of at most 200 characters; 1..40 charactersthe 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:

wrun
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:

json
{
  "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.