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): voidAll 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 |
|---|---|
| The |
| The event id the definition declared, such as |
| Unix seconds, UTC. The event's start time. |
| Unix seconds, UTC. Open time of the bar the event fired on. Equals |
| The instrument, as a string |
| The fields the definition emitted, exactly as emitted |
| The |
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()inscopereturns the study's earlier events, newest first. Use it for a group that depends on earlier events, such as a streak.ctx.collected(key)insideonEventreturns the rows this study collected for earlier events only. Use it for running measurements.scopecannot 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.publishinonEvent. It throws on a run over history.Comparing
ctx.event.flowto the slug instead of the alias.Reading forward bars or later events in
scope. It throws: measure them inonEvent.Returning a report-role dimension from
scope.A
scopepath that ends withoutreturn. Returnnullto 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.
