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- theDECLARATIONparameters, 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
{
"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:
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.candlesgrows with every processed candle (see Whensdk.candlesis 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 ofsdk.candles— is the classic trap that overruns the per-bar time budget and can abort the whole run with a fatalProtocolError. Keep every frame O(1): compute over a boundedsdk.candles[-LOOKBACK:]window with the injectedIndicator(no import), or maintain a rolling accumulator insdk.state.
Timestamp of the last bar (use it on every order):
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:
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
# ... continuesWhen 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:
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 fatalProtocolError(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:
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(andSignal, andta/pandas_ta) are pre-injected globals —import tesstrade_indicatorsis not available in the strategy editor: the client-side validator only allowsnumpy,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 insdk.stateinstead 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.
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:
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:
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
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"] = nowReal trailing stop
The classic case: keep the highest price seen since entry and use it to trigger the exit.
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"] = NoneNote on missing keys
sdk.state has a special behavior: missing numeric keys return 0.0 instead of KeyError. This simplifies classic indicators (counters, accumulators):
# 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) + 1For keys that hold objects (lists, dicts, strings), always check beforehand:
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):
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
| Item | Persists 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.candles | Recomputed 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.candlesas a list of dicts (not a DataFrame). - [ ] Use
sdk.candles[-1]["time"]for the order timestamp. - [ ] Convert
params/sdk.paramsvalues withint(...),float(...),bool(...). - [ ] Initialize
sdk.statekeys before indexing non-numeric objects. - [ ] Verify
len(sdk.candles) >= minimum_periodbefore computing indicators.