diff --git a/docs/rest-api.md b/docs/rest-api.md index 62ad586dd..5f604ef43 100644 --- a/docs/rest-api.md +++ b/docs/rest-api.md @@ -163,7 +163,7 @@ python3 scripts/rest_client.py --config rest_config.json [optional par | `strategy ` | Get specific Strategy content. **Alpha** | `available_pairs` | List available backtest data. **Alpha** | `version` | Show version. -| `sysinfo` | Show informations about the system load. +| `sysinfo` | Show information about the system load. | `health` | Show bot health (last bot loop). !!! Warning "Alpha status" @@ -192,6 +192,11 @@ blacklist :param add: List of coins to add (example: "BNB/BTC") +cancel_open_order + Cancel open order for trade. + + :param trade_id: Cancels open orders for this trade. + count Return the amount of open trades. @@ -274,7 +279,6 @@ reload_config Reload configuration. show_config - Returns part of the configuration, relevant for trading operations. start @@ -320,6 +324,7 @@ version whitelist Show the current whitelist. + ``` ### Message WebSocket diff --git a/docs/telegram-usage.md b/docs/telegram-usage.md index db4a309d0..4626944c5 100644 --- a/docs/telegram-usage.md +++ b/docs/telegram-usage.md @@ -162,26 +162,33 @@ official commands. You can ask at any moment for help with `/help`. | Command | Description | |----------|-------------| +| **System commands** | `/start` | Starts the trader | `/stop` | Stops the trader | `/stopbuy | /stopentry` | Stops the trader from opening new trades. Gracefully closes open trades according to their rules. | `/reload_config` | Reloads the configuration file | `/show_config` | Shows part of the current configuration with relevant settings to operation | `/logs [limit]` | Show last log messages. +| `/help` | Show help message +| `/version` | Show version +| **Status** | | `/status` | Lists all open trades | `/status ` | Lists one or more specific trade. Separate multiple with a blank space. | `/status table` | List all open trades in a table format. Pending buy orders are marked with an asterisk (*) Pending sell orders are marked with a double asterisk (**) | `/trades [limit]` | List all recently closed trades in a table format. -| `/delete ` | Delete a specific trade from the Database. Tries to close open orders. Requires manual handling of this trade on the exchange. | `/count` | Displays number of trades used and available | `/locks` | Show currently locked pairs. | `/unlock ` | Remove the lock for this pair (or for this lock id). -| `/profit []` | Display a summary of your profit/loss from close trades and some stats about your performance, over the last n days (all trades by default) +| **Modify Trade states** | | `/forceexit | /fx ` | Instantly exits the given trade (Ignoring `minimum_roi`). | `/forceexit all | /fx all` | Instantly exits all open trades (Ignoring `minimum_roi`). | `/fx` | alias for `/forceexit` | `/forcelong [rate]` | Instantly buys the given pair. Rate is optional and only applies to limit orders. (`force_entry_enable` must be set to True) | `/forceshort [rate]` | Instantly shorts the given pair. Rate is optional and only applies to limit orders. This will only work on non-spot markets. (`force_entry_enable` must be set to True) +| `/delete ` | Delete a specific trade from the Database. Tries to close open orders. Requires manual handling of this trade on the exchange. +| `/cancel_open_order | /coo ` | Cancel an open order for a trade. +| **Metrics** | +| `/profit []` | Display a summary of your profit/loss from close trades and some stats about your performance, over the last n days (all trades by default) | `/performance` | Show performance of each finished trade grouped by pair | `/balance` | Show account balance per currency | `/daily ` | Shows profit or loss per day, over the last n days (n defaults to 7) @@ -193,8 +200,7 @@ official commands. You can ask at any moment for help with `/help`. | `/whitelist [sorted] [baseonly]` | Show the current whitelist. Optionally display in alphabetical order and/or with just the base currency of each pairing. | `/blacklist [pair]` | Show the current blacklist, or adds a pair to the blacklist. | `/edge` | Show validated pairs by Edge if it is enabled. -| `/help` | Show help message -| `/version` | Show version + ## Telegram commands in action diff --git a/freqtrade/freqtradebot.py b/freqtrade/freqtradebot.py index 0f10effc2..ad50f69ba 100644 --- a/freqtrade/freqtradebot.py +++ b/freqtrade/freqtradebot.py @@ -363,7 +363,7 @@ class FreqtradeBot(LoggingMixin): "Order is older than 5 days. Assuming order was fully cancelled.") fo = order.to_ccxt_object() fo['status'] = 'canceled' - self.handle_timedout_order(fo, order.trade) + self.handle_cancel_order(fo, order.trade, constants.CANCEL_REASON['TIMEOUT']) except ExchangeError as e: @@ -1263,11 +1263,11 @@ class FreqtradeBot(LoggingMixin): if not_closed: if fully_cancelled or (order_obj and self.strategy.ft_check_timed_out( trade, order_obj, datetime.now(timezone.utc))): - self.handle_timedout_order(order, trade) + self.handle_cancel_order(order, trade, constants.CANCEL_REASON['TIMEOUT']) else: self.replace_order(order, order_obj, trade) - def handle_timedout_order(self, order: Dict, trade: Trade) -> None: + def handle_cancel_order(self, order: Dict, trade: Trade, reason: str) -> None: """ Check if current analyzed order timed out and cancel if necessary. :param order: Order dict grabbed with exchange.fetch_order() @@ -1275,10 +1275,10 @@ class FreqtradeBot(LoggingMixin): :return: None """ if order['side'] == trade.entry_side: - self.handle_cancel_enter(trade, order, constants.CANCEL_REASON['TIMEOUT']) + self.handle_cancel_enter(trade, order, reason) else: canceled = self.handle_cancel_exit( - trade, order, constants.CANCEL_REASON['TIMEOUT']) + trade, order, reason) canceled_count = trade.get_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: diff --git a/freqtrade/rpc/api_server/api_v1.py b/freqtrade/rpc/api_server/api_v1.py index b64f6c0e8..73bdde86b 100644 --- a/freqtrade/rpc/api_server/api_v1.py +++ b/freqtrade/rpc/api_server/api_v1.py @@ -41,7 +41,8 @@ logger = logging.getLogger(__name__) # 2.21: Add new_candle messagetype # 2.22: Add FreqAI to backtesting # 2.23: Allow plot config request in webserver mode -API_VERSION = 2.23 +# 2.24: Add cancel_open_order endpoint +API_VERSION = 2.24 # Public API, requires no auth. router_public = APIRouter() @@ -123,6 +124,12 @@ def trades_delete(tradeid: int, rpc: RPC = Depends(get_rpc)): return rpc._rpc_delete(tradeid) +@router.delete('/trades/{tradeid}/open-order', response_model=OpenTradeSchema, tags=['trading']) +def cancel_open_order(tradeid: int, rpc: RPC = Depends(get_rpc)): + rpc._rpc_cancel_open_order(tradeid) + return rpc._rpc_trade_status([tradeid])[0] + + # TODO: Missing response model @router.get('/edge', tags=['info']) def edge(rpc: RPC = Depends(get_rpc)): diff --git a/freqtrade/rpc/rpc.py b/freqtrade/rpc/rpc.py index f1dd3fe85..ddf6018ad 100644 --- a/freqtrade/rpc/rpc.py +++ b/freqtrade/rpc/rpc.py @@ -812,6 +812,29 @@ class RPC: else: raise RPCException(f'Failed to enter position for {pair}.') + def _rpc_cancel_open_order(self, trade_id: int): + if self._freqtrade.state != State.RUNNING: + raise RPCException('trader is not running') + with self._freqtrade._exit_lock: + # Query for trade + trade = Trade.get_trades( + trade_filter=[Trade.id == trade_id, Trade.is_open.is_(True), ] + ).first() + if not trade: + logger.warning('cancel_open_order: Invalid trade_id received.') + raise RPCException('Invalid trade_id.') + if not trade.open_order_id: + logger.warning('cancel_open_order: No open order for trade_id.') + raise RPCException('No open order for trade_id.') + + try: + order = self._freqtrade.exchange.fetch_order(trade.open_order_id, trade.pair) + except ExchangeError as e: + logger.info(f"Cannot query order for {trade} due to {e}.", exc_info=True) + raise RPCException("Order not found.") + self._freqtrade.handle_cancel_order(order, trade, CANCEL_REASON['USER_CANCEL']) + Trade.commit() + def _rpc_delete(self, trade_id: int) -> Dict[str, Union[str, int]]: """ Handler for delete . diff --git a/freqtrade/rpc/telegram.py b/freqtrade/rpc/telegram.py index c02a4000a..fbd675d02 100644 --- a/freqtrade/rpc/telegram.py +++ b/freqtrade/rpc/telegram.py @@ -174,6 +174,7 @@ class Telegram(RPCHandler): self._force_enter, order_side=SignalDirection.SHORT)), CommandHandler('trades', self._trades), CommandHandler('delete', self._delete_trade), + CommandHandler(['coo', 'cancel_open_order'], self._cancel_open_order), CommandHandler('performance', self._performance), CommandHandler(['buys', 'entries'], self._enter_tag_performance), CommandHandler(['sells', 'exits'], self._exit_reason_performance), @@ -1144,10 +1145,25 @@ class Telegram(RPCHandler): raise RPCException("Trade-id not set.") trade_id = int(context.args[0]) msg = self._rpc._rpc_delete(trade_id) - self._send_msg(( + self._send_msg( f"`{msg['result_msg']}`\n" 'Please make sure to take care of this asset on the exchange manually.' - )) + ) + + @authorized_only + def _cancel_open_order(self, update: Update, context: CallbackContext) -> None: + """ + Handler for /cancel_open_order . + Cancel open order for tradeid + :param bot: telegram bot + :param update: message update + :return: None + """ + if not context.args or len(context.args) == 0: + raise RPCException("Trade-id not set.") + trade_id = int(context.args[0]) + self._rpc._rpc_cancel_open_order(trade_id) + self._send_msg('Open order canceled.') @authorized_only def _performance(self, update: Update, context: CallbackContext) -> None: @@ -1456,6 +1472,10 @@ class Telegram(RPCHandler): "*/fx |all:* `Alias to /forceexit`\n" f"{force_enter_text if self._config.get('force_entry_enable', False) else ''}" "*/delete :* `Instantly delete the given trade in the database`\n" + "*/cancel_open_order :* `Cancels open orders for trade. " + "Only valid when the trade has open orders.`\n" + "*/coo |all:* `Alias to /cancel_open_order`\n" + "*/whitelist [sorted] [baseonly]:* `Show current whitelist. Optionally in " "order and/or only displaying the base currency of each pairing.`\n" "*/blacklist [pair]:* `Show current blacklist, or adds one or more pairs " diff --git a/scripts/rest_client.py b/scripts/rest_client.py index 3cf2199fb..144d428e5 100755 --- a/scripts/rest_client.py +++ b/scripts/rest_client.py @@ -177,8 +177,7 @@ class FtRestClient(): return self._get("version") def show_config(self): - """ - Returns part of the configuration, relevant for trading operations. + """ Returns part of the configuration, relevant for trading operations. :return: json object containing the version """ return self._get("show_config") @@ -232,6 +231,14 @@ class FtRestClient(): """ return self._delete(f"trades/{trade_id}") + def cancel_open_order(self, trade_id): + """Cancel open order for trade. + + :param trade_id: Cancels open orders for this trade. + :return: json object + """ + return self._delete(f"trades/{trade_id}/open-order") + def whitelist(self): """Show the current whitelist. diff --git a/tests/rpc/test_rpc_apiserver.py b/tests/rpc/test_rpc_apiserver.py index cd1a988d2..89a1b791d 100644 --- a/tests/rpc/test_rpc_apiserver.py +++ b/tests/rpc/test_rpc_apiserver.py @@ -706,6 +706,46 @@ def test_api_delete_trade(botclient, mocker, fee, markets, is_short): assert_response(rc, 502) +@pytest.mark.parametrize('is_short', [True, False]) +def test_api_delete_open_order(botclient, mocker, fee, markets, ticker, is_short): + ftbot, client = botclient + patch_get_signal(ftbot, enter_long=not is_short, enter_short=is_short) + stoploss_mock = MagicMock() + cancel_mock = MagicMock() + mocker.patch.multiple( + 'freqtrade.exchange.Exchange', + markets=PropertyMock(return_value=markets), + fetch_ticker=ticker, + cancel_order=cancel_mock, + cancel_stoploss_order=stoploss_mock, + ) + + rc = client_delete(client, f"{BASE_URI}/trades/10/open-order") + assert_response(rc, 502) + assert 'Invalid trade_id.' in rc.json()['error'] + + create_mock_trades(fee, is_short=is_short) + Trade.commit() + + rc = client_delete(client, f"{BASE_URI}/trades/5/open-order") + assert_response(rc, 502) + assert 'No open order for trade_id' in rc.json()['error'] + trade = Trade.get_trades([Trade.id == 6]).first() + mocker.patch('freqtrade.exchange.Exchange.fetch_order', + side_effect=ExchangeError) + rc = client_delete(client, f"{BASE_URI}/trades/6/open-order") + assert_response(rc, 502) + assert 'Order not found.' in rc.json()['error'] + + trade = Trade.get_trades([Trade.id == 6]).first() + mocker.patch('freqtrade.exchange.Exchange.fetch_order', + return_value=trade.orders[-1].to_ccxt_object()) + + rc = client_delete(client, f"{BASE_URI}/trades/6/open-order") + assert_response(rc) + assert cancel_mock.call_count == 1 + + def test_api_logs(botclient): ftbot, client = botclient rc = client_get(client, f"{BASE_URI}/logs") diff --git a/tests/rpc/test_rpc_telegram.py b/tests/rpc/test_rpc_telegram.py index 85475ae8e..5e3c2bd18 100644 --- a/tests/rpc/test_rpc_telegram.py +++ b/tests/rpc/test_rpc_telegram.py @@ -99,7 +99,7 @@ def test_telegram_init(default_conf, mocker, caplog) -> None: message_str = ("rpc.telegram is listening for following commands: [['status'], ['profit'], " "['balance'], ['start'], ['stop'], " "['forcesell', 'forceexit', 'fx'], ['forcebuy', 'forcelong'], ['forceshort'], " - "['trades'], ['delete'], ['performance'], " + "['trades'], ['delete'], ['coo', 'cancel_open_order'], ['performance'], " "['buys', 'entries'], ['sells', 'exits'], ['mix_tags'], " "['stats'], ['daily'], ['weekly'], ['monthly'], " "['count'], ['locks'], ['unlock', 'delete_locks'], " @@ -1678,6 +1678,40 @@ def test_telegram_delete_trade(mocker, update, default_conf, fee, is_short): assert "Please make sure to take care of this asset" in msg_mock.call_args_list[0][0][0] +@pytest.mark.parametrize('is_short', [True, False]) +def test_telegram_delete_open_order(mocker, update, default_conf, fee, is_short, ticker): + + mocker.patch.multiple( + 'freqtrade.exchange.Exchange', + fetch_ticker=ticker, + ) + telegram, _, msg_mock = get_telegram_testobject(mocker, default_conf) + context = MagicMock() + context.args = [] + + telegram._cancel_open_order(update=update, context=context) + assert "Trade-id not set." in msg_mock.call_args_list[0][0][0] + + msg_mock.reset_mock() + create_mock_trades(fee, is_short=is_short) + + context = MagicMock() + context.args = [5] + telegram._cancel_open_order(update=update, context=context) + assert "No open order for trade_id" in msg_mock.call_args_list[0][0][0] + + msg_mock.reset_mock() + + trade = Trade.get_trades([Trade.id == 6]).first() + mocker.patch('freqtrade.exchange.Exchange.fetch_order', + return_value=trade.orders[-1].to_ccxt_object()) + context = MagicMock() + context.args = [6] + telegram._cancel_open_order(update=update, context=context) + assert msg_mock.call_count == 1 + assert "Open order canceled." in msg_mock.call_args_list[0][0][0] + + def test_help_handle(default_conf, update, mocker) -> None: telegram, _, msg_mock = get_telegram_testobject(mocker, default_conf) diff --git a/tests/test_freqtradebot.py b/tests/test_freqtradebot.py index 91fc25a83..a070fce97 100644 --- a/tests/test_freqtradebot.py +++ b/tests/test_freqtradebot.py @@ -5028,7 +5028,7 @@ def test_startup_update_open_orders(mocker, default_conf_usdt, fee, caplog, is_s assert log_has_re(r"Error updating Order .*", caplog) mocker.patch('freqtrade.exchange.Exchange.fetch_order', side_effect=InvalidOrderException) - hto_mock = mocker.patch('freqtrade.freqtradebot.FreqtradeBot.handle_timedout_order') + hto_mock = mocker.patch('freqtrade.freqtradebot.FreqtradeBot.handle_cancel_order') # Orders which are no longer found after X days should be assumed as canceled. freqtrade.startup_update_open_orders() assert log_has_re(r"Order is older than \d days.*", caplog)