Skip to content
Single .md

The native indicator layer — Indicator (no import)

TessTrade ships one indicator math engine, tesstrade_core, and both the live chart (live-calc / WASM) and your strategy read from it. That is why the series you compute and the line the chart draws come from a single code path — there is no separate "chart math" that can disagree with yours.

Your strategy reaches that engine through the pre-injected Indicator global. It is already in scope — you do not import it, and you must not:

python
# ❌ WRONG — the editor's validator rejects this line:
#    "Linha N: import não permitido (tesstrade_indicators)"
import tesstrade_indicators as ti      # NOT importable from the strategy editor

# ✅ RIGHT — Indicator is pre-injected, no import needed:
rsi = Indicator.rsi(sdk.candles[-300:], 14)[-1]

tesstrade_indicators is not a user API. It is the internal native module (PyO3 / chart-side binding) that wires tesstrade_core into the renderer and the backtest backend. The strategy editor's client-side validator only allows imports from numpy, pandas, pandas_ta, talib, math, json, datetimetesstrade_indicators is not on that list, so import tesstrade_indicators fails validation before your script ever runs. The math is still yours: it is exposed to strategies through the injected Indicator global (no import) and, for anything outside its catalogue, through pandas_ta (import pandas_ta as ta, also pre-injected as ta).

New here? Start with the Anatomy of a custom indicator — it shows the canonical file structure (params and colors first) that every example on this page assumes.

What Indicator gives you (no import)

Indicator is the canonical dependency-free indicator layer. Every method returns a plain list aligned 1:1 with the input; the first argument may be a DataFrame (in the df= chart branch), a list of candle dicts (a sdk.candles[-N:] window), or a plain list of floats. When you pass candles or a DataFrame, source picks the column (default 'close').

python
Indicator.sma(data, period, source='close')                 # -> list
Indicator.ema(data, period, source='close')                 # -> list
Indicator.rsi(data, period, source='close')                 # -> list  (Wilder)
Indicator.macd(data, fast, slow, signal, source='close')    # -> (macd, signal, hist)
Indicator.bollinger(data, period, std_dev, source='close')  # -> (upper, middle, lower)

Warm-up positions (before there is enough history) are None. Read the latest value with series[-1].

That is the whole catalogue: sma, ema, rsi, macd, bollinger. There is no Indicator.wma, no Indicator.atr, and no streaming/stateful class. For WMA, ATR, or anything else, hand-roll the math (see Implementing SMA and EMA and RSI, MACD and Bollinger Bands) or reach for pandas_ta (ta.wma, ta.atr, …). A native ATR helper is below.

The O(1) rule — this is what prevents the fatal ProtocolError

sdk.candles grows every bar. Calling an indicator over the full history on every bar is O(n) per bar → O(n²) over the backtest → it overruns the ~800 ms per-bar budget and can desync the protocol into a fatal ProtocolError. There are two native fixes, and neither needs an import.

A fixed lookback window makes every bar O(1) in history length. A window comfortably larger than the longest period (≈10×) converges to the full-history value (verified on a synthetic 2000-bar series: RSI(14) over the last 250 vs full |Δ| = 4.5e-7; EMA(26)/MACD over the last 300 |Δ| ~ 1e-10; SMA exact):

python
LOOKBACK = 300   # fixed window >> period → O(1) in history length

def on_bar_strategy(sdk, params):
    period = int((params or {}).get("period", 14))
    if len(sdk.candles) < period + 2:
        return                                  # warm-up
    rows = sdk.candles[-LOOKBACK:]              # bounded — never the whole history
    rsi = Indicator.rsi(rows, period)[-1]       # injected global, no import
    if rsi is None:
        return
    if rsi < 30:
        sdk.buy(action="buy_to_open", qty=1, order_type="market")

SMA needs only period bars, so slice exactly (still O(1) in history length):

python
fast = Indicator.sma(sdk.candles[-(fast_p + 1):], fast_p)[-1]

MACD histogram cross (needs the last two histogram values):

python
_, _, hist = Indicator.macd(sdk.candles[-300:], fast, slow, sig)
hist_prev, hist_curr = hist[-2], hist[-1]

Pattern B — exact O(1) accumulator in sdk.state

sdk.state persists across bars (missing numeric keys read back as 0.0). Keep the recursive state and update it from only the newest close. EMA is one line of state:

python
def on_bar_strategy(sdk, params):
    period = int((params or {}).get("period", 20))
    k = 2 / (period + 1)
    close = sdk.candles[-1]["close"]
    ema = sdk.state["ema"]                                 # 0.0 on the first bar
    ema = close if ema == 0.0 else ema + k * (close - ema) # O(1) update
    sdk.state["ema"] = ema

RSI (Wilder avg_gain/avg_loss), MACD (three EMAs) and ATR (Wilder) follow the same idea. If the first frame can arrive with a batch of history, warm the accumulator once from sdk.candles on the first call, then update O(1) per bar. For most strategies Pattern A is simpler and accurate enough; reach for Pattern B only when you need values bit-identical to a full-history recompute. The full worked accumulators are in RSI, MACD and Bollinger Bands and Implementing SMA and EMA.

ATR helper (no import)

Indicator has no atr, so hand-roll a Wilder ATR over a bounded window (or use ta.atr):

python
def _atr(candles, period=14):
    """Wilder ATR over a bounded window — O(period) per bar, no import."""
    rows = candles[-(period * 4):]                 # bounded window converges to full-history ATR
    if len(rows) < period + 1:
        return None
    trs = []
    for prev, cur in zip(rows, rows[1:]):
        trs.append(max(cur["high"] - cur["low"],
                       abs(cur["high"] - prev["close"]),
                       abs(cur["low"] - prev["close"])))
    atr = sum(trs[:period]) / period               # seed
    for tr in trs[period:]:                        # Wilder smoothing over the window
        atr = (atr * (period - 1) + tr) / period
    return atr

_build_chart runs once — full-series Indicator is fine there

The df= branch of main() runs a single time, so a full-series Indicator.* call over the whole df is cheap and correct there — the O(1) rule only bites in the per-bar sdk= branch.

python
def _build_chart(df, params):
    period = int((params or {}).get("period", 14))
    return {**DECLARATION, "series": {"rsi": Indicator.rsi(df, period)}}

(Indicator.* accepts the DataFrame directly and selects source.)

Catalogue

The math exposed to the strategy editor without an import is exactly these five Indicator methods:

Indicator methodReturnsInputsEquivalent in pandas_ta
Indicator.sma(data, period)listclose-liketa.sma
Indicator.ema(data, period)listclose-liketa.ema (SMA-seeded)
Indicator.rsi(data, period)listclose-liketa.rsi (Wilder)
Indicator.macd(data, fast, slow, signal)(macd, signal, hist)close-liketa.macd
Indicator.bollinger(data, period, std_dev)(upper, middle, lower)close-liketa.bbands

Anything not on that list — WMA, ATR, Stochastic, ADX, Ichimoku, VWAP, Supertrend, … — has no Indicator.* method. Use pandas_ta (ta.wma, ta.atr, ta.stoch, …) or a hand-rolled implementation. For ATR specifically, the _atr helper above is native and O(period) per bar.

The broader tesstrade_core engine implements dozens more studies internally (HMA, VIDYA, KAMA, T3, ADX/DMI, Ichimoku, VWAP, Supertrend, … — the same kernels that draw those studies on the chart). Those are not surfaced to the strategy editor as Indicator methods today; for anything beyond the five above, reach for pandas_ta or hand-roll it.

Math correctness — what "the same" actually guarantees

The guarantee here is tiered and honest, not a single blanket tolerance. Three distinct properties hold:

LayerWhat is guaranteedHow it's verified
Same kernelsThe injected Indicator layer and the live chart (live-calc / WASM) call the same tesstrade_core kernels — one code path. What you compute is what the chart renders.Architectural: there is no second chart-math implementation to drift.
Backend == chart seriesThe PyO3 backend that runs your script and the subprocess that feeds the chart agree on the rendered series to < 1e-12.pyo3_parity test over real study series.
Kernels vs pandas_taThe kernels match pandas_ta to floating-point precision under a 200-candle golden reference, with per-indicator tolerances (roughly 1e-1 to 1e-6, varying by indicator — recursive/path-dependent ones like MACD are looser than SMA).golden_parity golden-vector test.

In plain terms: Indicator runs the kernels the chart uses, and against pandas_ta those kernels match to float precision under the golden gate. The chart-parity property is stronger than any single number because it is the same code path, not two implementations being compared.

Bounded window vs full history. Pattern A reads a fixed tail rather than the whole series, so it is not bit-identical to a full-history recompute — but for recursive indicators it converges fast (the deltas quoted above are at or below pandas_ta's own tolerance). When you need bit-identical values, use the Pattern B accumulator, which carries the exact recursive state.

Migration tips

From a per-bar pandas_ta full-history read

python
# ❌ Before — pandas_ta recomputes the whole RSI every bar: O(n)/bar, O(n²) total
def on_bar_strategy(sdk, params):
    closes = pd.Series([c["close"] for c in sdk.candles])
    rsi = ta.rsi(closes, length=14).iloc[-1]
    if not pd.isna(rsi) and rsi < 30:
        sdk.buy(action="buy_to_open", qty=1, order_type="market")
python
# ✅ After — bounded window with the injected Indicator: O(1)/bar, same kernel the chart draws
def on_bar_strategy(sdk, params):
    if len(sdk.candles) < 16:
        return
    rsi = Indicator.rsi(sdk.candles[-300:], 14)[-1]
    if rsi is not None and rsi < 30:
        sdk.buy(action="buy_to_open", qty=1, order_type="market")

The sdk.state accumulator is the endgame

If you already cache an EMA in sdk.state (see implementing SMA/EMA), you are already on the fastest path — there is no streaming class to swap it for. Keep the accumulator; it is O(1) per bar and, seeded the same way the kernel seeds, matches the chart series bar-for-bar.

A complete RSI strategy (chart pane + trading, no import)

The df= branch renders the whole series; the sdk= branch reads a bounded window per bar. Note the df= branch spreads **DECLARATION so type/pane/scale/plots travel with series (see Anatomy):

python
# COLORS FIRST — see anatomy.md
COLOR_RSI = "#A78BFA"

DECLARATION = {
    "type": "strategy",
    "inputs": [
        {"name": "period", "label": "RSI period", "type": "int",
         "default": 14, "min": 2, "max": 200, "step": 1},
        {"name": "rsi_color", "label": "RSI color", "type": "color",
         "default": COLOR_RSI},
    ],
    "plots": [
        {"name": "rsi", "source": "rsi", "type": "line",
         "color": COLOR_RSI, "width": 2},
    ],
    "levels": [
        {"value": 70, "color": "#EF4444"},
        {"value": 30, "color": "#22C55E"},
    ],
    "pane": "new",      # RSI is 0–100 — never overlay on price
    "scale": "right",
}

LOOKBACK = 300


def _declaration(params):
    """Wire the type:'color' parameter into the plot — colors don't auto-apply."""
    p = params or {}
    color = p.get("rsi_color", COLOR_RSI)
    plots = [dict(plot) for plot in DECLARATION["plots"]]
    plots[0]["color"] = color
    return {**DECLARATION, "plots": plots}


def on_bar_strategy(sdk, params):
    period = int((params or {}).get("period", 14))
    if len(sdk.candles) < period + 2:
        return
    rsi = Indicator.rsi(sdk.candles[-LOOKBACK:], period)[-1]   # O(1), no import
    if rsi is not None and rsi < 30:
        sdk.buy(action="buy_to_open", qty=1, order_type="market")


def main(df=None, sdk=None, params={}):
    params = params or {}
    if sdk is not None:
        return on_bar_strategy(sdk, params)
    if df is not None:
        # The chart pane needs the whole series — the df= branch runs once.
        period = int(params.get("period", 14))
        return {**_declaration(params), "series": {"rsi": Indicator.rsi(df, period)}}
    return _declaration(params)

FAQ

Do I import anything to use Indicator?

No. Indicator (and Signal, and ta/pandas_ta/talib shims) are pre-injected into every strategy. import tesstrade_indicators is rejected by the editor's validator — the module is an internal native detail, not a user API. The only imports the editor allows are numpy, pandas, pandas_ta, talib, math, json, datetime.

Can I mix Indicator with pandas_ta?

Yes. Use Indicator for the five catalogue indicators (chart-parity, and a bounded window keeps it O(1)), and pandas_ta (ta.*) for anything outside it. Just remember the O(1) rule: in the per-bar branch, slice a bounded sdk.candles[-N:] window rather than feeding the whole history.

What about indicators not on the catalogue?

Keep using pandas_ta or a manual implementation. The Indicator catalogue is exactly sma, ema, rsi, macd, bollinger; everything else (WMA, ATR, Stochastic, ADX, …) stays available through pandas_ta or hand-rolled math (the _atr helper covers ATR).

Will the chart show different numbers than my backtest?

No — that is the whole point of the unified math brain. Indicator and the chart's renderer call the same tesstrade_core kernels, so the computed series and the drawn line come from one code path. (See Math correctness for the exact, tiered guarantees. A bounded Pattern A window converges to that value; a Pattern B accumulator matches it bar-for-bar.)

Does Indicator work in chart trading too?

Yes. It is injected the same way in live chart trading as in backtests, and sdk.state persists for the duration of the live bot — see live vs backtest for how state is preserved across restarts.

Next steps