From 9f6aedea47b9d15f28e5aa30c050626a5ea41466 Mon Sep 17 00:00:00 2001 From: kryofly <34599184+kryofly@users.noreply.github.com> Date: Thu, 1 Feb 2018 07:05:23 +0100 Subject: [PATCH] telegram refactor 1/ (#389) * telegram refactor 1/ move out freqcode from telegram * telegram refactor 2/ move out rpc_trade_status * telegram refactor 3/ move out rpc_daily_profit * telegram refactor /4 move out rpc_trade_statistics * 5/ * rpc refactor 6/ * rpc refactor 7/ * rpc refactor 8/ * rpc refactor 9/ * rpc refactor 10/ cleanups two tests are broken * fiat * rpc: Add back fiat singleton usage * test: rpc_trade_statistics Test that rpc_trade_statistics can handle trades that lacks trade.open_rate (it is set to None) * test: rpc_forcesell Also some cleanups * test: telegram.py::init * test: telegram test_cleanup and test_status * test rcp cleanup --- freqtrade/rpc/__init__.py | 375 +++++++++++++++++++++- freqtrade/rpc/telegram.py | 386 +++++------------------ freqtrade/tests/conftest.py | 7 + freqtrade/tests/rpc/test_rpc.py | 353 +++++++++++++++++++++ freqtrade/tests/rpc/test_rpc_telegram.py | 95 ++++-- 5 files changed, 877 insertions(+), 339 deletions(-) diff --git a/freqtrade/rpc/__init__.py b/freqtrade/rpc/__init__.py index cd7523f78..163e0a8aa 100644 --- a/freqtrade/rpc/__init__.py +++ b/freqtrade/rpc/__init__.py @@ -1,10 +1,21 @@ import logging +import re +import arrow +from decimal import Decimal +from datetime import datetime, timedelta +from pandas import DataFrame +import sqlalchemy as sql +# from sqlalchemy import and_, func, text +from freqtrade.persistence import Trade +from freqtrade.misc import State, get_state, update_state +from freqtrade import exchange +from freqtrade.fiat_convert import CryptoToFiatConverter from . import telegram logger = logging.getLogger(__name__) - +_FIAT_CONVERT = CryptoToFiatConverter() REGISTERED_MODULES = [] @@ -40,3 +51,365 @@ def send_msg(msg: str) -> None: logger.info(msg) if 'telegram' in REGISTERED_MODULES: telegram.send_msg(msg) + + +def shorten_date(_date): + """ + Trim the date so it fits on small screens + """ + new_date = re.sub('seconds?', 'sec', _date) + new_date = re.sub('minutes?', 'min', new_date) + new_date = re.sub('hours?', 'h', new_date) + new_date = re.sub('days?', 'd', new_date) + new_date = re.sub('^an?', '1', new_date) + return new_date + + +# +# Below follows the RPC backend +# it is prefixed with rpc_ +# to raise awareness that it is +# a remotely exposed function + + +def rpc_trade_status(): + # Fetch open trade + trades = Trade.query.filter(Trade.is_open.is_(True)).all() + if get_state() != State.RUNNING: + return (True, '*Status:* `trader is not running`') + elif not trades: + return (True, '*Status:* `no active trade`') + else: + result = [] + for trade in trades: + order = None + if trade.open_order_id: + order = exchange.get_order(trade.open_order_id) + # calculate profit and send message to user + current_rate = exchange.get_ticker(trade.pair, False)['bid'] + current_profit = trade.calc_profit_percent(current_rate) + fmt_close_profit = '{:.2f}%'.format( + round(trade.close_profit * 100, 2) + ) if trade.close_profit else None + message = """ +*Trade ID:* `{trade_id}` +*Current Pair:* [{pair}]({market_url}) +*Open Since:* `{date}` +*Amount:* `{amount}` +*Open Rate:* `{open_rate:.8f}` +*Close Rate:* `{close_rate}` +*Current Rate:* `{current_rate:.8f}` +*Close Profit:* `{close_profit}` +*Current Profit:* `{current_profit:.2f}%` +*Open Order:* `{open_order}` + """.format( + trade_id=trade.id, + pair=trade.pair, + market_url=exchange.get_pair_detail_url(trade.pair), + date=arrow.get(trade.open_date).humanize(), + open_rate=trade.open_rate, + close_rate=trade.close_rate, + current_rate=current_rate, + amount=round(trade.amount, 8), + close_profit=fmt_close_profit, + current_profit=round(current_profit * 100, 2), + open_order='({} rem={:.8f})'.format( + order['type'], order['remaining'] + ) if order else None, + ) + result.append(message) + return (False, result) + + +def rpc_status_table(): + trades = Trade.query.filter(Trade.is_open.is_(True)).all() + if get_state() != State.RUNNING: + return (True, '*Status:* `trader is not running`') + elif not trades: + return (True, '*Status:* `no active order`') + else: + trades_list = [] + for trade in trades: + # calculate profit and send message to user + current_rate = exchange.get_ticker(trade.pair, False)['bid'] + trades_list.append([ + trade.id, + trade.pair, + shorten_date(arrow.get(trade.open_date).humanize(only_distance=True)), + '{:.2f}%'.format(100 * trade.calc_profit_percent(current_rate)) + ]) + + columns = ['ID', 'Pair', 'Since', 'Profit'] + df_statuses = DataFrame.from_records(trades_list, columns=columns) + df_statuses = df_statuses.set_index(columns[0]) + # The style used throughout is to return a tuple + # consisting of (error_occured?, result) + # Another approach would be to just return the + # result, or raise error + return (False, df_statuses) + + +def rpc_daily_profit(timescale, stake_currency, fiat_display_currency): + today = datetime.utcnow().date() + profit_days = {} + + if not (isinstance(timescale, int) and timescale > 0): + return (True, '*Daily [n]:* `must be an integer greater than 0`') + + fiat = _FIAT_CONVERT + for day in range(0, timescale): + profitday = today - timedelta(days=day) + trades = Trade.query \ + .filter(Trade.is_open.is_(False)) \ + .filter(Trade.close_date >= profitday)\ + .filter(Trade.close_date < (profitday + timedelta(days=1)))\ + .order_by(Trade.close_date)\ + .all() + curdayprofit = sum(trade.calc_profit() for trade in trades) + profit_days[profitday] = { + 'amount': format(curdayprofit, '.8f'), + 'trades': len(trades) + } + + stats = [ + [ + key, + '{value:.8f} {symbol}'.format( + value=float(value['amount']), + symbol=stake_currency + ), + '{value:.3f} {symbol}'.format( + value=fiat.convert_amount( + value['amount'], + stake_currency, + fiat_display_currency + ), + symbol=fiat_display_currency + ), + '{value} trade{s}'.format(value=value['trades'], s='' if value['trades'] < 2 else 's'), + ] + for key, value in profit_days.items() + ] + return (False, stats) + + +def rpc_trade_statistics(stake_currency, fiat_display_currency) -> None: + """ + :return: cumulative profit statistics. + """ + trades = Trade.query.order_by(Trade.id).all() + + profit_all_coin = [] + profit_all_percent = [] + profit_closed_coin = [] + profit_closed_percent = [] + durations = [] + + for trade in trades: + current_rate = None + + if not trade.open_rate: + continue + if trade.close_date: + durations.append((trade.close_date - trade.open_date).total_seconds()) + + if not trade.is_open: + profit_percent = trade.calc_profit_percent() + profit_closed_coin.append(trade.calc_profit()) + profit_closed_percent.append(profit_percent) + else: + # Get current rate + current_rate = exchange.get_ticker(trade.pair, False)['bid'] + profit_percent = trade.calc_profit_percent(rate=current_rate) + + profit_all_coin.append(trade.calc_profit(rate=Decimal(trade.close_rate or current_rate))) + profit_all_percent.append(profit_percent) + + best_pair = Trade.session.query(Trade.pair, + sql.func.sum(Trade.close_profit).label('profit_sum')) \ + .filter(Trade.is_open.is_(False)) \ + .group_by(Trade.pair) \ + .order_by(sql.text('profit_sum DESC')) \ + .first() + + if not best_pair: + return (True, '*Status:* `no closed trade`') + + bp_pair, bp_rate = best_pair + + # FIX: we want to keep fiatconverter in a state/environment, + # doing this will utilize its caching functionallity, instead we reinitialize it here + fiat = _FIAT_CONVERT + # Prepare data to display + profit_closed_coin = round(sum(profit_closed_coin), 8) + profit_closed_percent = round(sum(profit_closed_percent) * 100, 2) + profit_closed_fiat = fiat.convert_amount( + profit_closed_coin, + stake_currency, + fiat_display_currency + ) + profit_all_coin = round(sum(profit_all_coin), 8) + profit_all_percent = round(sum(profit_all_percent) * 100, 2) + profit_all_fiat = fiat.convert_amount( + profit_all_coin, + stake_currency, + fiat_display_currency + ) + num = float(len(durations) or 1) + return (False, + {'profit_closed_coin': profit_closed_coin, + 'profit_closed_percent': profit_closed_percent, + 'profit_closed_fiat': profit_closed_fiat, + 'profit_all_coin': profit_all_coin, + 'profit_all_percent': profit_all_percent, + 'profit_all_fiat': profit_all_fiat, + 'trade_count': len(trades), + 'first_trade_date': arrow.get(trades[0].open_date).humanize(), + 'latest_trade_date': arrow.get(trades[-1].open_date).humanize(), + 'avg_duration': str(timedelta(seconds=sum(durations) / + num)).split('.')[0], + 'best_pair': bp_pair, + 'best_rate': round(bp_rate * 100, 2) + }) + + +def rpc_balance(fiat_display_currency): + """ + :return: current account balance per crypto + """ + balances = [ + c for c in exchange.get_balances() + if c['Balance'] or c['Available'] or c['Pending'] + ] + if not balances: + return (True, '`All balances are zero.`') + + output = [] + total = 0.0 + for currency in balances: + coin = currency['Currency'] + if coin == 'BTC': + currency["Rate"] = 1.0 + else: + if coin == 'USDT': + currency["Rate"] = 1.0 / exchange.get_ticker('USDT_BTC', False)['bid'] + else: + currency["Rate"] = exchange.get_ticker('BTC_' + coin, False)['bid'] + currency['BTC'] = currency["Rate"] * currency["Balance"] + total = total + currency['BTC'] + output.append({'currency': currency['Currency'], + 'available': currency['Available'], + 'balance': currency['Balance'], + 'pending': currency['Pending'], + 'est_btc': currency['BTC'] + }) + fiat = _FIAT_CONVERT + symbol = fiat_display_currency + value = fiat.convert_amount(total, 'BTC', symbol) + return (False, (output, total, symbol, value)) + + +def rpc_start(): + """ + Handler for start. + """ + if get_state() == State.RUNNING: + return (True, '*Status:* `already running`') + else: + update_state(State.RUNNING) + + +def rpc_stop(): + """ + Handler for stop. + """ + if get_state() == State.RUNNING: + update_state(State.STOPPED) + return (False, '`Stopping trader ...`') + else: + return (True, '*Status:* `already stopped`') + + +# FIX: no test for this!!!! +def rpc_forcesell(trade_id) -> None: + """ + Handler for forcesell . + Sells the given trade at current price + :return: error or None + """ + def _exec_forcesell(trade: Trade) -> str: + # Check if there is there is an open order + if trade.open_order_id: + order = exchange.get_order(trade.open_order_id) + + # Cancel open LIMIT_BUY orders and close trade + if order and not order['closed'] and order['type'] == 'LIMIT_BUY': + exchange.cancel_order(trade.open_order_id) + trade.close(order.get('rate') or trade.open_rate) + # TODO: sell amount which has been bought already + return + + # Ignore trades with an attached LIMIT_SELL order + if order and not order['closed'] and order['type'] == 'LIMIT_SELL': + return + + # Get current rate and execute sell + current_rate = exchange.get_ticker(trade.pair, False)['bid'] + from freqtrade.main import execute_sell + execute_sell(trade, current_rate) + # ---- EOF def _exec_forcesell ---- + + if get_state() != State.RUNNING: + return (True, '`trader is not running`') + + if trade_id == 'all': + # Execute sell for all open orders + for trade in Trade.query.filter(Trade.is_open.is_(True)).all(): + _exec_forcesell(trade) + return (False, '') + + # Query for trade + trade = Trade.query.filter(sql.and_( + Trade.id == trade_id, + Trade.is_open.is_(True) + )).first() + if not trade: + logger.warning('forcesell: Invalid argument received') + return (True, 'Invalid argument.') + + _exec_forcesell(trade) + return (False, '') + + +def rpc_performance() -> None: + """ + Handler for performance. + Shows a performance statistic from finished trades + """ + if get_state() != State.RUNNING: + return (True, '`trader is not running`') + + pair_rates = Trade.session.query(Trade.pair, + sql.func.sum(Trade.close_profit).label('profit_sum'), + sql.func.count(Trade.pair).label('count')) \ + .filter(Trade.is_open.is_(False)) \ + .group_by(Trade.pair) \ + .order_by(sql.text('profit_sum DESC')) \ + .all() + trades = [] + for (pair, rate, count) in pair_rates: + trades.append({'pair': pair, 'profit': round(rate * 100, 2), 'count': count}) + + return (False, trades) + + +def rpc_count() -> None: + """ + Returns the number of trades running + :return: None + """ + if get_state() != State.RUNNING: + return (True, '`trader is not running`') + + trades = Trade.query.filter(Trade.is_open.is_(True)).all() + return (False, trades) diff --git a/freqtrade/rpc/telegram.py b/freqtrade/rpc/telegram.py index 3d4ab5aa5..ea170baa1 100644 --- a/freqtrade/rpc/telegram.py +++ b/freqtrade/rpc/telegram.py @@ -1,21 +1,25 @@ import logging -import re -from datetime import datetime, timedelta -from decimal import Decimal from typing import Any, Callable -import arrow -from pandas import DataFrame -from sqlalchemy import and_, func, text from tabulate import tabulate from telegram import Bot, ParseMode, ReplyKeyboardMarkup, Update from telegram.error import NetworkError, TelegramError from telegram.ext import CommandHandler, Updater -from freqtrade import __version__, exchange -from freqtrade.fiat_convert import CryptoToFiatConverter -from freqtrade.misc import State, get_state, update_state -from freqtrade.persistence import Trade +from freqtrade.rpc.__init__ import (rpc_status_table, + rpc_trade_status, + rpc_daily_profit, + rpc_trade_statistics, + rpc_balance, + rpc_start, + rpc_stop, + rpc_forcesell, + rpc_performance, + rpc_count, + ) + +from freqtrade import __version__ + # Remove noisy log messages logging.getLogger('requests.packages.urllib3').setLevel(logging.INFO) @@ -24,7 +28,6 @@ logger = logging.getLogger(__name__) _UPDATER: Updater = None _CONF = {} -_FIAT_CONVERT = CryptoToFiatConverter() def init(config: dict) -> None: @@ -129,51 +132,12 @@ def _status(bot: Bot, update: Update) -> None: return # Fetch open trade - trades = Trade.query.filter(Trade.is_open.is_(True)).all() - if get_state() != State.RUNNING: - send_msg('*Status:* `trader is not running`', bot=bot) - elif not trades: - send_msg('*Status:* `no active trade`', bot=bot) + (error, trades) = rpc_trade_status() + if error: + send_msg(trades, bot=bot) else: - for trade in trades: - order = None - if trade.open_order_id: - order = exchange.get_order(trade.open_order_id) - # calculate profit and send message to user - current_rate = exchange.get_ticker(trade.pair, False)['bid'] - current_profit = trade.calc_profit_percent(current_rate) - fmt_close_profit = '{:.2f}%'.format( - round(trade.close_profit * 100, 2) - ) if trade.close_profit else None - message = """ -*Trade ID:* `{trade_id}` -*Current Pair:* [{pair}]({pair_url}) -*Open Since:* `{date}` -*Amount:* `{amount}` -*Open Rate:* `{open_rate:.8f}` -*Close Rate:* `{close_rate}` -*Current Rate:* `{current_rate:.8f}` -*Close Profit:* `{close_profit}` -*Current Profit:* `{current_profit:.2f}%` -*Open Order:* `{open_order}` -*Total Open Trades:* `{total_trades}` - """.format( - trade_id=trade.id, - pair=trade.pair, - pair_url=exchange.get_pair_detail_url(trade.pair), - date=arrow.get(trade.open_date).humanize(), - open_rate=trade.open_rate, - close_rate=trade.close_rate, - current_rate=current_rate, - amount=round(trade.amount, 8), - close_profit=fmt_close_profit, - current_profit=round(current_profit * 100, 2), - open_order='({} rem={:.8f})'.format( - order['type'], order['remaining'] - ) if order else None, - total_trades=len(trades) - ) - send_msg(message, bot=bot) + for trademsg in trades: + send_msg(trademsg, bot=bot) @authorized_only @@ -186,27 +150,10 @@ def _status_table(bot: Bot, update: Update) -> None: :return: None """ # Fetch open trade - trades = Trade.query.filter(Trade.is_open.is_(True)).all() - if get_state() != State.RUNNING: - send_msg('*Status:* `trader is not running`', bot=bot) - elif not trades: - send_msg('*Status:* `no active order`', bot=bot) + (err, df_statuses) = rpc_status_table() + if err: + send_msg(df_statuses, bot=bot) else: - trades_list = [] - for trade in trades: - # calculate profit and send message to user - current_rate = exchange.get_ticker(trade.pair, False)['bid'] - trades_list.append([ - trade.id, - trade.pair, - shorten_date(arrow.get(trade.open_date).humanize(only_distance=True)), - '{:.2f}%'.format(100 * trade.calc_profit_percent(current_rate)) - ]) - - columns = ['ID', 'Pair', 'Since', 'Profit'] - df_statuses = DataFrame.from_records(trades_list, columns=columns) - df_statuses = df_statuses.set_index(columns[0]) - message = tabulate(df_statuses, headers='keys', tablefmt='simple') message = "
{}
".format(message) @@ -222,62 +169,26 @@ def _daily(bot: Bot, update: Update) -> None: :param update: message update :return: None """ - today = datetime.utcnow().date() - profit_days = {} - try: timescale = int(update.message.text.replace('/daily', '').strip()) except (TypeError, ValueError): timescale = 7 - - if not (isinstance(timescale, int) and timescale > 0): - send_msg('*Daily [n]:* `must be an integer greater than 0`', bot=bot) - return - - for day in range(0, timescale): - profitday = today - timedelta(days=day) - trades = Trade.query \ - .filter(Trade.is_open.is_(False)) \ - .filter(Trade.close_date >= profitday)\ - .filter(Trade.close_date < (profitday + timedelta(days=1)))\ - .order_by(Trade.close_date)\ - .all() - curdayprofit = sum(trade.calc_profit() for trade in trades) - profit_days[profitday] = { - 'amount': format(curdayprofit, '.8f'), - 'trades': len(trades) - } - - stats = [ - [ - key, - '{value:.8f} {symbol}'.format( - value=float(value['amount']), - symbol=_CONF['stake_currency'] - ), - '{value:.3f} {symbol}'.format( - value=_FIAT_CONVERT.convert_amount( - value['amount'], - _CONF['stake_currency'], - _CONF['fiat_display_currency'] - ), - symbol=_CONF['fiat_display_currency'] - ), - '{value} trade{s}'.format(value=value['trades'], s='' if value['trades'] < 2 else 's'), - ] - for key, value in profit_days.items() - ] - stats = tabulate(stats, - headers=[ - 'Day', - 'Profit {}'.format(_CONF['stake_currency']), - 'Profit {}'.format(_CONF['fiat_display_currency']), - '# Trades' - ], - tablefmt='simple') - - message = 'Daily Profit over the last {} days:\n
{}
'.format(timescale, stats) - send_msg(message, bot=bot, parse_mode=ParseMode.HTML) + (error, stats) = rpc_daily_profit(timescale, + _CONF['stake_currency'], + _CONF['fiat_display_currency']) + if error: + send_msg(stats, bot=bot) + else: + stats = tabulate(stats, + headers=[ + 'Day', + 'Profit {}'.format(_CONF['stake_currency']), + 'Profit {}'.format(_CONF['fiat_display_currency']) + ], + tablefmt='simple') + message = 'Daily Profit over the last {} days:\n
{}
'.format( + timescale, stats) + send_msg(message, bot=bot, parse_mode=ParseMode.HTML) @authorized_only @@ -289,62 +200,12 @@ def _profit(bot: Bot, update: Update) -> None: :param update: message update :return: None """ - trades = Trade.query.order_by(Trade.id).all() - - profit_all_coin = [] - profit_all_percent = [] - profit_closed_coin = [] - profit_closed_percent = [] - durations = [] - - for trade in trades: - current_rate = None - - if not trade.open_rate: - continue - if trade.close_date: - durations.append((trade.close_date - trade.open_date).total_seconds()) - - if not trade.is_open: - profit_percent = trade.calc_profit_percent() - profit_closed_coin.append(trade.calc_profit()) - profit_closed_percent.append(profit_percent) - else: - # Get current rate - current_rate = exchange.get_ticker(trade.pair, False)['bid'] - profit_percent = trade.calc_profit_percent(rate=current_rate) - - profit_all_coin.append(trade.calc_profit(rate=Decimal(trade.close_rate or current_rate))) - profit_all_percent.append(profit_percent) - - best_pair = Trade.session.query(Trade.pair, func.sum(Trade.close_profit).label('profit_sum')) \ - .filter(Trade.is_open.is_(False)) \ - .group_by(Trade.pair) \ - .order_by(text('profit_sum DESC')) \ - .first() - - if not best_pair: - send_msg('*Status:* `no closed trade`', bot=bot) + (error, stats) = rpc_trade_statistics(_CONF['stake_currency'], + _CONF['fiat_display_currency']) + if error: + send_msg(stats, bot=bot) return - bp_pair, bp_rate = best_pair - - # Prepare data to display - profit_closed_coin = round(sum(profit_closed_coin), 8) - profit_closed_percent = round(sum(profit_closed_percent) * 100, 2) - profit_closed_fiat = _FIAT_CONVERT.convert_amount( - profit_closed_coin, - _CONF['stake_currency'], - _CONF['fiat_display_currency'] - ) - profit_all_coin = round(sum(profit_all_coin), 8) - profit_all_percent = round(sum(profit_all_percent) * 100, 2) - profit_all_fiat = _FIAT_CONVERT.convert_amount( - profit_all_coin, - _CONF['stake_currency'], - _CONF['fiat_display_currency'] - ) - # Message to display markdown_msg = """ *ROI:* Close trades @@ -362,18 +223,18 @@ def _profit(bot: Bot, update: Update) -> None: """.format( coin=_CONF['stake_currency'], fiat=_CONF['fiat_display_currency'], - profit_closed_coin=profit_closed_coin, - profit_closed_percent=profit_closed_percent, - profit_closed_fiat=profit_closed_fiat, - profit_all_coin=profit_all_coin, - profit_all_percent=profit_all_percent, - profit_all_fiat=profit_all_fiat, - trade_count=len(trades), - first_trade_date=arrow.get(trades[0].open_date).humanize(), - latest_trade_date=arrow.get(trades[-1].open_date).humanize(), - avg_duration=str(timedelta(seconds=sum(durations) / float(len(durations)))).split('.')[0], - best_pair=bp_pair, - best_rate=round(bp_rate * 100, 2), + profit_closed_coin=stats['profit_closed_coin'], + profit_closed_percent=stats['profit_closed_percent'], + profit_closed_fiat=stats['profit_closed_fiat'], + profit_all_coin=stats['profit_all_coin'], + profit_all_percent=stats['profit_all_percent'], + profit_all_fiat=stats['profit_all_fiat'], + trade_count=stats['trade_count'], + first_trade_date=stats['first_trade_date'], + latest_trade_date=stats['latest_trade_date'], + avg_duration=stats['avg_duration'], + best_pair=stats['best_pair'], + best_rate=stats['best_rate'] ) send_msg(markdown_msg, bot=bot) @@ -382,41 +243,22 @@ def _profit(bot: Bot, update: Update) -> None: def _balance(bot: Bot, update: Update) -> None: """ Handler for /balance - Returns current account balance per crypto """ - output = '' - balances = [ - c for c in exchange.get_balances() - if c['Balance'] or c['Available'] or c['Pending'] - ] - if not balances: + (error, result) = rpc_balance(_CONF['fiat_display_currency']) + if error: send_msg('`All balances are zero.`') return - total = 0.0 - for currency in balances: - coin = currency['Currency'] - if coin == 'BTC': - currency["Rate"] = 1.0 - else: - if coin == 'USDT': - currency["Rate"] = 1.0 / exchange.get_ticker('USDT_BTC', False)['bid'] - else: - currency["Rate"] = exchange.get_ticker('BTC_' + coin, False)['bid'] - currency['BTC'] = currency["Rate"] * currency["Balance"] - total = total + currency['BTC'] - output += """*Currency*: {Currency} -*Available*: {Available} -*Balance*: {Balance} -*Pending*: {Pending} -*Est. BTC*: {BTC: .8f} - + (currencys, total, symbol, value) = result + output = '' + for currency in currencys: + output += """*Currency*: {currency} +*Available*: {available} +*Balance*: {balance} +*Pending*: {pending} +*Est. BTC*: {est_btc: .8f} """.format(**currency) - symbol = _CONF['fiat_display_currency'] - value = _FIAT_CONVERT.convert_amount( - total, 'BTC', symbol - ) output += """*Estimated Value*: *BTC*: {0: .8f} *{1}*: {2: .2f} @@ -433,10 +275,9 @@ def _start(bot: Bot, update: Update) -> None: :param update: message update :return: None """ - if get_state() == State.RUNNING: - send_msg('*Status:* `already running`', bot=bot) - else: - update_state(State.RUNNING) + (error, msg) = rpc_start() + if error: + send_msg(msg, bot=bot) @authorized_only @@ -448,13 +289,11 @@ def _stop(bot: Bot, update: Update) -> None: :param update: message update :return: None """ - if get_state() == State.RUNNING: - send_msg('`Stopping trader ...`', bot=bot) - update_state(State.STOPPED) - else: - send_msg('*Status:* `already stopped`', bot=bot) + (error, msg) = rpc_stop() + send_msg(msg, bot=bot) +# FIX: no test for this!!!! @authorized_only def _forcesell(bot: Bot, update: Update) -> None: """ @@ -464,29 +303,13 @@ def _forcesell(bot: Bot, update: Update) -> None: :param update: message update :return: None """ - if get_state() != State.RUNNING: - send_msg('`trader is not running`', bot=bot) - return trade_id = update.message.text.replace('/forcesell', '').strip() - if trade_id == 'all': - # Execute sell for all open orders - for trade in Trade.query.filter(Trade.is_open.is_(True)).all(): - _exec_forcesell(trade) + (error, message) = rpc_forcesell(trade_id) + if error: + send_msg(message, bot=bot) return - # Query for trade - trade = Trade.query.filter(and_( - Trade.id == trade_id, - Trade.is_open.is_(True) - )).first() - if not trade: - send_msg('Invalid argument. See `/help` to view usage') - logger.warning('/forcesell: Invalid argument received') - return - - _exec_forcesell(trade) - @authorized_only def _performance(bot: Bot, update: Update) -> None: @@ -497,26 +320,18 @@ def _performance(bot: Bot, update: Update) -> None: :param update: message update :return: None """ - if get_state() != State.RUNNING: - send_msg('`trader is not running`', bot=bot) + (error, trades) = rpc_performance() + if error: + send_msg(trades, bot=bot) return - pair_rates = Trade.session.query(Trade.pair, func.sum(Trade.close_profit).label('profit_sum'), - func.count(Trade.pair).label('count')) \ - .filter(Trade.is_open.is_(False)) \ - .group_by(Trade.pair) \ - .order_by(text('profit_sum DESC')) \ - .all() - stats = '\n'.join('{index}.\t{pair}\t{profit:.2f}% ({count})'.format( index=i + 1, - pair=pair, - profit=round(rate * 100, 2), - count=count - ) for i, (pair, rate, count) in enumerate(pair_rates)) - + pair=trade['pair'], + profit=trade['profit'], + count=trade['count'] + ) for i, trade in enumerate(trades)) message = 'Performance:\n{}'.format(stats) - logger.debug(message) send_msg(message, parse_mode=ParseMode.HTML) @@ -529,12 +344,11 @@ def _count(bot: Bot, update: Update) -> None: :param update: message update :return: None """ - if get_state() != State.RUNNING: - send_msg('`trader is not running`', bot=bot) + (error, trades) = rpc_count() + if error: + send_msg(trades, bot=bot) return - trades = Trade.query.filter(Trade.is_open.is_(True)).all() - message = tabulate({ 'current': [len(trades)], 'max': [_CONF['max_open_trades']] @@ -582,40 +396,6 @@ def _version(bot: Bot, update: Update) -> None: send_msg('*Version:* `{}`'.format(__version__), bot=bot) -def shorten_date(_date): - """ - Trim the date so it fits on small screens - """ - new_date = re.sub('seconds?', 'sec', _date) - new_date = re.sub('minutes?', 'min', new_date) - new_date = re.sub('hours?', 'h', new_date) - new_date = re.sub('days?', 'd', new_date) - new_date = re.sub('^an?', '1', new_date) - return new_date - - -def _exec_forcesell(trade: Trade) -> None: - # Check if there is there is an open order - if trade.open_order_id: - order = exchange.get_order(trade.open_order_id) - - # Cancel open LIMIT_BUY orders and close trade - if order and not order['closed'] and order['type'] == 'LIMIT_BUY': - exchange.cancel_order(trade.open_order_id) - trade.close(order.get('rate') or trade.open_rate) - # TODO: sell amount which has been bought already - return - - # Ignore trades with an attached LIMIT_SELL order - if order and not order['closed'] and order['type'] == 'LIMIT_SELL': - return - - # Get current rate and execute sell - current_rate = exchange.get_ticker(trade.pair, False)['bid'] - from freqtrade.main import execute_sell - execute_sell(trade, current_rate) - - def send_msg(msg: str, bot: Bot = None, parse_mode: ParseMode = ParseMode.MARKDOWN) -> None: """ Send given markdown message diff --git a/freqtrade/tests/conftest.py b/freqtrade/tests/conftest.py index 053b980f7..2b1d14268 100644 --- a/freqtrade/tests/conftest.py +++ b/freqtrade/tests/conftest.py @@ -272,3 +272,10 @@ def default_strategy(): strategy = Strategy() strategy.init({'strategy': 'default_strategy'}) return strategy + + +# FIX: +# Create an fixture/function +# that inserts a trade of some type and open-status +# return the open-order-id +# See tests in rpc/main that could use this diff --git a/freqtrade/tests/rpc/test_rpc.py b/freqtrade/tests/rpc/test_rpc.py index 9235cc674..06994ce8e 100644 --- a/freqtrade/tests/rpc/test_rpc.py +++ b/freqtrade/tests/rpc/test_rpc.py @@ -1,8 +1,21 @@ # pragma pylint: disable=missing-docstring, too-many-arguments, too-many-ancestors, C0103 +from datetime import datetime from copy import deepcopy from unittest.mock import MagicMock +from sqlalchemy import create_engine from freqtrade.rpc import init, cleanup, send_msg +from freqtrade.persistence import Trade +import freqtrade.main as main +import freqtrade.misc as misc +import freqtrade.rpc as rpc + + +def prec_satoshi(a, b): + """ + :return: True if A and B differs less than one satoshi. + """ + return abs(a - b) < 0.00000001 def test_init_telegram_enabled(default_conf, mocker): @@ -55,3 +68,343 @@ def test_send_msg_telegram_disabled(mocker): telegram_mock = mocker.patch('freqtrade.rpc.telegram.send_msg', MagicMock()) send_msg('test') assert telegram_mock.call_count == 0 + + +def test_rpc_forcesell(default_conf, update, ticker, mocker): + mocker.patch.dict('freqtrade.main._CONF', default_conf) + mocker.patch('freqtrade.main.get_signal', side_effect=lambda s, t: (True, False)) + mocker.patch('freqtrade.main.rpc.send_msg', MagicMock()) + mocker.patch.multiple('freqtrade.rpc.telegram', + _CONF=default_conf, + init=MagicMock()) + cancel_order_mock = MagicMock() + mocker.patch.multiple('freqtrade.main.exchange', + validate_pairs=MagicMock(), + get_ticker=ticker, + cancel_order=cancel_order_mock, + get_order=MagicMock(return_value={ + 'closed': True, + 'type': 'LIMIT_BUY', + })) + main.init(default_conf, create_engine('sqlite://')) + + misc.update_state(misc.State.STOPPED) + (error, res) = rpc.rpc_forcesell(None) + assert error + assert res == '`trader is not running`' + misc.update_state(misc.State.RUNNING) + (error, res) = rpc.rpc_forcesell(None) + assert error + assert res == 'Invalid argument.' + + (error, res) = rpc.rpc_forcesell('all') + assert not error + assert res == '' + + main.create_trade(0.001, 5) + (error, res) = rpc.rpc_forcesell('all') + assert not error + assert res == '' + + (error, res) = rpc.rpc_forcesell('1') + assert not error + assert res == '' + + misc.update_state(misc.State.STOPPED) + + (error, res) = rpc.rpc_forcesell(None) + assert error + assert res == '`trader is not running`' + + (error, res) = rpc.rpc_forcesell('all') + assert error + assert res == '`trader is not running`' + + misc.update_state(misc.State.RUNNING) + + assert cancel_order_mock.call_count == 0 + # make an limit-buy open trade + mocker.patch.multiple('freqtrade.exchange', + get_order=MagicMock(return_value={ + 'closed': None, + 'type': 'LIMIT_BUY' + })) + # check that the trade is called, which is done + # by ensuring exchange.cancel_order is called + (error, res) = rpc.rpc_forcesell('1') + assert not error + assert res == '' + assert cancel_order_mock.call_count == 1 + + main.create_trade(0.001, 5) + # make an limit-sell open trade + mocker.patch.multiple('freqtrade.exchange', + get_order=MagicMock(return_value={ + 'closed': None, + 'type': 'LIMIT_SELL' + })) + (error, res) = rpc.rpc_forcesell('2') + assert not error + assert res == '' + # status quo, no exchange calls + assert cancel_order_mock.call_count == 1 + + +def test_rpc_trade_status(default_conf, update, ticker, mocker): + mocker.patch.dict('freqtrade.main._CONF', default_conf) + mocker.patch('freqtrade.main.get_signal', side_effect=lambda s, t: (True, False)) + mocker.patch('freqtrade.main.rpc.send_msg', MagicMock()) + mocker.patch.multiple('freqtrade.rpc.telegram', + _CONF=default_conf, + init=MagicMock()) + mocker.patch.multiple('freqtrade.main.exchange', + validate_pairs=MagicMock(), + get_ticker=ticker) + main.init(default_conf, create_engine('sqlite://')) + + misc.update_state(misc.State.STOPPED) + (error, result) = rpc.rpc_trade_status() + assert error + assert result.find('trader is not running') >= 0 + + misc.update_state(misc.State.RUNNING) + (error, result) = rpc.rpc_trade_status() + assert error + assert result.find('no active trade') >= 0 + + main.create_trade(0.001, 5) + (error, result) = rpc.rpc_trade_status() + assert not error + trade = result[0] + assert trade.find('[BTC_ETH]') >= 0 + + +def test_rpc_daily_profit(default_conf, update, ticker, limit_buy_order, limit_sell_order, mocker): + mocker.patch.dict('freqtrade.main._CONF', default_conf) + mocker.patch('freqtrade.main.get_signal', side_effect=lambda s, t: (True, False)) + mocker.patch('freqtrade.main.rpc.send_msg', MagicMock()) + mocker.patch.multiple('freqtrade.rpc.telegram', + _CONF=default_conf, + init=MagicMock()) + mocker.patch.multiple('freqtrade.main.exchange', + validate_pairs=MagicMock(), + get_ticker=ticker) + mocker.patch.multiple('freqtrade.fiat_convert.Pymarketcap', + ticker=MagicMock(return_value={'price_usd': 15000.0}), + _cache_symbols=MagicMock(return_value={'BTC': 1})) + mocker.patch('freqtrade.fiat_convert.CryptoToFiatConverter._find_price', return_value=15000.0) + main.init(default_conf, create_engine('sqlite://')) + stake_currency = default_conf['stake_currency'] + fiat_display_currency = default_conf['fiat_display_currency'] + + # Create some test data + main.create_trade(0.001, 5) + trade = Trade.query.first() + assert trade + + # Simulate buy & sell + trade.update(limit_buy_order) + trade.update(limit_sell_order) + trade.close_date = datetime.utcnow() + trade.is_open = False + + # Try valid data + update.message.text = '/daily 2' + (error, days) = rpc.rpc_daily_profit(7, stake_currency, + fiat_display_currency) + assert not error + assert len(days) == 7 + for day in days: + # [datetime.date(2018, 1, 11), '0.00000000 BTC', '0.000 USD'] + assert (day[1] == '0.00000000 BTC' or + day[1] == '0.00006217 BTC') + + assert (day[2] == '0.000 USD' or + day[2] == '0.933 USD') + # ensure first day is current date + assert str(days[0][0]) == str(datetime.utcnow().date()) + + # Try invalid data + (error, days) = rpc.rpc_daily_profit(0, stake_currency, + fiat_display_currency) + assert error + assert days.find('must be an integer greater than 0') >= 0 + + +def test_rpc_trade_statistics( + default_conf, update, ticker, ticker_sell_up, limit_buy_order, limit_sell_order, mocker): + mocker.patch.dict('freqtrade.main._CONF', default_conf) + mocker.patch('freqtrade.main.get_signal', side_effect=lambda s, t: (True, False)) + mocker.patch('freqtrade.main.rpc.send_msg', MagicMock()) + mocker.patch.multiple('freqtrade.rpc.telegram', + _CONF=default_conf, + init=MagicMock()) + mocker.patch.multiple('freqtrade.main.exchange', + validate_pairs=MagicMock(), + get_ticker=ticker) + mocker.patch.multiple('freqtrade.fiat_convert.Pymarketcap', + ticker=MagicMock(return_value={'price_usd': 15000.0}), + _cache_symbols=MagicMock(return_value={'BTC': 1})) + mocker.patch('freqtrade.fiat_convert.CryptoToFiatConverter._find_price', return_value=15000.0) + main.init(default_conf, create_engine('sqlite://')) + stake_currency = default_conf['stake_currency'] + fiat_display_currency = default_conf['fiat_display_currency'] + + (error, stats) = rpc.rpc_trade_statistics(stake_currency, + fiat_display_currency) + assert error + assert stats.find('no closed trade') >= 0 + + # Create some test data + main.create_trade(0.001, 5) + trade = Trade.query.first() + # Simulate fulfilled LIMIT_BUY order for trade + trade.update(limit_buy_order) + # Update the ticker with a market going up + mocker.patch.multiple('freqtrade.main.exchange', + validate_pairs=MagicMock(), + get_ticker=ticker_sell_up) + trade.update(limit_sell_order) + trade.close_date = datetime.utcnow() + trade.is_open = False + + (error, stats) = rpc.rpc_trade_statistics(stake_currency, + fiat_display_currency) + assert not error + assert prec_satoshi(stats['profit_closed_coin'], 6.217e-05) + assert prec_satoshi(stats['profit_closed_percent'], 6.2) + assert prec_satoshi(stats['profit_closed_fiat'], 0.93255) + assert prec_satoshi(stats['profit_all_coin'], 6.217e-05) + assert prec_satoshi(stats['profit_all_percent'], 6.2) + assert prec_satoshi(stats['profit_all_fiat'], 0.93255) + assert stats['trade_count'] == 1 + assert stats['first_trade_date'] == 'just now' + assert stats['latest_trade_date'] == 'just now' + assert stats['avg_duration'] == '0:00:00' + assert stats['best_pair'] == 'BTC_ETH' + assert prec_satoshi(stats['best_rate'], 6.2) + + +# Test that rpc_trade_statistics can handle trades that lacks +# trade.open_rate (it is set to None) +def test_rpc_trade_statistics_closed( + default_conf, update, ticker, ticker_sell_up, limit_buy_order, limit_sell_order, mocker): + mocker.patch.dict('freqtrade.main._CONF', default_conf) + mocker.patch('freqtrade.main.get_signal', side_effect=lambda s, t: (True, False)) + mocker.patch('freqtrade.main.rpc.send_msg', MagicMock()) + mocker.patch.multiple('freqtrade.rpc.telegram', + _CONF=default_conf, + init=MagicMock()) + mocker.patch.multiple('freqtrade.main.exchange', + validate_pairs=MagicMock(), + get_ticker=ticker) + mocker.patch.multiple('freqtrade.fiat_convert.Pymarketcap', + ticker=MagicMock(return_value={'price_usd': 15000.0}), + _cache_symbols=MagicMock(return_value={'BTC': 1})) + mocker.patch('freqtrade.fiat_convert.CryptoToFiatConverter._find_price', return_value=15000.0) + main.init(default_conf, create_engine('sqlite://')) + stake_currency = default_conf['stake_currency'] + fiat_display_currency = default_conf['fiat_display_currency'] + + # Create some test data + main.create_trade(0.001, 5) + trade = Trade.query.first() + # Simulate fulfilled LIMIT_BUY order for trade + trade.update(limit_buy_order) + # Update the ticker with a market going up + mocker.patch.multiple('freqtrade.main.exchange', + validate_pairs=MagicMock(), + get_ticker=ticker_sell_up) + trade.update(limit_sell_order) + trade.close_date = datetime.utcnow() + trade.is_open = False + + for trade in Trade.query.order_by(Trade.id).all(): + trade.open_rate = None + + (error, stats) = rpc.rpc_trade_statistics(stake_currency, + fiat_display_currency) + assert not error + assert prec_satoshi(stats['profit_closed_coin'], 0) + assert prec_satoshi(stats['profit_closed_percent'], 0) + assert prec_satoshi(stats['profit_closed_fiat'], 0) + assert prec_satoshi(stats['profit_all_coin'], 0) + assert prec_satoshi(stats['profit_all_percent'], 0) + assert prec_satoshi(stats['profit_all_fiat'], 0) + assert stats['trade_count'] == 1 + assert stats['first_trade_date'] == 'just now' + assert stats['latest_trade_date'] == 'just now' + assert stats['avg_duration'] == '0:00:00' + assert stats['best_pair'] == 'BTC_ETH' + assert prec_satoshi(stats['best_rate'], 6.2) + + +def test_rpc_balance_handle(default_conf, update, mocker): + mock_balance = [{ + 'Currency': 'BTC', + 'Balance': 10.0, + 'Available': 12.0, + 'Pending': 0.0, + 'CryptoAddress': 'XXXX', + }, { + 'Currency': 'ETH', + 'Balance': 0.0, + 'Available': 0.0, + 'Pending': 0.0, + 'CryptoAddress': 'XXXX', + }] + mocker.patch.dict('freqtrade.main._CONF', default_conf) + mocker.patch.multiple('freqtrade.main.exchange', + get_balances=MagicMock(return_value=mock_balance)) + mocker.patch.multiple('freqtrade.fiat_convert.Pymarketcap', + ticker=MagicMock(return_value={'price_usd': 15000.0}), + _cache_symbols=MagicMock(return_value={'BTC': 1})) + + (error, res) = rpc.rpc_balance(default_conf['fiat_display_currency']) + assert not error + (trade, x, y, z) = res + assert prec_satoshi(x, 10) + assert prec_satoshi(z, 150000) + assert y == 'USD' + assert len(trade) == 1 + assert trade[0]['currency'] == 'BTC' + assert prec_satoshi(trade[0]['available'], 12) + assert prec_satoshi(trade[0]['balance'], 10) + assert prec_satoshi(trade[0]['pending'], 0) + assert prec_satoshi(trade[0]['est_btc'], 10) + + +def test_performance_handle( + default_conf, update, ticker, limit_buy_order, limit_sell_order, mocker): + mocker.patch.dict('freqtrade.main._CONF', default_conf) + mocker.patch('freqtrade.main.get_signal', side_effect=lambda s, t: (True, False)) + msg_mock = MagicMock() + mocker.patch('freqtrade.main.rpc.send_msg', MagicMock()) + mocker.patch.multiple('freqtrade.rpc.telegram', + _CONF=default_conf, + init=MagicMock(), + send_msg=msg_mock) + mocker.patch.multiple('freqtrade.main.exchange', + validate_pairs=MagicMock(), + get_ticker=ticker) + main.init(default_conf, create_engine('sqlite://')) + + # Create some test data + main.create_trade(0.001, int(default_conf['ticker_interval'])) + trade = Trade.query.first() + assert trade + + # Simulate fulfilled LIMIT_BUY order for trade + trade.update(limit_buy_order) + + # Simulate fulfilled LIMIT_SELL order for trade + trade.update(limit_sell_order) + + trade.close_date = datetime.utcnow() + trade.is_open = False + (error, res) = rpc.rpc_performance() + assert not error + assert len(res) == 1 + assert res[0]['pair'] == 'BTC_ETH' + assert res[0]['count'] == 1 + assert prec_satoshi(res[0]['profit'], 6.2) diff --git a/freqtrade/tests/rpc/test_rpc_telegram.py b/freqtrade/tests/rpc/test_rpc_telegram.py index 2dacf33ce..9a1dbcc69 100644 --- a/freqtrade/tests/rpc/test_rpc_telegram.py +++ b/freqtrade/tests/rpc/test_rpc_telegram.py @@ -15,8 +15,9 @@ from freqtrade.misc import update_state, State, get_state from freqtrade.persistence import Trade from freqtrade.rpc import telegram from freqtrade.rpc.telegram import authorized_only, is_enabled, send_msg, _status, _status_table, \ - _profit, _forcesell, _performance, _daily, _count, _start, _stop, _balance, _version, _help, \ - _exec_forcesell + _profit, _forcesell, _performance, _daily, _count, _start, _stop, _balance, _version, _help + +import freqtrade.rpc.telegram as tg def test_is_enabled(default_conf, mocker): @@ -283,30 +284,6 @@ def test_forcesell_down_handle(default_conf, update, ticker, ticker_sell_down, m assert '-0.824 USD' in rpc_mock.call_args_list[-1][0][0] -def test_exec_forcesell_open_orders(default_conf, ticker, mocker): - mocker.patch.dict('freqtrade.main._CONF', default_conf) - cancel_order_mock = MagicMock() - mocker.patch.multiple('freqtrade.main.exchange', - get_ticker=ticker, - get_order=MagicMock(return_value={ - 'closed': None, - 'type': 'LIMIT_BUY', - }), - cancel_order=cancel_order_mock) - trade = Trade( - pair='BTC_ETH', - open_rate=1, - exchange='BITTREX', - open_order_id='123456789', - amount=1, - fee=0.0, - ) - _exec_forcesell(trade) - - assert cancel_order_mock.call_count == 1 - assert trade.is_open is False - - def test_forcesell_all_handle(default_conf, update, ticker, mocker): mocker.patch.dict('freqtrade.main._CONF', default_conf) mocker.patch('freqtrade.main.get_signal', side_effect=lambda s, t: (True, False)) @@ -630,8 +607,7 @@ def test_stop_handle_already_stopped(default_conf, update, mocker): assert 'already stopped' in msg_mock.call_args_list[0][0][0] -def test_balance_handle(default_conf, update, mocker): - +def test_telegram_balance_handle(default_conf, update, mocker): mock_balance = [{ 'Currency': 'BTC', 'Balance': 10.0, @@ -766,16 +742,65 @@ def test_send_msg_network_error(default_conf, mocker): assert len(bot.method_calls) == 2 -def test_init(default_conf, update, ticker, limit_buy_order, limit_sell_order, mocker): +def test_init(default_conf, mocker): + start_polling = MagicMock() + mocker.patch.multiple('freqtrade.rpc.telegram', + _CONF=default_conf, + # mock telegram.ext.Updater + Updater=MagicMock(return_value=start_polling)) + # not enabled + tg.init(default_conf) + assert start_polling.call_count == 0 + # number of handles registered + assert start_polling.dispatcher.add_handler.call_count == 11 + assert start_polling.start_polling.call_count == 1 + + # enabled + default_conf['telegram'] = {} + default_conf['telegram']['enabled'] = True + default_conf['telegram']['token'] = '' + tg.init(default_conf) + + +def test_cleanup(default_conf, mocker): + default_conf['telegram'] = {} + default_conf['telegram']['enabled'] = False + updater_mock = MagicMock() + mocker.patch.multiple('freqtrade.rpc.telegram', + _CONF=default_conf, + _UPDATER=updater_mock) + # not enabled + tg.cleanup() + assert updater_mock.stop.call_count == 0 + + # enabled + default_conf['telegram']['enabled'] = True + tg.cleanup() + assert updater_mock.stop.call_count == 1 + + +def test_status(default_conf, update, mocker): + update.message.chat.id = 123 + default_conf['telegram'] = {} + default_conf['telegram']['chat_id'] = 123 + mocker.patch('telegram.update', MagicMock()) mocker.patch.dict('freqtrade.main._CONF', default_conf) - mocker.patch('freqtrade.main.get_signal', side_effect=lambda s, t: (True, False)) + mocker.patch.multiple('freqtrade.rpc.telegram', + _CONF=default_conf, + init=MagicMock()) msg_mock = MagicMock() - mocker.patch('freqtrade.main.rpc.send_msg', MagicMock()) + status_table = MagicMock() + mocker.patch.multiple('freqtrade.rpc', + send_msg=MagicMock()) mocker.patch.multiple('freqtrade.rpc.telegram', _CONF=default_conf, init=MagicMock(), + rpc_trade_status=MagicMock(return_value=(False, [1, 2, 3])), + _status_table=status_table, send_msg=msg_mock) - mocker.patch.multiple('freqtrade.main.exchange', - validate_pairs=MagicMock(), - get_ticker=ticker) - init(default_conf, create_engine('sqlite://')) + _status(bot=MagicMock(), update=update) + assert msg_mock.call_count == 3 + update.message.text = MagicMock() + update.message.text.replace = MagicMock(return_value='table 2 3') + _status(bot=MagicMock(), update=update) + assert status_table.call_count == 1