mirror of
https://github.com/KhizarImran/backtestingfx.git
synced 2026-11-02 03:26:11 +00:00
Document and test stop-first intrabar execution policy (#49)
This commit is contained in:
@@ -3,6 +3,8 @@
|
||||
## [Unreleased]
|
||||
|
||||
### 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
|
||||
API/platform compatibility policy, and a prioritized FX roadmap.
|
||||
|
||||
|
||||
@@ -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.
|
||||
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
|
||||
|
||||
By default `quote_to_account=1.0` which is correct for USD-quoted pairs (EURUSD, GBPUSD).
|
||||
|
||||
@@ -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) {
|
||||
let mut i = 0;
|
||||
while i < self.positions.len() {
|
||||
@@ -535,4 +537,46 @@ mod tests {
|
||||
// price moved up 50 pips, position still open (not closed)
|
||||
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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
Reference in New Issue
Block a user