Long from 84,120. Stop at 84,036, target at 84,271. The next minute's bar comes in: open 84,118, high 84,290, low 84,010, close 84,240.
Both levels are inside that bar. The stop was touched and the target was touched, and four numbers of OHLC contain exactly zero information about which came first. Your backtest still returned a number. Somewhere in the loop, a line decided — probably a line you didn't think of as a modelling assumption when you wrote it.
This is the single largest source of fake performance I see in short-horizon strategies, ahead of fees and ahead of slippage, because it doesn't look like an assumption. It looks like plumbing.
Wrong way one: let the if-chain decide
The usual shape:
if bar.high >= target:
exit(target, "tp")
elif bar.low <= stop:
exit(stop, "sl")
Nobody chose "targets resolve before stops." The target check just got typed first because that's the happy path and it's the one you were thinking about. Flip the two branches and the equity curve changes; that alone should tell you the strategy's P&L is partly a property of your editor.
I ran a deliberately ordinary mean-reversion scalper on BTCUSDT perp, three months of 1-minute bars, 4,812 trades, stop 0.10% and target 0.18% from entry. Same signals, same fees, only the tie-break convention changed.
An eighth of the trades carry the whole result. That's the arithmetic of a tight stop-and-target pair: the ambiguous trades are the ones where price went both ways, which is most of the interesting ones, and each is worth the full stop-to-target distance depending on the coin flip. 12.6% of trades × 0.28% of span is 3.5% of gross notional turnover per unit of sample, which dwarfs the strategy's actual edge.
The rate is a function of your bar size against your level spacing, and it gets worse fast as you slow the bars down. Same stop and target, same signals, resampled:
| Bar interval | Trades with both levels inside one bar | Sharpe (target-first) |
|---|---|---|
| 1s | 0.3% | 0.91 |
| 1m | 12.6% | 2.31 |
| 5m | 34% | 3.60 |
| 15m | 49% | 4.42 |
| 1h | 71% | 5.88 |
Look at what that table is actually saying. Coarser bars made the backtest better. Every researcher's instinct is that hourly bars are the conservative choice, less noise, less overfitting to microstructure. But with an intrabar convention that resolves in your favour, a coarser bar is just a bigger box inside which you get to assume you were lucky. On 1h bars, seven trades in ten are pure convention. That backtest isn't testing a strategy, it's testing the ordering of two `if` statements 3,400 times.
Wrong way two: assume the stop filled at the stop price
Say you fix the ordering. The stop resolves first, you book a loss of exactly 0.10% plus taker fee, and you feel rigorous. Two separate things are still wrong.
The first is that a stop is a trigger, not a fill. On Binance USDⓈ-M a STOP_MARKET order becomes a market order the instant the trigger condition is met, and it then eats whatever's in the book. In a quiet minute that's a tick or two of slippage. In the minute that actually triggered your stop — the one with a 40-point candle body and a liquidation cascade underneath it — the book is thin on precisely the side you're crossing. In my sample, matching stop triggers against the tick tape, the median fill was 1.4 bps past the trigger and the 95th percentile was 11 bps. On a 10 bps stop, the tail costs you an extra tenth of the risk you thought you'd defined.
The second is subtler and specific to perps: which price triggers it. Binance defaults stop orders to mark price, and mark price is built from the index plus a smoothed basis, not from the last trade on that venue. Your OHLC series is last price. They are different series, and they diverge most during the exact events that trigger stops.
| Last price (your klines) | Mark price (default trigger) | |
|---|---|---|
| Source | trades on this venue | index of several venues + basis |
| Wick behaviour | full excursion | heavily damped |
| Typical divergence | 1–3 bps calm, 20–35 bps in a cascade minute | |
| Backtest consequence | stops that fired but shouldn't have, and vice versa | |
So a 25 bps wick on the last-price tape stops you out in the backtest while the live mark price never got within 10 bps of your trigger. Or the reverse, on the day the index moves and your venue lags. If you set workingType to CONTRACT_PRICE you at least align the live behaviour with your data, and that is usually the right call for a researcher, since simulating a mark-price trigger honestly means carrying a second series through your whole fill engine.
The version of this I remember best: someone on our side "improved" a strategy by moving the take-profit from 0.18% to 0.21%. Sharpe went from 2.3 to 3.1. No new edge. The target had simply moved outside the fat part of the 1-minute wick distribution, so fewer trades landed in the ambiguous bucket where the code was quietly awarding them the win. They'd optimized the tie-breaker.
Wrong way three: always assume the worst and call it conservative
The reflex fix is pessimism. If both levels are touched, take the stop. Done, no more optimism, ship it.
I used to do this. It's better than the alternative and it's still wrong, for two reasons.
It kills strategies that are fine. A pessimistic resolution on 12.6% of trades cost this one 2.1 Sharpe points against a tick-resolved 0.94. If the true number is 0.94 and your convention reports 0.18, you throw the idea away and go work on something worse. Conservatism that's off by two Sharpe points isn't conservatism, it's noise with a moral posture.
Worse, it corrupts optimization. Hand a parameter sweep a pessimistic tie-break and the optimizer learns to avoid ambiguity, because ambiguity is now a pure penalty. It will walk toward wide stops and near targets, or toward slow bars where the two levels rarely coexist, and it will present you with parameters selected against your fill convention rather than against the market. Same failure as the generous version, opposite sign, equally invisible in the tearsheet.
Rule of thumb we use before anything else: if stop_distance + target_distance is smaller than the 75th percentile range of your bar interval, your intrabar assumption is a bigger term in the P&L than your signal. Compute both numbers. It takes four lines and it has ended more strategy reviews than any other single check.
What actually works
The path inside the bar is data. Go get it, or bound what you can't get.
- Resolve on the finest series you have. Binance aggTrades for the relevant minutes is a few hundred rows and settles the question outright: which level was touched first, and at what price the sweep filled. You don't need tick data for the whole backtest, only for the ambiguous bars. In my sample that was 606 minutes out of 129,600. That's a small download, not an infrastructure project.
- If ticks aren't available, drop one or two timeframe levels for resolution only. Signals on 15m, exits resolved on 1s or 1m bars. Ambiguity falls from 49% to a fraction of a percent, and the residual is small enough to ignore honestly.
- Report the band, always. Run every backtest twice, optimistic and pessimistic resolution, and print both Sharpes next to the resolved one. That spread is your intrabar uncertainty, and it belongs on the tearsheet beside the confidence interval you'd put on the Sharpe itself. When the band is 0.2–2.3, no conclusion inside it is real.
- Track ambiguity rate as a first-class metric. Ours sits at the top of every strategy card, next to trade count and turnover. A rate above about 5% means the exit logic, not the entry logic, is the thing under test.
- Model the trigger separately from the fill. Trigger on the price series the venue actually uses; fill at trigger plus a slippage draw calibrated from the tape, not at the trigger price.
Equities have the same problem wearing different clothes. A stop at 62.00 on a name that gaps to 58.40 overnight doesn't fill at 62.00, it fills somewhere below the open print, and a daily-bar backtest that books −$0.00 slippage on gap-throughs will happily tell you a stop-loss overlay improved your drawdown. It didn't. It just never got tested on the days that matter. Halts do it too: the reopening auction is where your stop actually clears, at a price the bar's low never shows.
None of this is exotic. It's the recognition that a bar is a summary, and a stop-and-target strategy is a bet on the order of events the summary threw away. When the paper-trading engine finally runs the thing against a live tape, the tape has an opinion about that order, and it has never once cared which branch of the if-statement you typed first.
← All posts


