Build your first custom indicator

This page builds a Donchian channel: the highest high and lowest low of the last N bars, and the line halfway between them.

1. Install LumiBot

pip install lumibot

2. Write the class

from collections import deque

from lumibot.indicators import Indicator, InputSpec, OutputSpec, register


@register
class DonchianMidline(Indicator):
    key = "donchian_mid"
    version = 1
    title = "Donchian Channel Midline"
    short_title = "DCM"
    overlay = True  # same pane as the price

    inputs = [InputSpec("length", "int", 20, min=1, label="Length")]
    outputs = [
        OutputSpec("upper", "Upper", "line", "#22C55E"),
        OutputSpec("mid", "Midline", "line", "#F59E0B"),
        OutputSpec("lower", "Lower", "line", "#EF4444"),
    ]

    def init_state(self):
        n = self.inputs["length"]
        return {"highs": deque(maxlen=n), "lows": deque(maxlen=n)}

    def step(self, state, bar):
        state["highs"].append(bar.high)
        state["lows"].append(bar.low)
        if len(state["highs"]) < self.inputs["length"]:
            return {"upper": None, "mid": None, "lower": None}
        upper, lower = max(state["highs"]), min(state["lows"])
        return {"upper": upper, "mid": (upper + lower) / 2, "lower": lower}

What each part does:

key and version

The name you use to find it, and the version of its math. If you change the formula later, raise version so saved charts and strategies that used the old numbers keep them.

inputs

The settings a person can change (here, length).

outputs

What it draws: three lines.

init_state()

The data that changes from bar to bar. Keep all of it here.

step(state, bar)

Called once per bar. Update state and return this bar’s values. Return None while there is not enough data yet. Never invent a number.

@register

Adds it to LumiBot’s registry so every part of LumiBot can find it by key.

3. Run it on bars, then feed it live bars

"""Run a custom indicator on bars, then feed it live bars.

Uses made-up prices so it runs anywhere without a network. Swap ``df`` for
any OHLCV DataFrame (a CSV, yfinance, your broker) with a time index.

    python -m lumibot.example_indicators.quickstart
"""

import numpy as np
import pandas as pd

from lumibot.example_indicators.donchian_midline import DonchianMidline


def main():
    # 1. Some daily bars (index = bar time; columns open/high/low/close/volume).
    index = pd.date_range("2025-01-01", periods=60, freq="1D", tz="UTC")
    close = 100 + np.cumsum(np.random.default_rng(0).normal(0, 1, len(index)))
    df = pd.DataFrame({"open": close, "high": close + 1, "low": close - 1, "close": close, "volume": 1000.0}, index=index)

    # 2. Whole series at once: lists aligned with the bars, None while warming up.
    series = DonchianMidline(length=20).compute(df)
    print("last 3 midline values:", series["mid"][-3:])

    # 3. Streaming: history first, then live bars one at a time.
    dcm = DonchianMidline(length=20)
    dcm.feed(df)  # every history bar is closed
    next_time = index[-1] + pd.Timedelta(days=1)
    tick = {"time": next_time, "open": close[-1], "high": close[-1] + 5, "low": close[-1] - 1, "close": close[-1] + 4}
    print("forming:", dcm.update(tick, closed=False))  # live tick: nothing saved
    print("closed: ", dcm.update(tick, closed=True))  # bar finished: saved
    return series, dcm


if __name__ == "__main__":
    main()

compute returns lists that line up with your bars. The first 19 values are None because 20 bars are needed. update(bar, closed=False) gives a live value for a forming bar without saving anything; closed=True saves it. Run it with python -m lumibot.example_indicators.quickstart.

4. Use it in a strategy

from lumibot.example_indicators import donchian_midline  # registers "donchian_mid"

def on_trading_iteration(self):
    dcm = self.indicators.get("donchian_mid", self.asset, length=20)
    if dcm and dcm["mid"] is not None and self.get_last_price(self.asset) > dcm["upper"]:
        ...

See Using an indicator in a backtest and Using an indicator in a live strategy.

Three rules that keep your numbers right

  1. All changing data goes in state. LumiBot copies it to try a forming bar, then throws the copy away. Data saved on self would leak live ticks into history. (Outside data you download is the exception; see Using outside data in an indicator.)

  2. Never look ahead. step only sees the current bar. A value for bar k may only use bars up to k.

  3. Do not recompute history. Keep running sums or a fixed window. Then every new bar costs the same small amount of time.

The full contract is on Building indicators.