Skip to content
GetProfitable
Search

Why vectorized breaks for stops and limits

Lesson 19 · about 11 min

Vectorized backtests assume that each bar's return depends only on the position held over that bar. A stop-loss breaks that assumption: whether it fires depends on the entry price, which depends on when the previous signal fired, which depends on the whole path before it. Limit orders are worse, because whether they fill depends on the bar's high and low relative to a price you chose earlier. This lesson shows the failure concretely, then names the simplest honest model, which is a loop.

The path-dependence problem

Take the crossover with a 2-ATR stop below entry. To know the stop level on bar t you need the entry price, which you only know if you know when the current trade began, which requires knowing that the previous trade's stop did not fire before the signal changed. Each bar's outcome depends on every bar since entry. No column expression computes that in one pass.

You can try. The tempting vectorized shortcut is: "exit on any bar where the low is below entry − 2 ATR". But "entry" is a per-trade constant, not a column, and the moment a stop fires the next trade's entry changes, so the column you built is wrong from that point onwards.

A loop that is actually correct

Here is the minimal bar-by-bar version. It is slower than pandas by a large factor and it is right. The trick that keeps it short is a single variable, mark, the price at which the position was last valued; every return is new price / mark, whether the new price is a close, an open, or a stop fill. Without that, the question "did I enter at this bar's open or was I already in?" sprouts a special case at every step.

import numpy as np
import pandas as pd
from src.data import synthetic_ohlcv
from src.indicators import sma, atr


def crossover_with_stop(bars: pd.DataFrame, fast: int = 20, slow: int = 50,
                        atr_mult: float = 2.0, cost_bps: float = 5.0) -> tuple[pd.DataFrame, int]:
    """Long-only crossover, next-open entry, exit on signal flip or on a stop atr_mult ATRs below entry."""
    close, open_, low = bars["close"].to_numpy(), bars["open"].to_numpy(), bars["low"].to_numpy()
    signal = (sma(bars["close"], fast) > sma(bars["close"], slow)).to_numpy()
    a = atr(bars).to_numpy()
    cost = cost_bps / 10_000.0

    equity, in_pos, mark, stop, pending = 1.0, False, np.nan, np.nan, None
    out_equity, stopped = np.empty(len(bars)), 0
    for t in range(len(bars)):
        if pending == "enter":                       # decided at t-1 close, filled at t open
            in_pos, mark, stop = True, open_[t], open_[t] - atr_mult * a[t - 1]
            equity *= 1 - cost
        elif pending == "exit":
            equity *= (open_[t] / mark) * (1 - cost)
            in_pos = False
        pending = None

        stopped_now = False
        if in_pos and low[t] <= stop:                # stop hit somewhere inside this bar
            fill = min(open_[t], stop)               # a gap through the stop fills at the open
            equity *= (fill / mark) * (1 - cost)
            in_pos, stopped, stopped_now = False, stopped + 1, True
        elif in_pos:
            equity *= close[t] / mark                # mark to market at the close
            mark = close[t]

        out_equity[t] = equity
        if not in_pos and signal[t] and not stopped_now:
            pending = "enter"                        # no re-entry on the bar we were stopped out of
        elif in_pos and not signal[t]:
            pending = "exit"
    return pd.DataFrame({"equity": out_equity}, index=bars.index), stopped


bars = synthetic_ohlcv(1500, seed=42)
result, n_stopped = crossover_with_stop(bars)
print(f"final equity {result['equity'].iloc[-1]:.3f}, trades stopped out: {n_stopped}")

Notice the decisions this loop makes that a column could not: the stop is set from the ATR of the bar before the fill, a gap through the stop fills at the open, and the strategy does not re-enter on the bar it was stopped out of. A real trader might make different choices on each. The point is not that this loop is perfect; it is that every one of those decisions is visible in it.

Same-bar ambiguity

Suppose a trade has a stop at 95 and a target at 105, and a bar has low 94 and high 106. Which fired first? Daily data cannot tell you. The conservative convention is to assume the stop fired (the worse outcome). How often does it matter? Count it:

a = atr(bars)
entry = bars["open"]
both = (bars["low"] <= entry - 2 * a.shift(1)) & (bars["high"] >= entry + 3 * a.shift(1))
print(f"bars where a 2-ATR stop and 3-ATR target would both be touched: {int(both.sum())} of {len(bars)}")

On daily bars with wide brackets it is rare; on hourly bars with tight brackets it is common, and the difference between "stop first" and "target first" can be the difference between a profitable and a losing backtest. If your result depends on that assumption, you need finer data, not a better assumption.

Key idea: Stops, limits and anything else that depends on the price path inside a bar are state that must be carried forward bar by bar. The honest model is a loop with explicit state, and the conservative rule when a bar touches both a stop and a target is that the stop fired first.

The cost of the loop

The loop above runs 1,500 bars in a few milliseconds; a million minute bars takes a few seconds in plain Python. That is fast enough for a single backtest and too slow for a parameter grid of ten thousand cells. The standard answer is to use the vectorized version for broad searches over rules that do not need intrabar logic, and the event loop for the final, realistic evaluation of the few candidates that survive. The next two lessons build the event loop as a reusable class so that the "final, realistic evaluation" is one call.

Try it: Modify the loop to assume the target fires first when both are touched (add a 3-ATR take-profit and swap the order of the checks). Run both conventions on the same bars and report the two final equities. Then repeat on synthetic_ohlcv(1500, seed=42, vol=0.03), which is three times more volatile, and watch the gap widen.

Recap

  • A stop level depends on the entry price, which depends on the path; no column expression can compute it.
  • A bar-by-bar loop with one mark price and one pending action is the simplest correct model.
  • When a bar touches both stop and target, assume the stop fired; if the result depends on that choice, get finer data.
  • Loops are slower; use vectorized code for searches and the loop for realistic evaluation.
  • Every execution decision should be a visible line in the loop, not an implicit property of a column.

See it drawn

Original diagrams for the ideas on this page. Illustrative, not real market data.

Risk and reward on one tradeA price scale showing an entry with a stop two points below and a target six points above, so the reward band is three times the risk band.PRICETARGET 106.00ENTRY 100.00STOP 98.00REWARDRISK6.00 pointsthree times the risk2.00 pointsthe most you loserisk : reward = 1 : 3
Risk and reward on one trade. One trade on a price scale: the entry sits 2.00 points above the stop and 6.00 points below the target, so the shaded reward band is three times the risk band. The ratio compares what is lost if the stop is hit with what is gained if the target is reached.
A trailing stop held two ATRs under a rising priceA rising price line with a stepped line below it that climbs whenever price climbs and holds its level whenever price falls, until price drops onto it.PRICE AND A TRAILING ATR STOP2 × ATRstop hittrailing stoppriceIllustrative prices. The stop follows price up and never moves back down.
A trailing stop set by ATR. Average true range measures how far a market typically travels in a session, so a stop placed a multiple of ATR under price leaves room for ordinary swings. The step line only ever ratchets up, and the circle marks where price falls onto it.
A fast and a slow moving average crossingA jagged price line with two smoother average lines through it; the fast average dips below the slow one on the left and cuts back above it in the middle, where a circle marks the crossing.pricefast averageslow averagefast crosses belowfast crosses abovethe slow averageAverages of recent closes; the fast one reacts sooner than the slow one.
Fast and slow moving averages crossing. A moving average is the average of the last few closing prices, redrawn each period. An average over fewer periods turns sooner than one over many, so the two lines cross whenever the recent pace of the market changes.