From 1698d968834df46b053fd500f79c5a030132000f Mon Sep 17 00:00:00 2001 From: Matthias Date: Sat, 12 Oct 2024 18:20:26 +0200 Subject: [PATCH 1/7] feat: Allow Backtesting to reverse position --- freqtrade/optimize/backtesting.py | 31 +++++++++++++++++++++++++++++++ 1 file changed, 31 insertions(+) diff --git a/freqtrade/optimize/backtesting.py b/freqtrade/optimize/backtesting.py index 03c7bcccc..14a63fb52 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] ): From ae325ff12456d0fbfac2424a2de685eb471f858f Mon Sep 17 00:00:00 2001 From: Matthias Date: Sat, 12 Oct 2024 19:26:58 +0200 Subject: [PATCH 2/7] tests: fix test based on changed behavior --- tests/optimize/test_backtesting.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/tests/optimize/test_backtesting.py b/tests/optimize/test_backtesting.py index f064247c6..f731db22f 100644 --- a/tests/optimize/test_backtesting.py +++ b/tests/optimize/test_backtesting.py @@ -1774,7 +1774,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 @@ -1784,7 +1784,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): From 1b8fd638534f2fd8808e72982379c0b0681d0562 Mon Sep 17 00:00:00 2001 From: Matthias Date: Sat, 12 Oct 2024 19:36:54 +0200 Subject: [PATCH 3/7] tests: BT Detail tests should cover position switch --- tests/optimize/test_backtest_detail.py | 45 ++++++++++++++++++++++++++ 1 file changed, 45 insertions(+) diff --git a/tests/optimize/test_backtest_detail.py b/tests/optimize/test_backtest_detail.py index e0a9e4480..845647a87 100644 --- a/tests/optimize/test_backtest_detail.py +++ b/tests/optimize/test_backtest_detail.py @@ -1121,6 +1121,49 @@ 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), + ], +) + + TESTS = [ tc0, tc1, @@ -1176,6 +1219,8 @@ TESTS = [ tc51, tc52, tc53, + tc54, + tc55, ] From 85a2c12768e7a43c80f6f9159b24a75c7a5ce677 Mon Sep 17 00:00:00 2001 From: Matthias Date: Sat, 12 Oct 2024 19:49:18 +0200 Subject: [PATCH 4/7] feat: Lock pairs in direction of the trade only --- freqtrade/freqtradebot.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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 = { From 8f6b83151230754abf59110ad59ddfa12f0f6097 Mon Sep 17 00:00:00 2001 From: Matthias Date: Sat, 12 Oct 2024 19:49:28 +0200 Subject: [PATCH 5/7] tests: adjust test_locked_pairs for new auto-lock --- tests/freqtradebot/test_freqtradebot.py | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) 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]) From 88f8e0b8b17bddb9c46102e370f7dd0466dbd326 Mon Sep 17 00:00:00 2001 From: Matthias Date: Sat, 12 Oct 2024 20:24:33 +0200 Subject: [PATCH 6/7] test: improve test for long/short switch --- tests/optimize/test_backtest_detail.py | 22 ++++++++++++++++++++++ 1 file changed, 22 insertions(+) diff --git a/tests/optimize/test_backtest_detail.py b/tests/optimize/test_backtest_detail.py index 845647a87..63fa1bd96 100644 --- a/tests/optimize/test_backtest_detail.py +++ b/tests/optimize/test_backtest_detail.py @@ -1163,6 +1163,27 @@ tc55 = BTContainer( ], ) +# 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, @@ -1221,6 +1242,7 @@ TESTS = [ tc53, tc54, tc55, + tc56, ] From c03f8afca1c33b39822a0e0e9fac8aff0ae8443f Mon Sep 17 00:00:00 2001 From: Matthias Date: Sun, 13 Oct 2024 09:32:54 +0200 Subject: [PATCH 7/7] docs: document new behavior --- docs/backtesting.md | 1 + 1 file changed, 1 insertion(+) 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.