From 0ed3bdc747839465c9ebd7e9f65e3dc9b1d2f200 Mon Sep 17 00:00:00 2001 From: Matthias Date: Fri, 12 Dec 2025 06:23:55 +0100 Subject: [PATCH 1/6] test: add test for force exit API logic --- tests/rpc/test_rpc_apiserver.py | 26 ++++++++++++++++++++++++++ 1 file changed, 26 insertions(+) diff --git a/tests/rpc/test_rpc_apiserver.py b/tests/rpc/test_rpc_apiserver.py index beb772712..768858aac 100644 --- a/tests/rpc/test_rpc_apiserver.py +++ b/tests/rpc/test_rpc_apiserver.py @@ -1852,9 +1852,35 @@ def test_api_forceexit(botclient, mocker, ticker, fee, markets): Trade.rollback() trade = Trade.get_trades([Trade.id == 5]).first() + last_order = trade.orders[-1] + + assert last_order.side == "sell" + assert last_order.status == "closed" + assert last_order.order_type == "market" + assert last_order.amount == 23 assert pytest.approx(trade.amount) == 100 assert trade.is_open is True + # Test with explicit price + rc = client_post( + client, + f"{BASE_URI}/forceexit", + data={"tradeid": "5", "ordertype": "limit", "amount": 25, "price": 0.12345}, + ) + assert_response(rc) + assert rc.json() == {"result": "Created exit order for trade 5."} + Trade.rollback() + + trade = Trade.get_trades([Trade.id == 5]).first() + last_order = trade.orders[-1] + assert last_order.status == "closed" + assert last_order.order_type == "limit" + assert pytest.approx(last_order.safe_price) == 0.12345 + assert pytest.approx(last_order.amount) == 25 + + assert pytest.approx(trade.amount) == 75 + assert trade.is_open is True + rc = client_post(client, f"{BASE_URI}/forceexit", data={"tradeid": "5"}) assert_response(rc) assert rc.json() == {"result": "Created exit order for trade 5."} From bac6219cc13b46a1482df104931cb2afa9a929f5 Mon Sep 17 00:00:00 2001 From: Matthias Date: Fri, 12 Dec 2025 06:24:21 +0100 Subject: [PATCH 2/6] feat: add price to force-exit --- freqtrade/rpc/api_server/api_schemas.py | 1 + freqtrade/rpc/api_server/api_v1.py | 7 +++++-- freqtrade/rpc/rpc.py | 26 +++++++++++++++++++------ 3 files changed, 26 insertions(+), 8 deletions(-) diff --git a/freqtrade/rpc/api_server/api_schemas.py b/freqtrade/rpc/api_server/api_schemas.py index 51507fb04..0dcd95edf 100644 --- a/freqtrade/rpc/api_server/api_schemas.py +++ b/freqtrade/rpc/api_server/api_schemas.py @@ -426,6 +426,7 @@ class ForceExitPayload(BaseModel): tradeid: str | int ordertype: OrderTypeValues | None = None amount: float | None = None + price: float | None = None class BlacklistPayload(BaseModel): diff --git a/freqtrade/rpc/api_server/api_v1.py b/freqtrade/rpc/api_server/api_v1.py index 3d1ec8433..39c8e9821 100644 --- a/freqtrade/rpc/api_server/api_v1.py +++ b/freqtrade/rpc/api_server/api_v1.py @@ -92,7 +92,8 @@ logger = logging.getLogger(__name__) # 2.42: Add /pair_history endpoint with live data # 2.43: Add /profit_all endpoint # 2.44: Add candle_types parameter to download-data endpoint -API_VERSION = 2.44 +# 2.45: Add price to forceexit endpoint +API_VERSION = 2.45 # Public API, requires no auth. router_public = APIRouter() @@ -325,7 +326,9 @@ def force_entry(payload: ForceEnterPayload, rpc: RPC = Depends(get_rpc)): @router.post("/forcesell", response_model=ResultMsg, tags=["trading"]) def forceexit(payload: ForceExitPayload, rpc: RPC = Depends(get_rpc)): ordertype = payload.ordertype.value if payload.ordertype else None - return rpc._rpc_force_exit(str(payload.tradeid), ordertype, amount=payload.amount) + return rpc._rpc_force_exit( + str(payload.tradeid), ordertype, amount=payload.amount, price=payload.price + ) @router.get("/blacklist", response_model=BlacklistResponse, tags=["info", "pairlist"]) diff --git a/freqtrade/rpc/rpc.py b/freqtrade/rpc/rpc.py index 22e457eef..b7630c47b 100644 --- a/freqtrade/rpc/rpc.py +++ b/freqtrade/rpc/rpc.py @@ -940,7 +940,11 @@ class RPC: return {"status": "Reloaded from orders from exchange"} def __exec_force_exit( - self, trade: Trade, ordertype: str | None, amount: float | None = None + self, + trade: Trade, + ordertype: str | None, + amount: float | None = None, + price: float | None = None, ) -> bool: # Check if there is there are open orders trade_entry_cancelation_registry = [] @@ -964,8 +968,13 @@ class RPC: # Order cancellation failed, so we can't exit. return False # Get current rate and execute sell - current_rate = self._freqtrade.exchange.get_rate( - trade.pair, side="exit", is_short=trade.is_short, refresh=True + + current_rate = ( + self._freqtrade.exchange.get_rate( + trade.pair, side="exit", is_short=trade.is_short, refresh=True + ) + if ordertype == "market" or price is None + else price ) exit_check = ExitCheckTuple(exit_type=ExitType.FORCE_EXIT) order_type = ordertype or self._freqtrade.strategy.order_types.get( @@ -990,11 +999,16 @@ class RPC: return False def _rpc_force_exit( - self, trade_id: str, ordertype: str | None = None, *, amount: float | None = None + self, + trade_id: str, + ordertype: str | None = None, + *, + amount: float | None = None, + price: float | None = None, ) -> dict[str, str]: """ Handler for forceexit . - Sells the given trade at current price + exts the given trade. Uses current price if price is None. """ if self._freqtrade.state == State.STOPPED: @@ -1024,7 +1038,7 @@ class RPC: logger.warning("force_exit: Invalid argument received") raise RPCException("invalid argument") - result = self.__exec_force_exit(trade, ordertype, amount) + result = self.__exec_force_exit(trade, ordertype, amount, price) Trade.commit() self._freqtrade.wallets.update() if not result: From e26529b6952bf54d462d48f1e3b459e765176985 Mon Sep 17 00:00:00 2001 From: Matthias Date: Fri, 12 Dec 2025 06:36:10 +0100 Subject: [PATCH 3/6] feat: Don't run custom_exit_price callback when exiting with price --- freqtrade/freqtradebot.py | 26 +++++++++++++++----------- freqtrade/rpc/rpc.py | 7 ++++++- 2 files changed, 21 insertions(+), 12 deletions(-) diff --git a/freqtrade/freqtradebot.py b/freqtrade/freqtradebot.py index 80de52025..8f34ee748 100644 --- a/freqtrade/freqtradebot.py +++ b/freqtrade/freqtradebot.py @@ -2054,6 +2054,7 @@ class FreqtradeBot(LoggingMixin): exit_tag: str | None = None, ordertype: str | None = None, sub_trade_amt: float | None = None, + skip_custom_exit_price: bool = False, ) -> bool: """ Executes a trade exit for the given trade and limit @@ -2080,26 +2081,29 @@ class FreqtradeBot(LoggingMixin): ): exit_type = "stoploss" + order_type = ordertype or self.strategy.order_types[exit_type] # set custom_exit_price if available proposed_limit_rate = limit + custom_exit_price = limit + current_profit = trade.calc_profit_ratio(limit) - custom_exit_price = strategy_safe_wrapper( - self.strategy.custom_exit_price, default_retval=proposed_limit_rate - )( - pair=trade.pair, - trade=trade, - current_time=datetime.now(UTC), - proposed_rate=proposed_limit_rate, - current_profit=current_profit, - exit_tag=exit_reason, - ) + if order_type == "limit" and not skip_custom_exit_price: + custom_exit_price = strategy_safe_wrapper( + self.strategy.custom_exit_price, default_retval=proposed_limit_rate + )( + pair=trade.pair, + trade=trade, + current_time=datetime.now(UTC), + proposed_rate=proposed_limit_rate, + current_profit=current_profit, + exit_tag=exit_reason, + ) limit = self.get_valid_price(custom_exit_price, proposed_limit_rate) # First cancelling stoploss on exchange ... trade = self.cancel_stoploss_on_exchange(trade, allow_nonblocking=True) - order_type = ordertype or self.strategy.order_types[exit_type] if exit_check.exit_type == ExitType.EMERGENCY_EXIT: # Emergency exits (default to market!) order_type = self.strategy.order_types.get("emergency_exit", "market") diff --git a/freqtrade/rpc/rpc.py b/freqtrade/rpc/rpc.py index b7630c47b..8a8376afb 100644 --- a/freqtrade/rpc/rpc.py +++ b/freqtrade/rpc/rpc.py @@ -992,7 +992,12 @@ class RPC: sub_amount = amount self._freqtrade.execute_trade_exit( - trade, current_rate, exit_check, ordertype=order_type, sub_trade_amt=sub_amount + trade, + current_rate, + exit_check, + ordertype=order_type, + sub_trade_amt=sub_amount, + skip_custom_exit_price=price is not None and ordertype == "limit", ) return True From fe187310578b25a5cfa4b783d8268ac5bf68cc7d Mon Sep 17 00:00:00 2001 From: Matthias Date: Fri, 12 Dec 2025 06:46:15 +0100 Subject: [PATCH 4/6] test: slightly improve custom_exit_rate test --- tests/freqtradebot/test_freqtradebot.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/freqtradebot/test_freqtradebot.py b/tests/freqtradebot/test_freqtradebot.py index f8b790c44..d08daa2f1 100644 --- a/tests/freqtradebot/test_freqtradebot.py +++ b/tests/freqtradebot/test_freqtradebot.py @@ -3092,7 +3092,7 @@ def test_execute_trade_exit_custom_exit_price( "exit_reason": "foo", "open_date": ANY, "close_date": ANY, - "close_rate": ANY, + "close_rate": 2.25, # the custom exit price "sub_trade": False, "cumulative_profit": 0.0, "stake_amount": pytest.approx(60), From 13c86452e9d3af5b5657069f9f38c1dbfa5e9dba Mon Sep 17 00:00:00 2001 From: Matthias Date: Sun, 14 Dec 2025 18:29:37 +0100 Subject: [PATCH 5/6] refactor: only assign order_type once --- freqtrade/freqtradebot.py | 11 ++++++----- 1 file changed, 6 insertions(+), 5 deletions(-) diff --git a/freqtrade/freqtradebot.py b/freqtrade/freqtradebot.py index 8f34ee748..4c5207e93 100644 --- a/freqtrade/freqtradebot.py +++ b/freqtrade/freqtradebot.py @@ -2081,7 +2081,12 @@ class FreqtradeBot(LoggingMixin): ): exit_type = "stoploss" - order_type = ordertype or self.strategy.order_types[exit_type] + order_type = ( + (ordertype or self.strategy.order_types[exit_type]) + if exit_check.exit_type != ExitType.EMERGENCY_EXIT + else self.strategy.order_types.get("emergency_exit", "market") + ) + # set custom_exit_price if available proposed_limit_rate = limit custom_exit_price = limit @@ -2104,10 +2109,6 @@ class FreqtradeBot(LoggingMixin): # First cancelling stoploss on exchange ... trade = self.cancel_stoploss_on_exchange(trade, allow_nonblocking=True) - if exit_check.exit_type == ExitType.EMERGENCY_EXIT: - # Emergency exits (default to market!) - order_type = self.strategy.order_types.get("emergency_exit", "market") - amount = self._safe_exit_amount(trade, trade.pair, sub_trade_amt or trade.amount) time_in_force = self.strategy.order_time_in_force["exit"] From a16d2a1ef90de96352cbf82d5dc6e5d3579d96b8 Mon Sep 17 00:00:00 2001 From: Matthias Date: Sun, 14 Dec 2025 18:29:43 +0100 Subject: [PATCH 6/6] chore: fix typo --- freqtrade/rpc/rpc.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/freqtrade/rpc/rpc.py b/freqtrade/rpc/rpc.py index 8a8376afb..81bb52ce5 100644 --- a/freqtrade/rpc/rpc.py +++ b/freqtrade/rpc/rpc.py @@ -1013,7 +1013,7 @@ class RPC: ) -> dict[str, str]: """ Handler for forceexit . - exts the given trade. Uses current price if price is None. + exits the given trade. Uses current price if price is None. """ if self._freqtrade.state == State.STOPPED: