Getting Started

Custom indicators in BacktestingMax let you write any technical indicator in Python and use it on your charts and in backtests. You write a small Python class, we run it sandboxed, and your output appears on the chart as lines, fills, markers, or boxes. Here's a working example, then we'll walk through it.

You don't have to write the Python yourself. Describe the indicator you want in AI Chat, or press the AI button inside the editor, and the code is written for you. These docs are then how you read, check, and tweak what it produced — or how you write one from scratch if you prefer.

The default template

When you open the indicator editor, the source pane is seeded with this:

from indicator_api import Indicator
class MyIndicator(Indicator):
    name = "My Indicator"
    params = {
        "period": {"type": "int", "default": 14, "min": 2, "max": 200},
    }
    def init(self, ctx):
        self.sma = ctx.rolling_mean(source=ctx.close, period=ctx.params.period)
    def on_bar(self, ctx):
        ctx.plot.series(
            "sma", value=self.sma.value,
            color="#2196f3", line_width=1.5,
            label=f"SMA({ctx.params.period})",
        )

Reading line by line:

  • from indicator_api import Indicator — the only import you need to start. Indicator is the base class your indicator must subclass.
  • class MyIndicator(Indicator): — every indicator is a subclass of Indicator. The class name is for you; the runtime finds your indicator by locating the Indicator subclass in your file.
  • name = "My Indicator" — optional metadata shown in the chart legend and your library. See Scripting basics.
  • params = {...} — declares what the user can tweak in the side panel. See Parameters for every type and option.
  • def init(self, ctx): — runs once, before the first bar. Create the calculation helpers you need here and store them on self. This one creates a rolling-mean (simple moving average) helper over the close.
  • def on_bar(self, ctx): — runs once per bar, in time order. Read the helper's current value (self.sma.value) and draw it with ctx.plot.series(...). It returns nothing — you draw by calling ctx.plot.*, not by returning a value.

The two-method model (read this once)

This is the single most important concept, so it gets its own section.

An indicator works one bar at a time:

Method Runs What it's for
init(self, ctx) Once, before any bars Create helpers (ctx.rolling_mean, ctx.ema, …) and initialise any state you'll carry from bar to bar. Store everything on self.
on_bar(self, ctx) Once per bar, in time order Read helper values and the current bar, do per-bar logic, and draw via ctx.plot.*.

State lives on self between bars. To remember the previous bar's value, stash it on self at the end of on_bar and read it back next time. This is expected and required — keeping state on self is how the bar-by-bar model works.

Because on_bar only ever sees the current bar and bars before it, your indicator cannot read the future — so a backtest built on it is honest by design. (More in Errors.)

Coming from Pine Script?

If you've written Pine before, here's the rough translation. The big shift: you create stateful helpers in init, then read them and draw each bar in on_bar — you don't return anything.

Pine                          Ours
─────────────────────────────────────────────────────────────────────
ta.sma(close, 20)             ctx.rolling_mean(source=ctx.close, period=20)
                              # create in init(), read .value in on_bar()
ta.ema(close, 20)             ctx.ema(source=ctx.close, period=20)
ta.rsi(close, 14)             ctx.rsi(source=ctx.close, period=14)
close                         ctx.close            (float(ctx.close) for a number)
close[1]                      ctx.close[1]         (1 bar back; None if N/A)
high - low                    ctx.high - ctx.low
plot(myval)                   ctx.plot.series("myval", value=myval)
plotshape(cond, ...)          if cond: ctx.plot.shape("sig", price=..., shape="arrow_up")
ta.crossover(a, b)            a > b and self._prev_a <= self._prev_b
                              # remember prev values on self in on_bar()

ta.crossover has no one-liner because crossover is inherently stateful here: store the previous bar's values on self at the end of on_bar, compare against them next bar. The tutorial does exactly this.

Anatomy of an indicator

Part What it is
Class A subclass of Indicator. Holds metadata (name, description, optional id) and your params.
Params A dict declaring the user-facing inputs. Each entry has a type, a default, and optional min / max / choices.
init Create helpers and state. Runs once.
on_bar Per-bar logic + drawing. Runs once per bar.
ctx.plot How you draw. There is no return value.

The Validate → Save → Apply loop

The editor has three actions:

Action Hotkey What it does
Validate Ctrl/Cmd + Enter Dry-runs your indicator against a fixed EURUSD 15m sample. Catches errors before you touch a live chart. Renders nothing.
Save Ctrl/Cmd + S Persists to your library as a new immutable version (1, 2, 3…).
Apply Ctrl/Cmd + Shift + Enter Adds the indicator to the chart you're looking at. Auto-saves first if the source has changed.

What blocks each action:

  • Validate is blocked by parse failures or a missing Indicator subclass.
  • Save is blocked by security-check failures (forbidden imports or builtins) — see Errors.
  • Apply runs the current source on the chart; it does not require a separate prior Validate.

⚠️ Gotcha: Validate runs on a fixed EURUSD 15m sample, not the chart you're looking at. If your indicator only works on a specific symbol or timeframe, Validate can pass while Apply on a different chart still surfaces a runtime error.

For a deeper tour of the editor — the library, versions, copying a built-in, publishing — see Working with the editor.

Where to go next

Documentation