Stops, targets and trailing
There are three mechanisms to protect a position: stop loss, take profit, and trailing stop. TessTrade offers native stop and target (attached to the position and monitored by the engine) and also allows manual stop/trailing (where the script computes and fires the exit).
Native stop loss and native take profit
Pass them as kwargs on the entry order:
close = sdk.candles[-1]["close"]
sdk.buy(
action="buy_to_open",
qty=1,
order_type="market",
stop_loss=close * 0.98, # 2% below
take_profit=close * 1.05, # 5% above
)After entry, the engine monitors the position on every candle:
- If the low of the candle touches
stop_loss-> emits an automatic exit order (for long:sell_to_close). - If the high of the candle touches
take_profit-> emits an automatic exit order. - If both are touched on the same candle, the result depends on the configured execution model (pessimistic: stop wins; optimistic: target wins).
The script does not need to do anything for the stop/target to work. The engine takes care of it.
Modify stop/target of a live position
Use update_position_exits (details in canonical actions):
if sdk.position > 0:
close = sdk.candles[-1]["close"]
new_stop = close * 0.97 # raises the stop (manual trailing)
sdk.close(
action="update_position_exits",
stop_loss=new_stop,
# take_profit omitted - keeps the previous one
)Or use the semantic shortcut:
sdk.update_exits(stop_loss=new_stop)
sdk.set_trailing_stop(new_stop) # same effect; clearer namePassing None (or 0) for a field does not clear an existing stop, it simply omits that field from the update. Values <= 0 are silently ignored — the frame-path normalizer drops the field (it does not raise an error), so the previously attached stop stays unchanged. There is no built-in way to remove an already-attached stop via update_exits; close the position or manage the stop in your script instead.
Trailing stop - manual implementation
There is no native trailing_stop=. The implementation is done in the script with sdk.state and update_exits. It requires more code but allows full flexibility.
Classic trailing stop (by percentage)
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"]
trail_pct = float((params or {}).get("trail_pct", 0.02))
if sdk.position > 0:
# Long open: track the high-water.
hw = sdk.state["high_water"]
sdk.state["high_water"] = close if hw is None else max(hw, close)
# Stop always trail_pct below the highest price seen.
new_stop = sdk.state["high_water"] * (1 - trail_pct)
# Close the position if the close falls below the stop.
if close <= new_stop:
sdk.sell(
action="sell_to_close",
qty=abs(sdk.position),
order_type="market",
)
sdk.state["high_water"] = None
else:
# Update the native stop to reflect the new level.
sdk.update_exits(stop_loss=new_stop)
else:
# No position, reset.
sdk.state["high_water"] = NoneWhat happens:
- Bar by bar,
high_watergrows as the price rises. - The stop is shifted up proportionally (
trail_pctbelow the high_water). - If the price falls below the current stop, the script closes manually (
sell_to_close). Even if the native stop also fires, the manual exit arrives first. - When the position flattens,
high_wateris reset.
Trailing with trailing_stop_pct
The buy/sell actions accept trailing_stop_pct as a kwarg, which the engine forwards to the execution model. In theory this simplifies the case above, but the logic of when to update is still the script's responsibility. The kwarg only records the preference in the signal. For effective trailing, implement it as above.
Short trailing
For a short position, track the low_water (lowest price seen) and place the stop above it:
if sdk.position < 0:
lw = sdk.state.get("low_water")
close = sdk.candles[-1]["close"]
sdk.state["low_water"] = close if lw is None else min(lw, close)
new_stop = sdk.state["low_water"] * (1 + trail_pct)
if close >= new_stop:
sdk.buy(
action="buy_to_cover",
qty=abs(sdk.position),
order_type="market",
)
sdk.state["low_water"] = None
else:
sdk.update_exits(stop_loss=new_stop)Exit strategies beyond stop/target
Time-based exit (maximum bars in position)
if sdk.position != 0:
entered_at = sdk.state.get("entry_time")
now = sdk.candles[-1]["time"]
max_hold_ms = 3_600_000 * 4 # 4 hours
if entered_at is not None and now - entered_at > max_hold_ms:
side = "sell_to_close" if sdk.position > 0 else "buy_to_cover"
sdk.close(action="close_position", qty=abs(sdk.position), order_type="market")
sdk.state["entry_time"] = None
elif sdk.position == 0 and sdk.state.get("entry_time") is None:
# (entry moment; save the timestamp)
passSet entry_time at the moment of entry.
Exit on RSI reversal
Combines persistent state with an indicator. Compute the RSI over a bounded sdk.candles[-LOOKBACK:] window with the injected Indicator global (no import), so every frame stays O(1) in history length:
LOOKBACK = 300 # fixed window >> period → O(1) in history length; converges to the full-history value
def on_bar_strategy(sdk, params):
period = int((params or {}).get("rsi_period", 14))
if len(sdk.candles) < period + 2:
return
rsi = Indicator.rsi(sdk.candles[-LOOKBACK:], period)[-1] # injected global, no import
if rsi is not None and sdk.position > 0 and rsi >= 70: # left the overbought zone
sdk.sell(action="sell_to_close", qty=abs(sdk.position), order_type="market")Do not recompute the indicator over the whole history each bar — e.g.
Indicator.rsi([c["close"] for c in sdk.candles], 14)over all ofsdk.candles. That is O(n) per bar and O(n²) over the backtest; a frame that overruns the per-bar budget can desync the request/response protocol and abort the whole run with a fatalProtocolError: Failed to parse persistent strategy output JSON: data did not match any variant of untagged enum StrategyOutput(which is not covered by the timeout tolerance). Keep every frame O(1) with the bounded window above.Note:
Indicator(andSignal, andta/pandas_ta) are pre-injected globals —import tesstrade_indicatorsis not available in the strategy editor (the validator allows onlynumpy,pandas,pandas_ta,talib,math,json,datetime). For a value bit-identical to a full recompute, keep the Wilder RSI state incrementally insdk.stateand update it from only the newest close each bar; otherwise the boundedsdk.candles[-LOOKBACK:]slice above is simpler and accurate enough.
Combining native stop + manual trailing
Recommended pattern: place an initial native stop at entry (protection against an extreme gap) and apply manual trailing as the price advances:
if sdk.position == 0 and buy_signal:
close = sdk.candles[-1]["close"]
initial_stop = close * 0.95 # 5% stop as a safety net
sdk.buy(
action="buy_to_open",
qty=1,
order_type="market",
stop_loss=initial_stop,
)
sdk.state["high_water"] = close
# Persist the entry stop: local variables do NOT survive across bars — only sdk.state does.
sdk.state["initial_stop"] = initial_stop
elif sdk.position > 0:
close = sdk.candles[-1]["close"]
sdk.state["high_water"] = max(sdk.state.get("high_water", close), close)
# Read the entry stop back from sdk.state (referencing the local here would NameError).
new_stop = max(sdk.state["initial_stop"], sdk.state["high_water"] * 0.97) # 3% trailing
sdk.update_exits(stop_loss=new_stop)Common mistakes
- A stop below the current price on a buy fires an immediate close. Always use
close * 0.98or lower. - Forgetting to reset
high_waterwhen the position flattens. On the next entry, the script inherits the high_water from the previous position. - Updating the stop on every candle without a real change.
update_exitsgenerates a signal and multiple updates pollute the log (the engine merges them, but it is noise). Checkif new_stop != sdk.state.get("last_stop")to avoid it. - Relying on native
stop_lossduring explosive volatility. On an opening gap, the stop may execute well below (slippage). For volatile markets, consider a volatility-based stop (ATR) or an early manual exit.
Next steps
- Persistent state - deep dive into
sdk.statewith complex trailing. - Canonical actions - how
update_position_exitsworks in detail.