From 8d74e8b8dd4f1a3f45dae8baf707ea5d2585155e Mon Sep 17 00:00:00 2001 From: Matthias Date: Thu, 20 Feb 2025 06:55:54 +0100 Subject: [PATCH 01/16] feat: add adjust_order_price callback --- freqtrade/strategy/interface.py | 97 +++++++++++++++++++++++++++++++++ 1 file changed, 97 insertions(+) diff --git a/freqtrade/strategy/interface.py b/freqtrade/strategy/interface.py index 77fe2a84a..d43057b32 100644 --- a/freqtrade/strategy/interface.py +++ b/freqtrade/strategy/interface.py @@ -690,6 +690,103 @@ class IStrategy(ABC, HyperStrategyMixin): """ return current_order_rate + def adjust_exit_price( + self, + trade: Trade, + order: Order | None, + pair: str, + current_time: datetime, + proposed_rate: float, + current_order_rate: float, + entry_tag: str | None, + side: str, + **kwargs, + ) -> float: + """ + Exit price re-adjustment logic, returning the user desired limit price. + This only executes when a order was already placed, still open (unfilled fully or partially) + and not timed out on subsequent candles after entry trigger. + + For full documentation please go to https://www.freqtrade.io/en/latest/strategy-callbacks/ + + When not implemented by a strategy, returns current_order_rate as default. + If current_order_rate is returned then the existing order is maintained. + If None is returned then order gets canceled but not replaced by a new one. + + :param pair: Pair that's currently analyzed + :param trade: Trade object. + :param order: Order object + :param current_time: datetime object, containing the current datetime + :param proposed_rate: Rate, calculated based on pricing settings in entry_pricing. + :param current_order_rate: Rate of the existing order in place. + :param entry_tag: Optional entry_tag (buy_tag) if provided with the buy signal. + :param side: 'long' or 'short' - indicating the direction of the proposed trade + :param **kwargs: Ensure to keep this here so updates to this won't break your strategy. + :return float: New entry price value if provided + + """ + return current_order_rate + + def adjust_order_price( + self, + trade: Trade, + order: Order | None, + pair: str, + current_time: datetime, + proposed_rate: float, + current_order_rate: float, + entry_tag: str | None, + side: str, + **kwargs, + ) -> float: + """ + Exit and entry order price re-adjustment logic, returning the user desired limit price. + This only executes when a order was already placed, still open (unfilled fully or partially) + and not timed out on subsequent candles after entry trigger. + + For full documentation please go to https://www.freqtrade.io/en/latest/strategy-callbacks/ + + When not implemented by a strategy, returns current_order_rate as default. + If current_order_rate is returned then the existing order is maintained. + If None is returned then order gets canceled but not replaced by a new one. + + :param pair: Pair that's currently analyzed + :param trade: Trade object. + :param order: Order object + :param current_time: datetime object, containing the current datetime + :param proposed_rate: Rate, calculated based on pricing settings in entry_pricing. + :param current_order_rate: Rate of the existing order in place. + :param entry_tag: Optional entry_tag (buy_tag) if provided with the buy signal. + :param side: 'long' or 'short' - indicating the direction of the proposed trade + :param **kwargs: Ensure to keep this here so updates to this won't break your strategy. + :return float: New entry price value if provided + + """ + if order.side == trade.entry_side: + return self.adjust_entry_price( + trade=trade, + order=order, + pair=pair, + current_time=current_time, + proposed_rate=proposed_rate, + current_order_rate=current_order_rate, + entry_tag=entry_tag, + side=side, + **kwargs, + ) + else: + return self.adjust_exit_price( + trade=trade, + order=order, + pair=pair, + current_time=current_time, + proposed_rate=proposed_rate, + current_order_rate=current_order_rate, + entry_tag=entry_tag, + side=side, + **kwargs, + ) + def leverage( self, pair: str, From 1970cc65c05eca5a7cd73ecccdb622110024c72e Mon Sep 17 00:00:00 2001 From: Matthias Date: Thu, 20 Feb 2025 07:04:59 +0100 Subject: [PATCH 02/16] feat: add "is_entry" attribute for order_replacement --- freqtrade/strategy/interface.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/freqtrade/strategy/interface.py b/freqtrade/strategy/interface.py index d43057b32..69ae94917 100644 --- a/freqtrade/strategy/interface.py +++ b/freqtrade/strategy/interface.py @@ -737,6 +737,7 @@ class IStrategy(ABC, HyperStrategyMixin): current_order_rate: float, entry_tag: str | None, side: str, + is_entry: bool, **kwargs, ) -> float: """ @@ -758,11 +759,12 @@ class IStrategy(ABC, HyperStrategyMixin): :param current_order_rate: Rate of the existing order in place. :param entry_tag: Optional entry_tag (buy_tag) if provided with the buy signal. :param side: 'long' or 'short' - indicating the direction of the proposed trade + :param is_entry: True if the order is an entry order, False if it's an exit order. :param **kwargs: Ensure to keep this here so updates to this won't break your strategy. :return float: New entry price value if provided """ - if order.side == trade.entry_side: + if is_entry: return self.adjust_entry_price( trade=trade, order=order, From 0f9e61371c47fec2bc93a436334ac6d03d2c0450 Mon Sep 17 00:00:00 2001 From: Matthias Date: Thu, 20 Feb 2025 07:23:36 +0100 Subject: [PATCH 03/16] feat: Implement live "replace_exit_order" functionality --- freqtrade/freqtradebot.py | 79 ++++++++++++++++++++++++--------------- 1 file changed, 48 insertions(+), 31 deletions(-) diff --git a/freqtrade/freqtradebot.py b/freqtrade/freqtradebot.py index 84731117a..a94e71e67 100644 --- a/freqtrade/freqtradebot.py +++ b/freqtrade/freqtradebot.py @@ -1603,27 +1603,29 @@ class FreqtradeBot(LoggingMixin): self.replace_order(order, open_order, trade) def handle_cancel_order( - self, order: CcxtOrder, order_obj: Order, trade: Trade, reason: str - ) -> None: + self, order: CcxtOrder, order_obj: Order, trade: Trade, reason: str, replacing: bool = False + ) -> bool: """ Check if current analyzed order timed out and cancel if necessary. :param order: Order dict grabbed with exchange.fetch_order() :param order_obj: Order object from the database. :param trade: Trade object. - :return: None + :return: True if the order was canceled, False otherwise. """ if order["side"] == trade.entry_side: - self.handle_cancel_enter(trade, order, order_obj, reason) + return self.handle_cancel_enter(trade, order, order_obj, reason, replacing) else: canceled = self.handle_cancel_exit(trade, order, order_obj, reason) - canceled_count = trade.get_canceled_exit_order_count() - max_timeouts = self.config.get("unfilledtimeout", {}).get("exit_timeout_count", 0) - if canceled and max_timeouts > 0 and canceled_count >= max_timeouts: - logger.warning( - f"Emergency exiting trade {trade}, as the exit order " - f"timed out {max_timeouts} times. force selling {order['amount']}." - ) - self.emergency_exit(trade, order["price"], order["amount"]) + if not replacing: + canceled_count = trade.get_canceled_exit_order_count() + max_timeouts = self.config.get("unfilledtimeout", {}).get("exit_timeout_count", 0) + if canceled and max_timeouts > 0 and canceled_count >= max_timeouts: + logger.warning( + f"Emergency exiting trade {trade}, as the exit order " + f"timed out {max_timeouts} times. force selling {order['amount']}." + ) + self.emergency_exit(trade, order["price"], order["amount"]) + return canceled def emergency_exit( self, trade: Trade, price: float, sub_trade_amt: float | None = None @@ -1675,17 +1677,17 @@ class FreqtradeBot(LoggingMixin): self.strategy.timeframe, latest_candle_open_date ) # Check if new candle - if ( - order_obj - and order_obj.side == trade.entry_side - and latest_candle_close_date > order_obj.order_date_utc - ): + if order_obj and latest_candle_close_date > order_obj.order_date_utc: + is_entry = order_obj.side == trade.entry_side # New candle proposed_rate = self.exchange.get_rate( - trade.pair, side="entry", is_short=trade.is_short, refresh=True + trade.pair, + side="entry" if is_entry else "exit", + is_short=trade.is_short, + refresh=True, ) adjusted_entry_price = strategy_safe_wrapper( - self.strategy.adjust_entry_price, default_retval=order_obj.safe_placement_price + self.strategy.adjust_order_price, default_retval=order_obj.safe_placement_price )( trade=trade, order=order_obj, @@ -1695,6 +1697,7 @@ class FreqtradeBot(LoggingMixin): current_order_rate=order_obj.safe_placement_price, entry_tag=trade.enter_tag, side=trade.trade_direction, + is_entry=is_entry, ) replacing = True @@ -1702,10 +1705,11 @@ class FreqtradeBot(LoggingMixin): if not adjusted_entry_price: replacing = False cancel_reason = constants.CANCEL_REASON["USER_CANCEL"] + if order_obj.safe_placement_price != adjusted_entry_price: # cancel existing order if new price is supplied or None - res = self.handle_cancel_enter( - trade, order, order_obj, cancel_reason, replacing=replacing + res = self.handle_cancel_order( + order, order_obj, trade, cancel_reason, replacing=replacing ) if not res: self.replace_order_failed( @@ -1715,16 +1719,29 @@ class FreqtradeBot(LoggingMixin): if adjusted_entry_price: # place new order only if new price is supplied try: - if not self.execute_entry( - pair=trade.pair, - stake_amount=( - order_obj.safe_remaining * order_obj.safe_price / trade.leverage - ), - price=adjusted_entry_price, - trade=trade, - is_short=trade.is_short, - mode="replace", - ): + if is_entry: + succeeded = self.execute_entry( + pair=trade.pair, + stake_amount=( + order_obj.safe_remaining * order_obj.safe_price / trade.leverage + ), + price=adjusted_entry_price, + trade=trade, + is_short=trade.is_short, + mode="replace", + ) + else: + succeeded = self.execute_trade_exit( + trade, + adjusted_entry_price, + exit_check=ExitCheckTuple( + exit_type=ExitType.CUSTOM_EXIT, + exit_reason=order_obj.ft_order_tag, + ), + ordertype="limit", + sub_trade_amt=order_obj.safe_remaining, + ) + if not succeeded: self.replace_order_failed( trade, f"Could not replace order for {trade}." ) From 3b03fae939b6b9aac27c3435d901981675955b0b Mon Sep 17 00:00:00 2001 From: Matthias Date: Thu, 20 Feb 2025 07:24:07 +0100 Subject: [PATCH 04/16] chore: improve variable naming --- freqtrade/freqtradebot.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/freqtrade/freqtradebot.py b/freqtrade/freqtradebot.py index a94e71e67..37d45bb93 100644 --- a/freqtrade/freqtradebot.py +++ b/freqtrade/freqtradebot.py @@ -1661,9 +1661,9 @@ class FreqtradeBot(LoggingMixin): def replace_order(self, order: CcxtOrder, order_obj: Order | None, trade: Trade) -> None: """ Check if current analyzed entry order should be replaced or simply cancelled. - To simply cancel the existing order(no replacement) adjust_entry_price() should return None - To maintain existing order adjust_entry_price() should return order_obj.price - To replace existing order adjust_entry_price() should return desired price for limit order + To simply cancel the existing order(no replacement) adjust_order_price() should return None + To maintain existing order adjust_order_price() should return order_obj.price + To replace existing order adjust_order_price() should return desired price for limit order :param order: Order dict grabbed with exchange.fetch_order() :param order_obj: Order object. :param trade: Trade object. From 25c00360125ec6b466364f4ebb706302b9046732 Mon Sep 17 00:00:00 2001 From: Matthias Date: Thu, 20 Feb 2025 07:25:38 +0100 Subject: [PATCH 05/16] feat: Update strategy template --- .../strategy_subtemplates/strategy_methods_advanced.j2 | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/freqtrade/templates/strategy_subtemplates/strategy_methods_advanced.j2 b/freqtrade/templates/strategy_subtemplates/strategy_methods_advanced.j2 index 84d7f40c8..5ff483243 100644 --- a/freqtrade/templates/strategy_subtemplates/strategy_methods_advanced.j2 +++ b/freqtrade/templates/strategy_subtemplates/strategy_methods_advanced.j2 @@ -40,7 +40,7 @@ def custom_entry_price( """ return proposed_rate -def adjust_entry_price( +def adjust_order_price( self, trade: Trade, order: Order | None, @@ -50,10 +50,11 @@ def adjust_entry_price( current_order_rate: float, entry_tag: str | None, side: str, + is_entry: bool, **kwargs, ) -> float: """ - Entry price re-adjustment logic, returning the user desired limit price. + Exit and entry order price re-adjustment logic, returning the user desired limit price. This only executes when a order was already placed, still open (unfilled fully or partially) and not timed out on subsequent candles after entry trigger. @@ -71,6 +72,7 @@ def adjust_entry_price( :param current_order_rate: Rate of the existing order in place. :param entry_tag: Optional entry_tag (buy_tag) if provided with the buy signal. :param side: 'long' or 'short' - indicating the direction of the proposed trade + :param is_entry: True if the order is an entry order, False if it's an exit order. :param **kwargs: Ensure to keep this here so updates to this won't break your strategy. :return float: New entry price value if provided From 756bada570cedd50f9ea1c610c2b8e3ec43ac846 Mon Sep 17 00:00:00 2001 From: Matthias Date: Thu, 20 Feb 2025 19:09:36 +0100 Subject: [PATCH 06/16] feat: Add "replace-exit_order" to backtesting --- freqtrade/optimize/backtesting.py | 45 +++++++++++++++++++++---------- 1 file changed, 31 insertions(+), 14 deletions(-) diff --git a/freqtrade/optimize/backtesting.py b/freqtrade/optimize/backtesting.py index d3f1bc63b..5304175d8 100644 --- a/freqtrade/optimize/backtesting.py +++ b/freqtrade/optimize/backtesting.py @@ -396,6 +396,8 @@ class Backtesting: self.canceled_trade_entries = 0 self.canceled_entry_orders = 0 self.replaced_entry_orders = 0 + self.canceled_exit_orders = 0 + self.replaced_exit_orders = 0 self.dataprovider.clear_cache() if enable_protections: self._load_protections(self.strategy) @@ -1234,8 +1236,8 @@ class Backtesting: for order in [o for o in trade.orders if o.ft_is_open]: if order.side == trade.entry_side: self.canceled_entry_orders += 1 - # elif order.side == trade.exit_side: - # self.canceled_exit_orders += 1 + elif order.side == trade.exit_side: + self.canceled_exit_orders += 1 # canceled orders are removed from the trade del trade.orders[trade.orders.index(order)] @@ -1300,8 +1302,9 @@ class Backtesting: """ # only check on new candles for open entry orders if order.side == trade.entry_side and current_time > order.order_date_utc: + is_entry = order.side == trade.entry_side requested_rate = strategy_safe_wrapper( - self.strategy.adjust_entry_price, default_retval=order.ft_price + self.strategy.adjust_order_price, default_retval=order.ft_price )( trade=trade, # type: ignore[arg-type] order=order, @@ -1311,6 +1314,7 @@ class Backtesting: current_order_rate=order.ft_price, entry_tag=trade.enter_tag, side=trade.trade_direction, + is_entry=is_entry, ) # default value is current order price # cancel existing order whenever a new rate is requested (or None) @@ -1319,22 +1323,35 @@ class Backtesting: return False else: del trade.orders[trade.orders.index(order)] - self.canceled_entry_orders += 1 + if is_entry: + self.canceled_entry_orders += 1 + else: + self.canceled_exit_orders += 1 # place new order if result was not None if requested_rate: - self._enter_trade( - pair=trade.pair, - row=row, - trade=trade, - requested_rate=requested_rate, - requested_stake=(order.safe_remaining * order.ft_price / trade.leverage), - direction="short" if trade.is_short else "long", - ) + if is_entry: + self._enter_trade( + pair=trade.pair, + row=row, + trade=trade, + requested_rate=requested_rate, + requested_stake=(order.safe_remaining * order.ft_price / trade.leverage), + direction="short" if trade.is_short else "long", + ) + self.replaced_entry_orders += 1 + else: + self._exit_trade( + trade=trade, + sell_row=row, + close_rate=requested_rate, + amount=order.safe_remaining, + exit_reason=order.ft_order_tag, + ) + self.replaced_exit_orders += 1 # Delete trade if no successful entries happened (if placing the new order failed) - if not trade.has_open_orders and trade.nr_of_successful_entries == 0: + if not trade.has_open_orders and is_entry and trade.nr_of_successful_entries == 0: return True - self.replaced_entry_orders += 1 else: # assumption: there can't be multiple open entry orders at any given time return trade.nr_of_successful_entries == 0 From 67ce9a41f2764727dbd9a74aa80d2ee04d6367a0 Mon Sep 17 00:00:00 2001 From: Matthias Date: Thu, 20 Feb 2025 19:21:37 +0100 Subject: [PATCH 07/16] feat: implement load error when colliding methods are decected --- freqtrade/resolvers/strategy_resolver.py | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/freqtrade/resolvers/strategy_resolver.py b/freqtrade/resolvers/strategy_resolver.py index a3ec03e2b..b56ed8e11 100644 --- a/freqtrade/resolvers/strategy_resolver.py +++ b/freqtrade/resolvers/strategy_resolver.py @@ -242,6 +242,14 @@ class StrategyResolver(IResolver): if has_after_fill: strategy._ft_stop_uses_after_fill = True + if check_override(strategy, IStrategy, "adjust_order_price") and ( + check_override(strategy, IStrategy, "adjust_entry_price") + or check_override(strategy, IStrategy, "adjust_exit_price") + ): + raise OperationalException( + "If you implement `adjust_order_price`, `adjust_entry_price` and " + "`adjust_exit_price` will not be used. Please pick one approach for your strategy." + ) return strategy @staticmethod From dd8938ced218c03b1802295480bbe43ce9ab706a Mon Sep 17 00:00:00 2001 From: Matthias Date: Thu, 20 Feb 2025 19:22:03 +0100 Subject: [PATCH 08/16] test: add test for adjust_order_price and adjust_entry_price collision --- .../broken_futures_strategies.py | 35 ++++++++++++++++++- tests/strategy/test_strategy_loading.py | 4 +++ 2 files changed, 38 insertions(+), 1 deletion(-) diff --git a/tests/strategy/strats/broken_strats/broken_futures_strategies.py b/tests/strategy/strats/broken_strats/broken_futures_strategies.py index b2131e63e..a3e51bc50 100644 --- a/tests/strategy/strats/broken_strats/broken_futures_strategies.py +++ b/tests/strategy/strats/broken_strats/broken_futures_strategies.py @@ -21,10 +21,12 @@ class TestStrategyNoImplementSell(TestStrategyNoImplements): return super().populate_entry_trend(dataframe, metadata) -class TestStrategyImplementCustomSell(TestStrategyNoImplementSell): +class TestStrategyImplementEmptyWorking(TestStrategyNoImplementSell): def populate_exit_trend(self, dataframe: DataFrame, metadata: dict) -> DataFrame: return super().populate_exit_trend(dataframe, metadata) + +class TestStrategyImplementCustomSell(TestStrategyImplementEmptyWorking): def custom_sell( self, pair: str, @@ -55,3 +57,34 @@ class TestStrategyImplementSellTimeout(TestStrategyNoImplementSell): self, pair: str, trade, order: Order, current_time: datetime, **kwargs ) -> bool: return False + + +class TestStrategyAdjustOrderPrice(TestStrategyImplementEmptyWorking): + def adjust_entry_price( + self, + trade, + order, + pair, + current_time, + proposed_rate, + current_order_rate, + entry_tag, + side, + **kwargs, + ): + return proposed_rate + + def adjust_order_price( + self, + trade, + order, + pair, + current_time, + proposed_rate, + current_order_rate, + entry_tag, + side, + is_entry, + **kwargs, + ): + return proposed_rate diff --git a/tests/strategy/test_strategy_loading.py b/tests/strategy/test_strategy_loading.py index 9b143ace6..927f33461 100644 --- a/tests/strategy/test_strategy_loading.py +++ b/tests/strategy/test_strategy_loading.py @@ -460,6 +460,10 @@ def test_missing_implements(default_conf, caplog): ): StrategyResolver.load_strategy(default_conf) + default_conf["strategy"] = "TestStrategyAdjustOrderPrice" + with pytest.raises(OperationalException, match=r"If you implement `adjust_order_price`.*"): + StrategyResolver.load_strategy(default_conf) + def test_call_deprecated_function(default_conf): default_location = Path(__file__).parent / "strats/broken_strats/" From 7f393252e238592fa076559886ea9745a462a75f Mon Sep 17 00:00:00 2001 From: Matthias Date: Thu, 20 Feb 2025 19:24:03 +0100 Subject: [PATCH 09/16] docs: update bot-basics with new callback --- docs/bot-basics.md | 10 +++++++--- 1 file changed, 7 insertions(+), 3 deletions(-) diff --git a/docs/bot-basics.md b/docs/bot-basics.md index 1c88559c0..ff73aecde 100644 --- a/docs/bot-basics.md +++ b/docs/bot-basics.md @@ -54,11 +54,13 @@ By default, the bot loop runs every few seconds (`internals.process_throttle_sec * Check timeouts for open orders. * Calls `check_entry_timeout()` strategy callback for open entry orders. * Calls `check_exit_timeout()` strategy callback for open exit orders. - * Calls `adjust_entry_price()` strategy callback for open entry orders. + * Calls `adjust_order_price()` strategy callback for open orders. + * Calls `adjust_entry_price()` strategy callback for open entry orders. *only called when `adjust_order_price()` is not implemented* + * Calls `adjust_exit_price()` strategy callback for open exit orders. *only called when `adjust_order_price()` is not implemented* * Verifies existing positions and eventually places exit orders. * Considers stoploss, ROI and exit-signal, `custom_exit()` and `custom_stoploss()`. * Determine exit-price based on `exit_pricing` configuration setting or by using the `custom_exit_price()` callback. - * Before a exit order is placed, `confirm_trade_exit()` strategy callback is called. + * Before an exit order is placed, `confirm_trade_exit()` strategy callback is called. * Check position adjustments for open trades if enabled by calling `adjust_trade_position()` and place additional order if required. * Check if trade-slots are still available (if `max_open_trades` is reached). * Verifies entry signal trying to enter new positions. @@ -80,7 +82,9 @@ This loop will be repeated again and again until the bot is stopped. * Loops per candle simulating entry and exit points. * Calls `bot_loop_start()` strategy callback. * Check for Order timeouts, either via the `unfilledtimeout` configuration, or via `check_entry_timeout()` / `check_exit_timeout()` strategy callbacks. - * Calls `adjust_entry_price()` strategy callback for open entry orders. + * Calls `adjust_order_price()` strategy callback for open orders. + * Calls `adjust_entry_price()` strategy callback for open entry orders. *only called when `adjust_order_price()` is not implemented!* + * Calls `adjust_exit_price()` strategy callback for open exit orders. *only called when `adjust_order_price()` is not implemented!* * Check for trade entry signals (`enter_long` / `enter_short` columns). * Confirm trade entry / exits (calls `confirm_trade_entry()` and `confirm_trade_exit()` if implemented in the strategy). * Call `custom_entry_price()` (if implemented in the strategy) to determine entry price (Prices are moved to be within the opening candle). From f8f10f27e99e1ef33edbae72c2581b6291055247 Mon Sep 17 00:00:00 2001 From: Matthias Date: Thu, 20 Feb 2025 19:39:33 +0100 Subject: [PATCH 10/16] chore: improved variable naming --- freqtrade/freqtradebot.py | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/freqtrade/freqtradebot.py b/freqtrade/freqtradebot.py index 37d45bb93..8b3a844be 100644 --- a/freqtrade/freqtradebot.py +++ b/freqtrade/freqtradebot.py @@ -1686,7 +1686,7 @@ class FreqtradeBot(LoggingMixin): is_short=trade.is_short, refresh=True, ) - adjusted_entry_price = strategy_safe_wrapper( + adjusted_price = strategy_safe_wrapper( self.strategy.adjust_order_price, default_retval=order_obj.safe_placement_price )( trade=trade, @@ -1702,11 +1702,11 @@ class FreqtradeBot(LoggingMixin): replacing = True cancel_reason = constants.CANCEL_REASON["REPLACE"] - if not adjusted_entry_price: + if not adjusted_price: replacing = False cancel_reason = constants.CANCEL_REASON["USER_CANCEL"] - if order_obj.safe_placement_price != adjusted_entry_price: + if order_obj.safe_placement_price != adjusted_price: # cancel existing order if new price is supplied or None res = self.handle_cancel_order( order, order_obj, trade, cancel_reason, replacing=replacing @@ -1716,7 +1716,7 @@ class FreqtradeBot(LoggingMixin): trade, f"Could not fully cancel order for {trade}, therefore not replacing." ) return - if adjusted_entry_price: + if adjusted_price: # place new order only if new price is supplied try: if is_entry: @@ -1725,7 +1725,7 @@ class FreqtradeBot(LoggingMixin): stake_amount=( order_obj.safe_remaining * order_obj.safe_price / trade.leverage ), - price=adjusted_entry_price, + price=adjusted_price, trade=trade, is_short=trade.is_short, mode="replace", @@ -1733,7 +1733,7 @@ class FreqtradeBot(LoggingMixin): else: succeeded = self.execute_trade_exit( trade, - adjusted_entry_price, + adjusted_price, exit_check=ExitCheckTuple( exit_type=ExitType.CUSTOM_EXIT, exit_reason=order_obj.ft_order_tag, From 90f52ba8ada3d1f81d970c0c12ae30ab7ed1baa3 Mon Sep 17 00:00:00 2001 From: Matthias Date: Thu, 20 Feb 2025 19:45:34 +0100 Subject: [PATCH 11/16] test: add integration test for adjust_exit_price --- tests/freqtradebot/test_integration.py | 22 +++++++++++++++++++++- 1 file changed, 21 insertions(+), 1 deletion(-) diff --git a/tests/freqtradebot/test_integration.py b/tests/freqtradebot/test_integration.py index 9fc580753..2cb4b6aa8 100644 --- a/tests/freqtradebot/test_integration.py +++ b/tests/freqtradebot/test_integration.py @@ -436,6 +436,7 @@ def test_dca_order_adjust(default_conf_usdt, ticker_usdt, leverage, fee, mocker) # Replace new order with diff. order at a lower price freqtrade.strategy.adjust_entry_price = MagicMock(return_value=1.95) + freqtrade.strategy.adjust_exit_price = MagicMock(side_effect=ValueError) freqtrade.strategy.adjust_trade_position = MagicMock(return_value=None) freqtrade.process() trade = Trade.get_trades().first() @@ -445,6 +446,8 @@ def test_dca_order_adjust(default_conf_usdt, ticker_usdt, leverage, fee, mocker) assert pytest.approx(trade.stake_amount) == 60 assert trade.orders[-1].price == 1.95 assert pytest.approx(trade.orders[-1].cost) == 120 * leverage + assert freqtrade.strategy.adjust_entry_price.call_count == 1 + assert freqtrade.strategy.adjust_exit_price.call_count == 0 # Fill DCA order freqtrade.strategy.adjust_trade_position = MagicMock(return_value=None) @@ -469,6 +472,7 @@ def test_dca_order_adjust(default_conf_usdt, ticker_usdt, leverage, fee, mocker) mocker.patch(f"{EXMS}._dry_is_price_crossed", return_value=False) freqtrade.strategy.custom_exit = MagicMock(return_value="Exit now") freqtrade.strategy.adjust_entry_price = MagicMock(return_value=2.02) + freqtrade.strategy.adjust_exit_price = MagicMock(side_effect=ValueError) freqtrade.process() trade = Trade.get_trades().first() assert len(trade.orders) == 5 @@ -478,8 +482,9 @@ def test_dca_order_adjust(default_conf_usdt, ticker_usdt, leverage, fee, mocker) assert pytest.approx(trade.amount) == 91.689215 * leverage assert pytest.approx(trade.orders[-1].amount) == 91.689215 * leverage assert freqtrade.strategy.adjust_entry_price.call_count == 0 + assert freqtrade.strategy.adjust_exit_price.call_count == 0 - # Process again, should not adjust entry price + # Process again, should not adjust price freqtrade.process() trade = Trade.get_trades().first() @@ -490,6 +495,21 @@ def test_dca_order_adjust(default_conf_usdt, ticker_usdt, leverage, fee, mocker) assert trade.orders[-1].price == 2.02 # Adjust entry price cannot be called - this is an exit order assert freqtrade.strategy.adjust_entry_price.call_count == 0 + assert freqtrade.strategy.adjust_exit_price.call_count == 1 + + freqtrade.strategy.adjust_exit_price = MagicMock(return_value=2.03) + + # Process again, should adjust exit price + freqtrade.process() + trade = Trade.get_trades().first() + + assert trade.orders[-2].status == "canceled" + assert len(trade.orders) == 6 + assert trade.orders[-1].side == trade.exit_side + assert trade.orders[-1].status == "open" + assert trade.orders[-1].price == 2.03 + assert freqtrade.strategy.adjust_entry_price.call_count == 0 + assert freqtrade.strategy.adjust_exit_price.call_count == 1 @pytest.mark.parametrize("leverage", [1, 2]) From 44182783c0c2e36624817cb115520b5fb58fd0e3 Mon Sep 17 00:00:00 2001 From: Matthias Date: Thu, 20 Feb 2025 20:02:03 +0100 Subject: [PATCH 12/16] feat: don't limit backtest calls to entry orders --- freqtrade/optimize/backtesting.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/freqtrade/optimize/backtesting.py b/freqtrade/optimize/backtesting.py index 5304175d8..ab3e1459b 100644 --- a/freqtrade/optimize/backtesting.py +++ b/freqtrade/optimize/backtesting.py @@ -1301,7 +1301,7 @@ class Backtesting: Returns True if the trade should be deleted. """ # only check on new candles for open entry orders - if order.side == trade.entry_side and current_time > order.order_date_utc: + if current_time > order.order_date_utc: is_entry = order.side == trade.entry_side requested_rate = strategy_safe_wrapper( self.strategy.adjust_order_price, default_retval=order.ft_price From 584b84a94179caff250283cc30e242eb74512dd7 Mon Sep 17 00:00:00 2001 From: Matthias Date: Thu, 20 Feb 2025 20:02:18 +0100 Subject: [PATCH 13/16] test: extend backtest-detail tests for exit_adjust --- tests/optimize/__init__.py | 1 + tests/optimize/test_backtest_detail.py | 23 +++++++++++++++++++++++ 2 files changed, 24 insertions(+) diff --git a/tests/optimize/__init__.py b/tests/optimize/__init__.py index 3299c6a53..cdc42956f 100644 --- a/tests/optimize/__init__.py +++ b/tests/optimize/__init__.py @@ -45,6 +45,7 @@ class BTContainer(NamedTuple): leverage: float = 1.0 timeout: int | None = None adjust_entry_price: float | None = None + adjust_exit_price: float | None = None adjust_trade_position: list[float] | None = None diff --git a/tests/optimize/test_backtest_detail.py b/tests/optimize/test_backtest_detail.py index 7ba53a1b3..8918f3b9d 100644 --- a/tests/optimize/test_backtest_detail.py +++ b/tests/optimize/test_backtest_detail.py @@ -1217,6 +1217,26 @@ tc57 = BTContainer( ], ) +# Test 58: Custom-exit-price short - below all candles +tc58 = 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, 5200, 4951, 5000, 6172, 0, 0, 0, 0], # enter trade (signal on last candle) + [2, 4900, 5250, 4900, 5100, 6172, 0, 0, 0, 1], # Exit - delayed + [3, 5100, 5100, 4650, 4750, 6172, 0, 0, 0, 0], # + [4, 4750, 5100, 4350, 4750, 6172, 0, 0, 0, 0], + ], + stop_loss=-0.10, + roi={"0": 1.00}, + profit_perc=-0.01, + use_exit_signal=True, + timeout=1000, + custom_exit_price=4300, + adjust_exit_price=5050, + trades=[BTrade(exit_reason=ExitType.EXIT_SIGNAL, open_tick=1, close_tick=4, is_short=True)], +) + TESTS = [ tc0, @@ -1277,6 +1297,7 @@ TESTS = [ tc55, tc56, tc57, + tc58, ] @@ -1330,6 +1351,8 @@ def test_backtest_results(default_conf, mocker, caplog, data: BTContainer) -> No ) if data.adjust_entry_price: backtesting.strategy.adjust_entry_price = MagicMock(return_value=data.adjust_entry_price) + if data.adjust_exit_price: + backtesting.strategy.adjust_exit_price = MagicMock(return_value=data.adjust_exit_price) backtesting.strategy.use_custom_stoploss = data.use_custom_stoploss backtesting.strategy.leverage = lambda **kwargs: data.leverage From 08587d826e70482ca23379c11d8946e5e6dccd7b Mon Sep 17 00:00:00 2001 From: Matthias Date: Thu, 20 Feb 2025 20:07:44 +0100 Subject: [PATCH 14/16] test: Long adjust-exit-price test --- tests/optimize/test_backtest_detail.py | 21 +++++++++++++++++++++ 1 file changed, 21 insertions(+) diff --git a/tests/optimize/test_backtest_detail.py b/tests/optimize/test_backtest_detail.py index 8918f3b9d..736c4dcab 100644 --- a/tests/optimize/test_backtest_detail.py +++ b/tests/optimize/test_backtest_detail.py @@ -1237,6 +1237,26 @@ tc58 = BTContainer( trades=[BTrade(exit_reason=ExitType.EXIT_SIGNAL, open_tick=1, close_tick=4, is_short=True)], ) +# Test 59: Custom-exit-price above all candles - readjust order +tc59 = BTContainer( + data=[ + # D O H L C V EL XL ES Xs BT + [0, 5000, 5050, 4950, 5000, 6172, 1, 0], + [1, 5000, 5500, 4951, 5000, 6172, 0, 0], + [2, 4900, 5250, 4500, 5100, 6172, 0, 1], # exit + [3, 5100, 5100, 4650, 4750, 6172, 0, 0], # order readjust + [4, 4750, 4950, 4350, 4750, 6172, 0, 0], + ], + stop_loss=-0.2, + roi={"0": 0.10}, + profit_perc=-0.02, + use_exit_signal=True, + timeout=1000, + custom_exit_price=5300, + adjust_exit_price=4900, + trades=[BTrade(exit_reason=ExitType.EXIT_SIGNAL, open_tick=1, close_tick=4, is_short=False)], +) + TESTS = [ tc0, @@ -1298,6 +1318,7 @@ TESTS = [ tc56, tc57, tc58, + tc59, ] From e76574b79ff4b3c61e916f1b85a3e87dd4aa7b75 Mon Sep 17 00:00:00 2001 From: Matthias Date: Thu, 20 Feb 2025 20:25:35 +0100 Subject: [PATCH 15/16] docs: update documentation for `adjust_order_price()` --- docs/strategy-callbacks.md | 65 ++++++++++++++++++++++++--------- freqtrade/strategy/interface.py | 1 - 2 files changed, 48 insertions(+), 18 deletions(-) diff --git a/docs/strategy-callbacks.md b/docs/strategy-callbacks.md index 64f7987f6..8c9dd1a2e 100644 --- a/docs/strategy-callbacks.md +++ b/docs/strategy-callbacks.md @@ -934,28 +934,25 @@ class DigDeeperStrategy(IStrategy): The total profit for this trade was 950$ on a 3350$ investment (`100@8$ + 100@9$ + 150@11$`). As such - the final relative profit is 28.35% (`950 / 3350`). -## Adjust Entry Price +## Adjust order Price -The `adjust_entry_price()` callback may be used by strategy developer to refresh/replace limit orders upon arrival of new candles. +The `adjust_order_price()` callback may be used by strategy developer to refresh/replace limit orders upon arrival of new candles. This callback is called once every iteration unless the order has been (re)placed within the current candle - limiting the maximum (re)placement of each order to once per candle. This also means that the first call will be at the start of the next candle after the initial order was placed. -Be aware that `custom_entry_price()` is still the one dictating initial entry limit order price target at the time of entry trigger. +Be aware that `custom_entry_price()`/`custom_exit_price()` is still the one dictating initial limit order price target at the time of the signal. Orders can be cancelled out of this callback by returning `None`. Returning `current_order_rate` will keep the order on the exchange "as is". Returning any other price will cancel the existing order, and replace it with a new order. -The trade open-date (`trade.open_date_utc`) will remain at the time of the very first order placed. -Please make sure to be aware of this - and eventually adjust your logic in other callbacks to account for this, and use the date of the first filled order instead. - If the cancellation of the original order fails, then the order will not be replaced - though the order will most likely have been canceled on exchange. Having this happen on initial entries will result in the deletion of the order, while on position adjustment orders, it'll result in the trade size remaining as is. -If the order has been partially filled, the order will not be replaced. You can however use [`adjust_trade_position()`](#adjust-trade-position) to adjust the trade size to the full, expected position size, should this be necessary / desired. +If the order has been partially filled, the order will not be replaced. You can however use [`adjust_trade_position()`](#adjust-trade-position) to adjust the trade size to the expected position size, should this be necessary / desired. !!! Warning "Regular timeout" - Entry `unfilledtimeout` mechanism (as well as `check_entry_timeout()`) takes precedence over this. - Entry Orders that are cancelled via the above methods will not have this callback called. Be sure to update timeout values to match your expectations. + Entry `unfilledtimeout` mechanism (as well as `check_entry_timeout()`/`check_exit_timeout()`) takes precedence over this callback. + Orders that are cancelled via the above methods will not have this callback called. Be sure to update timeout values to match your expectations. ```python # Default imports @@ -964,14 +961,26 @@ class AwesomeStrategy(IStrategy): # ... populate_* methods - def adjust_entry_price(self, trade: Trade, order: Order | None, pair: str, - current_time: datetime, proposed_rate: float, current_order_rate: float, - entry_tag: str | None, side: str, **kwargs) -> float: + def adjust_order_price( + self, + trade: Trade, + order: Order | None, + pair: str, + current_time: datetime, + proposed_rate: float, + current_order_rate: float, + entry_tag: str | None, + side: str, + is_entry: bool, + **kwargs, + ) -> float: """ - Entry price re-adjustment logic, returning the user desired limit price. + Exit and entry order price re-adjustment logic, returning the user desired limit price. This only executes when a order was already placed, still open (unfilled fully or partially) and not timed out on subsequent candles after entry trigger. + For full documentation please go to https://www.freqtrade.io/en/latest/strategy-callbacks/ + When not implemented by a strategy, returns current_order_rate as default. If current_order_rate is returned then the existing order is maintained. If None is returned then order gets canceled but not replaced by a new one. @@ -983,14 +992,16 @@ class AwesomeStrategy(IStrategy): :param proposed_rate: Rate, calculated based on pricing settings in entry_pricing. :param current_order_rate: Rate of the existing order in place. :param entry_tag: Optional entry_tag (buy_tag) if provided with the buy signal. - :param side: "long" or "short" - indicating the direction of the proposed trade + :param side: 'long' or 'short' - indicating the direction of the proposed trade + :param is_entry: True if the order is an entry order, False if it's an exit order. :param **kwargs: Ensure to keep this here so updates to this won't break your strategy. :return float: New entry price value if provided - """ - # Limit orders to use and follow SMA200 as price target for the first 10 minutes since entry trigger for BTC/USDT pair. + + # Limit entry orders to use and follow SMA200 as price target for the first 10 minutes since entry trigger for BTC/USDT pair. if ( - pair == "BTC/USDT" + is_entry + and pair == "BTC/USDT" and entry_tag == "long_sma200" and side == "long" and (current_time - timedelta(minutes=10)) <= trade.open_date_utc @@ -1007,6 +1018,26 @@ class AwesomeStrategy(IStrategy): return current_order_rate ``` +!!! danger "Incompatibility with `adjust_*_price()`" + If you have both `adjust_order_price()` and `adjust_entry_price()`/`adjust_exit_price()` implemented, only `adjust_order_price()` will be used. + If you need to adjust entry/exit prices, you can either implement the logic in `adjust_order_price()`, or use the split `adjust_entry_price()` / `adjust_exit_price()` callbacks, but not both. + Mixing these is not supported and will raise an error during bot startup. + +### Adjust Entry Price + +The `adjust_entry_price()` callback may be used by strategy developer to refresh/replace entry limit orders upon arrival. +It's a sub-set of `adjust_order_price()` and is called only for entry orders. +All remaining behavior is identical to `adjust_order_price()`. + +The trade open-date (`trade.open_date_utc`) will remain at the time of the very first order placed. +Please make sure to be aware of this - and eventually adjust your logic in other callbacks to account for this, and use the date of the first filled order instead. + +### Adjust Exit Price + +The `adjust_exit_price()` callback may be used by strategy developer to refresh/replace exit limit orders upon arrival. +It's a sub-set of `adjust_order_price()` and is called only for exit orders. +All remaining behavior is identical to `adjust_order_price()`. + ## Leverage Callback When trading in markets that allow leverage, this method must return the desired Leverage (Defaults to 1 -> No leverage). diff --git a/freqtrade/strategy/interface.py b/freqtrade/strategy/interface.py index 69ae94917..79ea094c9 100644 --- a/freqtrade/strategy/interface.py +++ b/freqtrade/strategy/interface.py @@ -762,7 +762,6 @@ class IStrategy(ABC, HyperStrategyMixin): :param is_entry: True if the order is an entry order, False if it's an exit order. :param **kwargs: Ensure to keep this here so updates to this won't break your strategy. :return float: New entry price value if provided - """ if is_entry: return self.adjust_entry_price( From 09b9ff2c683596732ca9b21d2c9652d6e3890790 Mon Sep 17 00:00:00 2001 From: Matthias Date: Thu, 20 Feb 2025 20:32:51 +0100 Subject: [PATCH 16/16] fix: provide default for exit reason --- freqtrade/freqtradebot.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/freqtrade/freqtradebot.py b/freqtrade/freqtradebot.py index 8b3a844be..a3bc6c11f 100644 --- a/freqtrade/freqtradebot.py +++ b/freqtrade/freqtradebot.py @@ -1736,7 +1736,7 @@ class FreqtradeBot(LoggingMixin): adjusted_price, exit_check=ExitCheckTuple( exit_type=ExitType.CUSTOM_EXIT, - exit_reason=order_obj.ft_order_tag, + exit_reason=order_obj.ft_order_tag or "order_replaced", ), ordertype="limit", sub_trade_amt=order_obj.safe_remaining,