Sandbox limits
The code runs in a hardened Python sandbox. The engine validates the source before executing, blocks unsafe modules and builtins, and enforces time and memory limits.
This document describes the applied limits and how to avoid them.
Available modules
Injected automatically as globals (no import required):
| Name in the script | Type | Common use |
|---|---|---|
np | numpy | np.mean, np.std, np.array |
pd | pandas | pd.DataFrame, pd.Series, pd.to_datetime |
math | math stdlib | math.sqrt, math.log, math.pi |
json | json stdlib | json.dumps, json.loads |
datetime | datetime module | datetime.datetime.fromtimestamp |
ta / pandas_ta | safe optimized version | subset of ta.ema, ta.rsi, etc. |
talib | safe optimized version | limited subset |
Indicator | native indicator math (no import) | Indicator.sma/ema/rsi/macd/bollinger over a bounded window |
Signal | signal constants | building order signals |
There are no extra importable indicator modules: the native indicator math reaches your script through the injected Indicator global (no import) and through ta/pandas_ta. In particular, import tesstrade_indicators is not allowed in the editor — the validator rejects it (see Indicator and pandas_ta).
Example: the script below runs without error, with no import:
def on_bar_strategy(sdk, params):
closes = np.array([c["close"] for c in sdk.candles])
vol = np.std(closes[-20:]) # standard deviation of the last 20 closes
...About ta / pandas_ta / talib
These are safe, optimized versions that expose a subset of the popular functions. For full control over behavior, implement indicators in pure Python or with np/pd. See implementing SMA/EMA.
About the Indicator global
The native indicator math is exposed to your script as the pre-injected Indicator global — no import. It provides the same Wilder/standard formulas as pandas_ta (Indicator.sma, .ema, .rsi, .macd, .bollinger), each returning a plain list aligned 1:1 with the input. The underlying tesstrade_indicators module is a native (PyO3, chart-side) implementation detail and is not importable from the strategy editor — the validator rejects import tesstrade_indicators. Full reference in Indicator and pandas_ta.
To stay O(1) per bar, call Indicator.* over a bounded sdk.candles[-N:] window (a fixed lookback comfortably larger than the period converges to the full-history value) rather than over the entire growing history. For a bit-exact, truly O(1) update, keep a recursive accumulator in sdk.state.
LOOKBACK = 300 # fixed window >> period → O(1) in history length
def on_bar_strategy(sdk, params):
if len(sdk.candles) < 15:
return
rows = sdk.candles[-LOOKBACK:] # bounded — never the whole history
rsi = Indicator.rsi(rows, 14)[-1] # injected global, no import
if rsi is not None and rsi < 30:
sdk.buy(action="buy_to_open", qty=1, order_type="market")Forbidden modules
The import whitelist accepts only the modules above. Any other triggers SecurityError:
import os # SecurityError
import sys # SecurityError
import subprocess # SecurityError
import requests # SecurityError
from scipy import ... # SecurityError
import random # SecurityError - not in the whitelistWhy random is blocked
To guarantee determinism — the same input always produces the same output, which is what makes a backtest reproducible. If you need a value that varies per bar, derive it deterministically from the candle timestamp (for example, hash(int(sdk.candles[-1]["time"]))).
Available builtins
Full whitelist:
Types and constants: None, True, False, bool, int, float, str, list, tuple, dict, set, frozenset
Math: abs, min, max, sum, round, pow, divmod
Iteration: len, range, enumerate, zip, reversed, sorted, filter, map
Logic: all, any
Type checking: isinstance, issubclass, type, callable, hasattr, getattr
String: chr, ord, format, repr, ascii
Object: id, hash, iter, next, slice
Exceptions (for try/except): Exception, ValueError, TypeError, KeyError, IndexError, AttributeError, RuntimeError, ZeroDivisionError, OverflowError, StopIteration
Debug: print (the output is captured and sent to the logs)
Blocked builtins
open("file.txt") # SecurityError - I/O
exec("code") # SecurityError - dynamic execution
eval("expression") # SecurityError
__import__("os") # SecurityError
input() # SecurityError - I/O
dir() # SecurityError - introspection
vars() # SecurityError
globals() # SecurityError
locals() # SecurityError
exit(), quit() # SecurityError - process control
breakpoint() # SecurityError - debugging
setattr(x, "y", 1) # SecurityError (removed)
delattr(x, "y") # SecurityError
memoryview(x) # SecurityErrorBlocked builtins are RESERVED NAMES — even as plain variables
The validator bans the bare identifier of every blocked builtin in any position — assignment and read included — because a stored name could alias the builtin (f = dir; f()). This code never calls dir() and still fails validation before running:
dir = 0 # SecurityError: Forbidden function: dir
if dir != 0: ... # same error — the NAME itself is what is bannedNever use these as variable names: dir, vars, input, exit, quit, help, file, locals, globals, eval, exec, compile, delattr, memoryview, breakpoint, reload. The natural trading-code traps and their renames:
| Instead of | Use |
|---|---|
dir (trade direction) | direction, trade_dir |
exit (exit price/bar) | exit_price, exit_bar |
input (user parameter) | user_input, param |
file / vars / help | file_name / variables / help_text |
Dict keys and strings are fine (c["open"], "exit"), and so are prefixed or suffixed names (exit_r, dir_up) — only the exact bare identifier is rejected.
Dunder attributes
__xxx__ attributes are not available to scripts (a small set such as __name__ is the exception). Trading logic never needs them — to check a type, use isinstance(x, T) instead of any form of introspection.
Lambdas
Lambdas are not allowed:
f = lambda x: x * 2 # SecurityErrorAlternative: use a normal def.
def f(x):
return x * 2Other restricted constructs
| Construct | Why it is blocked | Alternative |
|---|---|---|
lambda | Can hide arbitrary code | def helper(...) |
global / nonlocal | Mutates outer scopes implicitly | Pass values via parameters or sdk.state |
while True: (infinite loop) | Cannot terminate within budget | for ... in range(...) or a finite condition |
eval / exec / compile | Dynamic code execution | Express logic as plain Python |
del (statement) | Removes protections from objects | Re-bind the variable to None instead |
In short: write direct, explicit code. If a construct does not pass the validator, simplify the function. The whitelist intentionally favors predictable scripts over clever ones.
Resource limits
| Resource | Default limit | Raised if exceeded |
|---|---|---|
| Time per bar | 800ms | TimeoutError |
| Memory | per-strategy ceiling enforced by the engine | MemoryError |
| Source size | ~100KB | rejected at load time (SecurityError) |
| Nesting depth | 50 levels of blocks | rejected at load time |
| Code complexity | ~10000 syntax nodes | rejected at load time (SecurityError: Code too complex) |
About the 800ms time budget
For a typical strategy (indicators over 500 candles, simple logic), 800ms is generous headroom — usual execution time is between 5 and 50ms. Backtests can request a higher per-bar budget when running heavier strategies (ML inference, custom scientific computation); the backtest panel exposes the option when applicable.
A single bar that exceeds the budget produces a TimeoutError for that bar and the engine continues with subsequent bars. The backtest only aborts when transient failures cross 5% of the total bar count (and at least 5 bars failed) — single GC pauses or cold starts no longer kill the run.
⚠️ 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.
If timeouts are persistent, check:
- Building
pd.DataFrame(sdk.candles)on every candle is expensive. Prefer collecting directly with a list comprehension. - Iteration over the entire
sdk.candles. Use only the last N (sdk.candles[-period:]). - Nested loop over candles. Reduce complexity — it is usually possible to vectorize with
npor call the injectedIndicatorover a boundedsdk.candles[-N:]window. - Recomputing the same indicator from scratch over the full history every bar. Compute it over a bounded
sdk.candles[-N:]window with the injectedIndicatorglobal (no import), or keep an incremental accumulator insdk.state.
About the memory limit
For trading logic the per-strategy memory ceiling is generous — strategies very rarely hit it organically. When MemoryError does appear, there is usually a list accumulating in sdk.state without bound:
# Unbounded growth - causes MemoryError.
sdk.state["all_closes"] = sdk.state.get("all_closes", []) + [c["close"] for c in sdk.candles]Cap the size:
buf = sdk.state.setdefault("buffer", [])
buf.append(sdk.candles[-1]["close"])
if len(buf) > 1000:
sdk.state["buffer"] = buf[-1000:] # keep only the last 1000 (del is forbidden)Other important restrictions
printoutput goes to the engine logs, not to the frontend console. Useful for debugging, but does not appear in real time.- The return value must be JSON-serializable. Dict, list, str, int, float, bool, or None. Objects, sets (convert to list), and NaN (use None) are not supported.
- Writes to
paramsare ignored.paramsis treated as read-only by the engine. To persist something, usesdk.state. - Writes to
sdk.candles[i]may have non-deterministic effects. Do not modify them.
Diagnosing errors
When something fails, the engine categorizes the error. See error catalog for the full table:
| Error | Common cause |
|---|---|
SecurityError | Forbidden import, blocked builtin, lambda, dunder attribute |
TimeoutError | A single bar exceeded the time budget. Tolerated up to 5% of bars (min 5 absolute) — beyond that the run aborts. |
MemoryError | List/dict growing without bound |
ProtocolError | sdk.buy() without action, invalid signal, non-JSON return — or the request/response protocol desynced because a frame overran its time budget (produces did not match any variant of untagged enum StrategyOutput). Fatal: aborts the run immediately, unlike a tolerated TimeoutError. |
RuntimeError | Classic Python: IndexError, ValueError, ZeroDivisionError |
WorkerPoolTimeout | Engine queue full; the request waited beyond its limit. Retry. |
Next steps
- Script lifecycle - how the engine loads and calls the code.
- Error catalog - meaning of each error and how to resolve it.