When a candle's range includes both a stop-loss and a take-profit, OHLCV data alone usually cannot tell you which came first. If the position and both exit orders were already active before that candle opened, choosing the target first can turn an uncertain result into a reported win. To work through the checks with your own FX data, get a free SiftingIO API key and use the historical bars available through the Forex API.
The fix is not to guess a more convincing price path. Flag the affected bars, make the fill assumption explicit, and measure how much that assumption changes the result.
One EURUSD candle, two different outcomes#
Take a hypothetical long position entered at 1.1000 before the bar begins, with a fixed stop at 1.0990 and a fixed target at 1.1020. Both orders are active at the open. The next candle has these values:
| Open | High | Low | Close |
|---|---|---|---|
| 1.1000 | 1.1030 | 1.0980 | 1.1010 |
Here are two possible continuous paths through that same candle. For this illustration only, assume an exit fills exactly at its level and cancels the other exit. Ignore spread, fees and slippage.
| Hypothetical path | First exit reached | Gross result from 1.1000 |
|---|---|---|
| Open → high → low → close | Target at 1.1020 | +20 pips |
| Open → low → high → close | Stop at 1.0990 | -10 pips |
Both paths produce identical open, high, low and close values. The difference is 30 pips for this one illustrative trade. A bullish close does not prove that the high came before the low. Adding a single volume value does not restore the missing sequence either.
This is a limitation of the bar representation, not necessarily a faulty data feed. It is separate from the adjustments, gaps and session-boundary issues covered in why two backtests of the same strategy disagree.
Detect the ambiguity before simulating a fill#
The following Python function checks a long position that was already open, with fixed stop and target orders active before the bar opened. It uses only the standard library. The inputs are synthetic example prices, not a SiftingIO response schema.
It classifies the bar's range. It does not claim that an order filled. A price can jump across a level, and a reference-price observation is not an executable broker quote.
from decimal import Decimal
def classify_long_bar(open_, high, low, close, stop, target):
o, h, l, c, s, t = map(
Decimal, map(str, (open_, high, low, close, stop, target))
)
values = (o, h, l, c, s, t)
if not all(x.is_finite() and x > 0 for x in values):
raise ValueError("Use finite, positive prices")
if not (l <= min(o, c) <= max(o, c) <= h):
raise ValueError("Invalid OHLC range")
if s >= t:
raise ValueError("A long stop must be below its target")
# Handle opening gaps and exact-boundary opens separately.
if not s < o < t:
return "open_at_or_beyond_exit"
stop_in_range = l <= s
target_in_range = h >= t
if stop_in_range and target_in_range:
return "both_in_range"
if stop_in_range:
return "stop_in_range"
if target_in_range:
return "target_in_range"
return "neither_in_range"
result = classify_long_bar(
"1.1000", "1.1030", "1.0980", "1.1010", "1.0990", "1.1020"
)
print(result) # both_in_range
An open at or beyond either exit needs a separate opening-gap and order-fill rule. Do not silently fill a stop at its requested price after a gap. The function deliberately leaves that decision to the execution model.
For real data, map the documented OHLC fields into these arguments. Preserve decimals when parsing the original response if exact decimal handling matters; converting an already-rounded float to Decimal cannot recover lost precision. See parsing market-data JSON numbers as decimals.
This check does not cover short positions, trailing stops, orders activated partway through a bar, or partial fills. In particular, a position entered at the close cannot use that candle's earlier high and low as subsequent exit opportunities.
Compare policies, not invented certainty#
Keep the market data fixed and run the backtest twice:
- Stop-first run: when both exits are in range and their sequence is unresolved, apply the stop first under your chosen fill model.
- Target-first run: apply the target first under the same model.
Use the same entry logic, sizing rules, costs and gap handling in both runs. Recompute the whole strategy each time. If an earlier exit changes capital, later entries or position size, changing a few completed trade rows is not an equivalent test.
For the isolated example above, the two assumptions give -10 and +20 pips before costs. Across a stateful strategy, they are sensitivity scenarios, not guaranteed lower and upper bounds on portfolio performance. Report both results and the number of affected trades. Do not simply delete ambiguous trades and present the remainder as the strategy's performance.
Backtesting engines may already make an intrabar assumption. For example, TradingView's official strategy documentation explains how its broker emulator infers a price path from OHLC by default and can use lower-timeframe bars for more detail. Check the engine settings before attributing a different result to the data provider.
When one-minute data helps, and when it does not#
Suppose the ambiguous candle covers five minutes. Inspect the one-minute bars inside that exact interval, in time order, using the same source and price basis. If an earlier minute contains the target and the stop first appears in a later minute, the smaller bars can resolve the ordering under a compatible fill model.
If both levels fall inside the same one-minute bar, the sequence is still unresolved at that resolution. Missing minutes also prevent a complete reconstruction. Do not treat an absent bar as proof that neither level was reached.
SiftingIO's Forex API provides historical OHLCV bars over REST for this kind of comparison. Its aggregated reference data can support research, but it does not establish that a specific broker would have filled your order at that price. Broker-side bid/ask rules, spread, slippage, liquidity and order type still belong in the execution model. Live WebSocket updates are a separate product interface, not a substitute for historical intrabar evidence you did not record.
What to put beside the equity curve#
A compact audit table makes the assumption visible to anyone reading the result:
| Record | Why it matters |
|---|---|
| Number of trades with unresolved same-bar exits, and total trades | Shows how often the assumption is involved; count each affected trade once |
| Stop-first and target-first results | Shows sensitivity to the ordering policy |
| Data source, price basis and bar interval | Makes the input series identifiable |
| Entry timing and when exit orders become active | Prevents pre-entry price moves from becoming fills |
| Opening-gap, spread, fee and slippage rules | Separates bar logic from execution assumptions |
A practical first check is to run the classifier over a small historical window and inspect every both_in_range result. Refine those intervals where finer data is available, then rerun both policies for the cases that remain unresolved. Keep the ambiguity count with the results, rather than hiding it in the code.
Create a free account to get an API key, then use the API documentation to retrieve a supported pair and interval within your account's limits. Start with a small sample: the useful result is knowing which trades depend on an assumption, before spending time on a much larger backtest.
The prices and outcomes above are synthetic examples of backtest mechanics, not a trading recommendation or evidence of achievable returns.



