Features

Stop loss and take profit

Backtest stop losses, trailing stops, take profits, time stops, and exit ladders

Exits decide most of a strategy's risk. VBT lets you backtest stop losses, trailing stops, take profits, and time stops in Python as arguments of vbt.PF.from_signals, and test whole grids of them in one call. Stops are checked against each bar's open, high, low, and close, with explicit rules for what fills first.

Test 64 stop loss and take profit combinations at once
data = vbt.YFData.pull("BTC-USD", start="2020-01-01", end="2024-01-01")
fast = data.close.rolling(20).mean()
slow = data.close.rolling(50).mean()
entries = fast.vbt.crossed_above(slow)

pf = vbt.PF.from_signals(
    data,
    entries,  
    sl_stop=vbt.Param(np.arange(2, 17, 2) / 100),
    tp_stop=vbt.Param(np.arange(5, 45, 5) / 100),
    fees=0.001,
)
pf.sharpe_ratio.sort_values(ascending=False).head(3)
sl_stop  tp_stop
0.08     0.4        0.941107
0.12     0.4        0.917926
0.08     0.1        0.910485
Name: sharpe_ratio, dtype: float64
pf.sharpe_ratio.vbt.heatmap(x_level="sl_stop", y_level="tp_stop").show()
Heatmap of Sharpe ratios for stop loss values from 2% to 16% and take profit values from 5% to 40% on a Bitcoin moving average strategy Figure data (JSON)

The best cell sits on the edge of the grid, and its neighbors differ a lot. A heatmap makes that visible before a single best value becomes a decision.

Stops and targets

Four stop types are built in. sl_stop is a stop loss, tsl_stop a trailing stop that follows the best price since entry, tp_stop a take profit, and tsl_th turns the trailing stop into a trailing take profit that only starts trailing after the price has moved by that threshold. Each is measured from the entry price as a fraction by default:

Exit by take profit or by trailing stop
index = pd.date_range("2026-01-01", periods=5)
ohlc = pd.DataFrame({
    "open": [100.0, 101.0, 104.0, 108.0, 103.0],
    "high": [101.0, 105.0, 111.0, 109.0, 104.0],
    "low": [99.0, 100.0, 103.0, 102.0, 94.0],
    "close": [100.0, 104.0, 108.0, 103.0, 95.0],
}, index=index)
entries = pd.Series([True, False, False, False, False], index=index)

pf = vbt.PF.from_signals(
    ohlc["close"],
    entries,
    open=ohlc["open"],
    high=ohlc["high"],
    low=ohlc["low"],
    sl_stop=0.05,
    tsl_stop=vbt.Param([np.nan, 0.04], level=0),  
    tp_stop=vbt.Param([0.1, np.nan], level=0),
)
pf.orders.readable[["Column", "Fill Index", "Side", "Price", "Stop Type"]]
        Column Fill Index  Side   Price Stop Type
0   (nan, 0.1) 2026-01-01   Buy  100.00      None
1   (nan, 0.1) 2026-01-03  Sell  110.00        TP
2  (0.04, nan) 2026-01-01   Buy  100.00      None
3  (0.04, nan) 2026-01-04  Sell  106.56       TSL

The take profit fills at $110 on the third bar. The trailing stop follows the $111 high down to $106.56 and fills on the fourth bar. With delta_format="absolute", stop values are price distances instead of fractions, and with "target" they are price levels.

Stop values broadcast like any other argument, so they can differ per asset, per bar, and per direction. To use different stops for longs and shorts, fill one array with the long value at long entries and the short value at short entries. Stops are read when a position opens and stay fixed afterwards unless a callback changes them.

A stop is checked from the bar after the entry, even when the entry fills at the open, because the bar's high and low cannot be placed before or after the fill. When it fires, it closes the position. With stop_exit_type="reverse", the same order also opens the opposite position.

Combine a stop loss and a take profit to test a bracket around each position. When one closes the position, VBT clears the other stops, so an old target cannot act on a later position.

A risk-to-reward setup is two arrays. Here the risk is the distance to the lowest low of the last five bars, and the target is twice that distance:

Set the stop below recent lows and the target at 2R
data = vbt.YFData.pull("BTC-USD", start="2023-01-01", end="2024-01-01")
entries = data.close.vbt.crossed_above(data.close.rolling(20).max().shift(1))
risk = data.close - data.low.rolling(5).min()

pf = vbt.PF.from_signals(
    data,
    entries,
    sl_stop=risk,
    tp_stop=2 * risk,
    delta_format="absolute",
)
cols = ["Entry Index", "Avg Entry Price", "Avg Exit Price", "Return"]
pf.trades.readable[cols].head(4)
                Entry Index  Avg Entry Price  Avg Exit Price    Return
0 2023-01-23 00:00:00+00:00     22934.431641    20685.380859 -0.098064
1 2023-03-14 00:00:00+00:00     24746.074219    34981.714844  0.413627
2 2023-10-29 00:00:00+00:00     34538.480469    36781.667969  0.064947
3 2023-11-15 00:00:00+00:00     37880.582031    43744.746094  0.154807

Trailing take profit

A fixed target exits as soon as it is reached. A trailing take profit lets the move continue, then exits on a pullback. Set tsl_th to the gain that activates it and tsl_stop to the distance it trails by. You can keep a separate sl_stop in place before that threshold is reached.

Here the same price path first pulls back, then rises past 10%. One portfolio trails from the start, while the other waits for that gain before enabling its 5% trailing exit:

Start trailing only after a 10% gain
index = pd.date_range("2026-01-01", periods=6)
close = pd.Series([100.0, 104.0, 98.0, 112.0, 120.0, 113.0], index=index)
pf = vbt.PF.from_signals(
    close,
    entries=[True, False, False, False, False, False],
    sl_stop=0.1,
    tsl_stop=0.05,
    tsl_th=vbt.Param([np.nan, 0.1], keys=["trail immediately", "trail after 10%"]),
)
pf.orders.side_sell.readable[["Column", "Fill Index", "Price", "Stop Type"]]
              Column Fill Index  Price Stop Type
0  trail immediately 2026-01-03   98.0       TSL
1    trail after 10% 2026-01-06  113.0       TTP

Both portfolios have a 10% stop loss. The threshold changes when the trailing exit takes over. This example checks only the supplied closing prices, so the exits fill at those closes.

Time-based exits

A time stop exits after a holding period or at a point in time, whatever the price does. td_stop takes a duration such as "7D", or a number of bars with time_delta_format="rows". dt_stop takes a date, a period such as "M" for the end of the month, or a time of day such as "18:00". The holding period counts from the open of the entry bar, so a time stop fills at the open of the bar where it expires. The Time stops highlight below exits every position before the month ends.

Exit five bars after each entry
pf = vbt.PF.from_signals(data, entries, td_stop=5, time_delta_format="rows")
pf.orders.readable[["Fill Index", "Side", "Stop Type"]].head(4)
                 Fill Index  Side Stop Type
0 2023-01-23 00:00:00+00:00   Buy      None
1 2023-01-28 00:00:00+00:00  Sell        TD
2 2023-01-29 00:00:00+00:00   Buy      None
3 2023-02-03 00:00:00+00:00  Sell        TD

Adaptive stops

Volatility-scaled stops are arrays, too. Pass a multiple of ATR with delta_format="absolute" and each trade gets a distance that matches the market at its entry. The distance is read once, so a stop series does not make a trailing stop follow the current ATR. To move a stop while a trade is open, write an adjustment function. It runs at the start of every bar and can rewrite the active stop in c.last_sl_info. This one moves the stop to the entry price once the previous close is one risk unit in profit:

Move an ATR stop to breakeven after 1R
@njit
def breakeven_nb(c):
    sl_info = c.last_sl_info[c.col]
    if c.last_position[c.col] > 0 and vbt.pf_nb.is_stop_info_active_nb(sl_info):
        prev_close = vbt.pf_nb.select_nb(c, c.close, i=c.i - 1)  
        target = sl_info["init_price"] + sl_info["stop"]
        if sl_info["stop"] > 0 and prev_close >= target:
            sl_info["stop"] = 0.0  

atr = data.run("atr", window=14).atr
kwargs = dict(sl_stop=2 * atr, tp_stop=6 * atr, delta_format="absolute")
fixed = vbt.PF.from_signals(data, entries, **kwargs)
breakeven = vbt.PF.from_signals(data, entries, adjust_func_nb=breakeven_nb, **kwargs)
pd.DataFrame({
    "entry": fixed.trades.readable["Entry Index"],
    "fixed": fixed.trades.readable["Return"],
    "breakeven": breakeven.trades.readable["Return"],
}).iloc[3:6]
                      entry     fixed  breakeven
3 2023-05-28 00:00:00+00:00 -0.056052  -0.056052
4 2023-06-20 00:00:00+00:00 -0.060298   0.000000
5 2023-10-01 00:00:00+00:00 -0.041770  -0.041770

The June trade rose by more than its initial risk, then fell back. With the fixed stop it lost 6%. With the breakeven rule it closed flat. The same pattern can tighten a stop on a higher timeframe, trail by a chandelier distance, or rebase the stop on the average entry price after adding to a position.

Adding to a position

Each stop type keeps one active level per column. When an entry increases the position, upon_stop_update decides whether the existing stop is kept or replaced by the new one.

Stop ladders and partial exits

A stop normally closes the whole position. Pass a list of stop values with stop_ladder to scale out at successive levels instead. "uniform" exits an equal share at each level, "weighted" sizes each exit by the distance to the previous level, and the adaptive variants recompute the size from the remaining position. The Stop laddering highlight below tests two take profit ladders side by side.

A ladder keeps the remaining stops alive after a partial stop exit, combining staged profit-taking with a protective stop for the remaining position. For control over each step, set exit_size and exit_type on the stop records in a callback, for example to reverse instead of close.

Stop-market and stop-limit exits

By default, a triggered stop creates a market order. With stop_order_type="limit", it can create a limit order instead, with stop_limit_delta setting the offset from the stop's exit price. This lets you compare exiting when the stop fires with waiting for a chosen price. The limit can remain unfilled if that price is never reached. See Orders and execution for pending orders and fill-price choices.

Realistic stop fills

Daily bars hide the order of events inside the day. VBT splits each bar into its open, the part between open and close, and its close, and fills whatever is reached first. When a stop loss and a take profit are both reached inside the same part of the bar, the stop loss wins:

Resolve a bar that hits both the stop and the target
index = pd.date_range("2026-01-01", periods=2)
pf = vbt.PF.from_signals(
    pd.Series([100.0, 100.0], index=index),
    pd.Series([True, False], index=index),
    open=pd.Series([100.0, 100.0], index=index),
    high=pd.Series([100.0, 112.0], index=index),
    low=pd.Series([100.0, 94.0], index=index),
    sl_stop=0.05,
    tp_stop=0.1,
)
pf.orders.readable[["Fill Index", "Side", "Price", "Stop Type"]]
  Fill Index  Side  Price Stop Type
0 2026-01-01   Buy  100.0      None
1 2026-01-02  Sell   95.0        SL

The same pessimism applies to trailing stops: on each bar the stop is checked before the new high can raise it. When the stops are hit in different parts of the bar, the earlier one fills.

Gaps are the other trap. If the price opens beyond the stop, a real order fills at the open, not at the stop. stop_exit_price controls this:

Fill a stop after a gap down
pf = vbt.PF.from_signals(
    pd.Series([100.0, 92.0], index=index),
    pd.Series([True, False], index=index),
    open=pd.Series([100.0, 90.0], index=index),
    high=pd.Series([100.0, 93.0], index=index),
    low=pd.Series([100.0, 89.0], index=index),
    sl_stop=0.05,
    stop_exit_price=vbt.Param(["stop", "hardstop", "close"]),
)
pf.orders.readable[["Column", "Side", "Price"]]
     Column  Side  Price
0      stop   Buy  100.0
1      stop  Sell   90.0
2  hardstop   Buy  100.0
3  hardstop  Sell   95.0
4     close   Buy  100.0
5     close  Sell   92.0

The default, "stop", fills at the open after a gap. "hardstop" always fills at the stop level, which is optimistic, and "close" fills at the bar's close. On the entry side, stop_entry_price sets the reference the stop is measured from. It defaults to the order price, such as the open with price="nextopen". Set it to "fillprice" to include slippage, or to "close" to measure stops from the close of the entry bar.

Use finer data for tight stops

When stops are small relative to a bar's range, many bars hit both levels and the pessimistic rule decides the outcome. Test such stops on intraday data, where far fewer bars contain both levels.

Stop signals before simulation

Stops can also be computed as exit signals, without a portfolio. This is useful for signal research, labeling, and for checking how often a stop would fire before you size any trades:

Generate stop exits as signals
exits = entries.vbt.signals.generate_ohlc_stop_exits(
    entry_price=data.close,
    open=data.open,
    high=data.high,
    low=data.low,
    close=data.close,
    sl_stop=0.05,
    tp_stop=0.1,
    out_dict=(out := {}),  
)
stop_names = dict(enumerate(vbt.sig_enums.StopType._fields))
pd.DataFrame({
    "stop price": out["stop_price"][exits],
    "stop type": out["stop_type"][exits].map(stop_names),
}).head(4)
                             stop price stop type
Date
2023-02-09 00:00:00+00:00  22585.838086        SL
2023-02-24 00:00:00+00:00  23587.691016        SL
2023-03-17 00:00:00+00:00  27558.067969        TP
2023-03-22 00:00:00+00:00  26767.025586        SL

By default, an exit is placed only between two entries, so entries that arrive while a stop is still pending do not start a new stop.

Stop laddering

✅ Stop laddering is a technique for incrementally moving out of a position. Instead of providing a single stop value to close a position, you can provide an array of stop values, with each one removing a certain amount of the position when triggered. You can control this amount by choosing a different ladder mode. Thanks to a new broadcasting feature that allows arrays to broadcast along just one axis, the stop values do not need to have the same shape as the data. You can even provide stop arrays of different shapes as parameters!

Test two TP ladders
data = vbt.YFData.pull("BTC-USD", end="2017-01")
pf = vbt.PF.from_holding(
    data,
    stop_ladder="uniform",
    tp_stop=vbt.Param([
        [0.1, 0.2, 0.3, 0.4, 0.5],
        [0.4, 0.5, 0.6],
    ], keys=["tp_ladder_1", "tp_ladder_2"])
)
pf.trades.plot(column="tp_ladder_1").show()
BTC-USD holding trade with five uniform take-profit ladder exits Figure data (JSON)

Time stops

✅ Joining other stop orders, time stop orders can close a position either after a certain period of time or on a specific date.

Enter randomly, exit before the end of the month
data = vbt.YFData.pull("BTC-USD", start="2022-01", end="2022-04")
entries = vbt.pd_acc.signals.generate_random(data.symbol_wrapper, n=10, seed=42)
pf = vbt.PF.from_signals(data, entries, dt_stop="M")  
pf.orders.readable[["Fill Index", "Side", "Stop Type"]]
                 Fill Index  Side Stop Type
0 2022-01-19 00:00:00+00:00   Buy      None
1 2022-01-31 00:00:00+00:00  Sell        DT
2 2022-02-25 00:00:00+00:00   Buy      None
3 2022-02-28 00:00:00+00:00  Sell        DT
4 2022-03-11 00:00:00+00:00   Buy      None
5 2022-03-31 00:00:00+00:00  Sell        DT

Copyright © 2021–2026 Oleg Polakow. All rights reserved.

Site content and documentation are provided for using and evaluating VectorBT PRO and for educational purposes. Any other use, including building or supporting competing products or services, requires prior written consent.