Merge pull request #3611 from thopd88/telegram-delete-command

Add telegram /delete command
This commit is contained in:
Matthias 2020-08-08 15:19:40 +02:00 committed by GitHub
commit e2643103b6
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
9 changed files with 282 additions and 57 deletions

View File

@ -46,7 +46,7 @@ secrets.token_hex()
### Configuration with docker ### Configuration with docker
If you run your bot using docker, you'll need to have the bot listen to incomming connections. The security is then handled by docker. If you run your bot using docker, you'll need to have the bot listen to incoming connections. The security is then handled by docker.
``` json ``` json
"api_server": { "api_server": {
@ -106,26 +106,29 @@ python3 scripts/rest_client.py --config rest_config.json <command> [optional par
## Available commands ## Available commands
| Command | Default | Description | | Command | Description |
|----------|---------|-------------| |----------|-------------|
| `start` | | Starts the trader | `ping` | Simple command testing the API Readiness - requires no authentication.
| `stop` | | Stops the trader | `start` | Starts the trader
| `stopbuy` | | Stops the trader from opening new trades. Gracefully closes open trades according to their rules. | `stop` | Stops the trader
| `reload_config` | | Reloads the configuration file | `stopbuy` | Stops the trader from opening new trades. Gracefully closes open trades according to their rules.
| `show_config` | | Shows part of the current configuration with relevant settings to operation | `reload_config` | Reloads the configuration file
| `status` | | Lists all open trades | `trades` | List last trades.
| `count` | | Displays number of trades used and available | `delete_trade <trade_id>` | Remove trade from the database. Tries to close open orders. Requires manual handling of this trade on the exchange.
| `profit` | | Display a summary of your profit/loss from close trades and some stats about your performance | `show_config` | Shows part of the current configuration with relevant settings to operation
| `forcesell <trade_id>` | | Instantly sells the given trade (Ignoring `minimum_roi`). | `status` | Lists all open trades
| `forcesell all` | | Instantly sells all open trades (Ignoring `minimum_roi`). | `count` | Displays number of trades used and available
| `forcebuy <pair> [rate]` | | Instantly buys the given pair. Rate is optional. (`forcebuy_enable` must be set to True) | `profit` | Display a summary of your profit/loss from close trades and some stats about your performance
| `performance` | | Show performance of each finished trade grouped by pair | `forcesell <trade_id>` | Instantly sells the given trade (Ignoring `minimum_roi`).
| `balance` | | Show account balance per currency | `forcesell all` | Instantly sells all open trades (Ignoring `minimum_roi`).
| `daily <n>` | 7 | Shows profit or loss per day, over the last n days | `forcebuy <pair> [rate]` | Instantly buys the given pair. Rate is optional. (`forcebuy_enable` must be set to True)
| `whitelist` | | Show the current whitelist | `performance` | Show performance of each finished trade grouped by pair
| `blacklist [pair]` | | Show the current blacklist, or adds a pair to the blacklist. | `balance` | Show account balance per currency
| `edge` | | Show validated pairs by Edge if it is enabled. | `daily <n>` | Shows profit or loss per day, over the last n days (n defaults to 7)
| `version` | | Show version | `whitelist` | Show the current whitelist
| `blacklist [pair]` | Show the current blacklist, or adds a pair to the blacklist.
| `edge` | Show validated pairs by Edge if it is enabled.
| `version` | Show version
Possible commands can be listed from the rest-client script using the `help` command. Possible commands can be listed from the rest-client script using the `help` command.

View File

@ -47,29 +47,30 @@ Per default, the Telegram bot shows predefined commands. Some commands
are only available by sending them to the bot. The table below list the are only available by sending them to the bot. The table below list the
official commands. You can ask at any moment for help with `/help`. official commands. You can ask at any moment for help with `/help`.
| Command | Default | Description | | Command | Description |
|----------|---------|-------------| |----------|-------------|
| `/start` | | Starts the trader | `/start` | Starts the trader
| `/stop` | | Stops the trader | `/stop` | Stops the trader
| `/stopbuy` | | Stops the trader from opening new trades. Gracefully closes open trades according to their rules. | `/stopbuy` | Stops the trader from opening new trades. Gracefully closes open trades according to their rules.
| `/reload_config` | | Reloads the configuration file | `/reload_config` | Reloads the configuration file
| `/show_config` | | Shows part of the current configuration with relevant settings to operation | `/show_config` | Shows part of the current configuration with relevant settings to operation
| `/status` | | Lists all open trades | `/status` | Lists all open trades
| `/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 (**) | `/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. | `/trades [limit]` | List all recently closed trades in a table format.
| `/count` | | Displays number of trades used and available | `/delete <trade_id>` | Delete a specific trade from the Database. Tries to close open orders. Requires manual handling of this trade on the exchange.
| `/profit` | | Display a summary of your profit/loss from close trades and some stats about your performance | `/count` | Displays number of trades used and available
| `/forcesell <trade_id>` | | Instantly sells the given trade (Ignoring `minimum_roi`). | `/profit` | Display a summary of your profit/loss from close trades and some stats about your performance
| `/forcesell all` | | Instantly sells all open trades (Ignoring `minimum_roi`). | `/forcesell <trade_id>` | Instantly sells the given trade (Ignoring `minimum_roi`).
| `/forcebuy <pair> [rate]` | | Instantly buys the given pair. Rate is optional. (`forcebuy_enable` must be set to True) | `/forcesell all` | Instantly sells all open trades (Ignoring `minimum_roi`).
| `/performance` | | Show performance of each finished trade grouped by pair | `/forcebuy <pair> [rate]` | Instantly buys the given pair. Rate is optional. (`forcebuy_enable` must be set to True)
| `/balance` | | Show account balance per currency | `/performance` | Show performance of each finished trade grouped by pair
| `/daily <n>` | 7 | Shows profit or loss per day, over the last n days | `/balance` | Show account balance per currency
| `/whitelist` | | Show the current whitelist | `/daily <n>` | Shows profit or loss per day, over the last n days (n defaults to 7)
| `/blacklist [pair]` | | Show the current blacklist, or adds a pair to the blacklist. | `/whitelist` | Show the current whitelist
| `/edge` | | Show validated pairs by Edge if it is enabled. | `/blacklist [pair]` | Show the current blacklist, or adds a pair to the blacklist.
| `/help` | | Show help message | `/edge` | Show validated pairs by Edge if it is enabled.
| `/version` | | Show version | `/help` | Show help message
| `/version` | Show version
## Telegram commands in action ## Telegram commands in action
@ -114,6 +115,7 @@ For each open trade, the bot will send you the following message.
### /status table ### /status table
Return the status of all open trades in a table format. Return the status of all open trades in a table format.
``` ```
ID Pair Since Profit ID Pair Since Profit
---- -------- ------- -------- ---- -------- ------- --------
@ -124,6 +126,7 @@ Return the status of all open trades in a table format.
### /count ### /count
Return the number of trades used and available. Return the number of trades used and available.
``` ```
current max current max
--------- ----- --------- -----
@ -209,7 +212,7 @@ Shows the current whitelist
Shows the current blacklist. Shows the current blacklist.
If Pair is set, then this pair will be added to the pairlist. If Pair is set, then this pair will be added to the pairlist.
Also supports multiple pairs, seperated by a space. Also supports multiple pairs, separated by a space.
Use `/reload_config` to reset the blacklist. Use `/reload_config` to reset the blacklist.
> Using blacklist `StaticPairList` with 2 pairs > Using blacklist `StaticPairList` with 2 pairs
@ -217,7 +220,7 @@ Use `/reload_config` to reset the blacklist.
### /edge ### /edge
Shows pairs validated by Edge along with their corresponding winrate, expectancy and stoploss values. Shows pairs validated by Edge along with their corresponding win-rate, expectancy and stoploss values.
> **Edge only validated following pairs:** > **Edge only validated following pairs:**
``` ```

View File

@ -56,7 +56,7 @@ def require_login(func: Callable[[Any, Any], Any]):
# Type should really be Callable[[ApiServer], Any], but that will create a circular dependency # Type should really be Callable[[ApiServer], Any], but that will create a circular dependency
def rpc_catch_errors(func: Callable[[Any], Any]): def rpc_catch_errors(func: Callable[..., Any]):
def func_wrapper(obj, *args, **kwargs): def func_wrapper(obj, *args, **kwargs):
@ -200,6 +200,8 @@ class ApiServer(RPC):
view_func=self._ping, methods=['GET']) view_func=self._ping, methods=['GET'])
self.app.add_url_rule(f'{BASE_URI}/trades', 'trades', self.app.add_url_rule(f'{BASE_URI}/trades', 'trades',
view_func=self._trades, methods=['GET']) view_func=self._trades, methods=['GET'])
self.app.add_url_rule(f'{BASE_URI}/trades/<int:tradeid>', 'trades_delete',
view_func=self._trades_delete, methods=['DELETE'])
# Combined actions and infos # Combined actions and infos
self.app.add_url_rule(f'{BASE_URI}/blacklist', 'blacklist', view_func=self._blacklist, self.app.add_url_rule(f'{BASE_URI}/blacklist', 'blacklist', view_func=self._blacklist,
methods=['GET', 'POST']) methods=['GET', 'POST'])
@ -424,6 +426,19 @@ class ApiServer(RPC):
results = self._rpc_trade_history(limit) results = self._rpc_trade_history(limit)
return self.rest_dump(results) return self.rest_dump(results)
@require_login
@rpc_catch_errors
def _trades_delete(self, tradeid):
"""
Handler for DELETE /trades/<tradeid> endpoint.
Removes the trade from the database (tries to cancel open orders first!)
get:
param:
tradeid: Numeric trade-id assigned to the trade.
"""
result = self._rpc_delete(tradeid)
return self.rest_dump(result)
@require_login @require_login
@rpc_catch_errors @rpc_catch_errors
def _whitelist(self): def _whitelist(self):

View File

@ -6,14 +6,14 @@ from abc import abstractmethod
from datetime import date, datetime, timedelta from datetime import date, datetime, timedelta
from enum import Enum from enum import Enum
from math import isnan from math import isnan
from typing import Any, Dict, List, Optional, Tuple from typing import Any, Dict, List, Optional, Tuple, Union
import arrow import arrow
from numpy import NAN, mean from numpy import NAN, mean
from freqtrade.exceptions import ExchangeError, PricingError from freqtrade.exceptions import (ExchangeError, InvalidOrderException,
PricingError)
from freqtrade.exchange import timeframe_to_msecs, timeframe_to_minutes from freqtrade.exchange import timeframe_to_minutes, timeframe_to_msecs
from freqtrade.misc import shorten_date from freqtrade.misc import shorten_date
from freqtrade.persistence import Trade from freqtrade.persistence import Trade
from freqtrade.rpc.fiat_convert import CryptoToFiatConverter from freqtrade.rpc.fiat_convert import CryptoToFiatConverter
@ -538,6 +538,46 @@ class RPC:
else: else:
return None return None
def _rpc_delete(self, trade_id: str) -> Dict[str, Union[str, int]]:
"""
Handler for delete <id>.
Delete the given trade and close eventually existing open orders.
"""
with self._freqtrade._sell_lock:
c_count = 0
trade = Trade.get_trades(trade_filter=[Trade.id == trade_id]).first()
if not trade:
logger.warning('delete trade: Invalid argument received')
raise RPCException('invalid argument')
# Try cancelling regular order if that exists
if trade.open_order_id:
try:
self._freqtrade.exchange.cancel_order(trade.open_order_id, trade.pair)
c_count += 1
except (ExchangeError, InvalidOrderException):
pass
# cancel stoploss on exchange ...
if (self._freqtrade.strategy.order_types.get('stoploss_on_exchange')
and trade.stoploss_order_id):
try:
self._freqtrade.exchange.cancel_stoploss_order(trade.stoploss_order_id,
trade.pair)
c_count += 1
except (ExchangeError, InvalidOrderException):
pass
Trade.session.delete(trade)
Trade.session.flush()
self._freqtrade.wallets.update()
return {
'result': 'success',
'trade_id': trade_id,
'result_msg': f'Deleted trade {trade_id}. Closed {c_count} open orders.',
'cancel_order_count': c_count,
}
def _rpc_performance(self) -> List[Dict[str, Any]]: def _rpc_performance(self) -> List[Dict[str, Any]]:
""" """
Handler for performance. Handler for performance.

View File

@ -94,6 +94,7 @@ class Telegram(RPC):
CommandHandler('forcesell', self._forcesell), CommandHandler('forcesell', self._forcesell),
CommandHandler('forcebuy', self._forcebuy), CommandHandler('forcebuy', self._forcebuy),
CommandHandler('trades', self._trades), CommandHandler('trades', self._trades),
CommandHandler('delete', self._delete_trade),
CommandHandler('performance', self._performance), CommandHandler('performance', self._performance),
CommandHandler('daily', self._daily), CommandHandler('daily', self._daily),
CommandHandler('count', self._count), CommandHandler('count', self._count),
@ -533,6 +534,27 @@ class Telegram(RPC):
except RPCException as e: except RPCException as e:
self._send_msg(str(e)) self._send_msg(str(e))
@authorized_only
def _delete_trade(self, update: Update, context: CallbackContext) -> None:
"""
Handler for /delete <id>.
Delete the given trade
:param bot: telegram bot
:param update: message update
:return: None
"""
trade_id = context.args[0] if len(context.args) > 0 else None
try:
msg = self._rpc_delete(trade_id)
self._send_msg((
'`{result_msg}`\n'
'Please make sure to take care of this asset on the exchange manually.'
).format(**msg))
except RPCException as e:
self._send_msg(str(e))
@authorized_only @authorized_only
def _performance(self, update: Update, context: CallbackContext) -> None: def _performance(self, update: Update, context: CallbackContext) -> None:
""" """
@ -651,6 +673,7 @@ class Telegram(RPC):
"*/forcesell <trade_id>|all:* `Instantly sells the given trade or all trades, " "*/forcesell <trade_id>|all:* `Instantly sells the given trade or all trades, "
"regardless of profit`\n" "regardless of profit`\n"
f"{forcebuy_text if self._config.get('forcebuy_enable', False) else ''}" f"{forcebuy_text if self._config.get('forcebuy_enable', False) else ''}"
"*/delete <trade_id>:* `Instantly delete the given trade in the database`\n"
"*/performance:* `Show performance of each finished trade grouped by pair`\n" "*/performance:* `Show performance of each finished trade grouped by pair`\n"
"*/daily <n>:* `Shows profit or loss per day, over the last n days`\n" "*/daily <n>:* `Shows profit or loss per day, over the last n days`\n"
"*/count:* `Show number of trades running compared to allowed number of trades`" "*/count:* `Show number of trades running compared to allowed number of trades`"

View File

@ -62,6 +62,9 @@ class FtRestClient():
def _get(self, apipath, params: dict = None): def _get(self, apipath, params: dict = None):
return self._call("GET", apipath, params=params) return self._call("GET", apipath, params=params)
def _delete(self, apipath, params: dict = None):
return self._call("DELETE", apipath, params=params)
def _post(self, apipath, params: dict = None, data: dict = None): def _post(self, apipath, params: dict = None, data: dict = None):
return self._call("POST", apipath, params=params, data=data) return self._call("POST", apipath, params=params, data=data)
@ -164,6 +167,15 @@ class FtRestClient():
""" """
return self._get("trades", params={"limit": limit} if limit else 0) return self._get("trades", params={"limit": limit} if limit else 0)
def delete_trade(self, trade_id):
"""Delete trade from the database.
Tries to close open orders. Requires manual handling of this asset on the exchange.
:param trade_id: Deletes the trade with this ID from the database.
:return: json object
"""
return self._delete("trades/{}".format(trade_id))
def whitelist(self): def whitelist(self):
"""Show the current whitelist. """Show the current whitelist.

View File

@ -8,7 +8,7 @@ import pytest
from numpy import isnan from numpy import isnan
from freqtrade.edge import PairInfo from freqtrade.edge import PairInfo
from freqtrade.exceptions import ExchangeError, TemporaryError from freqtrade.exceptions import ExchangeError, InvalidOrderException, TemporaryError
from freqtrade.persistence import Trade from freqtrade.persistence import Trade
from freqtrade.rpc import RPC, RPCException from freqtrade.rpc import RPC, RPCException
from freqtrade.rpc.fiat_convert import CryptoToFiatConverter from freqtrade.rpc.fiat_convert import CryptoToFiatConverter
@ -291,6 +291,61 @@ def test_rpc_trade_history(mocker, default_conf, markets, fee):
assert trades['trades'][0]['pair'] == 'XRP/BTC' assert trades['trades'][0]['pair'] == 'XRP/BTC'
def test_rpc_delete_trade(mocker, default_conf, fee, markets, caplog):
mocker.patch('freqtrade.rpc.telegram.Telegram', MagicMock())
stoploss_mock = MagicMock()
cancel_mock = MagicMock()
mocker.patch.multiple(
'freqtrade.exchange.Exchange',
markets=PropertyMock(return_value=markets),
cancel_order=cancel_mock,
cancel_stoploss_order=stoploss_mock,
)
freqtradebot = get_patched_freqtradebot(mocker, default_conf)
freqtradebot.strategy.order_types['stoploss_on_exchange'] = True
create_mock_trades(fee)
rpc = RPC(freqtradebot)
with pytest.raises(RPCException, match='invalid argument'):
rpc._rpc_delete('200')
create_mock_trades(fee)
trades = Trade.query.all()
trades[1].stoploss_order_id = '1234'
trades[2].stoploss_order_id = '1234'
assert len(trades) > 2
res = rpc._rpc_delete('1')
assert isinstance(res, dict)
assert res['result'] == 'success'
assert res['trade_id'] == '1'
assert res['cancel_order_count'] == 1
assert cancel_mock.call_count == 1
assert stoploss_mock.call_count == 0
cancel_mock.reset_mock()
stoploss_mock.reset_mock()
res = rpc._rpc_delete('2')
assert isinstance(res, dict)
assert cancel_mock.call_count == 1
assert stoploss_mock.call_count == 1
assert res['cancel_order_count'] == 2
stoploss_mock = mocker.patch('freqtrade.exchange.Exchange.cancel_stoploss_order',
side_effect=InvalidOrderException)
res = rpc._rpc_delete('3')
assert stoploss_mock.call_count == 1
stoploss_mock.reset_mock()
cancel_mock = mocker.patch('freqtrade.exchange.Exchange.cancel_order',
side_effect=InvalidOrderException)
res = rpc._rpc_delete('4')
assert cancel_mock.call_count == 1
assert stoploss_mock.call_count == 0
def test_rpc_trade_statistics(default_conf, ticker, ticker_sell_up, fee, def test_rpc_trade_statistics(default_conf, ticker, ticker_sell_up, fee,
limit_buy_order, limit_sell_order, mocker) -> None: limit_buy_order, limit_sell_order, mocker) -> None:
mocker.patch.multiple( mocker.patch.multiple(

View File

@ -50,6 +50,12 @@ def client_get(client, url):
'Origin': 'http://example.com'}) 'Origin': 'http://example.com'})
def client_delete(client, url):
# Add fake Origin to ensure CORS kicks in
return client.delete(url, headers={'Authorization': _basic_auth_str(_TEST_USER, _TEST_PASS),
'Origin': 'http://example.com'})
def assert_response(response, expected_code=200, needs_cors=True): def assert_response(response, expected_code=200, needs_cors=True):
assert response.status_code == expected_code assert response.status_code == expected_code
assert response.content_type == "application/json" assert response.content_type == "application/json"
@ -352,7 +358,7 @@ def test_api_daily(botclient, mocker, ticker, fee, markets):
assert rc.json['data'][0]['date'] == str(datetime.utcnow().date()) assert rc.json['data'][0]['date'] == str(datetime.utcnow().date())
def test_api_trades(botclient, mocker, ticker, fee, markets): def test_api_trades(botclient, mocker, fee, markets):
ftbot, client = botclient ftbot, client = botclient
patch_get_signal(ftbot, (True, False)) patch_get_signal(ftbot, (True, False))
mocker.patch.multiple( mocker.patch.multiple(
@ -376,6 +382,47 @@ def test_api_trades(botclient, mocker, ticker, fee, markets):
assert rc.json['trades_count'] == 1 assert rc.json['trades_count'] == 1
def test_api_delete_trade(botclient, mocker, fee, markets):
ftbot, client = botclient
patch_get_signal(ftbot, (True, False))
stoploss_mock = MagicMock()
cancel_mock = MagicMock()
mocker.patch.multiple(
'freqtrade.exchange.Exchange',
markets=PropertyMock(return_value=markets),
cancel_order=cancel_mock,
cancel_stoploss_order=stoploss_mock,
)
rc = client_delete(client, f"{BASE_URI}/trades/1")
# Error - trade won't exist yet.
assert_response(rc, 502)
create_mock_trades(fee)
ftbot.strategy.order_types['stoploss_on_exchange'] = True
trades = Trade.query.all()
trades[1].stoploss_order_id = '1234'
assert len(trades) > 2
rc = client_delete(client, f"{BASE_URI}/trades/1")
assert_response(rc)
assert rc.json['result_msg'] == 'Deleted trade 1. Closed 1 open orders.'
assert len(trades) - 1 == len(Trade.query.all())
assert cancel_mock.call_count == 1
cancel_mock.reset_mock()
rc = client_delete(client, f"{BASE_URI}/trades/1")
# Trade is gone now.
assert_response(rc, 502)
assert cancel_mock.call_count == 0
assert len(trades) - 1 == len(Trade.query.all())
rc = client_delete(client, f"{BASE_URI}/trades/2")
assert_response(rc)
assert rc.json['result_msg'] == 'Deleted trade 2. Closed 2 open orders.'
assert len(trades) - 2 == len(Trade.query.all())
assert stoploss_mock.call_count == 1
def test_api_edge_disabled(botclient, mocker, ticker, fee, markets): def test_api_edge_disabled(botclient, mocker, ticker, fee, markets):
ftbot, client = botclient ftbot, client = botclient
patch_get_signal(ftbot, (True, False)) patch_get_signal(ftbot, (True, False))

View File

@ -74,9 +74,9 @@ def test_telegram_init(default_conf, mocker, caplog) -> None:
message_str = ("rpc.telegram is listening for following commands: [['status'], ['profit'], " message_str = ("rpc.telegram is listening for following commands: [['status'], ['profit'], "
"['balance'], ['start'], ['stop'], ['forcesell'], ['forcebuy'], ['trades'], " "['balance'], ['start'], ['stop'], ['forcesell'], ['forcebuy'], ['trades'], "
"['performance'], ['daily'], ['count'], ['reload_config', 'reload_conf'], " "['delete'], ['performance'], ['daily'], ['count'], ['reload_config', "
"['show_config', 'show_conf'], ['stopbuy'], ['whitelist'], ['blacklist'], " "'reload_conf'], ['show_config', 'show_conf'], ['stopbuy'], "
"['edge'], ['help'], ['version']]") "['whitelist'], ['blacklist'], ['edge'], ['help'], ['version']]")
assert log_has(message_str, caplog) assert log_has(message_str, caplog)
@ -1177,6 +1177,33 @@ def test_telegram_trades(mocker, update, default_conf, fee):
assert "<pre>" in msg_mock.call_args_list[0][0][0] assert "<pre>" in msg_mock.call_args_list[0][0][0]
def test_telegram_delete_trade(mocker, update, default_conf, fee):
msg_mock = MagicMock()
mocker.patch.multiple(
'freqtrade.rpc.telegram.Telegram',
_init=MagicMock(),
_send_msg=msg_mock
)
freqtradebot = get_patched_freqtradebot(mocker, default_conf)
telegram = Telegram(freqtradebot)
context = MagicMock()
context.args = []
telegram._delete_trade(update=update, context=context)
assert "invalid argument" in msg_mock.call_args_list[0][0][0]
msg_mock.reset_mock()
create_mock_trades(fee)
context = MagicMock()
context.args = [1]
telegram._delete_trade(update=update, context=context)
msg_mock.call_count == 1
assert "Deleted trade 1." in msg_mock.call_args_list[0][0][0]
assert "Please make sure to take care of this asset" in msg_mock.call_args_list[0][0][0]
def test_help_handle(default_conf, update, mocker) -> None: def test_help_handle(default_conf, update, mocker) -> None:
msg_mock = MagicMock() msg_mock = MagicMock()
mocker.patch.multiple( mocker.patch.multiple(