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
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 panelThe 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:
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.
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 istimeout_ms + 100).sdk.candlesis 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-injectedIndicatorglobal — 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 insdk.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 withProtocolError: Failed to parse persistent strategy output JSON: data did not match any variant of untagged enum StrategyOutput. Unlike aTimeoutError, thisProtocolErroris 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:
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:
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
seriesmust match thesourceof some plot exactly. - Each series array must have the same length as the list of candles. Use
Nonein the warmup positions (before there are enough points to compute). - Numeric values must be
floatorNone. Do not useNaN; useNone.
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.
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:
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 DECLARATIONAlternative: 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:
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 (
mainoron_bar). Definemain(df=None, sdk=None, params={})at the root level. sdk.buy()withoutaction: every order call requires an explicitactionkwarg (action="buy_to_open", and so on). Omitting it raisesProtocolError. See Canonical actions for details.- Returning a list instead of a dict in the
df=context: the engine expects{"plots": [...], "series": {...}}. Returningseriesalone withoutplotscauses the frontend to draw nothing. seriesarrays 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 withNone.- Mutating
paramsinsidemain: treatparamsas read-only. If you need a default, useint((params or {}).get("fast_period", 9))instead ofparams.setdefault(...).