diff --git a/docs/rest-api.md b/docs/rest-api.md index 7726ab875..9bb35ce91 100644 --- a/docs/rest-api.md +++ b/docs/rest-api.md @@ -127,6 +127,7 @@ python3 scripts/rest_client.py --config rest_config.json [optional par | `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). +| `stats` | Display a summary of profit / loss reasons as well as average holding times. | `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. @@ -229,6 +230,9 @@ show_config start Start the bot if it's in the stopped state. +stats + Return the stats report (durations, sell-reasons). + status Get the status of open trades. diff --git a/docs/telegram-usage.md b/docs/telegram-usage.md index f4bd0a12a..c940f59ac 100644 --- a/docs/telegram-usage.md +++ b/docs/telegram-usage.md @@ -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 | `/balance` | Show account balance per currency | `/daily ` | 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 | `/blacklist [pair]` | Show the current blacklist, or adds a pair to the blacklist. | `/edge` | Show validated pairs by Edge if it is enabled. diff --git a/freqtrade/rpc/api_server.py b/freqtrade/rpc/api_server.py index e8eaef933..d9bf4d14a 100644 --- a/freqtrade/rpc/api_server.py +++ b/freqtrade/rpc/api_server.py @@ -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}/profit', 'profit', 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', view_func=self._performance, methods=['GET']) self.app.add_url_rule(f'{BASE_URI}/status', 'status', @@ -417,6 +419,18 @@ class ApiServer(RPC): 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 @rpc_catch_errors def _performance(self): diff --git a/freqtrade/rpc/rpc.py b/freqtrade/rpc/rpc.py index 42f26fe74..2bc989e80 100644 --- a/freqtrade/rpc/rpc.py +++ b/freqtrade/rpc/rpc.py @@ -275,6 +275,39 @@ class RPC: "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( self, stake_currency: str, fiat_display_currency: str) -> Dict[str, Any]: """ Returns cumulative profit statistics """ diff --git a/freqtrade/rpc/telegram.py b/freqtrade/rpc/telegram.py index 26e87e654..1d36b7e4d 100644 --- a/freqtrade/rpc/telegram.py +++ b/freqtrade/rpc/telegram.py @@ -5,6 +5,7 @@ This module manage Telegram communication """ import json import logging +from datetime import timedelta from typing import Any, Callable, Dict, List, Union import arrow @@ -98,6 +99,7 @@ class Telegram(RPC): CommandHandler('trades', self._trades), CommandHandler('delete', self._delete_trade), CommandHandler('performance', self._performance), + CommandHandler('stats', self._stats), CommandHandler('daily', self._daily), CommandHandler('count', self._count), CommandHandler('locks', self._locks), @@ -388,6 +390,48 @@ class Telegram(RPC): f"*Best Performing:* `{best_pair}: {best_rate:.2f}%`") 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 def _balance(self, update: Update, context: CallbackContext) -> None: """ Handler for /balance """ @@ -743,6 +787,8 @@ class Telegram(RPC): "*/delete :* `Instantly delete the given trade in the database`\n" "*/performance:* `Show performance of each finished trade grouped by pair`\n" "*/daily :* `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" "*/locks:* `Show currently locked pairs`\n" "*/balance:* `Show account balance per currency`\n" diff --git a/scripts/rest_client.py b/scripts/rest_client.py index 268e81397..2232b8421 100755 --- a/scripts/rest_client.py +++ b/scripts/rest_client.py @@ -139,6 +139,13 @@ class FtRestClient(): """ return self._get("profit") + def stats(self): + """Return the stats report (durations, sell-reasons). + + :return: json object + """ + return self._get("stats") + def performance(self): """Return the performance of the different coins. diff --git a/tests/conftest_trades.py b/tests/conftest_trades.py index 78388f022..e84722041 100644 --- a/tests/conftest_trades.py +++ b/tests/conftest_trades.py @@ -1,3 +1,5 @@ +from datetime import datetime, timedelta, timezone + from freqtrade.persistence.models import Order, Trade @@ -82,6 +84,9 @@ def mock_trade_2(fee): is_open=False, open_order_id='dry_run_sell_12345', 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') trade.orders.append(o) @@ -134,6 +139,9 @@ def mock_trade_3(fee): close_profit=0.01, exchange='bittrex', 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') trade.orders.append(o) diff --git a/tests/rpc/test_rpc_apiserver.py b/tests/rpc/test_rpc_apiserver.py index 0dc43474f..2daa32bc7 100644 --- a/tests/rpc/test_rpc_apiserver.py +++ b/tests/rpc/test_rpc_apiserver.py @@ -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): ftbot, client = botclient patch_get_signal(ftbot, (True, False)) diff --git a/tests/rpc/test_rpc_telegram.py b/tests/rpc/test_rpc_telegram.py index 72d263bff..ecad05683 100644 --- a/tests/rpc/test_rpc_telegram.py +++ b/tests/rpc/test_rpc_telegram.py @@ -74,9 +74,10 @@ def test_telegram_init(default_conf, mocker, caplog) -> None: message_str = ("rpc.telegram is listening for following commands: [['status'], ['profit'], " "['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'], " - "['whitelist'], ['blacklist'], ['logs'], ['edge'], ['help'], ['version']]") + "['whitelist'], ['blacklist'], ['logs'], ['edge'], ['help'], ['version']" + "]") 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] +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: default_conf['dry_run'] = False mocker.patch('freqtrade.exchange.Exchange.get_balances', return_value=rpc_balance)