ChartnautDocs

onEvent and onFinish

A study has three handlers. scope runs first for each event, in time order, and decides which group the event belongs to. onEvent runs next for every event scope includes and measures what happened after it. onFinish runs once after the last event and turns what you measured into results.

export function scope(ctx: StudyScopeCtx): ScopeTuple | null
export function onEvent(ctx: StudyCtx, scope: Readonly<ScopeTuple>): void
export function onFinish(ctx: StudyCtx): void

All three are ES module exports. module.exports, require() and imports do not work in a study.

ctx.event

ctx.event exists in scope and onEvent.

Field

Value

flow

The as alias from flows.declare, not the slug

id

The event id the definition declared, such as "or_break_up"

time

Unix seconds, UTC. The event's start time.

firedAt

Unix seconds, UTC. Open time of the bar the event fired on. Equals time unless the definition back-dated the event with timeStart.

instrument

The instrument, as a string

payload

The fields the definition emitted, exactly as emitted

intent

The intent text from the definition's events.declare

scope runs for every event of every declared definition, merged in time order. Gate there, on the alias first and on the event id when the definition declares more than one. Returning null leaves the event out, and onEvent never sees it:

export function scope(ctx) {
  if (ctx.event.flow !== "orb") return null;
  if (ctx.event.id !== "or_break_up" && ctx.event.id !== "or_break_dn") return null;
  return { direction: ctx.event.id === "or_break_up" ? "up" : "down" };
}

Lint checks each alias you compare against (study/event-flow-branch-mismatch). Read payload keys from the definition's events.declare. A key it does not emit is undefined.

scope and onEvent

scope(ctx) returns the event's scope tuple: one value for every declared scope dimension, {} when the study declares none, or null to leave the event out. It sees only what was known when the event fired, so it cannot read forward bars, collect or publish. Forward Insights call the same scope() for each event on the chart, so a marker lands in exactly the group the study counted. scope has the full rules.

onEvent(ctx, scope) receives that tuple, frozen, as its second argument. It measures the outcome. Returning early when the outcome is missing leaves the event's group as it is: the event still has a marker on the chart.

export function onEvent(ctx, scope) {
  const after = ctx.candles.forward({ minutes: 60 });
  if (!after) return;
  ctx.collect("rows", { direction: scope.direction, close60: after.close });
}

State across events

A study has no ctx.accum and no bar-by-bar memory. It has three sources for what came before:

  • ctx.previous() in scope returns the study's earlier events, newest first. Use it for a group that depends on earlier events, such as a streak.

  • ctx.collected(key) inside onEvent returns the rows this study collected for earlier events only. Use it for running measurements. scope cannot read it.

  • ctx.flows.<as>.events({ before: ctx.event.time }) reads a definition's own events, including ones this study never collected. See Dependencies.

This groups each break by how many breaks in a row went the same way, with a streak_bucket scope dimension declared:

export function scope(ctx) {
  if (ctx.event.flow !== "orb") return null;
  const up = ctx.event.id === "or_break_up";
  let streak = 1;
  for (const e of ctx.previous({ flows: ["orb"], limit: 10 })) {
    if ((e.id === "or_break_up") !== up) break;
    streak += 1;
  }
  return { streak_bucket: streak >= 3 ? "3+" : String(streak) };
}

A value that decides the group is computed in scope, as above, even when you only aggregate by it in onFinish.

onFinish

onFinish reads ctx.collected(key), which there returns every row, computes, and calls ctx.publish. It has no current event, so anything tied to one returns nothing there. Lint fails with study/no-event-cursor-in-onFinish for ctx.candles, ctx.indicators, ctx.flows, ctx.event, ctx.mark and ctx.priceUnits inside onFinish. ctx.session and ctx.temporal are plain time maths and work in both.

If a baseline needs candles at another time, measure it in onEvent and collect it on the same row as the real measurement.

Marking events on the preview

Call ctx.mark in onEvent (never in scope) so you can see on the preview chart that the study found each event:

ctx.mark(ctx.event.time, ctx.event.payload.close, { color: "#26a69a", shape: "circle" });

Always pass the options object. Without it the mark is dropped when the study runs over history.

Helpers and ctx

A helper function sees ctx only if you pass it in. Lint flags ctx used in a function that neither receives it nor sits inside a handler (study/ctx-out-of-scope).

function risk(ctx) {
  return ctx.event.payload.orHigh - ctx.event.payload.orLow;
}

Common mistakes

  • Calling ctx.publish in onEvent. It throws on a run over history.

  • Comparing ctx.event.flow to the slug instead of the alias.

  • Reading forward bars or later events in scope. It throws: measure them in onEvent.

  • Returning a report-role dimension from scope.

  • A scope path that ends without return. Return null to leave an event out.

  • Reading candles or indicators in onFinish.

  • Expecting events the definition never recorded. A study sees only events from running the definition over history.