ChartnautDocs

scope

scope(ctx) decides which group each event belongs to, using only what was known when the event fired. It runs first, once per event, in time order, before onEvent. A marker on a live chart lands in exactly the group scope() returns for it, so live and historical markers agree with the study's counts.

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

Every event study exports scope(), once. Trade studies (kind: "trade_study") group by trade and never export it. Only export function scope is the handler: a private helper named scope is an ordinary function.

What scope() returns

Return

Meaning

{ trend: "up", session: "NY" }

The event's scope tuple: one value for every declared scope dimension (role: "scope", or no role).

{}

The event is in the study. Use it when the study declares no scope dimensions.

null

The event is left out. onEvent does not run for it.

These are run errors for that event. The event is left out and the run continues:

  • returning undefined, from a missing return or a bare return;

  • returning a non-object

  • a key that is not a declared dimension

  • a report dimension

  • a value outside the dimension's values

  • a scope dimension left out

  • an uncaught throw inside scope(), reported as scope threw: ...

A study with scope() does not call ctx.scopeEvent. Doing both is a bundle error (bundle/scope-and-scope-event), and so is exporting scope twice (bundle/scope-duplicate).

The fire bar

The fire bar is the bar the event fired on. ctx.event.firedAt is that bar's open time, in unix seconds. It equals ctx.event.time unless the definition back-dated the event: an initial balance stamped at its first bar and fired when it completes, or any ctx.emit(id, payload, { timeStart }). ctx.event.knownAt is the end of the fire bar: the moment the definition decided, with what it knew then (for an event whose timeframe is not recorded it equals firedAt).

scope() sees the world as it stood on the fire bar.

What scope() can read

Read

What it sees

ctx.params, ctx.event

All fields, including firedAt and knownAt

ctx.candles.window({ before, after }), ctx.candles.timeOf(anchor, offset)

Closed bars up to the fire bar. after may reach ctx.event.firedAt - ctx.event.time, no further.

ctx.indicators.<as>.<key>

value, at, last, history, valueAt, valuesInRange, historyAt, up to the fire bar

ctx.flows.<as>.events(...)

Only events that had fired by then. An open query ends at the fire bar.

hlines, marks, ranges, plots, trendlines on ctx.flows.<as> and ctx.indicators.<as>

Rows up to the fire bar

lifecycle(...)

Instances as they stood at the fire bar. Later instances are absent and a later close is hidden.

layer(id, ...)

Rows as they stood. A row whose fields changed after the fire bar throws.

ctx.previous(...)

Events that fired before this one. See ctx.previous below.

h.asOf(t), h.valueAt(t) on a dataset handle

The calendar or rates as known at t, for t up to ctx.event.knownAt: the forecast the definition saw at the end of its bar is visible, nothing published later is. cal.asOf(ctx.event.knownAt)?.next is the release that was due next when the event fired. A view made for a later time (in onEvent) is refused if read here. When knownAt is past now (the fire bar has not ended), the read is undefined and the event is not ready.

ctx.session, ctx.temporal, ctx.stat, ctx.bin, ctx.priceUnits, ctx.debug

As in onEvent

A read past the fire bar throws a TypeError that names the read:

ctx.candles.window({ after }) reads 1700000600, after the event fired (1700000500): scope() sees only what was known when the event fired; read later data in onEvent

Past data that is missing, such as an indicator with no value yet, marks the event not ready. Its group is provisional, and the chart shows not ready in place of a cell.

scope() must not read module variables that other events change: let or var at module scope, or a module object some function mutates. The chart groups one event on its own and would not see them (study/scope-reads-module-state). Use ctx.previous() for earlier events.

What scope() cannot read

Reading any of these in scope() throws, and lint reports the same message (study/scope-side-effect, study/scope-forward-read):

Member

Message

ctx.collect, ctx.collected, ctx.state

ctx.collect is not available in scope(): scope sees only what was known when the event fired. Use ctx.previous() for earlier events

ctx.rand, ctx.cluster

... a group must be reproducible from the event alone

ctx.scopeEvent

ctx.scopeEvent is not available in scope(): return the event's scope tuple from scope() instead

ctx.publish, ctx.popoverLayout, ctx.plot, ctx.range, ctx.mark, ctx.hline, ctx.note, ctx.groupByScope, ctx.emit, ctx.snapshot

... scope decides the event's group; measure and record outcomes in onEvent

ctx.candles.forward

ctx.candles.forward is not available in scope(): scope sees only what was known when the event fired; read forward bars in onEvent

events({ after }) in scope() is a lint warning: past the fire bar it returns nothing.

ctx.previous

ctx.previous({ limit?, within?, flows?, filter? }): EventRef[]

Returns earlier events of the study's declared definitions that fired strictly before this one, newest first by fire time, with the same fields as ctx.event. It returns events, never earlier events' groups or outcomes.

Option

Meaning

limit

At most this many. Default 50, max 500.

within

Only events that fired within this many seconds before this event fired

flows

Aliases to include. Default: every declared definition.

filter(e)

Keeps an event when it returns true. limit counts kept events.

This groups a break by how many breaks in a row went the same way, with a streak scope dimension declared with values: ["1", "2", "3+"]:

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: streak >= 3 ? "3+" : String(streak) };
}

Grouping by what happens next

What happened after the event is never a scope dimension. Declare it role: "report", measure it in onEvent, collect it, and publish it in a result with indexedBy: [...scope dimensions, reportDimension]. scope() returning a report dimension is an error (study/scope-report-dimension).

This way "split by the outcome" is a full readout in the study's results, while the group a live marker shows uses only what was known when the event fired.

onEvent's second argument

onEvent(ctx, scope) receives the tuple scope() returned for this event, frozen. Read the group from it: there is no need to compute it twice.

export function onEvent(ctx, scope) {
  const fwd = ctx.candles.forward({ bars: 3 });
  if (!fwd) return;
  ctx.collect("rows", { trend: scope.trend, close3: fwd.close });
}

Tags, groupByScope and pin cells

An object row collected in onEvent carries its event's tuple as __scope. ctx.groupByScope(rows) returns [{ scope, rows }], grouped by that tuple in first-seen order. Untagged rows are left out, and publishing drops the tag.

Build pin cells from these groups, so each cell's n is exactly the number of events a marker can land in:

export function onFinish(ctx) {
  const cells = ctx.groupByScope(ctx.collected("rows")).map((g) =>
    ctx.popoverLayout.cell({
      dims: g.scope,
      n: g.rows.length,
      blocks: [ctx.popoverLayout.metric("Win rate", winRate(g.rows), { unit: "%", scale: "fraction_0_1" })],
    }),
  );
  ctx.publish("pin_cell", cells);
}

// Win rate over the events whose outcome is known.
function winRate(rows) {
  const done = rows.filter((r) => r.win !== null);
  return done.length ? done.filter((r) => r.win).length / done.length : 0;
}

onEvent collects one row for every event scope() includes, with outcome fields null while the outcome is not known yet, and statistics use the rows whose outcome is known. That way a cell's n is the number of events in the group, which is every event a marker can land in. An onEvent that returns without collecting leaves its event out of g.rows, so n undercounts.

At the end of a run, a pin_cell whose n differs from the number of events scope() put in that group gets the warning study/pin-cell-count-mismatch.

Full example

Opening range breaks grouped by the trend into the break, with the next three bars as a report dimension:

meta({ name: "ORB follow-through by trend", kind: "study" });
flows.declare({ slug: "opening-range-break", as: "orb", version: 3 });
dimensions.declare({ id: "trend", title: "Trend into the break", description: "Close vs close 12 bars earlier", values: ["up", "down"] });
dimensions.declare({ id: "next", title: "Next 3 bars", description: "Direction over the 3 bars after the break", role: "report", values: ["up", "down"] });
results.declare({ id: "pin_cell", kind: "popover_layout", title: "By trend", indexedBy: ["trend"] });
results.declare({ id: "by_next", kind: "table", title: "Trend x next", indexedBy: ["trend", "next"] });

export function scope(ctx) {
  if (ctx.event.flow !== "orb") return null;
  const bars = ctx.candles.window({ before: 12 * 300 });
  if (bars.length < 2) return null;
  return { trend: bars[bars.length - 1].close > bars[0].close ? "up" : "down" };
}

export function onEvent(ctx, scope) {
  // One row per event in the study; the outcome is null until the forward bars exist.
  const now = ctx.candles.window({ before: 0 })[0];
  const fwd = ctx.candles.forward({ bars: 3 });
  const done = now && fwd;
  ctx.collect("rows", {
    next: done ? (fwd.close > now.close ? "up" : "down") : null,
    ret: done ? (fwd.close - now.close) / now.close : null,
  });
}

export function onFinish(ctx) {
  const groups = ctx.groupByScope(ctx.collected("rows"));
  ctx.publish("pin_cell", groups.map((g) => ctx.popoverLayout.cell({
    dims: g.scope, n: g.rows.length,
    blocks: [ctx.popoverLayout.metric("Avg 3-bar return", ctx.stat.mean(g.rows.filter((r) => r.ret !== null).map((r) => r.ret)))],
  })));
  const table = [];
  for (const g of groups) for (const next of ["up", "down"]) {
    table.push({ trend: g.scope.trend, next, n: g.rows.filter((r) => r.next === next).length });
  }
  ctx.publish("by_next", table);
}

Boundaries

  • A group cannot depend on the future. Anything measured after the fire bar is a report dimension.

  • "Wait N bars, then group" is the definition's job: fire the event later, or back-date it with timeStart, and scope() reads up to the fire bar.

  • scope() groups one event from what it can see. Earlier events come from ctx.previous(), never from rows collected for them.