Stops: SL, TP, and trailing

QTrade supports three flavors of position-exit logic:

  1. Static SL / TP — fixed levels set at order time. Fires when low <= sl (long) or high >= tp (long); symmetric for shorts.

  2. Mid-trade modification — change SL / TP on an open Trade and the next bar checks against the new level.

  3. Trailing stop — Broker auto-bumps the SL each bar based on the high-water (long) / low-water (short) mark. The stop only ever ratchets in the trade's favor.

Static SL / TP at order time

self.buy(size=10, sl=95.0, tp=110.0)

If the next bar's low touches 95 the trade exits at 95 (exit_reason='sl'); if the high touches 110 it exits at 110 (exit_reason='tp'). SL is checked before TP within a single bar.

Modify SL / TP mid-trade

Trade.sl and Trade.tp are now settable, so you can ratchet a stop after a profit threshold or shift a target further out:

class RatchetingStop(Strategy):
    def on_bar_close(self):
        for trade in self.active_trades:
            if trade.is_long and self.data['Close'].iloc[-1] > trade.entry_price * 1.05:
                # Up 5% — move SL to breakeven, lock in zero loss
                trade.sl = trade.entry_price

For both at once, use update_exit_levels(). It uses a sentinel so the difference between "leave alone" and "clear" is unambiguous:

trade.update_exit_levels(sl=110)         # bump SL only
trade.update_exit_levels(tp=None)        # explicitly CLEAR the TP
trade.update_exit_levels(sl=110, tp=130) # both at once

The Broker's per-bar SL/TP check reads the current values fresh, so any mutation in on_bar_close() takes effect immediately on the next bar.

Trailing stop

For the standard "lock in profit as price runs in your favor" pattern, use the Broker-managed trailing stop instead of writing the bookkeeping yourself:

self.buy(size=10, trail_percent=0.05)    # 5% trailing stop
self.sell(size=10, trail_amount=2.0)     # $2 trailing stop on a short

How it works:

  • Long: each bar, trail_high = max(trail_high, bar_high). The SL is recomputed as trail_high * (1 - trail_percent) (or trail_high - trail_amount). The stop only moves up.

  • Short: symmetric. trail_low = min(trail_low, bar_low), SL = trail_low * (1 + trail_percent). Only moves down.

The trail_high / trail_low is seeded with entry_price at trade open, so the initial trail SL is already in effect on bar 1.

Trail + explicit SL together

You can pass both. Whichever is tighter (better for the trader) wins, and the trail still only ratchets in your favor:

self.buy(size=10, sl=98.0, trail_percent=0.05)
  • Initial trail-implied SL = entry * 0.95 = 95. Explicit sl=98 is tighter → effective SL is 98.

  • Price climbs from 100 to 110: trail-implied = 104.5. Now tighter than 98 → effective SL becomes 104.5.

Validation

trail_percent and trail_amount are mutually exclusive and must be > 0. Both are keyword-only parameters on Order, Trade, Strategy.buy, and Strategy.sell.

self.buy(size=10, trail_percent=0.05, trail_amount=2.0)  # ValueError
self.buy(size=10, trail_percent=0)                       # ValueError

When the trail update fires (timing)

Within the per-bar pipeline:

process_bar(ts):
    process_executing_orders   # market orders fill at this bar's open
    check_sl_tp                # exit triggers using the OLD SL level
    update_trailing_stops      # bump SL using THIS bar's high/low
    process_pending_orders     # stop / limit orders triggered today

The trail update runs after the SL check, so the new tighter level applies starting on the next bar. This is the conservative bar-level convention — within one OHLC bar we don't know if the low (which would trigger the OLD SL) came before or after the high (which would tighten the trail). Assuming the unfavorable extreme came first matches how __check_sl_tp already treats single-bar extremes.

Inspecting trail state after exit

The Trade record preserves trailing metadata even after the position closes — useful for post-run analysis:

for t in bt.broker.closed_trades:
    if t.trail_percent is not None:
        print(f"Trail-stopped at {t.exit_price}, peak was {t.trail_high}")

What's NOT modeled

  • Trailing TP / take-profit ratcheting. Trail only manages SL. To ratchet a TP you'd write the loop yourself with update_exit_levels(tp=...).

  • Volatility-based trails (chandelier exit, ATR stop). Need ATR data, which you'd compute in prepare() and apply via mid-trade trade.sl = ....

  • Time-in-trade exits. Compare self.current_time - trade.entry_date in on_bar_close() and call self.close() if too long.

  • Maker-side stop orders. All exits fill at the trigger price (or worse on a gap, for pure stop orders). No queue-position modeling.