Your First Indicator

This tutorial builds a non-trivial indicator end to end — an EMA centreline with a ±N×σ envelope and markers when price crosses a band. By the end you'll have used parameters, helpers, per-bar state, a fill, sparse markers, and the full Validate → Save → Apply loop.

What we're building

Three lines on the main pane:

  • Centre — an EMA of the source price.
  • Upper / Lower — the centreline ± mult × the rolling standard deviation.

Plus a translucent fill between the bands, and a marker every time the close crosses above the upper band or below the lower band.

Step 1 — Open the editor and pick a name

From your library, click New Indicator. The editor opens with the default template. Rename the class to EmaBands and give it a friendly name:

from indicator_api import Indicator
class EmaBands(Indicator):
    name = "EMA Bands"
    description = "EMA centreline with a ±N×σ envelope and cross markers."

Step 2 — Declare the parameters

Three params: the EMA length, the σ multiplier, and the source series. Use Param for src because it needs kind="source" (see Parameters):

from indicator_api import Indicator, Param
class EmaBands(Indicator):
    name = "EMA Bands"
    params = {
        "period": Param(type=int, default=20, min=2, max=500,
                        description="EMA period"),
        "mult":   Param(type=float, default=2.0, min=0.5, max=5.0,
                        description="Standard deviations for the envelope"),
        "src":    Param(type=dict,
                        default={"type": "source", "value": "close"},
                        kind="source",
                        description="Price series the bands run on"),
    }

Step 3 — Create the helpers in init

init runs once, before any bars. Create an EMA helper for the centreline and a rolling-std helper for the band width, both over the chosen source. Also set up the state we need for cross detection — last bar's band values:

    def init(self, ctx):
        self.center = ctx.ema(source=ctx.params.src,
                              period=ctx.params.period)
        self.std    = ctx.rolling_std(source=ctx.params.src,
                                      period=ctx.params.period, ddof=0)
        self._prev_upper = None
        self._prev_lower = None

💡 Tip: ctx.params.src is already a usable price series — the runtime resolves the source-typed param before your code runs, so you can pass it straight into a helper's source=.

Step 4 — Draw the bands in on_bar

on_bar runs once per bar. Read the helper values, bail out with gaps during warm-up, compute the envelope, and draw the three lines plus the fill:

    def on_bar(self, ctx):
        center = self.center.value
        sd     = self.std.value
        if center is None or sd is None:
            # Warm-up: keep the series aligned to bar times with gaps.
            ctx.plot.series("center", value=None)
            ctx.plot.series("upper",  value=None)
            ctx.plot.series("lower",  value=None)
            self._prev_upper = None
            self._prev_lower = None
            return
        mult  = ctx.params.mult
        upper = center + mult * sd
        lower = center - mult * sd
        ctx.plot.series("center", value=center, color="#2196f3",
                        line_width=2.0, label="EMA")
        ctx.plot.series("upper",  value=upper, color="#9e9e9e")
        ctx.plot.series("lower",  value=lower, color="#9e9e9e")
        ctx.plot.fill("band", top="upper", bottom="lower",
                      color="#2196f3", fill_opacity=0.08)

Step 5 — Add markers on cross

A bar "crosses up" when the previous close was at or below the previous upper band and this close is above the current upper band — and the mirror for the lower band. The previous close comes free from ctx.close[1]; the previous band values we remembered on self. Add this before the end of on_bar, then update the state:

        prev_close = ctx.close[1]
        close_now  = float(ctx.close)
        if prev_close is not None and self._prev_upper is not None:
            if prev_close <= self._prev_upper and close_now > upper:
                ctx.plot.shape("cross", price=close_now,
                               shape="arrow_down", color="#ef4444",
                               position="above_bar")
            elif prev_close >= self._prev_lower and close_now < lower:
                ctx.plot.shape("cross", price=close_now,
                               shape="arrow_up", color="#22c55e",
                               position="below_bar")
        self._prev_upper = upper
        self._prev_lower = lower

⚠️ You can't read the future here. ctx.close[1] is the previous bar; there is no ctx.close[-1] or future index. The bar-by-bar model only ever exposes the current bar and earlier ones, so this indicator is backtest-safe by design — see Errors.

ctx.plot.shape is sparse — we only call it on the bars where a cross actually happens, so markers appear exactly there.

Final code

from indicator_api import Indicator, Param
class EmaBands(Indicator):
    id = "ema_bands"
    name = "EMA Bands"
    description = "EMA centreline with a ±N×σ envelope and cross markers."
    params = {
        "period": Param(type=int, default=20, min=2, max=500,
                        description="EMA period"),
        "mult":   Param(type=float, default=2.0, min=0.5, max=5.0,
                        description="Standard deviations for the envelope"),
        "src":    Param(type=dict,
                        default={"type": "source", "value": "close"},
                        kind="source",
                        description="Price series the bands run on"),
    }
    def init(self, ctx):
        self.center = ctx.ema(source=ctx.params.src,
                              period=ctx.params.period)
        self.std    = ctx.rolling_std(source=ctx.params.src,
                                      period=ctx.params.period, ddof=0)
        self._prev_upper = None
        self._prev_lower = None
    def on_bar(self, ctx):
        center = self.center.value
        sd     = self.std.value
        if center is None or sd is None:
            ctx.plot.series("center", value=None)
            ctx.plot.series("upper",  value=None)
            ctx.plot.series("lower",  value=None)
            self._prev_upper = None
            self._prev_lower = None
            return
        mult  = ctx.params.mult
        upper = center + mult * sd
        lower = center - mult * sd
        ctx.plot.series("center", value=center, color="#2196f3",
                        line_width=2.0, label="EMA")
        ctx.plot.series("upper",  value=upper, color="#9e9e9e")
        ctx.plot.series("lower",  value=lower, color="#9e9e9e")
        ctx.plot.fill("band", top="upper", bottom="lower",
                      color="#2196f3", fill_opacity=0.08)
        prev_close = ctx.close[1]
        close_now  = float(ctx.close)
        if prev_close is not None and self._prev_upper is not None:
            if prev_close <= self._prev_upper and close_now > upper:
                ctx.plot.shape("cross", price=close_now,
                               shape="arrow_down", color="#ef4444",
                               position="above_bar")
            elif prev_close >= self._prev_lower and close_now < lower:
                ctx.plot.shape("cross", price=close_now,
                               shape="arrow_up", color="#22c55e",
                               position="below_bar")
        self._prev_upper = upper
        self._prev_lower = lower

Step 6 — Validate, Save, Apply

  1. Validate (Ctrl/Cmd + Enter) — runs init then on_bar over the EURUSD 15m sample. You should see no errors. If there's a typo, the editor highlights the line.
  2. Save (Ctrl/Cmd + S) — your indicator goes into your library at version 1. Every subsequent save bumps the version.
  3. Apply (Ctrl/Cmd + Shift + Enter) — adds the indicator to your live chart. You'll see the EMA, the grey band lines, the translucent blue fill, and arrows on every cross.

What to read next

  • Reference: Built-in Calculations — every helper you can create in init.
  • Recipes: Common patterns — short, copy-pasteable snippets.
  • Reference: Plots and drawings — every ctx.plot.* method.
  • Reference: Errors — error categories and why custom indicators are backtest-safe by design.
  • Hands-on: Copy a built-in indicator from the library to see how more advanced indicators are organized — see Working with the editor.

Documentation