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.srcis 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'ssource=.
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 noctx.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
- Validate (
Ctrl/Cmd + Enter) — runsinitthenon_barover the EURUSD 15m sample. You should see no errors. If there's a typo, the editor highlights the line. - Save (
Ctrl/Cmd + S) — your indicator goes into your library at version1. Every subsequent save bumps the version. - 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.