Cards, frames and panels

View as MarkdownOpen the editor

Tables, cards, panels and widgets show one snapshot of your indicator rather than one value per bar: a styled table in a corner, a status card, a small chart in its own pane, a ladder, a feed or a meter.

Each is a declaration over outputs, string slots or frames the module already writes, and each selects one snapshot per run: levels docked on the price axis, a heatmap, a table or tiles in their own pane below the chart, and the widgets on the price pane. The per-bar vocabulary lives on other pages: marks, tints and fills on Plotting, coordinate drawings and handles on Drawing objects, the canvases drawn from cells (heatmaps, footprints, letter profiles, strike matrices) on Price canvases.

Every colour word on this page 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. A number prints through one format word (price, %, si, int, 0, 0.0, 0.00, 0.000, usd, auto) with decimals (0..8), signed and unit (0..8 characters) beside it (Styling).

Marks, tints and fills

The three per-bar visuals that sit beside a card, in one line each:

  • A shaped mark on some bars is render.shape(name, { output, shape, where?, color?, color_by?, colors?, width?, location?, glow?, fill?, fill_opacity?, char?, font_family?, tooltip? }), one of ten shapes (circle, cross, triangle_up, triangle_down with its apex down, diamond, arrow_up, arrow_down, flag, square, and char, one character in font_family) drawn at the output's value where the gate is nonzero, so the mark sits where the output points (low for below the bar, high for above, a level for an absolute price), or where location puts it ("above_bar", "below_bar", "top", "bottom") without a second output; width (px, any positive number) sizes it, glow halos it, fill: false keeps the outline only, fill_opacity fades the interior and tooltip is a template shown on the mark: Plotting.
  • A tinted candle is render.barcolor(name, { where, color?, color_by?, colors? }), body and wick, and a tint behind the bar is render.bgcolor with the same options; the two stack (a regime background under trend-colored candles). With width (0.5..10 px) and line_style a bgcolor draws one vertical line per gated bar instead of a band: Plotting.
  • A fill between two lines is a range() over two drawn outputs (edge_width: 0 keeps the band and drops the edge lines), a fill("upper", "lower", { color?, opacity?, color_by?, colors?, color_packed_by?, z? }) that tints the interior only on one pane, or a one-bar box on every bar, gated by when for a conditional fill: Plotting, Styling and Drawing objects.

Styled tables

render.table(name, { rows, cols, cells, position?, ...look, styles? }) draws a grid of words in a corner of the chart, and you style it in the same declaration: column widths, fills with opacity and gradients, a frame apart from the lines between cells, bold type, merged cells and rounded corners. Each cell is a string slot your file writes in onBar(), and the table shows the newest bar where every cell was written.

// A styled board in the top right corner: a title across both columns, then a trend row and a close row.
output("close_line", line, overlay, { color: "#94a3b8", description: "The close, drawn so the table has a chart" });
output("trend_state", none);
string("title", { max_bytes: 24 });
string("under_title", { max_bytes: 1 });
string("trend_label", { max_bytes: 16 });
string("trend_word", { max_bytes: 16 });
string("close_label", { max_bytes: 16 });
string("close_text", { max_bytes: 32 });

// The title spans both columns of row one, over the cell under_title holds. The label column is held at 90px and
// the value column is measured. Dotted lines sit between the rows; a solid, rounded frame sits around the table.
render.table("board", {
  rows: 3,
  cols: 2,
  cells: ["title", "under_title", "trend_label", "trend_word", "close_label", "close_text"],
  position: "top_right",
  width: 220,
  column_widths: [90, 0],
  cell_padding: 6,
  font_size: 11,
  align: "right",
  text_color: "#e2e8f0",
  background_gradient: ["#0f172a", "#1e293b"],
  background_opacity: 0.9,
  border_color: "#475569",
  border_width: 1,
  corner_radius: 8,
  grid_color: "#334155",
  grid_width: 1,
  grid_style: "dotted",
  grid_lines: "rows",
  styles: [
    { cell: "title", colspan: 2, align: "center", font_weight: "bold", gradient: ["#1d4ed8", "#0f172a"], gradient_direction: "horizontal" },
    { cell: "trend_label", align: "left" },
    { cell: "close_label", align: "left" },
    { cell: "trend_word", font_weight: "bold", color_by: "trend_state", colors: ["#7f1d1d", "#14532d"], opacity: 0.5 },
  ],
});

let average = new Sma(20);

function onBar(): void {
  const close = bar.close();
  if (isNaN(close)) return;
  const mean = average.update(close);
  const up = !isNaN(mean) && close > mean;
  out_close_line(close);
  out_trend_state(up ? 1.0 : 0.0);
  str_title("Trend board");
  // A covered cell is never drawn, but the table waits for a bar that wrote every cell, so write it empty.
  str_under_title("");
  str_trend_label("Trend");
  str_trend_word(up ? "up" : "down");
  str_close_label("Close");
  sb_clear();
  sb_auto(close);
  str_close_text_sb();
}
BTCUSDT perpetual on Binance, 1 hour bars, Aug 10 to Aug 18, 2026Real output from OpenMarket's engine
  • The title spans both columns. colspan: 2 on the title cell covers the cell to its right. That cell keeps a slot of its own, under_title, written empty every bar: a covered cell is never drawn, but the table still waits for a bar that wrote all six cells.
  • Two gradients. The table blends top to bottom at 90% opacity; the title cell has its own gradient, left to right.
  • A frame and a grid. border_* is the line around the table, here solid with rounded corners. grid_* is the lines between cells, here dotted and between rows only.
  • A cell that follows the data. The trend cell's fill is picked by the trend_state output every bar: 0 is the first colour, 1 the second.

Table options

Every key is optional except rows, cols and cells.

KeyWhat it setsValues
rows, cols, cellsthe grid, and one slot name per cell, row by rowat most 32 rows and 8 columns; rows * cols names
position, offsetthe corner, and a nudge from itone of the nine anchors, top_left to bottom_right; [x, y] in px, each within 200
width, column_widthsthe table's width, and each column'spx, up to 4096; one width per column, 0 fits the words, and spare width goes to the 0 columns
header_rows, header_row, header_columnwhich rows and column are headersheader_rows up to rows; header_row is the one-row switch; header_column makes the first column a header
font_size, font_family, font_weightthe type6 to 64 px; a font name; "normal" or "bold"
cell_padding, align, valignthe space and the alignment in every cell0 to 64 px; "left", "center", "right"; "top", "middle", "bottom"
text_color, header_text_colorthe text coloura colour
background_color, header_background_color, background_opacitythe fills, and how solid they area colour; opacity 0 to 1
background_gradient, gradient_directiona fill that blends from colour to colour2 to 8 colours; "vertical" (the default) or "horizontal"
border_color, border_width, border_style, corner_radiusthe frame around the tablewidth 0 to 10; "solid", "dashed" or "dotted"; corners 0 to 32 px
grid_color, grid_width, grid_style, grid_linesthe lines between cellswidth 0 to 10; the three styles; "all", "rows", "cols" or "none"
position_by + positions, font_size_bya corner or a type size an output picks per bara list of anchors; an output holding the size
rows_byhow many rows paint: a table whose rows come and goa data-only output; its value on the table's bar, rounded down and held to 0..rows, is the count of rows painted from the top, the rest left out
stylesone entry per styled cell, named by its slot{ cell, ... } with the cell keys below

The cell keys, inside a styles entry:

KeyWhat it sets
colspan, rowspanhow many columns to the right, and rows below, the cell covers
color, opacitythe cell's fill, and how solid it is
gradient, gradient_directionthe cell's own gradient (2 to 8 colours), and its own direction
text_color, font_size, font_weightthe cell's type
align, valignthe cell's alignment
tooltipthe cell's hover text: a string slot, its words on the table's bar

A table sized for its longest form can paint fewer rows, and a cell can explain itself on hover:

wrun
output("rows_shown", none); // 3 while the extra rows are on, else 2
string("label", { max_bytes: 16 });
string("value", { max_bytes: 16 });
string("note", { max_bytes: 96 });
// ... one slot per cell of rows 2 and 3 ...
render.table("board", { rows: 3, cols: 2, cells: ["label", "value", "a2", "b2", "a3", "b3"], rows_by: "rows_shown", styles: [{ cell: "value", tooltip: "note" }] });

Colours that follow the data

Every colour key takes a ladder, the way render.bgcolor does. Name an output with <key>_by, and its value on the table's bar picks an entry of <key>s: color_by: "trend_state", colors: ["#7f1d1d", "#14532d"]. The value is rounded down, a value outside the list picks the first entry, and NaN keeps the static colour. An output that holds a packed colour is named with <key>_packed_by instead. A colour reads one ladder, never both.

Colours in a table are literals. A param.color paints an output, a renderer's static colour or a legend entry, never a table.

A second board, with a header row and a tinted trend cell, in one literal:

output("close_line", line, overlay, { color: "#94a3b8", description: "The close, drawn so the table has a chart" });
output("trend_state", none);
string("tf_label", { max_bytes: 16 });
string("trend_word", { max_bytes: 16 });
string("close_label", { max_bytes: 16 });
string("close_text", { max_bytes: 32 });
// A 2 x 2 board: a bold label column held at 80px, numbers right aligned, hairline row separators,
// and the trend cell tinted by the trend output every bar (index 0 = down, 1 = up).
render.table("board", { rows: 2, cols: 2, cells: ["tf_label", "trend_word", "close_label", "close_text"], position: "top_right", width: 220, column_widths: [80, 0], header_rows: 1, cell_padding: 5, font_size: 11, align: "right", background_color: "#0f172a", background_opacity: 0.92, text_color: "#e2e8f0", header_background_color: "#1e293b", border_color: "#334155", border_width: 1, grid_color: "#1e293b", grid_width: 1, grid_lines: "rows", styles: [{ cell: "tf_label", align: "left", font_weight: "bold" }, { cell: "close_label", align: "left" }, { cell: "trend_word", color_by: "trend_state", colors: ["#7f1d1d", "#14532d"], opacity: 0.35 }] });

let average = new Sma(20);

function onBar(): void {
  const close = bar.close();
  if (isNaN(close)) return;
  const mean = average.update(close);
  out_close_line(close);
  const up = !isNaN(mean) && close > mean;
  out_trend_state(up ? 1.0 : 0.0);
  str_tf_label("Trend");
  str_trend_word(up ? "up" : "down");
  str_close_label("Close");
  sb_clear();
  sb_auto(close);
  str_close_text_sb();
}
BTCUSDT perpetual on Binance, 1 hour bars, Aug 10 to Aug 18, 2026Real output from OpenMarket's engine

Limits and refusals

RuleWhat the build says
one width per columnrender.table 'board' column_widths lists 1 widths for 2 columns (one per column; 0 = measured)
a span stays inside the gridtable 'board' cell 'title' spans 3 columns from column 1 of 2
a slot that spans must fit at every cell that lists it, so a covered cell needs its own slottable 'board' cell 'title' spans 2 columns from column 2 of 2
a style names one of the table's cellsrender.table 'board' styles names cell 'ghost', which is not one of its cells (title, under_title, trend_label, trend_word, close_label, close_text)
rows_by names a data-only outputrender.table 'board' rows_by names 'close_line', which is a drawn output; rows_by takes a data-only output (plot none) holding how many rows paint
a cell's tooltip names a string slotrender.table 'board' styles cell 'title' tooltip names 'ghost', which is not a declared string slot (the cell's hover text is the slot's text)
a ladder comes as a pairtable 'board' cell 'trend_word' declares color_by without colors (both or neither)
colours are literalsrender.table 'board' text_color references a param, but a param paints an output, a renderer's static colour or a legend entry, never a table; write a colour literal here
a gradient has 2 to 8 coloursgradient must list at least 2 color stops
opacity is 0 to 1opacity must be <= 1
corners are 0 to 32 pxcorner_radius must be <= 32
words come from the listoption 'grid_lines' takes "all", "rows", "cols", "none" (a string literal), not 'diagonal'

The words in a cell are plain text: never a link, never markup. A table declared without the look keys draws the chart's plain table. The chart draws tables with its own table engine, so a styled table shows in screenshots like every other plot. For inline bars and sparklines in a table under the chart, declare a panel.table over a frame instead (Frames, panels and compact widgets, below); it takes the same look words, and up to 128 rows by 12 columns.

Status cards

draw.card(name, { title, anchor?, offset?, z?, state_by?, rows, headline?, ...look }) declares a run-level status card in drawings[], available from abi_version: "wrun-2". Names share the output, renderer, drawing, box and segment namespace. A sheet accepts at most 8 cards within its 64-drawing limit; each card has 1 to 12 rows, or 0 to 12 when it has a headline. A pane holds 32 cards, feeds and meters across every indicator.

title takes 1 to 40 characters, 80 when it holds a {{template}}; each row's label takes 1 to 24, 64 with a template. A template reads a declared output as {{name}} (its declared format, else six significant digits) or {{name:format}}, a string slot as {{name}}, and the chart's names as {{symbol}}, {{exchange}} and {{timeframe}}; an unknown name stays as written, and a templated output joins the card's complete-row rule below. anchor uses the same nine positions as label renderers: top_left, top_center, top_right, middle_left, middle_center, middle_right, bottom_left, bottom_center, bottom_right. Defaults are top_right, offset: [0, 0] and z: 0. Each offset is an integer from -4096 to 4096; z is an integer.

Each row has an optional value: a literal string of 0 to 64 characters, { text: "slot_name" }, or { output: "output_name", format?, decimals?, signed?, unit? }. Output references always name declared numeric outputs; text always names a declared string slot. Slot contents retain their declared byte limit. The format words are the shared ten plus pct (the value times 100, two decimals and %); without one a value prints at six significant digits. usd prints a $ with two decimals under 1,000 and $1.2K, $1.2M, $1.2B, $1.2T above; auto prints six significant digits with , thousands; price prints at the chart's price digits; decimals overrides the word's digits, signed puts a + on positives, unit is appended as written. A row that declares usd, auto, int or pct with no decimals, signed or unit keeps the text it always printed ($1234567.00, six significant digits with no thousands separators, the rounded integer, the value times 100 at two decimals), so an older card reads exactly as before; the shared text above applies to the other words and to any row that declares a companion.

A row's color is a colour word or an object containing both color_by and colors, with at least two colour words. The output's floored value picks a palette entry; a finite index outside the palette selects entry 0. countdown_to: { output: "deadline" } carries the numeric output as raw epoch milliseconds, without converting it to seconds. clock: true asks the chart to show its clock. Both may appear beside a value.

A headline is one big number above the rows: { output | text, format?, decimals?, signed?, unit?, color?, font_size?, font_weight?, align? }, exactly one of output or text, the format and colour words as a row's, font_size 6..64 (default 24), font_weight "bold" unless declared, align "left", "center" or "right". rule draws a hairline between the headline and the rows (default on when both exist) in rule_color (default the label ink at 0.4 alpha).

The look, every key optional, absent keeping today's card:

KeysWhat they set
chrome"state" (the default: the state word, the stripe, the border in the state colour) or "plain" (no state word, no stripe, no border, a muted title, controls on hover); any key below declared beside it wins
show_state, state_colors, stripe, controlsthe state word; four colours for ok, armed, fired and error (exclusive with accent_color); the 3 px stripe; the hide and collapse glyphs "always", "hover" or "none" (with "none" the glyphs stay hidden at rest, and hovering the card still shows them, so a viewer can always hide or collapse it)
accent_color, title_text_color, title_font_size, title_font_weightthe accent (default the state ink), the title's ink (default the accent), size and weight
background_color, background_opacity, background_gradient, gradient_directionthe surface (default the theme card surface), its alpha (0..1), 2..8 stops "vertical" or "horizontal"
border_color, border_width, border_style, corner_radius, paddingthe border (default the accent, 0..10 px, "solid", "dashed" or "dotted"), the corners (0..32 px, default 6), the inset (0..24 px, default 6)
width, opacitya fixed width (80..1200 px; absent measures the text under a 40% cap, a declared width drops the cap and clamps to the pane width minus 16), the whole card's alpha
font_size, font_family, font_weight, text_color, label_text_colorthe type (6..64 px, default 12; "ui", "mono", "serif" or "rounded"; "normal", "medium" or "bold"), the value ink (default the theme text) and the label ink (default the theme muted ink)
above_drawingspaint over every handle and user drawing, still under the legend (default under them)
safe_areastart the offset past the chart's own chrome: the legend stack at top_left, the pane action bar at top_center and top_right, the price-axis tags on the right anchors (default the pane's 8 px edge)
panel"overlay" (the price pane) or "lower" (the indicator's own lower pane; an indicator with none keeps the card on price)

The newest ready bar with every referenced numeric output finite and every referenced slot present wins. Empty strings count as present. If no bar is complete, the newest ready bar still supplies the card: missing values become "", missing colours and countdowns are omitted, and missing state reads ok. With no ready bar the selection is { kind: "card", name, card: null }. state_by maps exactly 0 to ok, 1 to armed, 2 to fired, and 3 to error; other values read ok.

rows stays a literal object array, including the nested value, colour and countdown objects. References are literal names, never handles; variables, spreads and expressions are refused. Run reads these options and removes the declaration before AssemblyScript typechecks the file; no card accessor is needed. This declaration assumes the named outputs and string slot have already been declared:

wrun
draw.card("session_pnl", {
  title: "{{symbol}} session",
  anchor: "top_left",
  safe_area: true,
  chrome: "plain",
  headline: { output: "pnl", format: "usd", signed: true, color: { color_by: "pnl_side", colors: ["theme.down", "theme.up"] } },
  rows: [
    { label: "Trades", value: { output: "trades", format: "int" } },
    { label: "Win rate", value: { output: "win_rate_pct", format: "%", decimals: 1 } },
    { label: "Status", value: { text: "status_text" } },
    { label: "Next close", countdown_to: { output: "close_at_ms" } },
  ],
  background_color: "#0f141bf2",
  corner_radius: 10,
  width: 220,
});

draw.feed and draw.meter take the same look, placement and panel words, plus a title; a feed also takes time_format, a meter bar_height and track_color, and a ladder its own list (Frames, panels and compact widgets, below).

Cards share the selection's 8 MiB expanded-result budget. Accounting is 16 bytes per card and per card row, 8 per carried number (the two offsets, z, and each countdown), plus the UTF-8 bytes of every carried string: kind, name, title, headline, state, anchor, row labels, resolved values and colours. Exceeding the budget refuses with wrun_render_result_too_large.

Everything rolls back correctly

Markers, tints and bands are per-bar records over outputs, so they follow the engine's forming-bar rule: the chart snapshots the module after the last closed bar and replays the revised forming bar from that snapshot as live updates arrive, and that bar's marks, tints, and slices are replaced, never stacked. Cards replace their selected snapshot on each run. Handle drawings roll back the same way: the chart keeps the handles beside the module state, puts both back before each replay, and re-runs the closed bar once more when it closes, so a handle the forming bar created or moved is never duplicated by an update (Execution model).

Every primitive in one module

A marker on each bullish and bearish cross, a background tint by the sign of the delta, a shaded band, and a two-cell dashboard, over one moving-average pair.

param("fast", 9, { min: 1, max: 200 });
param("slow", 21, { min: 2, max: 400 });
const fast = output("fast", line, overlay, { color: "#2563eb", width: 2, description: "Fast average" });
const slow = output("slow", line, overlay, { color: "#94a3b8", width: 1, description: "Slow average" });
output("high", none, overlay, { description: "The bar high: where a bearish mark sits" });
output("low", none, overlay, { description: "The bar low: where a bullish mark sits" });
output("bullish", none, overlay, { description: "1 on a bullish-cross bar" });
output("bearish", none, overlay, { description: "1 on a bearish-cross bar" });
output("delta_bucket", none, overlay, { description: "0 fast below slow, 1 fast above: the tint ladder index" });
string("regime_text", { max_bytes: 8 });
string("delta_text", { max_bytes: 16 });
// Markers: an arrow below the bar on a bullish cross, one above it on a bearish cross.
render.shape("buy_mark", { output: "low", shape: "arrow_up", where: "bullish" });
render.shape("sell_mark", { output: "high", shape: "arrow_down", where: "bearish" });
// Bar color: a background tint by regime; index 0 is red, index 1 green, on every bar.
render.bgcolor("regime_tint", { where: "fast", color_by: "delta_bucket", colors: ["#ef444418", "#22c55e18"] });
// Fill between: a slice per bar between the two averages.
box("ribbon", { top: fast, bottom: slow, color: "#2563eb", opacity: 0.1, borderWidth: 0 });
// A 1x2 dashboard: regime word, delta value.
render.table("dashboard", { rows: 1, cols: 2, cells: ["regime_text", "delta_text"], position: "top_right" });

let fastSma = new Sma(9);
let slowSma = new Sma(21);
const cross = new Cross();

function onStart(): void {
  fastSma = new Sma(i32(p_fast()));
  slowSma = new Sma(i32(p_slow()));
}

function onBar(): void {
  const close = bar.close();
  const fastValue = fastSma.update(close);
  const slowValue = slowSma.update(close);
  const crossed = cross.update(fastValue, slowValue);
  if (isNaN(slowValue)) return;
  const above = fastValue >= slowValue;
  out_fast(fastValue);
  out_slow(slowValue);
  out_high(bar.high());
  out_low(bar.low());
  out_bullish(crossed == 1 ? 1.0 : 0.0);
  out_bearish(crossed == -1 ? 1.0 : 0.0);
  out_delta_bucket(above ? 1.0 : 0.0);
  str_regime_text(above ? "long" : "short");
  sb_clear();
  sb_f64(fastValue - slowValue, 2);
  sb_text(" delta");
  str_delta_text_sb();
}
BTCUSDT perpetual on Binance, 1 hour bars, Aug 10 to Aug 18, 2026Real output from OpenMarket's engine

regime_tint gates on fast, which is finite on every ready bar, so the tint appears everywhere and the ladder picks the color; gate on a 0/1 output instead to tint only some bars. The 8-digit hex colors carry the tint's alpha.

Frames, panels and compact widgets

Use wrun-4 frames for one JSON snapshot that survives the whole run. Declare frame("book"), then write it from onBar(): a frame rebuilt on every bar or live tick is built in the generated frame buffer with fb_clear(), fb_text(s), fb_str(s), fb_int(n), fb_f64(x, decimals) and fb_num(x) and sent with writeFrameBuffer(FRAME_BOOK), allocation-free; a small frame the module already holds as a string goes through writeFrame(FRAME_BOOK, json). Each frame holds its last write, up to 96 KiB; up to eight frames share a 2 MiB transport budget with strings.

DeclarationSnapshot
plot.levels({ name, frame, dock, width_frac?, poc?, labels?, color?, span?, ...style }){ prices, values, colors?, ...fields }; 1..512 monotonic prices and equally sized values, null for gaps; with span: "time", { spans: [{ start, end, prices, values, colors? }] }, 1..64 spans and at most 4096 rows in all
panel.bars, panel.line, panel.scatter, panel.histogram, panel.pie, panel.heatmap, panel.table, panel.tiles{ rows, ...chrome }; kind-specific tuples, at most 2000 rows (a table 128), empty allowed; a chrome field beside rows overrides the declared word for that run
plot.matrix({ name, frame, dock?, columns?, ...style }){ prices, cells, cols?, title?, highlight?, range? }; 1..128 prices, one cell row per price of 1..12 columns, at most 1536 cells (Price canvases)
draw.ladder({ name, frame, side, divider?, title?, ...style }){ rows, divider?, title? }; 1..256 price/value/fraction/color rows
draw.feed({ name, frame, anchor?, offset?, z?, title?, time_format?, ...style }){ lines, title? }; 1..50 millisecond-time/text/color rows
draw.minicharts({ name, frame, anchor?, x?, y?, columns?, panel_width?, panel_height?, gap?, ... }){ title?, panels }; 1..12 labelled panels of 0..100 candles

Levels default to a width fraction of 0.12.

Every colour in a declaration or a frame takes #rrggbb, #rrggbbaa or a theme token (theme.up, theme.down, theme.text, theme.muted, theme.bg, theme.grid, theme.accent), resolved by the chart at paint time and again on a theme switch. The levels style words (placement, bars, labels, the point of control, hover and the frame fields they read) are on Docked profiles.

Panels declare name, title, x (time, index, number, category), place (below, side) and frame. Bars, line and table also declare series (1..8, a table 1..12), each { name, color? } plus its own look. Histograms and heatmaps use category x; a number x takes decimal row keys (a price grid) on a line, bars or scatter. An unwritten frame leaves its consumer absent; a malformed written frame refuses with wrun_frame_invalid.

Panel words

Every word is optional and scoped by kind: a word on a kind it does not list refuses by name ("<word> is only valid for "). Absent words keep the chart's look, except font_family, whose default "ui" is the app font (the four words are "ui", "mono", "serif", "rounded").

KindWords
every kindheight_frac (0.05..0.9, the pane's share of the chart), maximize (the legend's fullscreen button), font_family, format with decimals (0..8), signed and unit (1..8 characters) for every value the panel prints
line, bars, histogram, scatter, piechrome ("box", "grid" or "none"; pie "box" or "none"), hover_card (a readout under the pointer, default on; not on pie or table), legend_style ("none", "title", "pane", "chips" on a line; bars drop "chips"; scatter and pie take "none" or "title"), x_title, y_title (1..40), y_min, y_max (the value axis), y_zero (line and scatter), x_min, x_max (a line with x index or number, scatter)
line, bars, histogram, scatterbadge ({ text: 1..24, color?, text_color? }): a chip on the panel's title row, filled theme.accent unless color says otherwise, its text in whichever of light or dark reads on the fill unless text_color picks one
linestats_row (with maximize), smooth, points, labels (each series' last value at its end), glow, animate, legend_latest (with legend_style: "chips"), fill_mode ("flat" or "signed"), fill_positive_color, fill_negative_color, fill_fade, stroke_fade (`{ pivot: number
a line seriesfill, fill_color, style ("line", "bars", "step"), smooth, points, labels, width (0.5..20), line_style, legend
barscolor_mode ("series" or "sign"), positive_color, negative_color, x_format, x_decimals, x_unit (with x index or number); a series legend
histogramcolor, labels (each bin's count), bins (2..200 slots reserved on the axis)
scattercolor, guides (0..8 of `{ axis: "x"
piehole_total, hole_caption (1..24; both need hole > 0), slice_gap (0..8 px), border_color, border_width (0..10)
heatmapscale ("palette" or "signed"), palette (2..8 colours), min, max, highlight ({ row?, col? }), row_title, col_title (1..24), positive_color, negative_color
tilescolumns (1..8), accent ("auto" or "neutral"), positive_color, negative_color
tablethe styled-table words (position, offset, width, column_widths, cell_padding, font_size, font_weight, align, valign, text_color, header_text_color, background_color, background_opacity, background_gradient, gradient_direction, header_background_color, border_color, border_width, border_style, grid_color, grid_width, grid_style, grid_lines, header_column) with the same ranges; a series align, format, decimals, signed, unit (the column's)

A colour word of a panel declaration may name a setting instead of a colour: "@<name>" for a declared param.color binds every colour word above: a series' color and fill_color, positive_color and negative_color, the fill_*_color pair, a heatmap's palette entries, a histogram's or a scatter's color, the badge, marker, guide and quadrant colours, the stroke fade's inks and the pie and table colours. The build writes the setting's default in its place, so the sheet reads as if you had written that colour, and the chart paints the trader's pick there before every run; the setting's row is the colour's one control (The Style page). A reference to anything but a param.color is refused by name (panel.bars 'flow' positive_color references "@side", which is not a param.color), and an @ in a title or a series name stays text. A colour inside a frame's rows (a pie slice, a scatter dot, a tile, a table cell) is run data: the module reads its param.color and writes the colour into the row.

wrun
param.color("bid_ink", "#22c55e", { label: "Bids" });
param.color("ask_ink", "#ef4444", { label: "Asks" });
panel.line({ name: "depth_curve", title: "Cumulative depth", x: "number", place: "below", frame: depth, chrome: "grid", series: [{ name: "Bids", color: "@bid_ink", fill: true, fill_color: "@bid_ink" }, { name: "Asks", color: "@ask_ink" }] });

The rich words of a line (smooth, points, glow, animate, the fills, the stroke fade, markers, stats_row, legend_latest, hover_card, legend_style: "chips", a series fill_color, style: "step", smooth, points or labels) need a chrome of "grid" or "none": the declared word, else "grid" on an unstacked index or number line, else "box". A time line with chrome: "grid" spaces its points by time and labels the axis in the chart's display timezone.

A rich line panel (a chrome of "grid" or "none", or a number or index x) keeps its own value axis on the left, so the pane's right-hand value strip stays blank: the strip belongs to series that share the chart's time axis, and a curve over strikes or distances does not. An indicator's panels also stack above its time-series panes: the panes that read the time axis sit at the bottom, next to the time axis, and the panels sit above them, each group in declaration order.

Panel frames

Beside rows, a frame may carry per-run chrome: title (1..40) and caption (1..64) on every kind; badge ({ text, color?, text_color? }) on line, bars, histogram and scatter; markers, x_min, x_max, y_min, y_max on a line; y_min, y_max on bars and histogram; x_min, x_max, y_min, y_max, guides, quadrants on scatter; min, max, highlight and summary (0..5 of { label, values }, the values following the columns) on a heatmap; hole_text and hole_caption (1..24) on a pie. A frame field overrides the declared word of the same name for that run, markers replaces the declared list and badge replaces the declared chip (a frame without the key leaves the declared chip standing); any other key beside rows refuses by name. Frame markers take the same words as declared ones, valign: "point" with y or series included, so a module can point at the peak it just found.

Rows take colour words wherever they took a hex colour: a scatter row's colour, a pie slice's, a tiles row's, a table cell's. A bars value may be { value, color? } to colour one bar. A tiles row may end with its own format word ([label, value, caption?, color?, spark?, format?], with null to skip an element). A table cell may be a styled object with text, value (printed through the column's or the table's format when text is absent), color, bar, spark, background_color, opacity, gradient, text_color, font_size, font_weight, align, valign, colspan and rowspan; a table takes 128 rows and 12 columns, the other kinds 2000 rows.

Side placement and windows

place: "side" marks a panel as side content. On a chart it mounts below the candles like every other panel, in a pane of its own with the same options. What it adds is a window: the chart offers an Indicator with side content a cell of its own, where the candles are hidden and the Indicator's panels, tables and lower pane fill the whole height. The window keeps the market and timeframe of its cell; that market is still the file's first input and its clock, so the Indicator runs exactly as it does on a chart.

How a reader opens one:

  • The window icon beside the Indicator's name in the legend. It shows while the sheet places any panel side; once the reader has seen or used it, it moves in with the row's hover buttons.
  • The legend menu: Open as window (a new cell beside the chart, which keeps its Indicator), Show as window here (this cell becomes the window) and, on a window, Show chart.
  • Add as window in the Indicators picker, on a package page and in search.

In a window the header carries the Indicator's name, its settings and its menu; the market chip still switches the market and the Indicator runs again. Drawing tools, price alerts and replay are off there. An Indicator with nothing to show beside the candles (price-pane plots only) gets a "Nothing to show as a window" card with Show chart. Removing the Indicator from a window that was opened into its own cell closes that cell and the layout returns to what it was; a chart turned into a window in place comes back as a chart.

The window sizes the panes itself: tables take the height their rows need (together at most half the window), the other panels and the lower pane share the rest. width_px (160..480) and height_px (80..800) are accepted only with place: "side" and are reserved for a strip beside the price pane that panels do not use today; the strip beside the price axis is for dock: "side" matrices (Price canvases).

A window also remembers and answers:

  • The pointer on a window's index or category panes (a curve over strikes, a bars panel): a guide snaps to the nearest key with a dot per series, a readout shows the values there, and the legend values follow the key; leaving the pane shows the last row again. tooltipEnabled: false removes the card only.
  • The split a reader drags between a window's panes is remembered across reloads for that Indicator; it resets when the Indicator's set of panes changes.
  • Closing a window cell can be undone: Ctrl+Z / Cmd+Z brings the cell back where it was, with the Indicator.
  • A draft you are editing can open as a window: press Open as window beside Run in the editor; every later Run lands in that window, and stopping the draft or closing its tab closes the cell. A draft window is not saved: after a reload the cell is an empty chart.

draw.meter({ name, label, fraction: { output }, ramp, text?, anchor?, offset?, z? }) reads the last ready numeric fraction. Ramp has 2..5 colour words; text is a short literal or { slot }. A card row's spark: { output, window } carries its last 2..64 ready values, oldest first.

A ladder, feed or meter takes a title (1..40 characters; a ladder or feed frame may carry its own "title" per run, and a meter title takes the card templates), the card surface and chrome words (the background, border, corner_radius, padding, width, opacity, the type words, chrome, stripe, controls, accent_color, above_drawings, safe_area and panel, with the card's ranges) and an offset of -4096..4096 px. A ladder also takes width_frac (0.02..0.5, default 0.14), offset: [x, 0] (x 0..4096 px inward from the axis), opacity, color (the row default), labels, format, decimals, signed, unit (the value text), text_color, font_size, font_family, font_weight and divider_color; its frame carries 1..256 rows with colour words. A feed takes time_format ("HH:mm:ss", "HH:mm", "MM-dd HH:mm" or "none"); a meter takes bar_height (2..40 px) and track_color, and its ramp takes colour words.

draw.minicharts({ name, frame, ... }) pins a grid of up to 12 small candle charts to the pane, the grid a multi-timeframe matrix draws. Its options: anchor (default top_right) and x, y pixel offsets from it (-2000..2000, default -16 and 48), z, columns 1..4 (2), panel_width 72..320 (128), panel_height 48..220 (72), gap 0..48 (8), the colours background_color, border_color, text_color, bull_color, bear_color and wick_color, candle_style (candles, hollow, bars, line, area), two moving averages (ma_fast_length, ma_slow_length, each drawn only when given, with ma_fast_color, ma_slow_color, ma_width 0.5..5 and the show_fast_ma / show_slow_ma switches) and show_change / show_volume for the change badge and the volume strip, which the chart computes from the candles. Every option takes a literal. The colours, the switches, the average lengths, ma_width and candle_style also take an "@<setting>" reference, so a setting changes that option: a param.color for a colour, a param.bool for a switch, a param.int with min 1 or more for a length, a param.number or param.int within 0.5 to 5 for ma_width, a param.choice over the style words for candle_style. The setting's default draws until it is changed, and one setting may drive several options. The frame carries the title and each panel's label and candles, [time_ms, open, high, low, close, volume] oldest first, volume null for none. Build it with the grid's writers: mc_begin(title) clears the frame buffer and opens the grid, mc_panel(label) opens a panel, mc_bar(openSec, open, high, low, close, volume) appends a candle (its open in epoch seconds), mc_candles(label, list, bars, formingSec, open, high, low, close, volume) writes a whole panel from a CandleList of closed candles plus the forming candle (NaN for none; it is drawn last when it opens after the newest closed one, bars candles at most), and writeMiniCharts(FRAME_GRID) sends it. mc_leg(label, list, bars, in_x_view(), in_x_cells(), bar.time()) reads that forming candle off a candles input declared with view: "forming", the timeframe's live candle on the live bar. A title of {{symbol}} reads as the chart's market. A grid with no reference gets settings of its own: the look's colours, candle style, averages and switches ("Panel background", "Bull candle", "Fast MA length", "Candle style", "Show volume", ...), each defaulting to the value written here; write colours as "#rrggbb" or "#rrggbbaa".

out.inset("vol", { dock: "bottom", height_px: 28, shape: "histogram" }) declares an ordinary numeric output in a compact strip. Every ready row appears in its history. Insets work on every ABI. An inset also takes color, colors ([up, down] by sign, or a ladder beside color_by) and opacity.

The build extracts these object declarations before AssemblyScript type checking. frame() returns its slot index; generated FRAME_<NAME> constants provide the same index. The frame builder exists only when frames are declared. Its UTF-8 scratch is one StaticArray sized to the largest declared max_bytes, reserved during module initialization and reused by every fb_* append and by writeFrame, so repeated frame writes never grow guest memory; the string handed to writeFrame is the caller's own allocation, which is why a frame rebuilt per bar builds through fb_* instead. fb_text appends raw JSON text (punctuation, keys, words), fb_str a quoted and escaped JSON string, fb_int an integer, fb_f64 a number with a fixed decimal count, fb_num a double's shortest spelling; fb_f64 and fb_num write null for a non-finite value, JSON's gap. Both senders pass the bytes the frame requires, so an oversized frame refuses host-side by the slot's max_bytes, never truncated. The caps an author meets are on Limits.

Pane pixel placement

Line, box, label and polyline handle declarations accept anchor as the default for newly created handles: handles.line({ anchor: "top_left" }). Absent means chart coordinates. The nine pane spots are top_left, top_center, top_right, middle_left, middle_center, middle_right, bottom_left, bottom_center, bottom_right; both coordinates become CSS pixel offsets. top and bottom change only y; x remains chart time. left and right change only x; y remains chart price.

Offsets start at the pane's content rectangle with no extra inset. Left and top measure inward to the right and down; right and bottom measure inward to the left and up. Centre offsets are signed, and negative offsets are allowed everywhere. The renderer scales pixels once by device pixel ratio and clips every drawing to its own pane. Time culling applies only when x remains chart time. safe_area: true on a handle declaration (handles.box({ anchor: "top_right", safe_area: true })) starts the offsets past the chart's own chrome instead: the legend stack at the top left, the pane action bar at the top centre and right, the price-axis tags on the right.

After a handle's set, call .anchor(ANCHOR_TOP_LEFT) or style.anchor(handle, ANCHOR_TOP_LEFT) with an ANCHOR_* constant. ANCHOR_CHART restores chart coordinates and clears a declared default until the handle is recreated. These helpers go through style with prop 7, integer values 0..13. Invalid values refuse as wrun_draw_style_out_of_range.

Label handles also take align: which edge of the text sits on x, with (x, y) staying the anchor point. handles.label({ align: "left" }) is the default for new labels; left starts the text at x, right ends it there, center or an omitted word centres it as before. After a label's text, call .align(ALIGN_RIGHT) or style.align(label, ALIGN_RIGHT) with an ALIGN_* constant; ALIGN_DEFAULT restores centred text and clears a declared default. These use prop 8, integer values 0..3, on labels and box text (a box takes align, valign and padding too); other kinds refuse as wrun_draw_prop_unsupported.

Docked profiles

plot.levels({ name, frame, dock, ... }) draws a profile docked on the price axis from a frame the module writes each run: one bar per price, its length the row's value. The required words and today's optional ones (width_frac, poc, labels, color) keep their meaning; everything below is opt-in, erased before the file compiles, and takes "@<param>" on any style key so the Style page can change it without a rerun of your code (The Style page).

Where the profile sits:

WordValueWhat it does
panel"overlay" (default), "lower"on the price pane, or on the indicator's own lower pane, where the frame's prices read on that pane's value scale (an RSI or CVD profile beside the oscillator); "lower" needs a lower pane
behind_candlesboolean (default false)the bar fills draw under the candles; labels, the POC, the value-area lines, borders and the outline stay above; refused on "lower", beside gradient, or with shape: "outline"
span"pane" (default), "time"docked on the pane edge, or one profile per time span the frame carries (Price canvases)
offset[x, 0], x 0..4096 pxinward from the pane edge, or from the neighbour named by beside
besideanother declared level's namedock right inside that level (open interest beside gamma); refused when it names itself, an undeclared level, a level on another panel or dock side, or closes a cycle
width_px16..600 pxa fixed dock width instead of width_frac; refused beside it
scale, scale_max"own" (default), "shared", "fixed"; a number > 0"shared" scales every "shared" level of the indicator by the largest row among them; "fixed" fills the dock at scale_max (required there, refused otherwise) and clamps longer rows; the frame's scale_max overrides it per run
thickness_px2..40 pxthe tallest a bar may be, centred on its price
stepnumber > 0 (price units)every row spans its price plus and minus half a step (a strike ladder)

How the bars paint:

WordValueWhat it does
color, opacitya colour word; 0..1 (default 0.65)the bar colour and the alpha every bar colour's own alpha is multiplied by; opacity: 1 paints the exact colour
baseline"edge" (default), "center""center" puts zero mid-dock: positive values grow one way, negative the other (a net gamma profile); under "edge" a negative row draws nothing
series1..8 of { name, color }, unique namesone row split into stacked segments (calls and puts), fed by the frame's series arrays
gradient, gradient_direction2..8 colour words; "horizontal" (default) or "vertical"fading bars: horizontal runs from the bar's root to its tip; a row or segment with its own colour keeps it and takes the stops' alpha profile; refused beside behind_candles
border_color, border_width, border_stylea colour word; 0..10 px (1 when a colour is set, else 0); "solid", "dashed", "dotted"an outline on every bar and segment
shape"bars" (default), "outline""outline" draws no fills, one stepped line along the bar tips per side, broken at null rows
outside_opacity0..1 (default 0.5)how far rows outside the frame's value area fade
value_area_color, value_area_width, value_area_line_style, value_area_labelsa colour word (default the level colour); 0..10 px (default 1, 0 draws no lines); "dashed" unless declared; booleanthe two lines across the dock at the value area's low and high prices, and the VAH and VAL tags beside them

Labels, the point of control and hover:

WordValueWhat it does
labelsbooleanthe row labels; a frame with labels_text turns them on unless the declaration says false
format, decimals, signed, unitthe shared number wordshow a row without its own text (and its hover value) prints; without a format word the value prints as written
text_color, font_size, font_weight, font_familya colour word (default the row colour at full alpha); 6..64 px (default 10); the weight word; "ui", "mono", "serif", "rounded" (default the chart axis font)the label type
label_place"outside" (default), "inside", "axis"beyond the bar tip, inside the bar at its tip, or inside at its root
hoverboolean (default false)hovering a row opens the chart's hover card: the price, the value, one line per series, the row's tooltip
poc, poc_color, poc_width, poc_line_style, poc_extend, poc_labelboolean; a colour word (default the POC row colour); 1..10 px; the line style; "dock" (default) or "pane" (across the whole pane); booleanthe point of control line (the largest row unless the frame names poc_price) and its tag (POC and the price, or the frame's poc_label_text)

The frame carries the data and may carry per-run chrome beside prices, values and colors:

FieldValueWhat it does
prices, values, colors1..512 strictly ordered numbers; as many numbers or null (a gap that keeps its band); colour strings (#rrggbb, #rrggbbaa, rgb(), rgba(), a theme token; anything else falls back to the level colour)the rows
labels_textone string of 0..24 characters or null per rowthat text on the row; "" leaves the row bare; null prints the formatted value
seriesone array of numbers or null per declared series, each the length of pricesthe segments, stacking outward from the baseline in series order; values still names the row for its label and hover; never beside colors
lows, highsone number or null per row, both arrays together, low below higha row's own price band; a null pair falls back to step, else to the neighbours' midpoints
tooltipsone string of 0..64 characters or null per rowthe hover card's hint
value_area[low, high] pricesthe value area: rows outside dim, two lines across the dock
poc_price, poc_label_texta number; 1..24 charactersthe price the POC line marks; the tag's text (turns the tag on)
scale_maxa number > 0the fixed scale for this run; scale: "fixed" only
spansthe time-anchored shapewith span: "time", in place of the fields above

A frame whose shape disagrees with the declaration (a series count that differs, colors beside series, a scale_max on a level whose scale is not "fixed", a spans frame on span: "pane") refuses the run as wrun_frame_invalid. A two-sided profile of calls and puts, net at the baseline, with a value area and a tagged point of control:

wrun
param.choice("side", ["right", "left"], "right");
param.color("call_ink", "#38bdf8");
param.color("put_ink", "#a78bfa");
const gexFrame = frame("gex_levels", { max_bytes: 32768 });
plot.levels({ name: "gex", frame: gexFrame, dock: "@side", baseline: "center", width_frac: 0.18, series: [{ name: "Calls", color: "@call_ink" }, { name: "Puts", color: "@put_ink" }], labels: true, format: "usd", label_place: "inside", poc: true, poc_extend: "pane", poc_label: true, hover: true, outside_opacity: 0.4 });
json
{
  "prices": [62000, 62500, 63000, 63500],
  "values": [41000, -18500, 25000, 9000],
  "series": [
    [52000, 11000, 30000, 12000],
    [-11000, -29500, -5000, -3000]
  ],
  "labels_text": ["C 52.0K / P 11.0K", null, "", null],
  "tooltips": ["Calls 1,240 OI / Puts 860 OI", null, null, null],
  "value_area": [62000, 63000],
  "poc_price": 62000,
  "poc_label_text": "Max pain"
}

A session volume profile the module bins itself, docked right: rows six pixels thick, the value area marked by dotted amber lines with their VAH and VAL tags, rows outside it faded, and the point of control drawn across the dock.

wrun
const sessionFrame = frame("session_rows", { max_bytes: 16384 });
plot.levels({
  name: "session_profile",
  frame: sessionFrame,
  dock: "right",
  width_frac: 0.2,
  color: "#38bdf8",
  thickness_px: 6,
  outside_opacity: 0.35,
  value_area_color: "#f59e0b",
  value_area_width: 1.5,
  value_area_line_style: "dotted",
  value_area_labels: true,
  poc: true,
});

Open interest by price with every bar outlined in a dotted hairline, the fills at half strength, and a strong dashed point of control across the whole pane with its tag on: the look for a profile that sits under other overlays and still has to read.

wrun
const oiRows = frame("oi_rows", { max_bytes: 16384 });
plot.levels({
  name: "oi_profile",
  frame: oiRows,
  dock: "left",
  width_frac: 0.15,
  color: "#a78bfa",
  opacity: 0.5,
  border_color: "#c4b5fd",
  border_width: 1,
  border_style: "dotted",
  poc: true,
  poc_color: "#f472b6",
  poc_width: 3,
  poc_line_style: "dashed",
  poc_extend: "pane",
  poc_label: true,
});

A liquidation map docked right inside that open interest profile, on a fixed scale so a new cluster never rescales the dock, each row spanning the price band its frame gives it instead of the midpoints between neighbours.

wrun
const oiRows = frame("oi_rows", { max_bytes: 16384 });
const liqRows = frame("liq_rows", { max_bytes: 16384 });
plot.levels({ name: "oi_profile", frame: oiRows, dock: "left", width_frac: 0.15, color: "#a78bfa", format: "si" });
plot.levels({
  name: "liq_map",
  frame: liqRows,
  dock: "left",
  beside: "oi_profile",
  width_frac: 0.08,
  color: "#f97316",
  scale: "fixed",
  scale_max: 50000000,
  format: "usd",
  hover: true,
});
json
{
  "prices": [61250, 61750, 62250, 62750],
  "values": [12500000, 48000000, 9000000, 31000000],
  "lows": [61000, 61500, 62000, 62500],
  "highs": [61500, 62000, 62500, 63000],
  "scale_max": 60000000
}

Session profiles from cells

The chart can also draw a profile itself from volume_profile cells, one per session, through plot.profile (Price canvases); it styles the same parts in its own words. Here each row splits buy and sell volume, rows outside the value area take a faded pair of colours, the point of control is thick, the three levels stay drawn until price trades through them, and hovering a cell reads it out.

wrun
input("close", ohlcv.close);
input("profile", volume_profile.cells, { max_cells: 8192 });
plot.profile({
  name: "session_vp",
  cells: "profile",
  span: "session",
  session: "1d",
  mode: "split_bar",
  buy_color: "#34d399",
  sell_color: "#f87171",
  row_height: 25,
  value_area: 0.7,
  outside_buy_color: "#34d39955",
  outside_sell_color: "#f8717155",
  poc: true,
  poc_width: 3,
  naked: true,
  level_labels: true,
  cell_hover: true,
});

The caps a profile meets (four levels, 512 rows, 64 spans and 4096 rows across them, 96 KiB per frame) are on Limits.

Panel rows, kind by kind

A panel draws a small chart of its own in a pane below the chart (place: "below"), or as side content ("side") that mounts below on a chart and fills a window (Side placement and windows). The file writes the panel's rows into a frame, usually once, on the newest bar, and each kind reads its rows in its own shape:

PanelOne rowOptions of its own
panel.bars, panel.line[key, v1, ..., vN]: one value per series, null for a gapseries (1 to 8, required), stacked; bars also orientation ("vertical" or "horizontal")
panel.scatter[key, x, y, size?, color?, label?]: a dot at (x, y), the label at most 24 charactersnone
panel.histogram[label, count]: a bin's label (1 to 24 characters) and its countbins (2 to 200), orientation; x: "category" only
panel.pie[name, value, color?]: one slice, at most 24hole (0 to 0.8)
panel.heatmap[x key, y key, value]: one cell, null for an empty onex: "category" only
panel.tableone cell per series and no key: words (at most 64 characters), a number, or a styled cell { text?, value?, color?, bar?, spark?, background_color?, opacity?, gradient?, text_color?, font_size?, font_weight?, align?, valign?, colspan?, rowspan? }; at most 128 rowsseries (1 to 12, required)
panel.tiles[label, value, caption?, color?, spark?]: one tile, at most 24none

Every panel also takes name, title, x, place and frame, and the look words of its kind (Frames, panels and compact widgets, above). x says what a key is: "time" an epoch second, "index" a whole number, "number" a decimal (a price grid), "category" a word of 1 to 64 characters. A panel holds at most 2,000 rows unless the table says fewer (128), and an empty rows list draws an empty panel. A color is a colour word: a hex literal such as "#16a34a", "#rrggbbaa" or a theme token.

Volume by weekday as stacked bars, one bar per day of the week:

// Volume by weekday in a pane below the chart: one bar per day of the week, up-bar and down-bar volume stacked.
output("weekday", none, overlay, { description: "The bar's weekday, 0 Sunday to 6 Saturday, UTC" });
const days = frame("days", { max_bytes: 1024 });
panel.bars({ name: "by_weekday", title: "Volume by weekday (UTC)", x: "category", place: "below", frame: days, stacked: true, series: [{ name: "Up bars", color: "#16a34a" }, { name: "Down bars", color: "#dc2626" }] });

const NAMES: StaticArray<string> = ["Sun", "Mon", "Tue", "Wed", "Thu", "Fri", "Sat"];
const upVolume = new StaticArray<f64>(7);
const downVolume = new StaticArray<f64>(7);
let clock = new Clock();

function onBar(): void {
  const close = bar.close();
  if (isNaN(close)) return;
  clock.update(bar.time());
  const day = clock.weekday();
  if (close >= bar.open()) upVolume[day] += bar.volume();
  else downVolume[day] += bar.volume();
  out_weekday(f64(day));
  if (!bar.isLast()) return;
  // One row per weekday: [category, up volume, down volume], one value per series.
  fb_clear();
  fb_text('{"rows":[');
  for (let i = 0; i < 7; i++) {
    if (i > 0) fb_text(",");
    fb_text("[");
    fb_str(NAMES[i]);
    fb_text(",");
    fb_f64(upVolume[i], 0);
    fb_text(",");
    fb_f64(downVolume[i], 0);
    fb_text("]");
  }
  fb_text("]}");
  writeFrameBuffer(FRAME_DAYS);
}
BTCUSDT perpetual on Binance, 1 hour bars, Aug 10 to Aug 18, 2026Real output from OpenMarket's engine
  • One value per series. ["Mon", up, down] carries the two declared series in their order; stacked: true puts them in one bar.
  • Written once, on the newest bar. The sums grow on every bar, and the frame is built on bar.isLast() with the fb_* writers, which never allocate.
  • The last write wins. The panel shows what the newest bar wrote; a frame never written leaves the panel off the chart, and a row in the wrong shape stops the run with wrun_frame_invalid.

The looks below take the words of the Panel words table, kind by kind; every declaration assumes onBar() writes the named frame.

Line panels

A gamma exposure curve: net dealer gamma by strike, filled green where it is positive and red where it is negative, the fill fading away from the line, the spot strike marked with a badge that prints the value there, a chip on the title row naming the regime, a callout pinned to the curve at the peak strike, and a stats row for the maximised view. Reach for the signed fill whenever a curve's sign is the story. The badge word is the chip: its text, an optional fill (theme.accent when absent) and an optional text colour (light or dark, whichever reads on the fill, when absent). A marker with valign: "point" is the callout: instead of a line down the pane it draws a dot on the curve with a short leader to its label, anchored where the named series crosses x (the first series when series is absent), or at an exact value when y is given.

wrun
const gexRows = frame("gex_rows", { max_bytes: 16384 });
panel.line({
  name: "gex_curve",
  title: "Gamma exposure by strike",
  x: "number",
  place: "below",
  frame: gexRows,
  height_frac: 0.3,
  chrome: "grid",
  maximize: true,
  stats_row: true,
  y_zero: true,
  fill_mode: "signed",
  fill_positive_color: "#22c55e",
  fill_negative_color: "#ef4444",
  fill_fade: true,
  badge: { text: "LONG GAMMA", color: "#22c55e", text_color: "#0b0d12" },
  markers: [
    { x: "spot", label: "Spot", badge: true, show_value: true },
    { x: 64000, label: "Peak", valign: "point", series: "Net gamma", color: "#22c55e" },
  ],
  series: [{ name: "Net gamma", color: "#e2e8f0", width: 2, smooth: true }],
});

The frame carries the rows and, per run, the chrome that moves: a caption naming the expiry window, a fixed value axis so the curve does not rescale on every tick, the badge (a frame badge replaces the declared chip for that run, so the regime flips to "SHORT GAMMA" in red when the module finds dealers short), and the markers. The frame's list replaces the declared one, so the spot marker is written again beside the flip strike, which the module finds where the curve crosses zero and washes down the pane in its colour, and two callouts pin the peak and the trough: the peak reads its height off the Net gamma series, the trough is pinned at an exact y.

json
{
  "title": "Gamma exposure by strike",
  "caption": "Dealer gamma per 1% move, 0 to 7 days to expiry",
  "y_min": -4000000,
  "y_max": 6000000,
  "badge": {
    "text": "SHORT GAMMA",
    "color": "#ef4444",
    "text_color": "#f4f4f4"
  },
  "markers": [
    { "x": "spot", "label": "Spot", "badge": true, "show_value": true },
    {
      "x": 63500,
      "label": "Flip",
      "color": "#f59e0b",
      "line_style": "dashed",
      "valign": "middle",
      "wash": true
    },
    {
      "x": 64000,
      "label": "Peak",
      "valign": "point",
      "series": "Net gamma",
      "color": "#22c55e",
      "show_value": true
    },
    {
      "x": 61000,
      "label": "Trough",
      "valign": "point",
      "y": -2500000,
      "color": "#ef4444"
    }
  ],
  "rows": [
    [60000, -1200000],
    [61000, -2500000],
    [62000, -800000],
    [63000, 1500000],
    [64000, 3900000],
    [65000, 2200000]
  ]
}

A cumulative depth curve, bids and asks by distance from the mid price: both axes titled, the x axis printed as a percentage with two decimals, the value axis pinned from 0 to 500 so the two sides share one scale across runs, and chips in the legend showing each side's latest value.

wrun
const depth = frame("depth", { max_bytes: 16384 });
panel.line({
  name: "depth_curve",
  title: "Cumulative depth",
  x: "number",
  place: "below",
  frame: depth,
  x_title: "Distance from mid",
  y_title: "Size (BTC)",
  x_format: "auto",
  x_decimals: 2,
  x_unit: "%",
  y_min: 0,
  y_max: 500,
  legend_style: "chips",
  legend_latest: true,
  format: "0.0",
  series: [
    { name: "Bids", color: "#22c55e" },
    { name: "Asks", color: "#ef4444" },
  ],
});

A liquidation density curve around spot, glowing and animated as it updates, its stroke fading toward the edges, red for the longs below spot and green for the shorts above, with a tint under the line in the same ink. Reach for the fade when the pivot is the point and the tails are context.

wrun
const liqs = frame("liqs", { max_bytes: 16384 });
panel.line({
  name: "liq_density",
  title: "Liquidation density",
  x: "number",
  place: "below",
  frame: liqs,
  glow: true,
  animate: true,
  stroke_fade: { pivot: "spot", edge_opacity: 0.15, left_color: "#ef4444", right_color: "#22c55e" },
  series: [{ name: "Liquidations", color: "#f59e0b", width: 1.5, fill: true, fill_color: "#f59e0b33" }],
});

Bars

Funding by venue as bars coloured by sign, the axis pinned from -0.05% to 0.05% so venues compare across runs, and a readout under the pointer.

wrun
const funding = frame("funding", { max_bytes: 4096 });
panel.bars({
  name: "funding_by_venue",
  title: "8h funding by venue",
  x: "category",
  place: "below",
  frame: funding,
  color_mode: "sign",
  positive_color: "#22c55e",
  negative_color: "#ef4444",
  y_title: "Funding",
  y_min: -0.05,
  y_max: 0.05,
  hover_card: true,
  format: "0.000",
  unit: "%",
  series: [{ name: "Funding", color: "#94a3b8" }],
});

Scatter

Each venue as a dot of funding against open interest change: the plane cut into four named quadrants at zero, a guide where funding turns rich, the funding axis pinned so the quadrants keep their place, venue names on the dots with leader lines where they would collide, and every dot trailing its last positions, the trail fading as it ages.

wrun
const venues = frame("venues", { max_bytes: 8192 });
panel.scatter({
  name: "funding_vs_oi",
  title: "Funding against OI change",
  x: "number",
  place: "below",
  frame: venues,
  x_title: "8h funding",
  y_title: "OI change, 24h",
  x_min: -0.05,
  x_max: 0.1,
  labels: true,
  label_overlap: "leader",
  quadrants: {
    x: 0,
    y: 0,
    colors: ["#22c55e22", "#3b82f622", "#ef444422", "#f59e0b22"],
    labels: ["Longs crowding", "Shorts crowding", "Shorts unwinding", "Longs unwinding"],
  },
  guides: [{ axis: "x", value: 0.03, label: "Rich", color: "#f59e0b", line_style: "dashed" }],
  trails: true,
  trail_width: 1.5,
  trail_fade: true,
});

Pie

Open interest by expiry as a ring: the hole prints the total with a caption under it, a small gap between slices and a thin border on each in the pane's own ink.

wrun
const expiries = frame("expiries", { max_bytes: 4096 });
panel.pie({
  name: "oi_by_expiry",
  title: "Open interest by expiry",
  x: "category",
  place: "below",
  frame: expiries,
  hole: 0.55,
  hole_total: true,
  hole_caption: "Total OI",
  slice_gap: 2,
  border_color: "#0f172a",
  border_width: 1,
  format: "usd",
  legend_style: "title",
});

The frame's rows are [name, value, color?] slices; for a run where the hole should say something other than the total, the frame carries its own words, hole_text with a hole_caption that replaces the declared one.

json
{
  "hole_text": "$1.92B",
  "hole_caption": "Expiring this week",
  "rows": [
    ["04 Oct", 820000000, "#38bdf8"],
    ["11 Oct", 460000000, "#818cf8"],
    ["25 Oct", 390000000, "#a78bfa"],
    ["27 Dec", 250000000, "#c4b5fd"]
  ]
}

Heatmap

Funding by venue and hour as a signed heatmap, positive cells warm and negative cells cool around a neutral zero, the axes titled and a readout under the pointer. Reach for the signed scale when the data has a meaningful zero; a palette scale suits ranks and counts.

wrun
const hours = frame("hours", { max_bytes: 16384 });
panel.heatmap({
  name: "funding_heat",
  title: "Funding by venue and hour",
  x: "category",
  place: "below",
  frame: hours,
  scale: "signed",
  positive_color: "#f97316",
  negative_color: "#38bdf8",
  row_title: "Venue",
  col_title: "Hour (UTC)",
  hover_card: true,
  format: "0.000",
  unit: "%",
});

The frame's rows are [hour, venue, value] cells; beside them it can light the live hour's column and add summary rows under the grid, their values following the columns in the order the rows first named them.

json
{
  "caption": "8h funding, the last 7 days",
  "highlight": { "col": "16:00" },
  "summary": [{ "label": "Mean", "values": [0.0047, 0.0037] }],
  "rows": [
    ["08:00", "Binance", 0.01],
    ["08:00", "Bybit", 0.0062],
    ["08:00", "OKX", -0.002],
    ["16:00", "Binance", 0.0071],
    ["16:00", "Bybit", 0.0045],
    ["16:00", "OKX", -0.0004]
  ]
}

Tiles

Tiles for the headline numbers, three across, each tile's accent following the sign of its value: green ink on a positive flow, red on a negative one, the numbers printed compact and signed.

wrun
const summary = frame("summary", { max_bytes: 4096 });
panel.tiles({
  name: "flow_tiles",
  title: "24h flow",
  x: "category",
  place: "below",
  frame: summary,
  columns: 3,
  accent: "auto",
  positive_color: "#22c55e",
  negative_color: "#ef4444",
  hover_card: true,
  format: "si",
  signed: true,
  unit: "BTC",
});

Table

A table beside the chart with its first column as the row headers: a dashed frame, muted header ink, hairlines between rows, every cell centred vertically, the two value columns right-aligned and each formatted its own way.

wrun
const board = frame("board", { max_bytes: 8192 });
panel.table({
  name: "venue_board",
  title: "Venue board",
  x: "category",
  place: "side",
  frame: board,
  width_px: 320,
  header_column: true,
  header_text_color: "#94a3b8",
  header_background_color: "#1e293b",
  text_color: "#e2e8f0",
  border_color: "#475569",
  border_width: 1,
  border_style: "dashed",
  grid_color: "#334155",
  grid_width: 1,
  grid_lines: "rows",
  valign: "middle",
  cell_padding: 6,
  column_widths: [96, 0, 0, 0],
  series: [
    { name: "Venue" },
    { name: "Market" },
    { name: "OI", align: "right", format: "usd" },
    { name: "Funding", align: "right", format: "0.000", unit: "%" },
  ],
});

The frame's cells are words, numbers or styled cells: a styled cell may span rows, so a venue's name sits once beside its two markets, and a value cell with bar draws an inline bar behind the number, its length 0 to 1 of the cell; caption prints a line under the table.

json
{
  "caption": "USDT perpetuals, 15:04 UTC",
  "rows": [
    [
      {
        "text": "Binance",
        "rowspan": 2,
        "valign": "middle",
        "font_weight": "bold"
      },
      "BTCUSDT",
      { "value": 4210000000, "bar": 1 },
      0.01
    ],
    ["", "ETHUSDT", { "value": 1830000000, "bar": 0.43 }, 0.0082],
    [
      { "text": "Bybit", "font_weight": "bold" },
      "BTCUSDT",
      { "value": 2640000000, "bar": 0.63 },
      -0.0041
    ]
  ]
}

Ladder, feed and meter

Three small widgets sit on the price pane. A ladder is a column of bars at their prices along one side, a feed is a few lines of text with their times, and a meter is one bar filled from 0 to 1.

WidgetDeclareWrite
Ladderdraw.ladder({ name, frame, side, divider?, title?, ...style }), side "left" or "right"a frame { rows, divider?, title? }: 1 to 256 rows [price, value, fraction, color?], the fraction (0 to 1) the bar's length; divider { label, price } marks one price, and a divider in the frame replaces the declared one
Feeddraw.feed({ name, frame, anchor?, offset?, z?, title?, time_format?, ...style })a frame { lines, title? }: 1 to 50 lines [time, text, color?], the time in epoch milliseconds and the text at most 80 characters
Meterdraw.meter({ name, label, fraction: { output }, ramp, text?, anchor?, offset?, z?, title?, bar_height?, track_color?, ...style })no frame: the meter reads the output on the newest ready bar, a fraction from 0 to 1, colored along ramp (2 to 5 colors); text is a literal or { slot }

The title, time_format, bar_height and track_color words, and the card surface and chrome a widget shares, are listed under Frames, panels and compact widgets above; a ladder's frame rows and a meter's ramp take colour words.

The last bars' volume by price as a ladder on the right of the pane:

// The last bars' volume by price as a ladder on the right of the pane: eight rows from the window's low to its high, the busiest in amber.
param.int("window", 100, { min: 10, max: 500, label: "Bars in the window" });
output("close_line", line, overlay, { color: "#94a3b8", description: "The close" });
const rows = frame("rows", { max_bytes: 2048 });
draw.ladder({ name: "volume_by_price", frame: rows, side: "right" });

const MAX = 500;
const ROWS = 8;
const closes = new StaticArray<f64>(MAX);
const volumes = new StaticArray<f64>(MAX);
const binned = new StaticArray<f64>(ROWS);
let window: i32 = 100;
let head: i32 = 0;
let count: i32 = 0;

function onStart(): void {
  window = i32(p_window());
}

function onBar(): void {
  const close = bar.close();
  if (isNaN(close)) return;
  out_close_line(close);
  closes[head] = close;
  volumes[head] = bar.volume();
  head = (head + 1) % window;
  if (count < window) count += 1;
  if (!bar.isLast()) return;
  let lo = Infinity;
  let hi = -Infinity;
  for (let i = 0; i < count; i++) {
    lo = Math.min(lo, closes[i]);
    hi = Math.max(hi, closes[i]);
  }
  if (!(hi > lo)) return;
  const step = (hi - lo) / f64(ROWS);
  for (let r = 0; r < ROWS; r++) binned[r] = 0.0;
  for (let i = 0; i < count; i++) {
    const r = i32(Math.min(f64(ROWS - 1), Math.floor((closes[i] - lo) / step)));
    binned[r] += volumes[i];
  }
  let busiest = 0.0;
  for (let r = 0; r < ROWS; r++) busiest = Math.max(busiest, binned[r]);
  if (busiest <= 0.0) return;
  // One row per price band: [price, volume, the bar's length 0 to 1, color]. The divider marks the last close.
  fb_clear();
  fb_text('{"rows":[');
  for (let r = 0; r < ROWS; r++) {
    if (r > 0) fb_text(",");
    fb_text("[");
    fb_num(lo + step * (f64(r) + 0.5));
    fb_text(",");
    fb_f64(binned[r], 0);
    fb_text(",");
    fb_f64(binned[r] / busiest, 3);
    fb_text(",");
    fb_str(binned[r] == busiest ? "#f59e0b" : "#64748b");
    fb_text("]");
  }
  fb_text('],"divider":{"label":"Last","price":');
  fb_num(close);
  fb_text("}}");
  writeFrameBuffer(FRAME_ROWS);
}
BTCUSDT perpetual on Binance, 1 hour bars, Aug 10 to Aug 18, 2026Real output from OpenMarket's engine
  • Rows at prices. Each row is a price band's middle price, its volume, its bar's length against the busiest band, and its color.
  • The divider. "divider": { "label": "Last", "price": close } in the frame marks the last close.
  • Sized once. The window lives in two StaticArrays made at module start, so nothing allocates per bar.

An RSI meter in the corner, and a feed of the RSI's last five crossings of 70 and 30:

// An RSI meter in the top-left corner, and a feed of the last five times the RSI crossed 70 or 30.
param.int("length", 14, { min: 2, max: 100, label: "RSI length" });
output("rsi", line, lower, { color: "#a855f7", description: "RSI" });
output("rsi_fraction", none, lower, { description: "The RSI as a fraction, 0 to 1: the meter's fill" });
string("rsi_text", { max_bytes: 8 });
draw.meter({ name: "rsi_meter", label: "RSI", fraction: { output: "rsi_fraction" }, ramp: ["#3b82f6", "#64748b", "#ef4444"], text: { slot: "rsi_text" }, anchor: "top_left" });
const events = frame("events", { max_bytes: 2048 });
draw.feed({ name: "rsi_events", frame: events, anchor: "bottom_left" });

const KEEP = 5;
const times = new StaticArray<f64>(KEEP);
const ups = new StaticArray<bool>(KEEP);
let head: i32 = 0;
let count: i32 = 0;
let rsi = new Rsi(14);
let prev: f64 = NaN;

function onStart(): void {
  rsi = new Rsi(i32(p_length()));
}

function onBar(): void {
  const value = rsi.update(bar.close());
  if (isNaN(value)) return;
  out_rsi(value);
  out_rsi_fraction(value / 100.0);
  sb_clear();
  sb_f64(value, 1);
  str_rsi_text_sb();
  // A crossing joins the feed; the oldest of the five leaves.
  const above = prev <= 70.0 && value > 70.0;
  const below = prev >= 30.0 && value < 30.0;
  if (above || below) {
    times[head] = bar.time() * 1000.0;
    ups[head] = above;
    head = (head + 1) % KEEP;
    if (count < KEEP) count += 1;
  }
  prev = value;
  if (!bar.isLast() || count == 0) return;
  // One line per crossing, newest first: [time in milliseconds, text, color].
  fb_clear();
  fb_text('{"lines":[');
  for (let k = 0; k < count; k++) {
    const i = (head - 1 - k + KEEP) % KEEP;
    if (k > 0) fb_text(",");
    fb_text("[");
    fb_f64(times[i], 0);
    fb_text(",");
    fb_str(ups[i] ? "RSI crossed above 70" : "RSI crossed below 30");
    fb_text(",");
    fb_str(ups[i] ? "#ef4444" : "#3b82f6");
    fb_text("]");
  }
  fb_text("]}");
  writeFrameBuffer(FRAME_EVENTS);
}
BTCUSDT perpetual on Binance, 1 hour bars, Aug 10 to Aug 18, 2026Real output from OpenMarket's engine
  • The meter reads an output. fraction: { output: "rsi_fraction" } takes the RSI over 100 on the newest bar, and text: { slot: "rsi_text" } prints the reading on it.
  • The feed is a frame. Each line is [time, text, color], the time bar.time() * 1000 because a feed counts milliseconds, newest first.
  • Nothing to show, nothing written. The feed is written only once a crossing exists; until then the frame stays unwritten and the feed is off the chart.

Next