Merge pull request #3799 from imxuwang/issue3783

Introduce Telegram /stats endpoint
This commit is contained in:
Matthias 2020-12-10 15:41:09 +01:00 committed by GitHub
commit 76594d5dde
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
9 changed files with 180 additions and 2 deletions

View File

@ -127,6 +127,7 @@ python3 scripts/rest_client.py --config rest_config.json <command> [optional par
| `performance` | Show performance of each finished trade grouped by pair. | `performance` | Show performance of each finished trade grouped by pair.
| `balance` | Show account balance per currency. | `balance` | Show account balance per currency.
| `daily <n>` | Shows profit or loss per day, over the last n days (n defaults to 7). | `daily <n>` | Shows profit or loss per day, over the last n days (n defaults to 7).
| `stats` | Display a summary of profit / loss reasons as well as average holding times.
| `whitelist` | Show the current whitelist. | `whitelist` | Show the current whitelist.
| `blacklist [pair]` | Show the current blacklist, or adds a pair to the blacklist. | `blacklist [pair]` | Show the current blacklist, or adds a pair to the blacklist.
| `edge` | Show validated pairs by Edge if it is enabled. | `edge` | Show validated pairs by Edge if it is enabled.
@ -229,6 +230,9 @@ show_config
start start
Start the bot if it's in the stopped state. Start the bot if it's in the stopped state.
stats
Return the stats report (durations, sell-reasons).
status status
Get the status of open trades. Get the status of open trades.

View File

@ -113,6 +113,7 @@ official commands. You can ask at any moment for help with `/help`.
| `/performance` | Show performance of each finished trade grouped by pair | `/performance` | Show performance of each finished trade grouped by pair
| `/balance` | Show account balance per currency | `/balance` | Show account balance per currency
| `/daily <n>` | Shows profit or loss per day, over the last n days (n defaults to 7) | `/daily <n>` | Shows profit or loss per day, over the last n days (n defaults to 7)
| `/stats` | Shows Wins / losses by Sell reason as well as Avg. holding durations for buys and sells
| `/whitelist` | Show the current whitelist | `/whitelist` | Show the current whitelist
| `/blacklist [pair]` | Show the current blacklist, or adds a pair to the blacklist. | `/blacklist [pair]` | Show the current blacklist, or adds a pair to the blacklist.
| `/edge` | Show validated pairs by Edge if it is enabled. | `/edge` | Show validated pairs by Edge if it is enabled.

View File

@ -198,6 +198,8 @@ class ApiServer(RPC):
self.app.add_url_rule(f'{BASE_URI}/logs', 'log', view_func=self._get_logs, methods=['GET']) self.app.add_url_rule(f'{BASE_URI}/logs', 'log', view_func=self._get_logs, methods=['GET'])
self.app.add_url_rule(f'{BASE_URI}/profit', 'profit', self.app.add_url_rule(f'{BASE_URI}/profit', 'profit',
view_func=self._profit, methods=['GET']) view_func=self._profit, methods=['GET'])
self.app.add_url_rule(f'{BASE_URI}/stats', 'stats',
view_func=self._stats, methods=['GET'])
self.app.add_url_rule(f'{BASE_URI}/performance', 'performance', self.app.add_url_rule(f'{BASE_URI}/performance', 'performance',
view_func=self._performance, methods=['GET']) view_func=self._performance, methods=['GET'])
self.app.add_url_rule(f'{BASE_URI}/status', 'status', self.app.add_url_rule(f'{BASE_URI}/status', 'status',
@ -417,6 +419,18 @@ class ApiServer(RPC):
return jsonify(stats) return jsonify(stats)
@require_login
@rpc_catch_errors
def _stats(self):
"""
Handler for /stats.
Returns a Object with "durations" and "sell_reasons" as keys.
"""
stats = self._rpc_stats()
return jsonify(stats)
@require_login @require_login
@rpc_catch_errors @rpc_catch_errors
def _performance(self): def _performance(self):

View File

@ -275,6 +275,39 @@ class RPC:
"trades_count": len(output) "trades_count": len(output)
} }
def _rpc_stats(self) -> Dict[str, Any]:
"""
Generate generic stats for trades in database
"""
def trade_win_loss(trade):
if trade.close_profit > 0:
return 'wins'
elif trade.close_profit < 0:
return 'losses'
else:
return 'draws'
trades = trades = Trade.get_trades([Trade.is_open.is_(False)])
# Sell reason
sell_reasons = {}
for trade in trades:
if trade.sell_reason not in sell_reasons:
sell_reasons[trade.sell_reason] = {'wins': 0, 'losses': 0, 'draws': 0}
sell_reasons[trade.sell_reason][trade_win_loss(trade)] += 1
# Duration
dur: Dict[str, List[int]] = {'wins': [], 'draws': [], 'losses': []}
for trade in trades:
if trade.close_date is not None and trade.open_date is not None:
trade_dur = (trade.close_date - trade.open_date).total_seconds()
dur[trade_win_loss(trade)].append(trade_dur)
wins_dur = sum(dur['wins']) / len(dur['wins']) if len(dur['wins']) > 0 else 'N/A'
draws_dur = sum(dur['draws']) / len(dur['draws']) if len(dur['draws']) > 0 else 'N/A'
losses_dur = sum(dur['losses']) / len(dur['losses']) if len(dur['losses']) > 0 else 'N/A'
durations = {'wins': wins_dur, 'draws': draws_dur, 'losses': losses_dur}
return {'sell_reasons': sell_reasons, 'durations': durations}
def _rpc_trade_statistics( def _rpc_trade_statistics(
self, stake_currency: str, fiat_display_currency: str) -> Dict[str, Any]: self, stake_currency: str, fiat_display_currency: str) -> Dict[str, Any]:
""" Returns cumulative profit statistics """ """ Returns cumulative profit statistics """

View File

@ -5,6 +5,7 @@ This module manage Telegram communication
""" """
import json import json
import logging import logging
from datetime import timedelta
from typing import Any, Callable, Dict, List, Union from typing import Any, Callable, Dict, List, Union
import arrow import arrow
@ -98,6 +99,7 @@ class Telegram(RPC):
CommandHandler('trades', self._trades), CommandHandler('trades', self._trades),
CommandHandler('delete', self._delete_trade), CommandHandler('delete', self._delete_trade),
CommandHandler('performance', self._performance), CommandHandler('performance', self._performance),
CommandHandler('stats', self._stats),
CommandHandler('daily', self._daily), CommandHandler('daily', self._daily),
CommandHandler('count', self._count), CommandHandler('count', self._count),
CommandHandler('locks', self._locks), CommandHandler('locks', self._locks),
@ -388,6 +390,48 @@ class Telegram(RPC):
f"*Best Performing:* `{best_pair}: {best_rate:.2f}%`") f"*Best Performing:* `{best_pair}: {best_rate:.2f}%`")
self._send_msg(markdown_msg) self._send_msg(markdown_msg)
@authorized_only
def _stats(self, update: Update, context: CallbackContext) -> None:
"""
Handler for /stats
Show stats of recent trades
"""
stats = self._rpc_stats()
reason_map = {
'roi': 'ROI',
'stop_loss': 'Stoploss',
'trailing_stop_loss': 'Trail. Stop',
'stoploss_on_exchange': 'Stoploss',
'sell_signal': 'Sell Signal',
'force_sell': 'Forcesell',
'emergency_sell': 'Emergency Sell',
}
sell_reasons_tabulate = [
[
reason_map.get(reason, reason),
sum(count.values()),
count['wins'],
count['losses']
] for reason, count in stats['sell_reasons'].items()
]
sell_reasons_msg = tabulate(
sell_reasons_tabulate,
headers=['Sell Reason', 'Sells', 'Wins', 'Losses']
)
durations = stats['durations']
duration_msg = tabulate([
['Wins', str(timedelta(seconds=durations['wins']))
if durations['wins'] != 'N/A' else 'N/A'],
['Losses', str(timedelta(seconds=durations['losses']))
if durations['losses'] != 'N/A' else 'N/A']
],
headers=['', 'Avg. Duration']
)
msg = (f"""```\n{sell_reasons_msg}```\n```\n{duration_msg}```""")
self._send_msg(msg, ParseMode.MARKDOWN)
@authorized_only @authorized_only
def _balance(self, update: Update, context: CallbackContext) -> None: def _balance(self, update: Update, context: CallbackContext) -> None:
""" Handler for /balance """ """ Handler for /balance """
@ -743,6 +787,8 @@ class Telegram(RPC):
"*/delete <trade_id>:* `Instantly delete the given trade in the database`\n" "*/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"
"*/stats:* `Shows Wins / losses by Sell reason as well as "
"Avg. holding durationsfor buys and sells.`\n"
"*/count:* `Show number of active trades compared to allowed number of trades`\n" "*/count:* `Show number of active trades compared to allowed number of trades`\n"
"*/locks:* `Show currently locked pairs`\n" "*/locks:* `Show currently locked pairs`\n"
"*/balance:* `Show account balance per currency`\n" "*/balance:* `Show account balance per currency`\n"

View File

@ -139,6 +139,13 @@ class FtRestClient():
""" """
return self._get("profit") return self._get("profit")
def stats(self):
"""Return the stats report (durations, sell-reasons).
:return: json object
"""
return self._get("stats")
def performance(self): def performance(self):
"""Return the performance of the different coins. """Return the performance of the different coins.

View File

@ -1,3 +1,5 @@
from datetime import datetime, timedelta, timezone
from freqtrade.persistence.models import Order, Trade from freqtrade.persistence.models import Order, Trade
@ -82,6 +84,9 @@ def mock_trade_2(fee):
is_open=False, is_open=False,
open_order_id='dry_run_sell_12345', open_order_id='dry_run_sell_12345',
strategy='DefaultStrategy', strategy='DefaultStrategy',
sell_reason='sell_signal',
open_date=datetime.now(tz=timezone.utc) - timedelta(minutes=20),
close_date=datetime.now(tz=timezone.utc),
) )
o = Order.parse_from_ccxt_object(mock_order_2(), 'ETC/BTC', 'buy') o = Order.parse_from_ccxt_object(mock_order_2(), 'ETC/BTC', 'buy')
trade.orders.append(o) trade.orders.append(o)
@ -134,6 +139,9 @@ def mock_trade_3(fee):
close_profit=0.01, close_profit=0.01,
exchange='bittrex', exchange='bittrex',
is_open=False, is_open=False,
sell_reason='roi',
open_date=datetime.now(tz=timezone.utc) - timedelta(minutes=20),
close_date=datetime.now(tz=timezone.utc),
) )
o = Order.parse_from_ccxt_object(mock_order_3(), 'XRP/BTC', 'buy') o = Order.parse_from_ccxt_object(mock_order_3(), 'XRP/BTC', 'buy')
trade.orders.append(o) trade.orders.append(o)

View File

@ -559,6 +559,35 @@ def test_api_profit(botclient, mocker, ticker, fee, markets, limit_buy_order, li
} }
@pytest.mark.usefixtures("init_persistence")
def test_api_stats(botclient, mocker, ticker, fee, markets,):
ftbot, client = botclient
patch_get_signal(ftbot, (True, False))
mocker.patch.multiple(
'freqtrade.exchange.Exchange',
get_balances=MagicMock(return_value=ticker),
fetch_ticker=ticker,
get_fee=fee,
markets=PropertyMock(return_value=markets)
)
rc = client_get(client, f"{BASE_URI}/stats")
assert_response(rc, 200)
assert 'durations' in rc.json
assert 'sell_reasons' in rc.json
create_mock_trades(fee)
rc = client_get(client, f"{BASE_URI}/stats")
assert_response(rc, 200)
assert 'durations' in rc.json
assert 'sell_reasons' in rc.json
assert 'wins' in rc.json['durations']
assert 'losses' in rc.json['durations']
assert 'draws' in rc.json['durations']
def test_api_performance(botclient, mocker, ticker, fee): def test_api_performance(botclient, mocker, ticker, fee):
ftbot, client = botclient ftbot, client = botclient
patch_get_signal(ftbot, (True, False)) patch_get_signal(ftbot, (True, False))

View File

@ -74,9 +74,10 @@ 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'], "
"['delete'], ['performance'], ['daily'], ['count'], ['locks'], " "['delete'], ['performance'], ['stats'], ['daily'], ['count'], ['locks'], "
"['reload_config', 'reload_conf'], ['show_config', 'show_conf'], ['stopbuy'], " "['reload_config', 'reload_conf'], ['show_config', 'show_conf'], ['stopbuy'], "
"['whitelist'], ['blacklist'], ['logs'], ['edge'], ['help'], ['version']]") "['whitelist'], ['blacklist'], ['logs'], ['edge'], ['help'], ['version']"
"]")
assert log_has(message_str, caplog) assert log_has(message_str, caplog)
@ -468,6 +469,41 @@ def test_profit_handle(default_conf, update, ticker, ticker_sell_up, fee,
assert '*Best Performing:* `ETH/BTC: 6.20%`' in msg_mock.call_args_list[-1][0][0] assert '*Best Performing:* `ETH/BTC: 6.20%`' in msg_mock.call_args_list[-1][0][0]
def test_telegram_stats(default_conf, update, ticker, ticker_sell_up, fee,
limit_buy_order, limit_sell_order, mocker) -> None:
mocker.patch('freqtrade.rpc.rpc.CryptoToFiatConverter._find_price', return_value=15000.0)
mocker.patch.multiple(
'freqtrade.exchange.Exchange',
fetch_ticker=ticker,
get_fee=fee,
)
msg_mock = MagicMock()
mocker.patch.multiple(
'freqtrade.rpc.telegram.Telegram',
_init=MagicMock(),
_send_msg=msg_mock
)
freqtradebot = get_patched_freqtradebot(mocker, default_conf)
patch_get_signal(freqtradebot, (True, False))
telegram = Telegram(freqtradebot)
telegram._stats(update=update, context=MagicMock())
assert msg_mock.call_count == 1
# assert 'No trades yet.' in msg_mock.call_args_list[0][0][0]
msg_mock.reset_mock()
# Create some test data
create_mock_trades(fee)
telegram._stats(update=update, context=MagicMock())
assert msg_mock.call_count == 1
assert 'Sell Reason' in msg_mock.call_args_list[-1][0][0]
assert 'ROI' in msg_mock.call_args_list[-1][0][0]
assert 'Avg. Duration' in msg_mock.call_args_list[-1][0][0]
msg_mock.reset_mock()
def test_telegram_balance_handle(default_conf, update, mocker, rpc_balance, tickers) -> None: def test_telegram_balance_handle(default_conf, update, mocker, rpc_balance, tickers) -> None:
default_conf['dry_run'] = False default_conf['dry_run'] = False
mocker.patch('freqtrade.exchange.Exchange.get_balances', return_value=rpc_balance) mocker.patch('freqtrade.exchange.Exchange.get_balances', return_value=rpc_balance)