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 |
|---|---|
| Required. Names the layer in the manifest and in |
| A |
| Required. Fields a person may set through a gesture; a hook that creates a row must supply every one. |
| Fields hooks compute. |
|
|
|
|
|
|
|
|
| Legend name, a boolean input that toggles it, paint order ( |
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 |
|---|---|
| The key as a string for script rows. A |
| Bar time of the first write. It never moves. |
| Bar time of the latest write. |
| Bar time the row was sealed. Absent while the row is live. |
|
|
| The event id for |
Lifecycle
The first
upsertfor a key creates the row and stampscreatedAt.Later
upsertcalls replace the row;patchmerges into it. Key fields never change throughpatch. A write with identical values is not a change and ships nothing.The row seals once: through
seal(key), asealWhenpredicate, asealOnevent orexpires. 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.remove(key)deletes a row. Usesealfor a row that ended andremovefor 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 |
|---|---|---|
|
| Replace by key; drops |
| same | Merge. |
| same | See the lifecycle above. |
| everywhere | Indexed. |
| everywhere | Scans every row; lint |
| 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 |
|---|---|
|
|
| Rows alive at that second: |
| Filter by |
| Keeps the latest N when |
|
|
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 |
|---|---|---|
| error | Move |
| error | Write from the chart |
| error | Add |
| error | Nothing writes rows and the layer has no |
| warn | Use |
| warn | No |
| warn |
|
| warn | Definitions: |
