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, datetime — tesstrade_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 ​