Merge pull request #3799 from imxuwang/issue3783
Introduce Telegram /stats endpoint
This commit is contained in:
commit
76594d5dde
@ -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.
|
||||||
|
|
||||||
|
@ -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.
|
||||||
|
@ -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):
|
||||||
|
@ -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 """
|
||||||
|
@ -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"
|
||||||
|
@ -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.
|
||||||
|
|
||||||
|
@ -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)
|
||||||
|
@ -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))
|
||||||
|
@ -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)
|
||||||
|
Loading…
Reference in New Issue
Block a user