Skip to content
Single .md

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.

python
# ── 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 only

3. 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_color and 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 ​

python
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):

python
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 ​

python
if not isinstance(sdk.state, dict):
    sdk.state = {}
sdk.state["trades_taken"] = sdk.state.get("trades_taken", 0) + 1

Details 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 ​