ChartnautDocs

Collect and publish

In onEvent you store each measurement with ctx.collect. In onFinish you read the rows back with ctx.collected and turn them into declared results with ctx.publish. Most measurements come from ctx.candles, which reads the bars after the event.

collect and collected

ctx.collect(key: string, value: any): void   // onEvent only
ctx.collected(key: string): any[]

Collect keys are private scratch lists. They need no declaration, and one key can hold numbers or row objects. Values must be plain JSON: numbers, strings, booleans, arrays and objects.

ctx.collected(key) returns the rows collected before the current event when called in onEvent, and every row when called in onFinish.

export function scope(ctx) {
  if (ctx.event.flow !== "orb") return null;
  return {};
}

export function onEvent(ctx, scope) {
  ctx.collect("rows", { time: ctx.event.time, close: ctx.event.payload.close });
}

export function onFinish(ctx) {
  const rows = ctx.collected("rows");
  ctx.publish("event_total", { value: rows.length });
}

Calling ctx.collect in onFinish is a lint error (study/collect-not-in-onFinish), and scope cannot read either one. A run stops when one key passes 100,000 rows or all keys together pass 500,000.

An object row carries its event's scope tuple as __scope. ctx.groupByScope(rows) returns [{ scope, rows }], one entry per group in first-seen order, and publishing drops the tag. Number rows carry no tag. scope shows how to build pin cells from these groups.

publish

ctx.publish(resultId: string, payload: any): void   // onFinish only

resultId must match a results.declare id. The payload is checked against the declared kind, and a wrong shape is reported as an error on the run. Results and stats lists the shape for each kind.

Calling ctx.publish in onEvent throws. Publishing the same id twice keeps the last payload.

Published payloads have a size cap: 2 MB in the preview and 100 MB on a run over history, with warnings from 500 KB and 10 MB. Aggregate before you publish. A table of every event is too big to read anyway: keep tables to 100 rows or fewer, and use a distribution or scatter for per-event values.

Reading bars after the event

ctx.candles works in onEvent and reads bars relative to the current event. In scope, only window and timeOf exist, and they stop at the bar the event fired on. On a run over history, the bars start 8 days before the run's start, so window and timeOf can read up to 8 days back from an event near the start. Events still come only from inside the run's dates.

ctx.candles.forward(opts): Candle | undefined
ctx.candles.window({ before, after }): Candle[]
ctx.candles.timeOf(anchorTime, barOffset): number | undefined

A candle has time, open, high, low, close and volume. Do not rely on volume: it is not set on every run.

forward

forward returns one bar, or nothing when the bar is outside the loaded data. Always check the result before using it.

Option

Returns

minutes: n

The bar at or just before ctx.event.time + n * 60

bars: n

The bar n bars after the event bar. bars: 1 is the next bar.

atBar: n

Same as bars

untilTime: t

The bar at or just before the absolute unix time t

stopAt, targetAt, firstHit

Scans forward bar by bar until a level is hit

untilTime is an absolute timestamp, never an offset. forward({ untilTime: 3600 }) asks for a bar in 1970.

const day = ctx.temporal.containing(ctx.event.time, { period: "day" });
const dayClose = ctx.candles.forward({ untilTime: day.end });
if (!dayClose) return;

Any other option key is a lint error (study/forward-bad-opts).

Stop and target scans

With stopAt, targetAt or both, forward walks the bars after the event and returns the bar where a level was touched, with an extra hit field: "stop", "target" or "neither".

const res = ctx.candles.forward({ stopAt: 4980, targetAt: 5020, firstHit: true });
if (!res) return;
ctx.collect("hits", res.hit);
  • A target above the stop is a long: the stop is hit by a bar's low, the target by its high. A target below the stop is a short.

  • With one level only, its side is judged against the event bar's close.

  • When one bar touches both levels, firstHit: true picks the level nearer that bar's open. Without firstHit, it counts as the stop.

  • The scan stops after 1,000 bars in the preview and 5,000 on a run over history. "neither" means no level was hit before the scan stopped or the data ran out, and the returned bar is the last one scanned.

There is no horizon option. To cap a scan at a time, compare res.time with your own limit.

This is a measurement of which level price reached first, not an order. Label these results as hypothetical.

window

window returns a flat array of bars from ctx.event.time - before to ctx.event.time + after. Both are offsets in seconds, not timestamps and not bar counts.

const bars = ctx.candles.window({ before: 0, after: 60 * 60 });
let high = -Infinity;
for (const b of bars) high = Math.max(high, b.high);

Lint catches reading .bars or .candles off the result (study/window-as-object) and a before that looks like a timestamp (study/window-absolute-time).

To measure something the same way at two different times, such as a baseline earlier in the session, fetch one window that spans both and take both numbers from that array.

timeOf

ctx.candles.timeOf(anchorTime, barOffset) returns the unix time of the bar barOffset bars from the bar at anchorTime.

Which timeframe

Candles come from the timeframe you pick when you run the study. Without one they come from the timeframe of the first declared definition's events.

Budgets

Every candle read costs time, and a run has a per-event and a total time limit. Budgets and limits lists them.