Example script
A minimal, runnable first script. It buys when the price rises relative to the previous bar and is above a moving average, then closes when either condition fails. The moving average is drawn on the chart so you can see the filter working. Uses:
- Confirm that the Code Editor is working.
- Observe a plotted indicator and trade signals on the chart.
- Understand the params-and-colors-first layout and the
main()dispatcher in a real run.
This is not a profitable strategy — it is only a working skeleton. For the canonical, field-by-field structure every indicator and strategy should mirror, read Anatomy of a custom indicator right after this page.
1. Open the Code Editor
In Backtest or Chart Trading, click Editor. A code window appears with a scaffold.
2. Erase the scaffold and paste the code below
Notice the order: colors → param defaults → declaration → math → dispatcher. Everything the user can tune — the period and the line color — is declared at the very top, before any math. The moving average is computed with the pre-injected Indicator global (no import), so the line you plot is drawn from the same math the chart renders with — no "looks right in backtest, wrong on the chart" drift.
# ── Strategy: Rising-bar above SMA ────────────────────────────────────
# 1) COLORS FIRST — one place to retheme the indicator.
# `Indicator` is a pre-injected global (the same math the chart renders with) —
# no import needed, and `import tesstrade_indicators` is NOT allowed in the editor.
COLOR_SMA = "#22D3EE" # cyan
# 2) PARAM DEFAULTS — the math reads these; never a magic number mid-function.
DEFAULT_QTY = 1.0
DEFAULT_SMA = 20
# 3) DECLARATION — params and colors are the FIRST thing the engine sees.
DECLARATION = {
"type": "strategy",
"inputs": [
{"name": "qty", "label": "Quantity", "type": "float",
"default": DEFAULT_QTY, "min": 0.001, "max": 1000.0, "step": 0.001},
{"name": "sma_period", "label": "SMA period", "type": "int",
"default": DEFAULT_SMA, "min": 2, "max": 400, "step": 1},
# The color field lives right next to the number it styles.
{"name": "sma_color", "label": "SMA color", "type": "color",
"default": COLOR_SMA},
],
"plots": [
{"name": "sma", "source": "sma", "type": "line",
"color": COLOR_SMA, "width": 2},
],
"pane": "overlay", # SMA shares the price scale → draw on the price pane
"scale": "none",
}
# 4) MATH — read every tunable value out of params, once, with safe defaults.
def _resolve(params):
p = params or {}
return {
"qty": float(p.get("qty", DEFAULT_QTY)),
"sma": int(p.get("sma_period", DEFAULT_SMA)),
"sma_color": p.get("sma_color", COLOR_SMA),
}
def _declaration(params):
"""DECLARATION with the user's chosen color wired into the plot.
A type:"color" field does NOT auto-apply — we must read it from params
and inject it into the plot's color here, or the line stays cyan no
matter what the user picks.
"""
cfg = _resolve(params)
plots = [dict(plot) for plot in DECLARATION["plots"]] # copy, don't mutate
plots[0]["color"] = cfg["sma_color"]
return {**DECLARATION, "plots": plots}
def on_bar_strategy(sdk, params):
cfg = _resolve(params)
# Needs enough candles to compare bars and warm up the SMA.
if len(sdk.candles) < cfg["sma"] + 1:
return
# Slice to the TAIL only. Recomputing Indicator.sma over the whole
# sdk.candles every bar is O(n) per bar → O(n²) over the backtest — the
# classic trap that overruns the per-bar budget and can abort the run with a
# fatal ProtocolError. A bounded window keeps each frame O(1) in history
# length. `Indicator` is a pre-injected global — no import.
rows = sdk.candles[-(cfg["sma"] + 1):]
sma = Indicator.sma(rows, cfg["sma"]) # list, same length; None during warm-up
last_close = rows[-1]["close"]
prev_close = rows[-2]["close"]
last_sma = sma[-1]
if last_sma is None: # still warming up — do nothing
return
went_up = last_close > prev_close
above_sma = last_close > last_sma
if sdk.position == 0 and went_up and above_sma:
sdk.buy(action="buy_to_open", qty=cfg["qty"], order_type="market")
elif sdk.position > 0 and not (went_up and above_sma):
sdk.sell(action="sell_to_close", qty=abs(sdk.position), order_type="market")
# 5) DISPATCHER — one entry point, three contexts.
def main(df=None, sdk=None, params={}):
params = params or {}
if sdk is not None: # per-bar: trade
return on_bar_strategy(sdk, params)
if df is not None: # chart: full series for the plot
cfg = _resolve(params)
# Runs once over the whole df — a full-series call is fine here.
# Indicator accepts a DataFrame directly and reads `source` (close).
return {**_declaration(params),
"series": {"sma": Indicator.sma(df, cfg["sma"])}}
return _declaration(params) # no args: metadata only3. Click Run (or Backtest)
- In Backtest: choose the symbol, period, and click start. In 1-2 min the results panel appears.
- In Chart Trading: start a paper trading bot (see paper bots).
4. What to expect
A cyan SMA line on the price pane, plus trades that fire only when the bar rises and sits above that line. Fewer trades than a pure coin-flip, but still no edge — this is a noise generator with a filter, not a strategy.
Checkpoints:
- The parameter panel appeared with editable Quantity, SMA period, and SMA color fields.
- Changing SMA color in the panel actually recolors the line (because the script reads
sma_colorand injects it — see the dispatcher). - The SMA line is drawn on the chart, and orders were emitted (markers).
- Equity evolved candle by candle, and the script compiled without error.
5. Variations
Modifications in order of difficulty:
Only buy when it rises 2 bars in a row
closes = [c["close"] for c in sdk.candles[-3:]] # only the last 3 are needed
went_up_twice = closes[-1] > closes[-2] > closes[-3]Swap the SMA for an EMA (same injected math)
The Indicator global exposes sma, ema, rsi, macd, and bollinger — the same math the chart renders with, no import. Switching is a one-line change (still over the bounded tail from on_bar_strategy):
sma = Indicator.ema(rows, cfg["sma"]) # was Indicator.sma(...)Keep it O(1): Indicator.* recomputes its whole input on every call, so in the per-bar on_bar_strategy branch feed it only the bounded tail (sdk.candles[-(period+1):] for SMA, a wider window like sdk.candles[-300:] for EMA/RSI/MACD, which converges to the full-history value). For a bit-exact, truly O(1) update, keep a recursive accumulator in sdk.state instead (see persistent state). Reserve the full-series call for the df= chart branch, where it runs exactly once. Recomputing over the entire sdk.candles every bar is O(n) per bar → O(n²) over the run and is what overruns the per-bar budget.
For indicators outside that catalogue (Stochastic, ADX, …) write the math yourself or use pandas_ta — see Indicator and pandas_ta.
Add another plot or input
See Anatomy of a custom indicator for the canonical structure, and SMA Crossover for a full strategy template commented line by line.
Store state between bars
if not isinstance(sdk.state, dict):
sdk.state = {}
sdk.state["trades_taken"] = sdk.state.get("trades_taken", 0) + 1Details in persistent state.
6. Error handling
"Strict Mode" error
The main() function was not defined at the root level. Check that it is not indented.
"requires explicit action" error
sdk.buy() or sdk.sell() was called without action=. Only sdk.buy() and sdk.sell() require that argument; sdk.close() and the semantic helpers (buy_to_open, sell_to_close, …) supply it for you. The canonical actions documentation lists the 7.
"Import not allowed"
Import outside the whitelist. See sandbox limits. This script has no import — it uses the pre-injected Indicator global. The full import whitelist is exactly numpy, pandas, pandas_ta, talib, math, json, datetime. (np, pd, ta, talib, math, json, datetime, plus Indicator and Signal, are pre-injected — no import needed.) In particular, import tesstrade_indicators is not allowed in the editor and is rejected by the validator; use the injected Indicator global instead. Note re is not allowed either.
The SMA color picker shows but the line never changes
The type:"color" input is declared but never wired. A color input only stores its value in params; it does not auto-apply to a plot. Read it with params.get("sma_color") and inject it into the plot's color before returning the declaration — see _declaration() above and Anatomy: colors do not auto-apply.
0 trades
For an event-driven script like this one, no trades usually means the entry logic never triggered (e.g. sdk.position / price comparison conditions were never met) or there were fewer candles than sma_period + 1. Check that sdk.buy() is actually reached. Note: declarative entry_conditions in the DECLARATION do not block on_bar_strategy from emitting trades - at runtime they are ignored (with a warning) unless you set params['runtime_declarative_fallback'] = True (details in when to use declarative mode).
Empty parameter panel
DECLARATION["inputs"] is empty or main() does not return DECLARATION in the no-argument branch. Review the contract in main dispatcher.
Next steps
- Anatomy of a custom indicator - the canonical colors→params→declaration→math→dispatcher structure to mirror everywhere.
- main dispatcher - why this script has 3 branches.
- DECLARATION - how to add more inputs and plots.
Indicatorand pandas_ta - the injected, no-import indicator math and how to reach it from a script.- SMA Crossover - first utility template.