Document and test stop-first intrabar execution policy (#49)

This commit is contained in:
Khizar Imran
2026-10-18 15:26:34 +01:00
committed by GitHub
parent 867eee6831
commit c490f274e6
4 changed files with 118 additions and 0 deletions
+2
View File
@@ -3,6 +3,8 @@
## [Unreleased] ## [Unreleased]
### Added ### Added
- Documented and regression-tested the stop-first intrabar SL/TP policy,
inclusive touches, next-bar checks for new entries, and current gap-fill limits.
- Contributor setup and validation guidance, bug/feature issue templates, an - Contributor setup and validation guidance, bug/feature issue templates, an
API/platform compatibility policy, and a prioritized FX roadmap. API/platform compatibility policy, and a prioritized FX roadmap.
+24
View File
@@ -220,6 +220,30 @@ previous entry/exit prices and completed-trade PnL, pass twice your old value
spread, so historical equity curves and equity-based decisions can still change. spread, so historical equity curves and equity-based decisions can still change.
Supply midpoint data: convert bid-only or ask-only data before running a backtest. Supply midpoint data: convert bid-only or ask-only data before running a backtest.
### Intrabar stop-loss and take-profit policy
OHLC bars do not reveal the order in which prices were visited. If a bar touches
both a position's stop-loss and take-profit, the broker deterministically chooses
the **stop-loss first**, for both long and short positions. Exact touches count
as hits. This conservative assumption can differ from the outcome on tick data;
it does not reconstruct the intrabar price path.
For example, a long entered at midpoint `1.1000`, with SL `1.0900` and TP
`1.1100`, exits at the stop midpoint when the next bar has low `1.0800` and
high `1.1200`. Bid/ask spread and exit commission still apply to the fill.
The engine checks existing positions' SL/TP **before** calling the strategy for
each bar. Orders opened by `next()` fill at that bar's close; their SL/TP levels
are first checked on the following bar. The entry bar's earlier high/low is not
replayed against the new position. A position opened on the final bar instead
undergoes the usual final liquidation at that bar's close.
**Current gap limitation:** SL/TP fills use the configured midpoint level, even
if the bar opens beyond it. If both levels are touched, stop-first still applies
regardless of the open. Such fills may be unattainable after a gap; gap-aware
execution is tracked in [#8](https://github.com/KhizarImran/backtestingfx/issues/8).
This policy is fixed, with no configurable intrabar ordering option.
### Trading non-USD pairs ### Trading non-USD pairs
By default `quote_to_account=1.0` which is correct for USD-quoted pairs (EURUSD, GBPUSD). By default `quote_to_account=1.0` which is correct for USD-quoted pairs (EURUSD, GBPUSD).
+44
View File
@@ -59,6 +59,8 @@ impl Broker {
}); });
} }
/// Midpoint OHLC triggers are inclusive; if both levels are touched, stop wins.
/// The engine calls this before the strategy, so new entries wait until the next bar.
pub fn check_sl_tp(&mut self, bar: &Bar) { pub fn check_sl_tp(&mut self, bar: &Bar) {
let mut i = 0; let mut i = 0;
while i < self.positions.len() { while i < self.positions.len() {
@@ -535,4 +537,46 @@ mod tests {
// price moved up 50 pips, position still open (not closed) // price moved up 50 pips, position still open (not closed)
assert_close(broker.equity(1.1050), 10_000.0 + 500.0); assert_close(broker.equity(1.1050), 10_000.0 + 500.0);
} }
#[test]
fn ambiguous_bars_choose_stop_for_longs_and_shorts_including_exact_touches() {
for is_long in [true, false] {
for exact_touch in [true, false] {
let mut broker = test_broker(7.0, 0.0001);
let (stop, target) = if is_long { (1.09, 1.11) } else { (1.11, 1.09) };
if is_long {
broker.buy(1.1, 1.0, 0, Some(stop), Some(target));
} else {
broker.sell(1.1, 1.0, 0, Some(stop), Some(target));
}
let (high, low) = if exact_touch { (1.11, 1.09) } else { (1.12, 1.08) };
broker.check_sl_tp(&Bar::new(1, 1.1, high, low, 1.1, 0.0));
assert!(broker.positions.is_empty());
assert_eq!(broker.trade_history.len(), 1);
let trade = &broker.trade_history[0];
assert_close(trade.exit_price, if is_long { stop - 0.00005 } else { stop + 0.00005 });
assert_close(trade.pnl, -1024.0);
assert_close(broker.cash, 8976.0);
assert_eq!(trade.exit_timestamp, 1);
}
}
}
#[test]
fn gap_beyond_stop_currently_fills_at_level_not_open() {
// This records the current limitation; gap-aware fills are tracked in #8.
for is_long in [true, false] {
let mut broker = test_broker(0.0, 0.0);
let (stop, open) = if is_long { (1.09, 1.08) } else { (1.11, 1.12) };
if is_long {
broker.buy(1.1, 1.0, 0, Some(stop), None);
} else {
broker.sell(1.1, 1.0, 0, Some(stop), None);
}
broker.check_sl_tp(&Bar::new(1, open, open, open, open, 0.0));
assert!(broker.positions.is_empty());
assert_close(broker.trade_history[0].exit_price, stop);
}
}
} }
+48
View File
@@ -0,0 +1,48 @@
import unittest
import pandas as pd
from backtestingfx import Backtest, Strategy
class IntrabarExecutionTest(unittest.TestCase):
def test_new_entries_are_checked_from_the_following_bar(self):
for is_long in (True, False):
for count in (1, 2):
with self.subTest(is_long=is_long, count=count):
seen = []
class EnterOnce(Strategy):
def next(self):
seen.append(len(self.positions))
if self.index == 0:
if is_long:
self.buy(1.0, stop_loss=1.09, take_profit=1.11)
else:
self.sell(1.0, stop_loss=1.11, take_profit=1.09)
index = pd.date_range("2026-01-01", periods=count, freq="h", tz="UTC")
data = pd.DataFrame(
{"open": [1.1] * count, "high": [1.12] * count,
"low": [1.08] * count, "close": [1.1] * count},
index=index,
)
stats = Backtest(data, EnterOnce, commission=0, spread=0).run()
self.assertEqual(stats.num_trades, 1)
trade = stats.trades[0]
self.assertEqual(trade.entry_timestamp, int(index[0].timestamp()))
self.assertEqual(trade.exit_timestamp, int(index[-1].timestamp()))
if count == 1:
# Final liquidation at close: the entry bar's range is not replayed.
self.assertAlmostEqual(trade.exit_price, 1.1)
self.assertAlmostEqual(trade.pnl, 0.0)
self.assertEqual(seen, [0])
else:
# Both levels touched on the next bar: stop is processed before next().
self.assertAlmostEqual(trade.exit_price, 1.09 if is_long else 1.11)
self.assertAlmostEqual(trade.pnl, -1000.0)
self.assertEqual(seen, [0, 0])
if __name__ == "__main__":
unittest.main()