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): voidEvery 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 |
|---|---|
| The event's scope tuple: one value for every declared scope dimension ( |
| The event is in the study. Use it when the study declares no scope dimensions. |
| The event is left out. |
These are run errors for that event. The event is left out and the run continues:
returning
undefined, from a missingreturnor a barereturn;returning a non-object
a key that is not a declared dimension
a report dimension
a value outside the dimension's
valuesa scope dimension left out
an uncaught throw inside
scope(), reported asscope 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 |
|---|---|
| All fields, including |
| Closed bars up to the fire bar. |
|
|
| Only events that had fired by then. An open query ends at the fire bar. |
| Rows up to the fire bar |
| Instances as they stood at the fire bar. Later instances are absent and a later close is hidden. |
| Rows as they stood. A row whose fields changed after the fire bar throws. |
| Events that fired before this one. See ctx.previous below. |
| The calendar or rates as known at |
| As in |
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 onEventPast 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 |
|---|---|
|
|
|
|
|
|
|
|
|
|
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 |
|---|---|
| At most this many. Default 50, max 500. |
| Only events that fired within this many seconds before this event fired |
| Aliases to include. Default: every declared definition. |
| Keeps an event when it returns true. |
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, andscope()reads up to the fire bar.scope()groups one event from what it can see. Earlier events come fromctx.previous(), never from rows collected for them.
