Skip to content
Single .md

Candles, params and state

The sdk object delivered to the script exposes three frequently used data sources:

  • sdk.candles - the candle history, including the current one.
  • sdk.params - the DECLARATION parameters, already populated with user values.
  • sdk.state - a dictionary that persists across bars (flags, cooldown, trailing stop).

sdk.candles

A list of dictionaries, from the oldest (sdk.candles[0]) to the most recent (sdk.candles[-1]).

Shape of each candle

python
{
    "time":   1700001234000,  # int - Unix timestamp in milliseconds
    "open":   50123.45,       # float
    "high":   50345.67,       # float
    "low":    49987.12,       # float
    "close":  50234.00,       # float
    "volume": 1234.567,       # float
}

Common idioms

Collect closing prices — bounded to the lookback you actually need:

python
period = int((sdk.params or {}).get("period", 20))
closes = [c["close"] for c in sdk.candles[-(period + 1):]]
highs  = [c["high"]  for c in sdk.candles[-(period + 1):]]
lows   = [c["low"]   for c in sdk.candles[-(period + 1):]]

⚠️ Slice to a window; do not scan the whole history every bar. sdk.candles grows with every processed candle (see When sdk.candles is updated), so a bare [c["close"] for c in sdk.candles] re-read on every bar is O(n) per bar and O(n²) over the backtest. Feeding that full list to an indicator each frame — e.g. Indicator.rsi([c["close"] for c in sdk.candles], 14) over all of sdk.candles — is the classic trap that overruns the per-bar time budget and can abort the whole run with a fatal ProtocolError. Keep every frame O(1): compute over a bounded sdk.candles[-LOOKBACK:] window with the injected Indicator (no import), or maintain a rolling accumulator in sdk.state.

Timestamp of the last bar (use it on every order):

python
last_time = sdk.candles[-1]["time"]
sdk.buy(time=last_time, action="buy_to_open", qty=1, order_type="market")

Check whether there is enough data for an indicator:

python
def on_bar_strategy(sdk, params):
    period = int((params or {}).get("period", 20))
    if len(sdk.candles) < period + 1:
        return  # warm-up, nothing to do yet
    # ... continues

When sdk.candles is updated

During the backtest, the list grows with each processed candle. In live chart trading, it is updated at the candle close. In other words, sdk.candles[-1] is always the last closed candle, not the candle in formation.

Intra-bar updates (tick-by-tick) are not part of the public API.

Convert to a pandas DataFrame

pd (pandas) is available globally; the conversion can be done at any time:

python
df = pd.DataFrame(sdk.candles)
df["ret"] = df["close"].pct_change()
last_ret = float(df["ret"].iloc[-1])

⚠️ The real hazard is not pandas — it is any full-history scan on every bar. pd.DataFrame(sdk.candles) rebuilt each frame has a cost, but so does a plain [c["close"] for c in sdk.candles] comprehension: both are O(n) per bar over a list that grows with the backtest, which is O(n²) overall. A single frame that overruns the default ~800 ms per-bar budget can desynchronize the request/response protocol and abort the entire run with a fatal ProtocolError (data did not match any variant of untagged enum StrategyOutput) — this is not covered by the transient-timeout tolerance. Do not reach for list comprehensions as the "cheap" fix; the cheap fix is to keep every frame O(1).

Canonical bounded-window pattern (each frame O(1) in history length; no import). Indicator is a pre-injected global — you do not import it:

python
LOOKBACK = 300  # fixed window >> period → O(1) in history length; converges to the full-history value

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

def on_bar_strategy(sdk, params):
    period = int((params or {}).get("period", 14))
    if len(sdk.candles) < period + 2:
        return  # warm-up, nothing to do yet
    rows = sdk.candles[-LOOKBACK:]            # bounded — never the whole history
    rsi = Indicator.rsi(rows, period)[-1]     # injected global, no import; aligned 1:1 with rows
    if rsi is not None and rsi < 30:
        sdk.buy(action="buy_to_open", qty=1, order_type="market")

If you genuinely need a DataFrame or a list, slice it to the lookback first — pd.DataFrame(sdk.candles[-(period + 1):]) or [c["close"] for c in sdk.candles[-(period + 1):]] — never the whole sdk.candles.

No import needed. Indicator (and Signal, and ta/pandas_ta) are pre-injected globals — import tesstrade_indicators is not available in the strategy editor: the client-side validator only allows numpy, pandas, pandas_ta, talib, math, json, datetime. A bounded window converges to the full-history value (for RSI(14) over the last 250 bars the difference from a full recompute is ~1e-7). When you need a value bit-identical to a full recompute, keep a recursive accumulator in sdk.state instead and update it from only the newest close each bar.


sdk.params

Dictionary with the current values of the inputs declared in DECLARATION["inputs"]. The values arrive already adjusted by the user in the UI panel.

python
sdk.params  # {"fast_period": 9, "slow_period": 21, "use_volume": True}

Converting to the correct type: even with "type": "int" declared, the engine may deliver a string through some hydration paths. The robust idiom is to convert explicitly:

python
fast = int((sdk.params or {}).get("fast_period", 9))
risk = float((sdk.params or {}).get("risk", 0.02))
use_volume = bool((sdk.params or {}).get("use_volume", False))

Note: when the engine invokes via the dispatcher main(df=None, sdk=None, params={}), params (the main argument) and sdk.params contain the same dictionary. Use whichever is more readable.

Global PARAMS (legacy mode)

If the script uses the on_bar(sdk) entrypoint instead of main(), the parameters live in a global constant named PARAMS:

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

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

PARAMS is injected automatically by the engine with the current input values.


sdk.state

Persistent dictionary across candles. The engine keeps the same active object for the entire execution.

Basic usage

python
def on_bar_strategy(sdk, params):
    if not isinstance(sdk.state, dict):
        sdk.state = {}
    if "last_signal_time" not in sdk.state:
        sdk.state["last_signal_time"] = 0

    now = sdk.candles[-1]["time"]
    cooldown_ms = 60_000  # 1 minute

    if now - sdk.state["last_signal_time"] < cooldown_ms:
        return  # cooldown active, do not emit a new signal

    # ... entry logic ...
    sdk.state["last_signal_time"] = now

Real trailing stop

The classic case: keep the highest price seen since entry and use it to trigger the exit.

python
def on_bar_strategy(sdk, params):
    if not isinstance(sdk.state, dict):
        sdk.state = {}
    if "high_water" not in sdk.state:
        sdk.state["high_water"] = None

    close = sdk.candles[-1]["close"]

    if sdk.position > 0:
        # Long open: update the high water
        hw = sdk.state["high_water"]
        sdk.state["high_water"] = close if hw is None else max(hw, close)

        # Stop at 2% below the high water
        stop = sdk.state["high_water"] * 0.98
        if close <= stop:
            sdk.sell(action="sell_to_close", qty=abs(sdk.position),
                     order_type="market")
            sdk.state["high_water"] = None
    else:
        # No position, reset the trailing
        sdk.state["high_water"] = None

Note on missing keys

sdk.state has a special behavior: missing numeric keys return 0.0 instead of KeyError. This simplifies classic indicators (counters, accumulators):

python
# These two are equivalent:
sdk.state["hits"] = sdk.state["hits"] + 1         # "hits" did not exist, starts at 0.0
sdk.state["hits"] = sdk.state.get("hits", 0) + 1

For keys that hold objects (lists, dicts, strings), always check beforehand:

python
if "buffer" not in sdk.state:
    sdk.state["buffer"] = []
sdk.state["buffer"].append(sdk.candles[-1]["close"])

Incremental indicator in sdk.state (exact O(1) accumulator)

When you need a value bit-identical to a full-history recompute, keep the recursive indicator state in sdk.state and update it from only the newest close — O(1) per bar, no full-history scan and no import. EMA in one line of state (missing numeric keys read back as 0.0):

python
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"] = ema

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

RSI (Wilder avg_gain/avg_loss), MACD (three EMAs) and ATR follow the same idea. For most strategies the bounded-window Indicator call above is simpler and accurate enough; reach for a sdk.state accumulator only when you need bit-exact parity.

What persists, what does not

ItemPersists across candles
sdk.state[key]Yes, maintained while the runner is active
Local variables inside main()No, reset on every call
Global variables (PARAMS, etc.)Yes, module scope, alive throughout the entire execution
Objects in sdk.candlesRecomputed by the engine; do not modify

Rule: if the script needs to remember something between calls, place it in sdk.state.


Quick checklist

  • [ ] Read sdk.candles as a list of dicts (not a DataFrame).
  • [ ] Use sdk.candles[-1]["time"] for the order timestamp.
  • [ ] Convert params / sdk.params values with int(...), float(...), bool(...).
  • [ ] Initialize sdk.state keys before indexing non-numeric objects.
  • [ ] Verify len(sdk.candles) >= minimum_period before computing indicators.