ChartnautDocs

Layers & rows

output.layer is the fourth output family, after series, markers and the hline / range / trendline lane. Its output is rows: keyed, schema-typed records written from onBar / onRun, stored per layer, and readable by other scripts. Indicators and definitions declare layers with the same shape. This page covers the row model and how rows travel. Drawing them is Layers and Painting a layer.

The row model

A row is what a person would point at: one session, one bar, one detected zone, one thing somebody drew. It is also the unit the host culls, ships across the worker boundary and seals.

const zones = output.layer({
  id: "zones",
  key: "start",                                   // the field(s) that identify a row; default "id"
  row: { start: "number" },                       // fields a person may set; persisted for drawn rows
  derived: { top: "number", bottom: "number" },   // fields hooks compute; never persisted
  time: { start: "start" },                       // which fields bound a row in time (culling)
  expires: "10d",                                 // how long a live row lives if nothing seals it
  claims: { rowsPer: "event", seals: true },      // what one row is; checked by the bench
});

Option

Meaning

id

Required. Names the layer in the manifest and in .layer(id) reads.

key

A row field, or an array of them for a composite key. Default "id": you then pass id on upsert.

row

Required. Fields a person may set through a gesture; a hook that creates a row must supply every one.

derived

Fields hooks compute. float32array, bins and grid are legal only here.

time

start (a row field) and end (any field) bound a row in time. Default start: the key when it holds a time, else createdAt. Default end: closedAt.

expires

"5d", "4w", "90m", "12h" or "max". Required once the layer registers sealWhen or sealOn (lint layer/expires-required).

persist

{ version } for drawn rows, default { version: 1 }. Bump it when row changes shape; stored rows with another version are dropped on load. false keeps drawn rows for the session only.

claims

{ rowsPer, seals }. Required on a layer that paints. See Layers.

name, visibleFrom, z, pane, scale

Legend name, a boolean input that toggles it, paint order ("bottom" \| "normal" \| "top"), pane and price scale.

Field types are "number", "string", "boolean", "object", "array", "float32array", "bins", "grid", { enum: [...] }, or the object form { type, label, default }, which labels the field in a drawn row's settings dialog.

What the host stamps

Every stored row carries fields you never write. The names are reserved.

Field

Meaning

id

The key as a string for script rows. A user-… id for rows a person drew.

createdAt

Bar time of the first write. It never moves.

updatedAt

Bar time of the latest write.

closedAt

Bar time the row was sealed. Absent while the row is live.

closeReason

"explicit" (seal(key)), "predicate" (sealWhen), "event" (sealOn) or "expired" (expires elapsed).

closeDetail

The event id for "event", "custom" for "predicate".

Lifecycle

  1. The first upsert for a key creates the row and stamps createdAt.

  2. Later upsert calls replace the row; patch merges into it. Key fields never change through patch. A write with identical values is not a change and ships nothing.

  3. The row seals once: through seal(key), a sealWhen predicate, a sealOn event or expires. A sealed row keeps painting as finished and rejects writes. In the builder a write to a sealed key throws; on the chart it is ignored with one warning.

  4. remove(key) deletes a row. Use seal for a row that ended and remove for a row that was wrong or too old to keep. Hooks cannot remove a row a person drew.

The seal sweep runs after onBar on every bar, over live rows created strictly before that bar, so the bar that creates a row never seals it. sealOn runs first, then sealWhen, then expires. The Go runtime that runs definitions over history uses the same order. A seal is never undone, and the forming bar re-runs on every tick, so judge a break on a closed bar: c.history[c.history.length - 1] is the last closed bar before the one being swept.

Handle methods

Method

Live in

Notes

upsert(row, opts?)

onBar, onRun, gestures

Replace by key; drops derived fields the new row omits. opts.time is required in onRun.

patch(key, partial, opts?)

same

Merge.

seal(key, opts?) / remove(key)

same

See the lifecycle above.

get(key) / last() / range(t1, t2)

everywhere

Indexed. range returns rows alive anywhere in [t1, t2].

rows()

everywhere

Scans every row; lint layer/rows-in-bar-hook warns inside onBar.

layout, paint, paintGL, hit, autoscale, legend, gesture, sealWhen, sealOn

module scope, once each

Registrations.

Rows are plain data: numbers, strings, booleans, plain objects and arrays, Float32Array, and the two containers. A function in a row fails at upsert with the field named. A container's wire form is its own enumerable properties, so it survives structuredClone, the Go runtime and JSON unchanged, and the worker ships only the bin range that changed.

Reading rows from other scripts

A script that declares a layer-producing indicator reads its rows on the alias:

indicators.declare({ slug: "initial-balance", as: "ib", version: 1 });
// in onBar
const live = ctx.indicators.ib.layer("ib", { state: "live", at: ctx.time });

A study reads a definition's rows the same way, at the event time:

const boxes = ctx.flows.session.layer("boxes", { at: ctx.event.time });

Query field

Effect

state

"live", "closed" or "any" (default).

at

Rows alive at that second: createdAt <= at and (closedAt absent or closedAt > at). Ignores after / before.

after / before

Filter by createdAt.

limit

Keeps the latest N when before is set without after, else the first N.

window

[from, to], as on the other visual queries.

Rows come back sorted by createdAt, then id, with every field typed unknown: wrap numbers in Number(...). Float32Array fields arrive intact and bins / grid fields arrive as read-only views with the full read surface (at(price), argmax(), valueArea, counts). The schema is in the producer's manifest under outputs[id]; never guess a field name.

An app built on a canvas attaches a layer by its output id on a record attach and receives the rows as a Collection<Row>. Read fields off the row, never through a dotted output key such as tpo.poc. The paint half never reaches a canvas.

In a definition

Declare with output.layer(...) exactly as an indicator does. boxes.sealOn("sessionClosed") seals live rows when the definition emits that event on a later bar. outputs.declare({ kind: "layer" }) records the schema only and returns no handle, so nothing can write rows; lint warns outputs/layer-use-output-layer. Running a definition over history stores its rows beside its events.

Examples

Gaps that close when price trades through them

Three-bar fair value gaps as event rows, sealed by sealWhen when a closed bar closes beyond the far edge. Detection reads only closed bars, so a tick on the forming bar never creates or seals a gap.

meta({ shortName: "FVG", kind: "overlay" });

dialog({ title: "Fair value gaps", description: "Three-bar gaps that stay open until a bar closes through them." });

const minTicks = input.number({ id: "minTicks", label: "Minimum gap (ticks)", default: 4, min: 1, step: 1 });
const bullColor = input.color({ id: "bullColor", label: "Bullish gap", default: "#26a69a" });
const bearColor = input.color({ id: "bearColor", label: "Bearish gap", default: "#ef5350" });
warmup((w) => w.forever()); // an open gap can sit for days before a bar closes through it

layout(tab({ id: "main", title: "Main" }, section({ id: "s", title: "Settings" }, minTicks, bullColor, bearColor)));

const gaps = output.layer({
  id: "gaps",
  key: "start",
  row: { start: "number" },
  derived: { top: "number", bottom: "number", side: { enum: ["bull", "bear"] } },
  name: "Fair value gaps",
  time: { start: "start" },          // no end field: live rows run to the right edge, sealed rows end at closedAt
  expires: "10d",
  claims: { rowsPer: "event", seals: true },
});

// Judge the last CLOSED bar before the swept one: a seal is permanent.
gaps.sealWhen((row, c) => {
  const prev = c.history[c.history.length - 1];
  if (!prev || row.top === undefined || row.bottom === undefined) return false;
  return row.side === "bull" ? prev.close < row.bottom : prev.close > row.top;
});

/** @param {ScriptedCtx} ctx */
export function onBar(ctx) {
  const st = ctx.accum("gaps", () => ({
    t: /** @type {number[]} */ ([]),
    h: /** @type {number[]} */ ([]),
    l: /** @type {number[]} */ ([]),
    keys: /** @type {number[]} */ ([]),
  }), (s) => s);

  const last = st.t.length - 1;
  if (last >= 0 && st.t[last] === ctx.time) return;      // forming bar again: nothing new has closed
  st.t.push(ctx.time); st.h.push(ctx.high); st.l.push(ctx.low);
  if (st.t.length > 4) { st.t.shift(); st.h.shift(); st.l.shift(); }
  if (st.t.length < 4) return;

  // Entries 0..2 are the three bars before this one, all closed.
  const gap = ctx.instrument.tickSize * Number(ctx.params.minTicks);
  const start = st.t[1];
  if (gaps.get(start)) return;
  if (st.l[2] - st.h[0] >= gap) gaps.upsert({ start, top: st.l[2], bottom: st.h[0], side: "bull" });
  else if (st.l[0] - st.h[2] >= gap) gaps.upsert({ start, top: st.l[0], bottom: st.h[2], side: "bear" });
  else return;

  st.keys.push(start);
  if (st.keys.length > 1500) gaps.remove(st.keys.shift());   // stay far under the row caps
}

gaps.paint((p, rows) => {
  for (const r of rows) {
    if (r.top === undefined || r.bottom === undefined) continue;
    const live = r.closedAt === undefined;
    const { x0, w } = p.spanX(r.start, live ? "edge" : r.closedAt);
    const { top, h } = p.spanY(r.top, r.bottom);
    const color = String(r.side === "bull" ? p.params.bullColor : p.params.bearColor);
    p.alpha(live ? 0.3 : 0.12, () => p.rect(x0, top, w, Math.max(1, h), color));
  }
});

gaps.legend((p, rows) => `${rows.filter((r) => r.closedAt === undefined).length} open`);

A session box per day

One row per session, keyed by the session open, written on every bar of the session and sealed explicitly on the first bar of the next one. high and low only grow, so re-running the forming bar writes the same row.

meta({ shortName: "London box", kind: "overlay" });

dialog({ title: "Session box", description: "The London session's range as a box, one per day." });

const boxColor = input.color({ id: "boxColor", label: "Outline", default: "#90a4ae" });
warmup((w) => w.day()); // one session

layout(tab({ id: "main", title: "Main" }, section({ id: "s", title: "Style" }, boxColor)));

const sessions = output.layer({
  id: "sessions",
  key: "start",
  row: { start: "number" },
  derived: { end: "number", open: "number", high: "number", low: "number", close: "number" },
  name: "Session boxes",
  time: { start: "start", end: "end" },
  claims: { rowsPer: "session", seals: true },
});

/** @param {ScriptedCtx} ctx */
export function onBar(ctx) {
  const s = ctx.session({ period: "daily", open: { hour: 8 }, close: { hour: 16, minute: 30 }, tz: "Europe/London" });
  const previous = sessions.last();
  if (previous && previous.start !== s.start && previous.closedAt === undefined) sessions.seal(previous.start);
  if (!s.isOpen) return;

  const cur = sessions.get(s.start);
  sessions.upsert({
    start: s.start,
    end: ctx.time,
    open: cur ? cur.open ?? ctx.open : ctx.open,
    high: Math.max(cur ? cur.high ?? ctx.high : ctx.high, ctx.high),
    low: Math.min(cur ? cur.low ?? ctx.low : ctx.low, ctx.low),
    close: ctx.close,
  });
}

sessions.paint((p, rows) => {
  const outline = String(p.params.boxColor);
  for (const r of rows) {
    if (r.high === undefined || r.low === undefined || r.end === undefined) continue;
    const { x0, w } = p.spanX(r.start, r.end + p.chart.timeframeSeconds);
    const { top, h } = p.spanY(r.high, r.low);
    const up = (r.close ?? 0) >= (r.open ?? 0);
    p.alpha(0.1, () => p.rect(x0, top, w, h, up ? p.theme.upColor : p.theme.downColor));
    p.rect(x0, top, w, h, undefined, outline, 1);
    const label = `${Math.round((r.high - r.low) / p.instrument.tickSize)} ticks`;
    const font = p.fit(label, w, p.theme.fontSize + 6);
    if (font) p.text(label, x0 + w / 2, top - 2, { font, align: "center", baseline: "bottom", color: outline });
  }
});

A row set another script reads

The producer writes one initial balance row per session. The reader plots extension levels from the live row and skips the plot while the balance is still forming, which leaves a gap between sessions.

// Producer. Save it with the slug "initial-balance".
meta({ shortName: "IB", kind: "overlay" });

dialog({ title: "Initial balance" });

const ibMinutes = input.number({ id: "ibMinutes", label: "Initial balance (minutes)", default: 60, min: 5, step: 5 });
const ibColor = input.color({ id: "ibColor", label: "Colour", default: "#ffb300" });
warmup((w) => w.day()); // one session

layout(tab({ id: "main", title: "Main" }, section({ id: "s", title: "Settings" }, ibMinutes, ibColor)));

const ib = output.layer({
  id: "ib",
  key: "start",
  row: { start: "number" },
  derived: { end: "number", ibEnd: "number", ibHigh: "number", ibLow: "number", complete: "boolean" },
  name: "Initial balance",
  time: { start: "start", end: "end" },
  claims: { rowsPer: "session", seals: true },
});

/** @param {ScriptedCtx} ctx */
export function onBar(ctx) {
  const s = ctx.session({ period: "daily", open: { hour: 8 }, close: { hour: 16, minute: 30 }, tz: "Europe/London" });
  const previous = ib.last();
  if (previous && previous.start !== s.start && previous.closedAt === undefined) ib.seal(previous.start);
  if (!s.isOpen) return;

  const ibEnd = s.start + Number(ctx.params.ibMinutes) * 60;
  const inIb = ctx.time < ibEnd;
  const cur = ib.get(s.start);
  const hi = cur ? cur.ibHigh ?? ctx.high : ctx.high;
  const lo = cur ? cur.ibLow ?? ctx.low : ctx.low;
  ib.upsert({
    start: s.start,
    end: ctx.time,
    ibEnd,
    ibHigh: inIb ? Math.max(hi, ctx.high) : hi,
    ibLow: inIb ? Math.min(lo, ctx.low) : lo,
    complete: !inIb,
  });
}

ib.paint((p, rows) => {
  const color = String(p.params.ibColor);
  for (const r of rows) {
    if (r.ibHigh === undefined || r.ibLow === undefined || r.ibEnd === undefined) continue;
    const { x0, w } = p.spanX(r.start, r.ibEnd);
    const { top, h } = p.spanY(r.ibHigh, r.ibLow);
    p.alpha(0.15, () => p.rect(x0, top, w, h, color));
    const span = /** @type {const} */ ([r.start, r.closedAt === undefined ? "edge" : r.closedAt]);
    p.priceLine(r.ibHigh, { color, label: "IBH", span, axisLabel: r.closedAt === undefined });
    p.priceLine(r.ibLow, { color, label: "IBL", span, axisLabel: r.closedAt === undefined });
  }
});
// Reader: a separate indicator.
meta({ shortName: "IB ext", kind: "overlay" });

dialog({ title: "Initial balance extensions" });

const mult = input.number({ id: "mult", label: "Extension (× IB range)", default: 1, min: 0.25, step: 0.25 });

layout(tab({ id: "main", title: "Main" }, section({ id: "s", title: "Settings" }, mult)));

indicators.declare({ slug: "initial-balance", as: "ib", version: 1 });

output.line({ id: "extUp", color: "#26a69a" });
output.line({ id: "extDown", color: "#ef5350" });

/** @param {ScriptedCtx} ctx */
export function onBar(ctx) {
  const live = ctx.indicators.ib.layer("ib", { state: "live", at: ctx.time });
  const row = live[live.length - 1];
  if (!row || row.complete !== true) return;          // no plot: a gap while the balance forms
  const hi = Number(row.ibHigh);
  const lo = Number(row.ibLow);
  const ext = (hi - lo) * Number(ctx.params.mult);
  ctx.plot("extUp", hi + ext);
  ctx.plot("extDown", lo - ext);
}

Budget

Cap

Soft

Hard

Rows per layer

5,000

20,000

Bytes per layer

4 MiB

32 MiB

Persisted drawn rows per layer

500

A hard cap raises a layer_rows or layer_bytes budget error naming the layer. Coarsen the key (one row per session, not per bar) and remove the oldest rows, as the gap example does.

Lint

Rule

Severity

Fix

layer/no-write-in-renderer

error

Move upsert / patch / seal / remove, and any ctx reference, out of layout / paint / paintGL / hit / autoscale / legend.

layer/no-write-in-timeframe-handler

error

Write from the chart onBar, not inside a timeframe(...) handler.

layer/expires-required

error

Add expires to a layer that registers sealWhen or sealOn.

layer/upsert-never-called

error

Nothing writes rows and the layer has no gesture.

layer/rows-in-bar-hook

warn

Use get / last / range in onBar and onRun.

layer/renderer-not-registered

warn

No paint or paintGL: a data-only layer. Fine when intended.

layer/escape-hatch

warn

p.all, p.bitmap, g.raw or g.program in use.

outputs/layer-use-output-layer

warn

Definitions: outputs.declare({ kind: "layer" }) gives no handle.