Results and stats
Each result has a kind, set in results.declare, and the kind fixes the payload shape ctx.publish accepts. This page lists those shapes and the helpers for computing them: ctx.stat, ctx.bin, ctx.rand, ctx.cluster, ctx.priceUnits, ctx.session and ctx.temporal.
Result kinds
Kind | Publish payload |
|---|---|
|
|
|
|
| An array of numbers, or pre-binned |
|
|
|
|
|
|
|
|
| An array of row objects. Keep it to 100 rows or fewer. |
| A Vega-Lite spec object |
| An object |
|
|
| An array of |
A payload with the wrong shape is refused and reported as an error on the run. Lint also warns when a literal payload misses a required key (study/payload-shape-mismatch).
metric
format controls display and is never guessed from the title or the size of the number. "percent" (or "pct") means value is a fraction from 0 to 1: 0.53 shows as 53.00%. Other formats are "currency" ("usd") and "integer" ("int"). Pass n to show the sample behind the number.
ctx.publish("hit_rate", { value: targets / rows.length, format: "percent", n: rows.length });table
Row keys become column headers, so name them for a reader: events_in_group, not n. A table of every event is a chart in disguise. Group it first, or publish a distribution or scatter instead.
When the table is declared with indexedBy, every row must include each indexed dimension as an explicit key, such as direction: d (study/publish-missing-index-column).
prose
A prose result is a short written answer. role is "question", "answer", "caveat", "note" or "section", and format is "text" or "markdown". Build the body from the same variables you publish as metrics, so the text and the numbers agree.
ctx.publish("readout__summary", {
body: `${n} breaks. Target hit first in ${(rate * 100).toFixed(0)}% of them.`,
role: "answer",
});Readouts lint expects
These are warnings, not errors:
A study with two or more non-prose results and no prose result declared with
role: "answer"(study/missing-answer-prose). The usual id isreadout__summary, declared first.A study with a chart or table kind and no result whose id starts with
readout__(study/readout-missing). A small metric such asreadout__eventscovers it.A result without a
description(study/result-missing-description).
ctx.stat
Each takes an array of numbers.
ctx.stat.mean(xs) ctx.stat.median(xs) ctx.stat.std(xs)
ctx.stat.variance(xs) ctx.stat.skewness(xs) ctx.stat.kurtosis(xs)
ctx.stat.sum(xs) ctx.stat.min(xs) ctx.stat.max(xs)
ctx.stat.percentile(xs, p) // p from 0 to 1; a p above 1 is read as a percent
ctx.stat.percentileRank(xs, value) // 0 to 1
ctx.stat.corr(xs, ys)
ctx.stat.covariance(xs, ys)
ctx.stat.regress(xs, ys) // { slope, intercept, r2 }percentile returns NaN on an empty array. Check rows.length before publishing a statistic.
ctx.bin
ctx.bin.equalWidth(values, n) // [{ lo, hi, mid, count }]
ctx.bin.quantile(values, n) // [{ lo, hi, mid, count }]
ctx.bin.byKey(rows, keyFn, valueFn, agg) // [[key, value], ...]agg is "mean", "median", "sum", "count", "min" or "max". The key keeps the type keyFn returned, so a numeric key compares cleanly with === against a numeric row field.
const byDay = ctx.bin.byKey(rows, (r) => r.weekday, (r) => r.ret, "mean");
ctx.publish("mean_by_weekday", byDay.map(([day, mean]) => ({ cat: day, value: mean })));ctx.rand and ctx.cluster
ctx.rand.bootstrap(values, n, statFn) // n resampled values of statFn
ctx.rand.seeded(seed) // { float(), intN(n) }
ctx.cluster.kmeans(points, k) // { centers, assignments }
ctx.cluster.pca(points, k) // { components, projections }seeded gives the same sequence for the same seed in the preview and on every run, so a random baseline built on it is reproducible. pca is a stub in the preview. Resample counts and k-means iterations are capped, see Budgets and limits.
const means = ctx.rand.bootstrap(rets, 1000, (s) => ctx.stat.mean(s));
ctx.publish("mean_ret", {
value: ctx.stat.mean(rets),
ci: [ctx.stat.percentile(means, 0.025), ctx.stat.percentile(means, 0.975)],
n: rets.length,
});ctx.priceUnits
Converts a price difference into comparable units. Use it in onEvent.
ctx.priceUnits.inBP(price, refPrice) // (price - refPrice) / refPrice * 10000
ctx.priceUnits.inPct(price, refPrice) // (price - refPrice) / refPrice * 100
ctx.priceUnits.inATR(price, refPrice, atr) // (price - refPrice) / atrPass the ATR value yourself, read from an indicator dependency at the right time. See Dependencies. The preview ignores the third argument of inATR and divides by refPrice, so its numbers differ from a run over history. Divide by the ATR yourself, or use inBP or inPct, when you want the preview to match.
ctx.session
All three work on the UTC calendar day.
ctx.session.key(t) // "2026-03-14"
ctx.session.boundsOf(t) // { start, end, key } for that UTC day
ctx.session.bucket(t, mode)
| Returns |
|---|---|
|
|
| 0 to 23 |
| 0 to 47 |
| Day of the month in the preview, day of the year on a run over history. Group by |
key is a label for grouping, not a number. Subtracting or comparing two keys is a lint error (study/session-key-not-numeric). boundsOf takes one argument (study/session-boundsof-arity); use ctx.temporal for weeks, months and other time zones.
ctx.temporal
ctx.temporal.containing(t, { period, tz? }) // { start, end, key }
ctx.temporal.custom({ startUtc, endUtc }) // { start, end, key }
ctx.temporal.forwardFrom(t, endUtc) // { start: t, end: endUtc }period is "day", "week" (starting Monday), "month" or "year". tz is an IANA name such as "America/New_York" and defaults to UTC. start is inclusive and end exclusive. There are no exchange-session or instrument presets: build those with custom.
Day offsets after an event come from start times, not session keys:
const eventDay = ctx.temporal.containing(ctx.event.time, { period: "day" });
for (const bar of ctx.candles.window({ before: 0, after: 3 * 86400 })) {
const barDay = ctx.temporal.containing(bar.time, { period: "day" });
const dayIndex = Math.floor((barDay.start - eventDay.start) / 86400);
// dayIndex 0, 1, 2 ...
}Drawing on the preview
Studies draw on the preview chart with their own helpers, called in onEvent:
ctx.mark(time, price, { color?, shape?, text? })
ctx.plot(seriesId, time, value)
ctx.range(rangeId, t1, t2, { top?, bottom?, color? })
ctx.hline(price, { color?, label? })
ctx.note(time, text)These take different arguments from the indicator and definition versions in Drawing on the chart. Pass the options object to ctx.mark every time: a mark without it is dropped on a run over history.
