Skip to content
Single .md

The main() dispatcher

The TessTrade engine calls the script's main function in three distinct contexts. If any of the three is not handled, parts of execution fail silently, with no error, no trade, and no plot.

Canonical signature

python
def main(df=None, sdk=None, params={}):
    params = params or {}
    if sdk is not None:
        return on_bar_strategy(sdk, params)   # bar-by-bar trading
    if df is not None:
        return _build_chart(df, params)       # chart plots
    return DECLARATION                        # metadata / parameters panel

The three parameters are always passed as keyword arguments. The order of the checks (sdk before df, df before the fallback) is the recommended idiom.


Context 1 - sdk= (bar-by-bar execution)

When it happens: on every closed candle, during a historical backtest or during live chart trading.

What the engine passes:

python
main(sdk=strategy_sdk_instance, params={"fast_period": 9, "slow_period": 21})

What the script does: reads sdk.candles, computes indicators, decides whether to open or close a position, and calls sdk.buy(...) / sdk.sell(...).

What the script returns: typically nothing (implicit None) — orders are emitted as a side effect through the SDK (sdk.buy(...) / sdk.sell(...)). The engine does, however, also inspect the return value: if you return a list of signal dicts, or a dict with a "signals" list, those entries are merged into the emitted signals.

python
def on_bar_strategy(sdk, params):
    fast = int((params or {}).get("fast_period", 9))
    slow = int((params or {}).get("slow_period", 21))

    if len(sdk.candles) < max(fast, slow) + 1:
        return  # warmup; not enough candles yet

    # Slice to the lookback you need — NOT the whole growing history.
    closes = [c["close"] for c in sdk.candles[-max(fast, slow):]]
    fast_ma = sum(closes[-fast:]) / fast
    slow_ma = sum(closes[-slow:]) / slow

    if sdk.position == 0 and fast_ma > slow_ma:
        sdk.buy(action="buy_to_open", qty=1, order_type="market")

⚠️ Per-bar budget — keep each sdk= call O(1). main(sdk=) runs once per closed candle and must finish within the per-bar budget (default 800 ms; the Rust read deadline is timeout_ms + 100). sdk.candles is the full, persisted history and grows every bar, so rebuilding [c["close"] for c in sdk.candles] and recomputing an indicator over all of it each bar is O(n) per bar → O(n²) over the run. Compute over a bounded tail window with the pre-injected Indicator global — no import — (Indicator.rsi(sdk.candles[-300:], 14)[-1], Indicator.ema(...), …): a fixed lookback is O(1) in history length and converges to the full-history value. Or keep an incremental accumulator in sdk.state (it persists across frames) and update it from only the newest close once per bar.

⚠️ A persistently slow frame is not just a tolerated TimeoutError. When a bar overruns the per-bar budget, the worker's reader abandons the late response and the request/response protocol can desynchronize — the next read then consumes an out-of-order line and the backtest dies with ProtocolError: Failed to parse persistent strategy output JSON: data did not match any variant of untagged enum StrategyOutput. Unlike a TimeoutError, this ProtocolError is fatal and is not covered by the 5% tolerance — it aborts the whole run immediately. The only reliable cure is to keep every frame O(1) per bar (incremental indicators), not to rely on the tolerance.


Context 2 - df= (indicator chart)

When it happens: the frontend calls once with all available candles to render the indicator lines on the chart.

What the engine passes:

python
main(df=pd.DataFrame({"time": [...], "open": [...], "high": [...], ...}), params={...})

df is a real pandas DataFrame with the columns time, open, high, low, close, volume.

What the script returns: a dictionary with plots and series:

python
def _build_chart(df, params):
    fast = int((params or {}).get("fast_period", 9))
    closes = list(df["close"])
    return {
        **DECLARATION,
        "series": {
            "ma_fast": _sma_series(closes, fast),
        },
    }

Rules for the return value:

  • Spread DECLARATION ({**DECLARATION, "series": {...}}) so every metadata field — type, pane, scale, plots, levels — travels with the data. Returning only {"plots": ..., "series": ...} works in simple cases but silently drops fields the renderer needs for oscillators and custom panes.
  • Every key in series must match the source of some plot exactly.
  • Each series array must have the same length as the list of candles. Use None in the warmup positions (before there are enough points to compute).
  • Numeric values must be float or None. Do not use NaN; use None.

Context 3 - no arguments (metadata)

When it happens: the engine needs to build the strategy's parameters panel (the form that appears when you open a script with editable inputs).

What the engine passes: nothing. All arguments keep their defaults (df=None, sdk=None, params={}).

What the script returns: the DECLARATION, described in detail in The DECLARATION shape.

python
DECLARATION = {
    "type": "strategy",
    "inputs": [
        {"name": "fast_period", "type": "int", "default": 9,  "min": 1, "max": 100},
        {"name": "slow_period", "type": "int", "default": 21, "min": 2, "max": 200},
    ],
}

The complete pattern

These three contexts combine in the dispatcher of a real strategy:

python
DECLARATION = {
    "type": "strategy",
    "inputs": [
        {"name": "fast_period", "type": "int", "default": 9,  "min": 1, "max": 100},
        {"name": "slow_period", "type": "int", "default": 21, "min": 2, "max": 200},
    ],
}

def _sma_series(values, period):
    out = []
    for i in range(len(values)):
        if i + 1 < period:
            out.append(None)
        else:
            out.append(sum(values[i - period + 1:i + 1]) / period)
    return out

DECLARATION["plots"] = [
    {"name": "ma_fast", "title": "SMA fast", "source": "ma_fast",
     "type": "line", "color": "#22D3EE", "width": 2},
]
DECLARATION["pane"] = "overlay"

def _build_chart(df, params):
    fast = int((params or {}).get("fast_period", 9))
    closes = list(df["close"])
    return {
        **DECLARATION,
        "series": {
            "ma_fast": _sma_series(closes, fast),
        },
    }

def on_bar_strategy(sdk, params):
    fast = int((params or {}).get("fast_period", 9))
    if len(sdk.candles) < fast + 1:
        return
    closes = [c["close"] for c in sdk.candles[-fast:]]  # bounded tail, not full history
    fast_ma = sum(closes[-fast:]) / fast
    if sdk.position == 0 and closes[-1] > fast_ma:
        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:
        return _build_chart(df, params)
    return DECLARATION

Alternative: on_bar(sdk) (legacy mode)

If your strategy does not use df= (it does not plot anything on the chart) and does not declare editable inputs, the engine also accepts the classic on_bar(sdk) function:

python
PARAMS = {"fast_period": 10, "slow_period": 20}

def on_bar(sdk):
    fast = int(PARAMS.get("fast_period", 10))
    slow = int(PARAMS.get("slow_period", 20))
    # ...

In this mode, the parameters live in a global PARAMS constant, there is no DECLARATION, and there are no plots. It is leaner, but not recommended for new scripts. The main() dispatcher is the canonical pattern because it supports all three contexts.


Common mistakes

  • "Strict Mode" error: the code does not define any of the expected entry points (main or on_bar). Define main(df=None, sdk=None, params={}) at the root level.
  • sdk.buy() without action: every order call requires an explicit action kwarg (action="buy_to_open", and so on). Omitting it raises ProtocolError. See Canonical actions for details.
  • Returning a list instead of a dict in the df= context: the engine expects {"plots": [...], "series": {...}}. Returning series alone without plots causes the frontend to draw nothing.
  • series arrays with a different length from candles: the frontend aligns by index. An array shorter than the number of candles misaligns every point. Pad the warmup with None.
  • Mutating params inside main: treat params as read-only. If you need a default, use int((params or {}).get("fast_period", 9)) instead of params.setdefault(...).