ChartnautDocs

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

metric

{ value: number, format?, ci?: [lo, hi], n? }

series

[{ x, y, series? }]

distribution

An array of numbers, or pre-binned [{ lo, hi, mid, count }]

boxplot

[{ group: string, values: number[] }]

heatmap

[{ x, y, value: number }]

scatter

[{ x, y, group? }]

bar

[{ cat, value: number, series? }]

table

An array of row objects. Keep it to 100 rows or fewer.

vega

A Vega-Lite spec object

annotation

An object

prose

{ body: string, role?, format?, tone?, refs? }

popover_layout

An array of ctx.popoverLayout.cell(...) rows. Written by Attach to chart, see Forward Insights.

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 is readout__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 as readout__events covers 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) / atr

Pass 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)

mode

Returns

"dow"

"Mon" to "Sun"

"hour"

0 to 23

"halfHour"

0 to 47

"day"

Day of the month in the preview, day of the year on a run over history. Group by key or ctx.temporal instead.

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.