Skip to content
Single .md

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.

python
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:
        return

Visual flowchart of how data persists across engine calls and restarts.


Principle

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 with ProtocolError: Failed to parse persistent strategy output JSON: data did not match any variant of untagged enum StrategyOutput. Unlike a TimeoutError, this ProtocolError is 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:

python
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:

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

RSI (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.