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
markprice and onependingaction 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.