diff --git a/docs/backtesting.md b/docs/backtesting.md index 12a6c346e..9059d28df 100644 --- a/docs/backtesting.md +++ b/docs/backtesting.md @@ -558,6 +558,7 @@ Since backtesting lacks some detailed information about what happens within a ca - Stoploss - ROI - Trailing stoploss +- Position reversals (futures only) happen if an entry signal in the other direction than the closing trade triggers at the candle the existing trade closes. Taking these assumptions, backtesting tries to mirror real trading as closely as possible. However, backtesting will **never** replace running a strategy in dry-run mode. Also, keep in mind that past results don't guarantee future success. diff --git a/freqtrade/freqtradebot.py b/freqtrade/freqtradebot.py index 0ca107b17..6f0f53959 100644 --- a/freqtrade/freqtradebot.py +++ b/freqtrade/freqtradebot.py @@ -2284,7 +2284,7 @@ class FreqtradeBot(LoggingMixin): def handle_protections(self, pair: str, side: LongShort) -> None: # Lock pair for one candle to prevent immediate re-entries - self.strategy.lock_pair(pair, datetime.now(timezone.utc), reason="Auto lock") + self.strategy.lock_pair(pair, datetime.now(timezone.utc), reason="Auto lock", side=side) prot_trig = self.protections.stop_per_pair(pair, side=side) if prot_trig: msg: RPCProtectionMsg = { diff --git a/freqtrade/optimize/backtesting.py b/freqtrade/optimize/backtesting.py index fd4c98b4d..f9bc45fca 100644 --- a/freqtrade/optimize/backtesting.py +++ b/freqtrade/optimize/backtesting.py @@ -1335,11 +1335,38 @@ class Backtesting: trade_dir: Optional[LongShort], can_enter: bool, ) -> None: + """ + Conditionally call backtest_loop_inner a 2nd time if shorting is enabled, + a position closed and a new signal in the other direction is available. + """ + if not self._can_short or trade_dir is None: + # No need to reverse position if shorting is disabled or there's no new signal + self.backtest_loop_inner(row, pair, current_time, trade_dir, can_enter) + else: + for _ in (0, 1): + a = self.backtest_loop_inner(row, pair, current_time, trade_dir, can_enter) + if not a or a == trade_dir: + # the trade didn't close or position change is in the same direction + break + + def backtest_loop_inner( + self, + row: tuple, + pair: str, + current_time: datetime, + trade_dir: Optional[LongShort], + can_enter: bool, + ) -> Optional[LongShort]: """ NOTE: This method is used by Hyperopt at each iteration. Please keep it optimized. Backtesting processing for one candle/pair. """ + exiting_dir: Optional[LongShort] = None + if not self._position_stacking and len(LocalTrade.bt_trades_open_pp[pair]) > 0: + # position_stacking not supported for now. + exiting_dir = "short" if LocalTrade.bt_trades_open_pp[pair][0].is_short else "long" + for t in list(LocalTrade.bt_trades_open_pp[pair]): # 1. Manage currently open orders of active trades if self.manage_open_orders(t, current_time, row): @@ -1380,6 +1407,10 @@ class Backtesting: if order: self._process_exit_order(order, trade, current_time, row, pair) + if exiting_dir and len(LocalTrade.bt_trades_open_pp[pair]) == 0: + return exiting_dir + return None + def time_pair_generator( self, start_date: datetime, end_date: datetime, increment: timedelta, pairs: list[str] ): diff --git a/tests/freqtradebot/test_freqtradebot.py b/tests/freqtradebot/test_freqtradebot.py index 836f84580..bd7a0dd47 100644 --- a/tests/freqtradebot/test_freqtradebot.py +++ b/tests/freqtradebot/test_freqtradebot.py @@ -3483,16 +3483,17 @@ def test_locked_pairs( exit_check=ExitCheckTuple(exit_type=ExitType.STOP_LOSS), ) trade.close(ticker_usdt_sell_down()["bid"]) - assert freqtrade.strategy.is_pair_locked(trade.pair, side="*") + assert not freqtrade.strategy.is_pair_locked(trade.pair, side="*") # Both sides are locked - assert freqtrade.strategy.is_pair_locked(trade.pair, side="long") - assert freqtrade.strategy.is_pair_locked(trade.pair, side="short") + assert freqtrade.strategy.is_pair_locked(trade.pair, side="long") != is_short + assert freqtrade.strategy.is_pair_locked(trade.pair, side="short") == is_short # reinit - should buy other pair. caplog.clear() freqtrade.enter_positions() + direction = "short" if is_short else "long" - assert log_has_re(rf"Pair {trade.pair} \* is locked.*", caplog) + assert log_has_re(rf"Pair {trade.pair} {direction} is locked.*", caplog) @pytest.mark.parametrize("is_short", [False, True]) diff --git a/tests/optimize/test_backtest_detail.py b/tests/optimize/test_backtest_detail.py index e0a9e4480..63fa1bd96 100644 --- a/tests/optimize/test_backtest_detail.py +++ b/tests/optimize/test_backtest_detail.py @@ -1121,6 +1121,70 @@ tc53 = BTContainer( trades=[BTrade(exit_reason=ExitType.STOP_LOSS, open_tick=1, close_tick=2, is_short=True)], ) +# Test 54: Switch position from long to short +tc54 = BTContainer( + data=[ + # D O H L C V EL XL ES Xs BT + [0, 5000, 5050, 4950, 5000, 6172, 1, 0, 0, 0], + [1, 5000, 5000, 4951, 5000, 6172, 0, 0, 0, 0], + [2, 4910, 5150, 4910, 5100, 6172, 0, 0, 1, 0], # Enter short signal being ignored + [3, 5100, 5100, 4950, 4950, 6172, 0, 1, 1, 0], # exit - re-enter short + [4, 5000, 5100, 4950, 4950, 6172, 0, 0, 0, 1], + [5, 5000, 5100, 4950, 4950, 6172, 0, 0, 0, 0], + ], + stop_loss=-0.10, + roi={"0": 0.10}, + profit_perc=0.00, + use_exit_signal=True, + trades=[ + BTrade(exit_reason=ExitType.EXIT_SIGNAL, open_tick=1, close_tick=4, is_short=False), + BTrade(exit_reason=ExitType.EXIT_SIGNAL, open_tick=4, close_tick=5, is_short=True), + ], +) + +# Test 55: Switch position from short to long +tc55 = BTContainer( + data=[ + # D O H L C V EL XL ES Xs BT + [0, 5000, 5050, 4950, 5000, 6172, 0, 0, 1, 0], + [1, 5000, 5000, 4951, 5000, 6172, 1, 0, 0, 0], # Enter long signal being ignored + [2, 4910, 5150, 4910, 5100, 6172, 1, 0, 0, 1], # Exit - reenter long + [3, 5100, 5100, 4950, 4950, 6172, 0, 0, 0, 0], + [4, 5000, 5100, 4950, 4950, 6172, 0, 1, 0, 0], + [5, 5000, 5100, 4950, 4950, 6172, 0, 0, 0, 0], + ], + stop_loss=-0.10, + roi={"0": 0.10}, + profit_perc=-0.04, + use_exit_signal=True, + trades=[ + BTrade(exit_reason=ExitType.EXIT_SIGNAL, open_tick=1, close_tick=3, is_short=True), + BTrade(exit_reason=ExitType.EXIT_SIGNAL, open_tick=3, close_tick=5, is_short=False), + ], +) + +# Test 56: Switch position from long to short +tc56 = BTContainer( + data=[ + # D O H L C V EL XL ES Xs BT + [0, 5000, 5050, 4950, 5000, 6172, 1, 0, 0, 0], + [1, 5000, 5000, 4951, 5000, 6172, 0, 0, 0, 0], + [2, 4910, 5150, 4910, 5100, 6172, 0, 0, 1, 0], # exit on stoploss - re-enter short + [3, 5100, 5100, 4888, 4950, 6172, 0, 0, 0, 0], + [4, 5000, 5100, 4950, 4950, 6172, 0, 0, 0, 1], + [5, 5000, 5100, 4950, 4950, 6172, 0, 0, 0, 0], + ], + stop_loss=-0.02, + roi={"0": 0.10}, + profit_perc=-0.0, + use_exit_signal=True, + trades=[ + BTrade(exit_reason=ExitType.STOP_LOSS, open_tick=1, close_tick=3, is_short=False), + BTrade(exit_reason=ExitType.EXIT_SIGNAL, open_tick=3, close_tick=5, is_short=True), + ], +) + + TESTS = [ tc0, tc1, @@ -1176,6 +1240,9 @@ TESTS = [ tc51, tc52, tc53, + tc54, + tc55, + tc56, ] diff --git a/tests/optimize/test_backtesting.py b/tests/optimize/test_backtesting.py index 416ffa262..e9c5d9132 100644 --- a/tests/optimize/test_backtesting.py +++ b/tests/optimize/test_backtesting.py @@ -1899,7 +1899,7 @@ def test_backtest_multi_pair_long_short_switch( if use_detail: # Backtest loop is called once per candle per pair - assert bl_spy.call_count == 1071 + assert bl_spy.call_count == 1523 else: assert bl_spy.call_count == 479 @@ -1909,7 +1909,7 @@ def test_backtest_multi_pair_long_short_switch( assert len(evaluate_result_multi(results["results"], "5m", 1)) == 0 # Expect 26 results initially - assert len(results["results"]) == 30 + assert len(results["results"]) == 53 def test_backtest_start_timerange(default_conf, mocker, caplog, testdatadir):