mirror of
https://github.com/KhizarImran/backtestingfx.git
synced 2026-11-02 11:36:31 +00:00
Document and test stop-first intrabar execution policy (#49)
This commit is contained in:
@@ -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.
|
||||||
|
|
||||||
|
|||||||
@@ -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).
|
||||||
|
|||||||
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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