ChartnautDocs

Forward Insights

A Forward Insight puts a study's answer on the chart: click a definition's mark and a popover shows what happened next after events like it, with the sample behind each number. This page covers what a study script needs before Attach to chart can turn it into one, and what the wizard adds to your code. The clicks are in Show studies on charts.

What makes a study attachable

Attach to chart is offered when the study measures what happens after the event. The app looks for any of these in the source:

  • a ctx.candles.forward(...) call

  • a ctx.flows.<as>.events({ after: ... }) query

  • a dimension declared with role: "report"

A study that only reads bars before or during the event shows Not a forward insight yet instead.

The wizard's first step, What do you care about?, offers only numbers the study already publishes: metrics, table columns and collected rows. With none, it says This study doesn't publish a metric that can go on the chart yet. Declare the number you want as a metric result, run the study again, and attach that run.

Breakdowns come from scope dimensions

The Break down by step lists the study's role: "scope" dimensions. Each combination you pick becomes its own reading, and the chart shows the reading for the group the clicked event belongs to. A study with no scope dimensions pins as one global card that shows the same number on every event.

For a breakdown to work:

  • Declare each dimension with dimensions.declare, with values when the set is fixed.

  • Only facts known when the event fires can be scope dimensions: direction, session, weekday, a volatility bucket at the event. What happened afterwards is role: "report". Putting an outcome in scope means the insight needs to know the answer before it can show it.

  • Return every scope dimension from export function scope(ctx), on every path, or null to leave the event out.

  • Use the canonical values in scope(), never display labels.

scope has the rules and an example.

Where a marker lands

A marker's cell is exactly the tuple scope() returns for that event as of its fire bar. The chart makes the same call the study's run made, so live and historical markers land in the groups the study counted. When past data the grouping needs is missing, such as an indicator with no value yet, the marker shows not ready in place of a cell.

A study without scope() groups events by calling ctx.scopeEvent(tuple) inside onEvent. Reads after the event can decide that group, so its live markers can land in a different cell than the study counted. The study page, Pin Studio and the chart popover show This insight can be wrong on live events with an Update button for such a study. Update asks the study authoring agent to move the grouping into scope().

What Apply breakdown adds

When you click Apply breakdown, an agent edits the study, saves it as a new version and runs it again. The edit adds one result:

results.declare({
  id: "pin_cell",
  kind: "popover_layout",
  title: "Chart card",
  description: "Break outcome for each break direction.",
  indexedBy: ["direction"],
});

and publishes one cell per group in onFinish:

const groups = ctx.groupByScope(ctx.collected("rows"));
ctx.publish("pin_cell", groups.map((g) => {
  const rate = g.rows.filter((r) => r.hit === "target").length / g.rows.length;
  return ctx.popoverLayout.cell({
    dims: g.scope,
    n: g.rows.length,
    signal: rate > 0.5 ? "bullish" : "bearish",
    blocks: [
      ctx.popoverLayout.metric("Target hit first", rate, {
        unit: "%",
        scale: "fraction_0_1",
        baseline: overallRate,
        description: "Share of breaks where the target hit before the stop.",
      }),
      ctx.popoverLayout.text("Typical exit", modalExit(g.rows), {
        description: "Most common way these breaks resolved.",
      }),
      ctx.popoverLayout.badge("Above baseline", "pos"),
      ctx.popoverLayout.note("Fewer than 30 events in some groups."),
    ],
  });
}));

The id is always pin_cell and the kind always popover_layout. indexedBy lists the dimensions you picked in Break down by, and every cell sets each of them under dims. Your other results stay. The edit also marks outcome dimensions role: "report" when the study has those wrong, and every edit leaves the study's grouping in scope().

ctx.popoverLayout

These build plain objects. They compute nothing, so call them in onFinish and publish the result.

Builder

Makes

metric(label, value, opts?)

A number row. opts: unit, scale, baseline, delta, tone, hint, description.

text(label, value, opts?)

A text row. opts: hint, description, tone.

badge(text, tone?)

A short label, such as a verdict

note(text)

A line of small print

cell({ dims, n, signal?, blocks })

One group's card

With unit: "%", always set scale: "fraction_0_1" when the value is 0.53 for 53%, "percent_0_100" when it is 53. Without it the popover guesses from the size of the number, and a value of 1 could be 1% or 100%.

n is the number of events in that group. Keep it true: cells built from ctx.groupByScope count exactly the events a marker can land in, and a pin_cell whose n differs from what scope() put in the group gets the run warning study/pin-cell-count-mismatch. A small group is valid and the popover shows it with a small-sample caution.

When the outcome falls into categories, such as trending, rotational or ranging, give each cell every category's share. A cell that shows only one outcome at 100% means the outcome leaked into the breakdown.

From the API or an AI app

The API, the CLI and connected AI apps create Forward Insights without the wizard, so no agent edits your study and no AI credits are spent. You, or your AI, write pin_cell yourself. Before you create one, the study needs:

  • results.declare({ id: "pin_cell", kind: "popover_layout", indexedBy: [...] }), with your scope dimensions in indexedBy, or [] for one global card.

  • export function scope(ctx) returning every scope dimension, using canonical values, from what was known when the event fired.

  • Outcomes as role: "report" dimensions, never in indexedBy.

  • Exactly one ctx.popoverLayout.cell per combination of the indexedBy values, built from ctx.groupByScope and published in onFinish. A global card publishes exactly one.

  • One of your own definitions declared. To use someone else's, save a copy and declare that.

Then:

  1. Save the study and run it on the instrument and timeframe you chart. The insight shows only there.

  2. Read the run's pin_cell result and check there is one row per combination.

  3. Create the insight as a dry run, with dry_run: true on POST /insights, chartnaut insights create --dry-run or create_forward_insight, and check the preview card.

  4. Create it for real with the same run and name.

A refusal says what the study is missing, often with the code to add. Fix the study, save it, run it again and create from the new run. To change an insight's numbers later, do the same with the same name: the new one replaces the old.

When saving fails

The wizard checks the edited study before saving. Pin validation errors lists the messages and fixes. If Couldn't apply your breakdown appears, nothing was saved to your chart.