Persistent state and trailing stop
sdk.state is the SDK mechanism for non-trivial strategies. It is a dictionary that the engine keeps alive between script calls.
Common patterns for managing state bar-by-bar, from flags to trailing stops.
def on_bar_strategy(sdk, params):
trail_pct = float(params.get("trail_pct", 0.02))
cooldown_ms = int(params.get("cooldown_ms", 0))
# `sdk.state` is already a persistent dict — do NOT reassign it to {} (that
# would drop its auto-zero behavior). Initialize individual keys on demand.
# The current price is sdk.candles[-1]["close"]; the bare name `close` is
# bound to sdk.close (an order method), not a price.
close = sdk.candles[-1]["close"]
# Pattern 1: Trail Stop Logic
if sdk.position > 0:
hw = sdk.state.get("high_water")
sdk.state["high_water"] = max(hw, close) if hw else close
new_stop = sdk.state["high_water"] * (1 - trail_pct)
sdk.update_exits(stop_loss=new_stop)
# Pattern 2: Cooldowns
now = sdk.candles[-1]["time"]
if now - sdk.state.get("last_entry", 0) < cooldown_ms:
returnPrinciple
Always initialize keys before using them, at least for non-numeric objects. Missing numeric keys return 0.0 automatically, which simplifies counters but can hide bugs for lists or dicts.
Compute indicators incrementally
Keeping every bar O(1) is the goal. Each frame runs under a per-bar time budget (~800 ms by default). Recomputing an indicator over the whole sdk.candles history on every bar — e.g. Indicator.rsi([c["close"] for c in sdk.candles], period) — is O(n) per bar and O(n²) over the backtest. As history grows, a late frame overruns the budget, and that is not merely a slow 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.
There are two dependency-free cures, and neither needs an import.
Pattern A — bounded window (recommended default; simplest). Slice a fixed lookback off sdk.candles and call the pre-injected Indicator global over that window. A window comfortably larger than the period (≈10×) makes each bar O(1) in history length and converges to the full-history value:
LOOKBACK = 300 # fixed window >> period → O(1) per bar; converges to the full-history value
def on_bar_strategy(sdk, params):
period = int(params.get("period", 14))
if len(sdk.candles) < period + 2:
return
rows = sdk.candles[-LOOKBACK:] # bounded — never the whole history
rsi = Indicator.rsi(rows, period)[-1] # native global, no import
if rsi is None:
return
# ... use rsi ...Pattern B — exact accumulator in sdk.state (advanced / bit-exact parity). Keep the recursive indicator state (missing numeric keys read back as 0.0) and update it from only the newest close. This is bit-identical to a full-history recompute — reach for it when you need exact parity. An EMA is a single line of state:
def on_bar_strategy(sdk, params):
period = int(params.get("period", 20))
k = 2 / (period + 1)
close = sdk.candles[-1]["close"]
ema = sdk.state["ema"] # 0.0 on the first bar (auto-zero)
ema = close if ema == 0.0 else ema + k * (close - ema) # O(1) update
sdk.state["ema"] = emaRSI (Wilder avg_gain/avg_loss), MACD (three EMAs) and ATR (Wilder smoothing) follow the same idea; if the first frame can arrive with a batch of history, warm the accumulator once from sdk.candles, then update O(1) per bar. Either way, never rescan the whole history each frame. (The Indicator global exposes the native indicator kernel with no import — see Native indicators.)
Size limit
sdk.state lives in sandbox memory subject to the per-strategy memory ceiling. Avoid memory leaks by capping list sizes — see sandbox limits for a bounded-buffer example.