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:
# ❌ 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_indicatorsis not a user API. It is the internal native module (PyO3 / chart-side binding) that wirestesstrade_coreinto the renderer and the backtest backend. The strategy editor's client-side validator only allows imports fromnumpy,pandas,pandas_ta,talib,math,json,datetime—tesstrade_indicatorsis not on that list, soimport tesstrade_indicatorsfails validation before your script ever runs. The math is still yours: it is exposed to strategies through the injectedIndicatorglobal (no import) and, for anything outside its catalogue, throughpandas_ta(import pandas_ta as ta, also pre-injected asta).
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').
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.
Pattern A — bounded-window Indicator (recommended default)
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):
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):
fast = Indicator.sma(sdk.candles[-(fast_p + 1):], fast_p)[-1]MACD histogram cross (needs the last two histogram values):
_, _, 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:
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"] = emaRSI (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):
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.
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 method | Returns | Inputs | Equivalent in pandas_ta |
|---|---|---|---|
Indicator.sma(data, period) | list | close-like | ta.sma |
Indicator.ema(data, period) | list | close-like | ta.ema (SMA-seeded) |
Indicator.rsi(data, period) | list | close-like | ta.rsi (Wilder) |
Indicator.macd(data, fast, slow, signal) | (macd, signal, hist) | close-like | ta.macd |
Indicator.bollinger(data, period, std_dev) | (upper, middle, lower) | close-like | ta.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_coreengine 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 asIndicatormethods today; for anything beyond the five above, reach forpandas_taor 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:
| Layer | What is guaranteed | How it's verified |
|---|---|---|
| Same kernels | The 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 series | The 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_ta | The 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
# ❌ 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")# ✅ 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):
# 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
- Anatomy of a custom indicator — the canonical file structure (params and colors first).
- Implementing SMA and EMA — pure-Python reference implementations and when to prefer them.
- RSI, MACD and Bollinger Bands — formula derivations for the harder indicators.
- Persistent state — how
sdk.stateand module-scope state interact with the engine across runs.