Stops: SL, TP, and trailing¶
QTrade supports three flavors of position-exit logic:
Static SL / TP — fixed levels set at order time. Fires when
low <= sl(long) orhigh >= tp(long); symmetric for shorts.Mid-trade modification — change SL / TP on an open Trade and the next bar checks against the new level.
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 astrail_high * (1 - trail_percent)(ortrail_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. Explicitsl=98is 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-tradetrade.sl = ....Time-in-trade exits. Compare
self.current_time - trade.entry_dateinon_bar_close()and callself.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.