# Stop loss and take profit (/features/backtesting/stop-loss-and-take-profit)

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.

```python title="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,  # (1)
...     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()
```

1.  There are no exit signals. Every position is closed by its stop loss or its take profit.

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)](/assets/figures/features/backtesting/stop-loss-take-profit-grid.45ce7fc98a45.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 \[#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:

```python title="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),  # (1)
...     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
```

1.  Sharing `level=0` pairs the values instead of building a product: a 10% take profit alone, then a
    4% trailing stop alone.

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:

```python title="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 \[#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:

```python title="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 \[#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](#time-stops) highlight below exits every position before the
month ends.

```python title="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 \[#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:

```python title="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)  # (1)
...         target = sl_info["init_price"] + sl_info["stop"]
...         if sl_info["stop"] > 0 and prev_close >= target:
...             sl_info["stop"] = 0.0  # (2)

>>> 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
```

1.  The function runs before the current bar is known, so it decides on the previous close.
2.  A distance of zero puts the stop at the entry price.

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.

!!! info "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 \[#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](#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 \[#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](/features/backtesting/orders-and-execution/#pending-limit-and-stop-entry-orders)
for pending orders and fill-price choices.

## Realistic stop fills \[#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:

```python title="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:

```python title="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.

!!! warning "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](/features/backtesting/intraday-and-tick-backtesting/), where far fewer bars
    contain both levels.

## Stop signals before simulation \[#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:

```python title="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 := {}),  # (1)
... )
>>> 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
```

1.  Collect the price and the type of each stop that fired.

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]

New in 1.12.0

✅ 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!

```python title="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)](/assets/figures/features/portfolio/stop-laddering.e265b961a2c9.json)

## Time stops \[#time-stops]

New in 1.11.0

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

```python title="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")  # (1)
>>> 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
```

1.  Use `dt_stop` for datetime-based stops and `td_stop` for timedelta-based stops. Datetime-based
    stops can be periods ("D"), timestamps ("2023-01-01"), and even specific times ("18:00").


## Related pages

*   [Orders and execution](/features/backtesting/orders-and-execution/): Simulate limit and stop-entry orders, fill prices, timing, rejections, and real fills
*   [Signal backtesting](/features/backtesting/signal-backtesting/): Turn entry and exit signals into long, short, reversing, and pyramided positions
*   [Event-driven backtesting](/features/backtesting/event-driven-backtesting/): Write compiled callbacks for cooldowns, position limits, and custom simulators
*   [Intraday and tick backtesting](/features/backtesting/intraday-and-tick-backtesting/): Backtest minute bars, ticks, and sessions, and resolve stops on finer data