• Home
  • Features
  • Pricing
  • Docs
  • Announcements
  • Sign In

freqtrade / freqtrade / 6181253459

08 Sep 2023 06:04AM UTC coverage: 94.614% (+0.06%) from 94.556%
6181253459

push

github-actions

web-flow
Merge pull request #9159 from stash86/fix-adjust

remove old codes when we only can do partial entries

2 of 2 new or added lines in 1 file covered. (100.0%)

19114 of 20202 relevant lines covered (94.61%)

0.95 hits per line

Source File
Press 'n' to go to next uncovered line, 'b' for previous

94.73
/freqtrade/rpc/telegram.py
1
# pragma pylint: disable=unused-argument, unused-variable, protected-access, invalid-name
2

3
"""
1✔
4
This module manage Telegram communication
5
"""
6
import asyncio
1✔
7
import json
1✔
8
import logging
1✔
9
import re
1✔
10
from copy import deepcopy
1✔
11
from dataclasses import dataclass
1✔
12
from datetime import date, datetime, timedelta
1✔
13
from functools import partial
1✔
14
from html import escape
1✔
15
from itertools import chain
1✔
16
from math import isnan
1✔
17
from threading import Thread
1✔
18
from typing import Any, Callable, Coroutine, Dict, List, Optional, Union
1✔
19

20
from tabulate import tabulate
1✔
21
from telegram import (CallbackQuery, InlineKeyboardButton, InlineKeyboardMarkup, KeyboardButton,
1✔
22
                      ReplyKeyboardMarkup, Update)
23
from telegram.constants import MessageLimit, ParseMode
1✔
24
from telegram.error import BadRequest, NetworkError, TelegramError
1✔
25
from telegram.ext import Application, CallbackContext, CallbackQueryHandler, CommandHandler
1✔
26
from telegram.helpers import escape_markdown
1✔
27

28
from freqtrade.__init__ import __version__
1✔
29
from freqtrade.constants import DUST_PER_COIN, Config
1✔
30
from freqtrade.enums import MarketDirection, RPCMessageType, SignalDirection, TradingMode
1✔
31
from freqtrade.exceptions import OperationalException
1✔
32
from freqtrade.misc import chunks, plural, round_coin_value
1✔
33
from freqtrade.persistence import Trade
1✔
34
from freqtrade.rpc import RPC, RPCException, RPCHandler
1✔
35
from freqtrade.rpc.rpc_types import RPCSendMsg
1✔
36
from freqtrade.util import dt_humanize
1✔
37

38

39
MAX_MESSAGE_LENGTH = MessageLimit.MAX_TEXT_LENGTH
1✔
40

41

42
logger = logging.getLogger(__name__)
1✔
43

44
logger.debug('Included module rpc.telegram ...')
1✔
45

46

47
@dataclass
1✔
48
class TimeunitMappings:
1✔
49
    header: str
1✔
50
    message: str
1✔
51
    message2: str
1✔
52
    callback: str
1✔
53
    default: int
1✔
54
    dateformat: str
1✔
55

56

57
def authorized_only(command_handler: Callable[..., Coroutine[Any, Any, None]]):
1✔
58
    """
59
    Decorator to check if the message comes from the correct chat_id
60
    :param command_handler: Telegram CommandHandler
61
    :return: decorated function
62
    """
63

64
    async def wrapper(self, *args, **kwargs):
1✔
65
        """ Decorator logic """
66
        update = kwargs.get('update') or args[0]
1✔
67

68
        # Reject unauthorized messages
69
        if update.callback_query:
1✔
70
            cchat_id = int(update.callback_query.message.chat.id)
1✔
71
        else:
72
            cchat_id = int(update.message.chat_id)
1✔
73

74
        chat_id = int(self._config['telegram']['chat_id'])
1✔
75
        if cchat_id != chat_id:
1✔
76
            logger.info(f'Rejected unauthorized message from: {update.message.chat_id}')
1✔
77
            return wrapper
1✔
78
        # Rollback session to avoid getting data stored in a transaction.
79
        Trade.rollback()
1✔
80
        logger.debug(
1✔
81
            'Executing handler: %s for chat_id: %s',
82
            command_handler.__name__,
83
            chat_id
84
        )
85
        try:
1✔
86
            return await command_handler(self, *args, **kwargs)
1✔
87
        except RPCException as e:
1✔
88
            await self._send_msg(str(e))
1✔
89
        except BaseException:
1✔
90
            logger.exception('Exception occurred within Telegram module')
1✔
91
        finally:
92
            Trade.session.remove()
1✔
93

94
    return wrapper
1✔
95

96

97
class Telegram(RPCHandler):
1✔
98
    """  This class handles all telegram communication """
99

100
    def __init__(self, rpc: RPC, config: Config) -> None:
1✔
101
        """
102
        Init the Telegram call, and init the super class RPCHandler
103
        :param rpc: instance of RPC Helper class
104
        :param config: Configuration object
105
        :return: None
106
        """
107
        super().__init__(rpc, config)
1✔
108

109
        self._app: Application
1✔
110
        self._loop: asyncio.AbstractEventLoop
1✔
111
        self._init_keyboard()
1✔
112
        self._start_thread()
1✔
113

114
    def _start_thread(self):
1✔
115
        """
116
        Creates and starts the polling thread
117
        """
118
        self._thread = Thread(target=self._init, name='FTTelegram')
1✔
119
        self._thread.start()
1✔
120

121
    def _init_keyboard(self) -> None:
1✔
122
        """
123
        Validates the keyboard configuration from telegram config
124
        section.
125
        """
126
        self._keyboard: List[List[Union[str, KeyboardButton]]] = [
1✔
127
            ['/daily', '/profit', '/balance'],
128
            ['/status', '/status table', '/performance'],
129
            ['/count', '/start', '/stop', '/help']
130
        ]
131
        # do not allow commands with mandatory arguments and critical cmds
132
        # TODO: DRY! - its not good to list all valid cmds here. But otherwise
133
        #       this needs refactoring of the whole telegram module (same
134
        #       problem in _help()).
135
        valid_keys: List[str] = [
1✔
136
            r'/start$', r'/stop$', r'/status$', r'/status table$',
137
            r'/trades$', r'/performance$', r'/buys', r'/entries',
138
            r'/sells', r'/exits', r'/mix_tags',
139
            r'/daily$', r'/daily \d+$', r'/profit$', r'/profit \d+',
140
            r'/stats$', r'/count$', r'/locks$', r'/balance$',
141
            r'/stopbuy$', r'/stopentry$', r'/reload_config$', r'/show_config$',
142
            r'/logs$', r'/whitelist$', r'/whitelist(\ssorted|\sbaseonly)+$',
143
            r'/blacklist$', r'/bl_delete$',
144
            r'/weekly$', r'/weekly \d+$', r'/monthly$', r'/monthly \d+$',
145
            r'/forcebuy$', r'/forcelong$', r'/forceshort$',
146
            r'/forcesell$', r'/forceexit$',
147
            r'/edge$', r'/health$', r'/help$', r'/version$', r'/marketdir (long|short|even|none)$',
148
            r'/marketdir$'
149
        ]
150
        # Create keys for generation
151
        valid_keys_print = [k.replace('$', '') for k in valid_keys]
1✔
152

153
        # custom keyboard specified in config.json
154
        cust_keyboard = self._config['telegram'].get('keyboard', [])
1✔
155
        if cust_keyboard:
1✔
156
            combined = "(" + ")|(".join(valid_keys) + ")"
1✔
157
            # check for valid shortcuts
158
            invalid_keys = [b for b in chain.from_iterable(cust_keyboard)
1✔
159
                            if not re.match(combined, b)]
160
            if len(invalid_keys):
1✔
161
                err_msg = ('config.telegram.keyboard: Invalid commands for '
1✔
162
                           f'custom Telegram keyboard: {invalid_keys}'
163
                           f'\nvalid commands are: {valid_keys_print}')
164
                raise OperationalException(err_msg)
1✔
165
            else:
166
                self._keyboard = cust_keyboard
1✔
167
                logger.info('using custom keyboard from '
1✔
168
                            f'config.json: {self._keyboard}')
169

170
    def _init_telegram_app(self):
1✔
171
        return Application.builder().token(self._config['telegram']['token']).build()
×
172

173
    def _init(self) -> None:
1✔
174
        """
175
        Initializes this module with the given config,
176
        registers all known command handlers
177
        and starts polling for message updates
178
        Runs in a separate thread.
179
        """
180
        try:
1✔
181
            self._loop = asyncio.get_running_loop()
1✔
182
        except RuntimeError:
1✔
183
            self._loop = asyncio.new_event_loop()
1✔
184
            asyncio.set_event_loop(self._loop)
1✔
185

186
        self._app = self._init_telegram_app()
1✔
187

188
        # Register command handler and start telegram message polling
189
        handles = [
1✔
190
            CommandHandler('status', self._status),
191
            CommandHandler('profit', self._profit),
192
            CommandHandler('balance', self._balance),
193
            CommandHandler('start', self._start),
194
            CommandHandler('stop', self._stop),
195
            CommandHandler(['forcesell', 'forceexit', 'fx'], self._force_exit),
196
            CommandHandler(['forcebuy', 'forcelong'], partial(
197
                self._force_enter, order_side=SignalDirection.LONG)),
198
            CommandHandler('forceshort', partial(
199
                self._force_enter, order_side=SignalDirection.SHORT)),
200
            CommandHandler('reload_trade', self._reload_trade_from_exchange),
201
            CommandHandler('trades', self._trades),
202
            CommandHandler('delete', self._delete_trade),
203
            CommandHandler(['coo', 'cancel_open_order'], self._cancel_open_order),
204
            CommandHandler('performance', self._performance),
205
            CommandHandler(['buys', 'entries'], self._enter_tag_performance),
206
            CommandHandler(['sells', 'exits'], self._exit_reason_performance),
207
            CommandHandler('mix_tags', self._mix_tag_performance),
208
            CommandHandler('stats', self._stats),
209
            CommandHandler('daily', self._daily),
210
            CommandHandler('weekly', self._weekly),
211
            CommandHandler('monthly', self._monthly),
212
            CommandHandler('count', self._count),
213
            CommandHandler('locks', self._locks),
214
            CommandHandler(['unlock', 'delete_locks'], self._delete_locks),
215
            CommandHandler(['reload_config', 'reload_conf'], self._reload_config),
216
            CommandHandler(['show_config', 'show_conf'], self._show_config),
217
            CommandHandler(['stopbuy', 'stopentry'], self._stopentry),
218
            CommandHandler('whitelist', self._whitelist),
219
            CommandHandler('blacklist', self._blacklist),
220
            CommandHandler(['blacklist_delete', 'bl_delete'], self._blacklist_delete),
221
            CommandHandler('logs', self._logs),
222
            CommandHandler('edge', self._edge),
223
            CommandHandler('health', self._health),
224
            CommandHandler('help', self._help),
225
            CommandHandler('version', self._version),
226
            CommandHandler('marketdir', self._changemarketdir)
227
        ]
228
        callbacks = [
1✔
229
            CallbackQueryHandler(self._status_table, pattern='update_status_table'),
230
            CallbackQueryHandler(self._daily, pattern='update_daily'),
231
            CallbackQueryHandler(self._weekly, pattern='update_weekly'),
232
            CallbackQueryHandler(self._monthly, pattern='update_monthly'),
233
            CallbackQueryHandler(self._profit, pattern='update_profit'),
234
            CallbackQueryHandler(self._balance, pattern='update_balance'),
235
            CallbackQueryHandler(self._performance, pattern='update_performance'),
236
            CallbackQueryHandler(self._enter_tag_performance,
237
                                 pattern='update_enter_tag_performance'),
238
            CallbackQueryHandler(self._exit_reason_performance,
239
                                 pattern='update_exit_reason_performance'),
240
            CallbackQueryHandler(self._mix_tag_performance, pattern='update_mix_tag_performance'),
241
            CallbackQueryHandler(self._count, pattern='update_count'),
242
            CallbackQueryHandler(self._force_exit_inline, pattern=r"force_exit__\S+"),
243
            CallbackQueryHandler(self._force_enter_inline, pattern=r"\S+\/\S+"),
244
        ]
245
        for handle in handles:
1✔
246
            self._app.add_handler(handle)
1✔
247

248
        for callback in callbacks:
1✔
249
            self._app.add_handler(callback)
1✔
250

251
        logger.info(
1✔
252
            'rpc.telegram is listening for following commands: %s',
253
            [[x for x in sorted(h.commands)] for h in handles]
254
        )
255
        self._loop.run_until_complete(self._startup_telegram())
1✔
256

257
    async def _startup_telegram(self) -> None:
1✔
258
        await self._app.initialize()
1✔
259
        await self._app.start()
1✔
260
        if self._app.updater:
1✔
261
            await self._app.updater.start_polling(
1✔
262
                bootstrap_retries=-1,
263
                timeout=20,
264
                # read_latency=60,  # Assumed transmission latency
265
                drop_pending_updates=True,
266
                # stop_signals=[],  # Necessary as we don't run on the main thread
267
            )
268
            while True:
1✔
269
                await asyncio.sleep(10)
1✔
270
                if not self._app.updater.running:
1✔
271
                    break
1✔
272

273
    async def _cleanup_telegram(self) -> None:
1✔
274
        if self._app.updater:
1✔
275
            await self._app.updater.stop()
1✔
276
        await self._app.stop()
1✔
277
        await self._app.shutdown()
1✔
278

279
    def cleanup(self) -> None:
1✔
280
        """
281
        Stops all running telegram threads.
282
        :return: None
283
        """
284
        # This can take up to `timeout` from the call to `start_polling`.
285
        asyncio.run_coroutine_threadsafe(self._cleanup_telegram(), self._loop)
1✔
286
        self._thread.join()
1✔
287

288
    def _exchange_from_msg(self, msg: Dict[str, Any]) -> str:
1✔
289
        """
290
        Extracts the exchange name from the given message.
291
        :param msg: The message to extract the exchange name from.
292
        :return: The exchange name.
293
        """
294
        return f"{msg['exchange']}{' (dry)' if self._config['dry_run'] else ''}"
1✔
295

296
    def _add_analyzed_candle(self, pair: str) -> str:
1✔
297
        candle_val = self._config['telegram'].get(
1✔
298
            'notification_settings', {}).get('show_candle', 'off')
299
        if candle_val != 'off':
1✔
300
            if candle_val == 'ohlc':
1✔
301
                analyzed_df, _ = self._rpc._freqtrade.dataprovider.get_analyzed_dataframe(
1✔
302
                    pair, self._config['timeframe'])
303
                candle = analyzed_df.iloc[-1].squeeze() if len(analyzed_df) > 0 else None
1✔
304
                if candle is not None:
1✔
305
                    return (
1✔
306
                        f"*Candle OHLC*: `{candle['open']}, {candle['high']}, "
307
                        f"{candle['low']}, {candle['close']}`\n"
308
                    )
309

310
        return ''
1✔
311

312
    def _format_entry_msg(self, msg: Dict[str, Any]) -> str:
1✔
313
        if self._rpc._fiat_converter:
1✔
314
            msg['stake_amount_fiat'] = self._rpc._fiat_converter.convert_amount(
1✔
315
                msg['stake_amount'], msg['stake_currency'], msg['fiat_currency'])
316
        else:
317
            msg['stake_amount_fiat'] = 0
1✔
318
        is_fill = msg['type'] in [RPCMessageType.ENTRY_FILL]
1✔
319
        emoji = '\N{CHECK MARK}' if is_fill else '\N{LARGE BLUE CIRCLE}'
1✔
320

321
        entry_side = ({'enter': 'Long', 'entered': 'Longed'} if msg['direction'] == 'Long'
1✔
322
                      else {'enter': 'Short', 'entered': 'Shorted'})
323
        message = (
1✔
324
            f"{emoji} *{self._exchange_from_msg(msg)}:*"
325
            f" {entry_side['entered'] if is_fill else entry_side['enter']} {msg['pair']}"
326
            f" (#{msg['trade_id']})\n"
327
        )
328
        message += self._add_analyzed_candle(msg['pair'])
1✔
329
        message += f"*Enter Tag:* `{msg['enter_tag']}`\n" if msg.get('enter_tag') else ""
1✔
330
        message += f"*Amount:* `{msg['amount']:.8f}`\n"
1✔
331
        if msg.get('leverage') and msg.get('leverage', 1.0) != 1.0:
1✔
332
            message += f"*Leverage:* `{msg['leverage']}`\n"
1✔
333

334
        if msg['type'] in [RPCMessageType.ENTRY_FILL]:
1✔
335
            message += f"*Open Rate:* `{msg['open_rate']:.8f}`\n"
1✔
336
        elif msg['type'] in [RPCMessageType.ENTRY]:
1✔
337
            message += f"*Open Rate:* `{msg['open_rate']:.8f}`\n"\
1✔
338
                       f"*Current Rate:* `{msg['current_rate']:.8f}`\n"
339

340
        message += f"*Total:* `({round_coin_value(msg['stake_amount'], msg['stake_currency'])}"
1✔
341

342
        if msg.get('fiat_currency'):
1✔
343
            message += f", {round_coin_value(msg['stake_amount_fiat'], msg['fiat_currency'])}"
1✔
344

345
        message += ")`"
1✔
346
        return message
1✔
347

348
    def _format_exit_msg(self, msg: Dict[str, Any]) -> str:
1✔
349
        msg['amount'] = round(msg['amount'], 8)
1✔
350
        msg['profit_percent'] = round(msg['profit_ratio'] * 100, 2)
1✔
351
        msg['duration'] = msg['close_date'].replace(
1✔
352
            microsecond=0) - msg['open_date'].replace(microsecond=0)
353
        msg['duration_min'] = msg['duration'].total_seconds() / 60
1✔
354

355
        msg['enter_tag'] = msg['enter_tag'] if "enter_tag" in msg.keys() else None
1✔
356
        msg['emoji'] = self._get_sell_emoji(msg)
1✔
357
        msg['leverage_text'] = (f"*Leverage:* `{msg['leverage']:.1f}`\n"
1✔
358
                                if msg.get('leverage') and msg.get('leverage', 1.0) != 1.0
359
                                else "")
360

361
        # Check if all sell properties are available.
362
        # This might not be the case if the message origin is triggered by /forceexit
363
        if (all(prop in msg for prop in ['gain', 'fiat_currency', 'stake_currency'])
1✔
364
                and self._rpc._fiat_converter):
365
            msg['profit_fiat'] = self._rpc._fiat_converter.convert_amount(
1✔
366
                msg['profit_amount'], msg['stake_currency'], msg['fiat_currency'])
367
            msg['profit_extra'] = f" / {msg['profit_fiat']:.3f} {msg['fiat_currency']}"
1✔
368
        else:
369
            msg['profit_extra'] = ''
1✔
370
        msg['profit_extra'] = (
1✔
371
            f" ({msg['gain']}: {msg['profit_amount']:.8f} {msg['stake_currency']}"
372
            f"{msg['profit_extra']})")
373

374
        is_fill = msg['type'] == RPCMessageType.EXIT_FILL
1✔
375
        is_sub_trade = msg.get('sub_trade')
1✔
376
        is_sub_profit = msg['profit_amount'] != msg.get('cumulative_profit')
1✔
377
        profit_prefix = ('Sub ' if is_sub_profit else 'Cumulative ') if is_sub_trade else ''
1✔
378
        cp_extra = ''
1✔
379
        exit_wording = 'Exited' if is_fill else 'Exiting'
1✔
380
        if is_sub_profit and is_sub_trade:
1✔
381
            if self._rpc._fiat_converter:
1✔
382
                cp_fiat = self._rpc._fiat_converter.convert_amount(
1✔
383
                    msg['cumulative_profit'], msg['stake_currency'], msg['fiat_currency'])
384
                cp_extra = f" / {cp_fiat:.3f} {msg['fiat_currency']}"
1✔
385
            exit_wording = f"Partially {exit_wording.lower()}"
1✔
386
            cp_extra = (
1✔
387
                f"*Cumulative Profit:* (`{msg['cumulative_profit']:.8f} "
388
                f"{msg['stake_currency']}{cp_extra}`)\n"
389
            )
390

391
        message = (
1✔
392
            f"{msg['emoji']} *{self._exchange_from_msg(msg)}:* "
393
            f"{exit_wording} {msg['pair']} (#{msg['trade_id']})\n"
394
            f"{self._add_analyzed_candle(msg['pair'])}"
395
            f"*{f'{profit_prefix}Profit' if is_fill else f'Unrealized {profit_prefix}Profit'}:* "
396
            f"`{msg['profit_ratio']:.2%}{msg['profit_extra']}`\n"
397
            f"{cp_extra}"
398
            f"*Enter Tag:* `{msg['enter_tag']}`\n"
399
            f"*Exit Reason:* `{msg['exit_reason']}`\n"
400
            f"*Direction:* `{msg['direction']}`\n"
401
            f"{msg['leverage_text']}"
402
            f"*Amount:* `{msg['amount']:.8f}`\n"
403
            f"*Open Rate:* `{msg['open_rate']:.8f}`\n"
404
        )
405
        if msg['type'] == RPCMessageType.EXIT:
1✔
406
            message += f"*Current Rate:* `{msg['current_rate']:.8f}`\n"
1✔
407
            if msg['order_rate']:
1✔
408
                message += f"*Exit Rate:* `{msg['order_rate']:.8f}`"
1✔
409

410
        elif msg['type'] == RPCMessageType.EXIT_FILL:
1✔
411
            message += f"*Exit Rate:* `{msg['close_rate']:.8f}`"
1✔
412
        if is_sub_trade:
1✔
413
            if self._rpc._fiat_converter:
1✔
414
                msg['stake_amount_fiat'] = self._rpc._fiat_converter.convert_amount(
1✔
415
                    msg['stake_amount'], msg['stake_currency'], msg['fiat_currency'])
416
            else:
417
                msg['stake_amount_fiat'] = 0
×
418
            rem = round_coin_value(msg['stake_amount'], msg['stake_currency'])
1✔
419
            message += f"\n*Remaining:* `({rem}"
1✔
420

421
            if msg.get('fiat_currency', None):
1✔
422
                message += f", {round_coin_value(msg['stake_amount_fiat'], msg['fiat_currency'])}"
1✔
423

424
            message += ")`"
1✔
425
        else:
426
            message += f"\n*Duration:* `{msg['duration']} ({msg['duration_min']:.1f} min)`"
1✔
427
        return message
1✔
428

429
    def compose_message(self, msg: Dict[str, Any], msg_type: RPCMessageType) -> Optional[str]:
1✔
430
        if msg_type in [RPCMessageType.ENTRY, RPCMessageType.ENTRY_FILL]:
1✔
431
            message = self._format_entry_msg(msg)
1✔
432

433
        elif msg_type in [RPCMessageType.EXIT, RPCMessageType.EXIT_FILL]:
1✔
434
            message = self._format_exit_msg(msg)
1✔
435

436
        elif msg_type in (RPCMessageType.ENTRY_CANCEL, RPCMessageType.EXIT_CANCEL):
1✔
437
            msg['message_side'] = 'enter' if msg_type in [RPCMessageType.ENTRY_CANCEL] else 'exit'
1✔
438
            message = (f"\N{WARNING SIGN} *{self._exchange_from_msg(msg)}:* "
1✔
439
                       f"Cancelling {'partial ' if msg.get('sub_trade') else ''}"
440
                       f"{msg['message_side']} Order for {msg['pair']} "
441
                       f"(#{msg['trade_id']}). Reason: {msg['reason']}.")
442

443
        elif msg_type == RPCMessageType.PROTECTION_TRIGGER:
1✔
444
            message = (
1✔
445
                f"*Protection* triggered due to {msg['reason']}. "
446
                f"`{msg['pair']}` will be locked until `{msg['lock_end_time']}`."
447
            )
448

449
        elif msg_type == RPCMessageType.PROTECTION_TRIGGER_GLOBAL:
1✔
450
            message = (
1✔
451
                f"*Protection* triggered due to {msg['reason']}. "
452
                f"*All pairs* will be locked until `{msg['lock_end_time']}`."
453
            )
454

455
        elif msg_type == RPCMessageType.STATUS:
1✔
456
            message = f"*Status:* `{msg['status']}`"
1✔
457

458
        elif msg_type == RPCMessageType.WARNING:
1✔
459
            message = f"\N{WARNING SIGN} *Warning:* `{msg['status']}`"
1✔
460
        elif msg_type == RPCMessageType.EXCEPTION:
1✔
461
            # Errors will contain exceptions, which are wrapped in tripple ticks.
462
            message = f"\N{WARNING SIGN} *ERROR:* \n {msg['status']}"
×
463

464
        elif msg_type == RPCMessageType.STARTUP:
1✔
465
            message = f"{msg['status']}"
1✔
466
        elif msg_type == RPCMessageType.STRATEGY_MSG:
1✔
467
            message = f"{msg['msg']}"
1✔
468
        else:
469
            logger.debug("Unknown message type: %s", msg_type)
1✔
470
            return None
1✔
471
        return message
1✔
472

473
    def send_msg(self, msg: RPCSendMsg) -> None:
1✔
474
        """ Send a message to telegram channel """
475

476
        default_noti = 'on'
1✔
477

478
        msg_type = msg['type']
1✔
479
        noti = ''
1✔
480
        if msg['type'] == RPCMessageType.EXIT:
1✔
481
            sell_noti = self._config['telegram'] \
1✔
482
                .get('notification_settings', {}).get(str(msg_type), {})
483
            # For backward compatibility sell still can be string
484
            if isinstance(sell_noti, str):
1✔
485
                noti = sell_noti
×
486
            else:
487
                noti = sell_noti.get(str(msg['exit_reason']), default_noti)
1✔
488
        else:
489
            noti = self._config['telegram'] \
1✔
490
                .get('notification_settings', {}).get(str(msg_type), default_noti)
491

492
        if noti == 'off':
1✔
493
            logger.info(f"Notification '{msg_type}' not sent.")
×
494
            # Notification disabled
495
            return
×
496

497
        message = self.compose_message(deepcopy(msg), msg_type)  # type: ignore
1✔
498
        if message:
1✔
499
            asyncio.run_coroutine_threadsafe(
1✔
500
                self._send_msg(message, disable_notification=(noti == 'silent')),
501
                self._loop)
502

503
    def _get_sell_emoji(self, msg):
1✔
504
        """
505
        Get emoji for sell-side
506
        """
507

508
        if float(msg['profit_percent']) >= 5.0:
1✔
509
            return "\N{ROCKET}"
1✔
510
        elif float(msg['profit_percent']) >= 0.0:
1✔
511
            return "\N{EIGHT SPOKED ASTERISK}"
1✔
512
        elif msg['exit_reason'] == "stop_loss":
1✔
513
            return "\N{WARNING SIGN}"
1✔
514
        else:
515
            return "\N{CROSS MARK}"
1✔
516

517
    def _prepare_order_details(self, filled_orders: List, quote_currency: str, is_open: bool):
1✔
518
        """
519
        Prepare details of trade with entry adjustment enabled
520
        """
521
        lines_detail: List[str] = []
1✔
522
        if len(filled_orders) > 0:
1✔
523
            first_avg = filled_orders[0]["safe_price"]
1✔
524
        order_nr = 0
1✔
525
        for order in filled_orders:
1✔
526
            lines: List[str] = []
1✔
527
            if order['is_open'] is True:
1✔
528
                continue
1✔
529
            order_nr += 1
1✔
530
            wording = 'Entry' if order['ft_is_entry'] else 'Exit'
1✔
531

532
            cur_entry_amount = order["filled"] or order["amount"]
1✔
533
            cur_entry_average = order["safe_price"]
1✔
534
            lines.append("  ")
1✔
535
            lines.append(f"*{wording} #{order_nr}:*")
1✔
536
            if order_nr == 1:
1✔
537
                lines.append(
1✔
538
                    f"*Amount:* {cur_entry_amount:.8g} "
539
                    f"({round_coin_value(order['cost'], quote_currency)})"
540
                )
541
                lines.append(f"*Average Price:* {cur_entry_average:.8g}")
1✔
542
            else:
543
                # TODO: This calculation ignores fees.
544
                price_to_1st_entry = ((cur_entry_average - first_avg) / first_avg)
1✔
545
                if is_open:
1✔
546
                    lines.append("({})".format(dt_humanize(order["order_filled_date"],
1✔
547
                                                           granularity=["day", "hour", "minute"])))
548
                lines.append(f"*Amount:* {cur_entry_amount:.8g} "
1✔
549
                             f"({round_coin_value(order['cost'], quote_currency)})")
550
                lines.append(f"*Average {wording} Price:* {cur_entry_average:.8g} "
1✔
551
                             f"({price_to_1st_entry:.2%} from 1st entry rate)")
552
                lines.append(f"*Order Filled:* {order['order_filled_date']}")
1✔
553

554
            lines_detail.append("\n".join(lines))
1✔
555

556
        return lines_detail
1✔
557

558
    @authorized_only
1✔
559
    async def _status(self, update: Update, context: CallbackContext) -> None:
1✔
560
        """
561
        Handler for /status.
562
        Returns the current TradeThread status
563
        :param bot: telegram bot
564
        :param update: message update
565
        :return: None
566
        """
567

568
        if context.args and 'table' in context.args:
1✔
569
            await self._status_table(update, context)
1✔
570
            return
×
571
        else:
572
            await self._status_msg(update, context)
1✔
573

574
    async def _status_msg(self, update: Update, context: CallbackContext) -> None:
1✔
575
        """
576
        handler for `/status` and `/status <id>`.
577

578
        """
579
        # Check if there's at least one numerical ID provided.
580
        # If so, try to get only these trades.
581
        trade_ids = []
1✔
582
        if context.args and len(context.args) > 0:
1✔
583
            trade_ids = [int(i) for i in context.args if i.isnumeric()]
1✔
584

585
        results = self._rpc._rpc_trade_status(trade_ids=trade_ids)
1✔
586
        position_adjust = self._config.get('position_adjustment_enable', False)
1✔
587
        max_entries = self._config.get('max_entry_position_adjustment', -1)
1✔
588
        for r in results:
1✔
589
            r['open_date_hum'] = dt_humanize(r['open_date'])
1✔
590
            r['num_entries'] = len([o for o in r['orders'] if o['ft_is_entry']])
1✔
591
            r['num_exits'] = len([o for o in r['orders'] if not o['ft_is_entry']
1✔
592
                                 and not o['ft_order_side'] == 'stoploss'])
593
            r['exit_reason'] = r.get('exit_reason', "")
1✔
594
            r['stake_amount_r'] = round_coin_value(r['stake_amount'], r['quote_currency'])
1✔
595
            r['max_stake_amount_r'] = round_coin_value(
1✔
596
                r['max_stake_amount'] or r['stake_amount'], r['quote_currency'])
597
            r['profit_abs_r'] = round_coin_value(r['profit_abs'], r['quote_currency'])
1✔
598
            r['realized_profit_r'] = round_coin_value(r['realized_profit'], r['quote_currency'])
1✔
599
            r['total_profit_abs_r'] = round_coin_value(
1✔
600
                r['total_profit_abs'], r['quote_currency'])
601
            lines = [
1✔
602
                "*Trade ID:* `{trade_id}`" +
603
                (" `(since {open_date_hum})`" if r['is_open'] else ""),
604
                "*Current Pair:* {pair}",
605
                f"*Direction:* {'`Short`' if r.get('is_short') else '`Long`'}"
606
                + " ` ({leverage}x)`" if r.get('leverage') else "",
607
                "*Amount:* `{amount} ({stake_amount_r})`",
608
                "*Total invested:* `{max_stake_amount_r}`" if position_adjust else "",
609
                "*Enter Tag:* `{enter_tag}`" if r['enter_tag'] else "",
610
                "*Exit Reason:* `{exit_reason}`" if r['exit_reason'] else "",
611
            ]
612

613
            if position_adjust:
1✔
614
                max_buy_str = (f"/{max_entries + 1}" if (max_entries > 0) else "")
1✔
615
                lines.extend([
1✔
616
                    "*Number of Entries:* `{num_entries}" + max_buy_str + "`",
617
                    "*Number of Exits:* `{num_exits}`"
618
                ])
619

620
            lines.extend([
1✔
621
                "*Open Rate:* `{open_rate:.8g}`",
622
                "*Close Rate:* `{close_rate:.8g}`" if r['close_rate'] else "",
623
                "*Open Date:* `{open_date}`",
624
                "*Close Date:* `{close_date}`" if r['close_date'] else "",
625
                " \n*Current Rate:* `{current_rate:.8g}`" if r['is_open'] else "",
626
                ("*Unrealized Profit:* " if r['is_open'] else "*Close Profit: *")
627
                + "`{profit_ratio:.2%}` `({profit_abs_r})`",
628
            ])
629

630
            if r['is_open']:
1✔
631
                if r.get('realized_profit'):
1✔
632
                    lines.extend([
×
633
                        "*Realized Profit:* `{realized_profit_ratio:.2%} ({realized_profit_r})`",
634
                        "*Total Profit:* `{total_profit_ratio:.2%} ({total_profit_abs_r})`"
635
                    ])
636

637
                # Append empty line to improve readability
638
                lines.append(" ")
1✔
639
                if (r['stop_loss_abs'] != r['initial_stop_loss_abs']
1✔
640
                        and r['initial_stop_loss_ratio'] is not None):
641
                    # Adding initial stoploss only if it is different from stoploss
642
                    lines.append("*Initial Stoploss:* `{initial_stop_loss_abs:.8f}` "
1✔
643
                                 "`({initial_stop_loss_ratio:.2%})`")
644

645
                # Adding stoploss and stoploss percentage only if it is not None
646
                lines.append("*Stoploss:* `{stop_loss_abs:.8g}` " +
1✔
647
                             ("`({stop_loss_ratio:.2%})`" if r['stop_loss_ratio'] else ""))
648
                lines.append("*Stoploss distance:* `{stoploss_current_dist:.8g}` "
1✔
649
                             "`({stoploss_current_dist_ratio:.2%})`")
650
                if r['open_order']:
1✔
651
                    lines.append(
1✔
652
                        "*Open Order:* `{open_order}`"
653
                        + "- `{exit_order_status}`" if r['exit_order_status'] else "")
654

655
            lines_detail = self._prepare_order_details(
1✔
656
                r['orders'], r['quote_currency'], r['is_open'])
657
            lines.extend(lines_detail if lines_detail else "")
1✔
658
            await self.__send_status_msg(lines, r)
1✔
659

660
    async def __send_status_msg(self, lines: List[str], r: Dict[str, Any]) -> None:
1✔
661
        """
662
        Send status message.
663
        """
664
        msg = ''
1✔
665

666
        for line in lines:
1✔
667
            if line:
1✔
668
                if (len(msg) + len(line) + 1) < MAX_MESSAGE_LENGTH:
1✔
669
                    msg += line + '\n'
1✔
670
                else:
671
                    await self._send_msg(msg.format(**r))
1✔
672
                    msg = "*Trade ID:* `{trade_id}` - continued\n" + line + '\n'
1✔
673

674
        await self._send_msg(msg.format(**r))
1✔
675

676
    @authorized_only
1✔
677
    async def _status_table(self, update: Update, context: CallbackContext) -> None:
1✔
678
        """
679
        Handler for /status table.
680
        Returns the current TradeThread status in table format
681
        :param bot: telegram bot
682
        :param update: message update
683
        :return: None
684
        """
685
        fiat_currency = self._config.get('fiat_display_currency', '')
1✔
686
        statlist, head, fiat_profit_sum = self._rpc._rpc_status_table(
1✔
687
            self._config['stake_currency'], fiat_currency)
688

689
        show_total = not isnan(fiat_profit_sum) and len(statlist) > 1
1✔
690
        max_trades_per_msg = 50
1✔
691
        """
1✔
692
        Calculate the number of messages of 50 trades per message
693
        0.99 is used to make sure that there are no extra (empty) messages
694
        As an example with 50 trades, there will be int(50/50 + 0.99) = 1 message
695
        """
696
        messages_count = max(int(len(statlist) / max_trades_per_msg + 0.99), 1)
1✔
697
        for i in range(0, messages_count):
1✔
698
            trades = statlist[i * max_trades_per_msg:(i + 1) * max_trades_per_msg]
1✔
699
            if show_total and i == messages_count - 1:
1✔
700
                # append total line
701
                trades.append(["Total", "", "", f"{fiat_profit_sum:.2f} {fiat_currency}"])
×
702

703
            message = tabulate(trades,
1✔
704
                               headers=head,
705
                               tablefmt='simple')
706
            if show_total and i == messages_count - 1:
1✔
707
                # insert separators line between Total
708
                lines = message.split("\n")
×
709
                message = "\n".join(lines[:-1] + [lines[1]] + [lines[-1]])
×
710
            await self._send_msg(f"<pre>{message}</pre>", parse_mode=ParseMode.HTML,
1✔
711
                                 reload_able=True, callback_path="update_status_table",
712
                                 query=update.callback_query)
713

714
    async def _timeunit_stats(self, update: Update, context: CallbackContext, unit: str) -> None:
1✔
715
        """
716
        Handler for /daily <n>
717
        Returns a daily profit (in BTC) over the last n days.
718
        :param bot: telegram bot
719
        :param update: message update
720
        :return: None
721
        """
722

723
        vals = {
1✔
724
            'days': TimeunitMappings('Day', 'Daily', 'days', 'update_daily', 7, '%Y-%m-%d'),
725
            'weeks': TimeunitMappings('Monday', 'Weekly', 'weeks (starting from Monday)',
726
                                      'update_weekly', 8, '%Y-%m-%d'),
727
            'months': TimeunitMappings('Month', 'Monthly', 'months', 'update_monthly', 6, '%Y-%m'),
728
        }
729
        val = vals[unit]
1✔
730

731
        stake_cur = self._config['stake_currency']
1✔
732
        fiat_disp_cur = self._config.get('fiat_display_currency', '')
1✔
733
        try:
1✔
734
            timescale = int(context.args[0]) if context.args else val.default
1✔
735
        except (TypeError, ValueError, IndexError):
1✔
736
            timescale = val.default
1✔
737
        stats = self._rpc._rpc_timeunit_profit(
1✔
738
            timescale,
739
            stake_cur,
740
            fiat_disp_cur,
741
            unit
742
        )
743
        stats_tab = tabulate(
1✔
744
            [[f"{period['date']:{val.dateformat}} ({period['trade_count']})",
745
              f"{round_coin_value(period['abs_profit'], stats['stake_currency'])}",
746
              f"{period['fiat_value']:.2f} {stats['fiat_display_currency']}",
747
              f"{period['rel_profit']:.2%}",
748
              ] for period in stats['data']],
749
            headers=[
750
                f"{val.header} (count)",
751
                f'{stake_cur}',
752
                f'{fiat_disp_cur}',
753
                'Profit %',
754
                'Trades',
755
            ],
756
            tablefmt='simple')
757
        message = (
1✔
758
            f'<b>{val.message} Profit over the last {timescale} {val.message2}</b>:\n'
759
            f'<pre>{stats_tab}</pre>'
760
        )
761
        await self._send_msg(message, parse_mode=ParseMode.HTML, reload_able=True,
1✔
762
                             callback_path=val.callback, query=update.callback_query)
763

764
    @authorized_only
1✔
765
    async def _daily(self, update: Update, context: CallbackContext) -> None:
1✔
766
        """
767
        Handler for /daily <n>
768
        Returns a daily profit (in BTC) over the last n days.
769
        :param bot: telegram bot
770
        :param update: message update
771
        :return: None
772
        """
773
        await self._timeunit_stats(update, context, 'days')
1✔
774

775
    @authorized_only
1✔
776
    async def _weekly(self, update: Update, context: CallbackContext) -> None:
1✔
777
        """
778
        Handler for /weekly <n>
779
        Returns a weekly profit (in BTC) over the last n weeks.
780
        :param bot: telegram bot
781
        :param update: message update
782
        :return: None
783
        """
784
        await self._timeunit_stats(update, context, 'weeks')
1✔
785

786
    @authorized_only
1✔
787
    async def _monthly(self, update: Update, context: CallbackContext) -> None:
1✔
788
        """
789
        Handler for /monthly <n>
790
        Returns a monthly profit (in BTC) over the last n months.
791
        :param bot: telegram bot
792
        :param update: message update
793
        :return: None
794
        """
795
        await self._timeunit_stats(update, context, 'months')
1✔
796

797
    @authorized_only
1✔
798
    async def _profit(self, update: Update, context: CallbackContext) -> None:
1✔
799
        """
800
        Handler for /profit.
801
        Returns a cumulative profit statistics.
802
        :param bot: telegram bot
803
        :param update: message update
804
        :return: None
805
        """
806
        stake_cur = self._config['stake_currency']
1✔
807
        fiat_disp_cur = self._config.get('fiat_display_currency', '')
1✔
808

809
        start_date = datetime.fromtimestamp(0)
1✔
810
        timescale = None
1✔
811
        try:
1✔
812
            if context.args:
1✔
813
                timescale = int(context.args[0]) - 1
1✔
814
                today_start = datetime.combine(date.today(), datetime.min.time())
1✔
815
                start_date = today_start - timedelta(days=timescale)
1✔
816
        except (TypeError, ValueError, IndexError):
1✔
817
            pass
1✔
818

819
        stats = self._rpc._rpc_trade_statistics(
1✔
820
            stake_cur,
821
            fiat_disp_cur,
822
            start_date)
823
        profit_closed_coin = stats['profit_closed_coin']
1✔
824
        profit_closed_ratio_mean = stats['profit_closed_ratio_mean']
1✔
825
        profit_closed_percent = stats['profit_closed_percent']
1✔
826
        profit_closed_fiat = stats['profit_closed_fiat']
1✔
827
        profit_all_coin = stats['profit_all_coin']
1✔
828
        profit_all_ratio_mean = stats['profit_all_ratio_mean']
1✔
829
        profit_all_percent = stats['profit_all_percent']
1✔
830
        profit_all_fiat = stats['profit_all_fiat']
1✔
831
        trade_count = stats['trade_count']
1✔
832
        first_trade_date = f"{stats['first_trade_humanized']} ({stats['first_trade_date']})"
1✔
833
        latest_trade_date = f"{stats['latest_trade_humanized']} ({stats['latest_trade_date']})"
1✔
834
        avg_duration = stats['avg_duration']
1✔
835
        best_pair = stats['best_pair']
1✔
836
        best_pair_profit_ratio = stats['best_pair_profit_ratio']
1✔
837
        winrate = stats['winrate']
1✔
838
        expectancy = stats['expectancy']
1✔
839
        expectancy_ratio = stats['expectancy_ratio']
1✔
840

841
        if stats['trade_count'] == 0:
1✔
842
            markdown_msg = f"No trades yet.\n*Bot started:* `{stats['bot_start_date']}`"
1✔
843
        else:
844
            # Message to display
845
            if stats['closed_trade_count'] > 0:
1✔
846
                markdown_msg = ("*ROI:* Closed trades\n"
1✔
847
                                f"∙ `{round_coin_value(profit_closed_coin, stake_cur)} "
848
                                f"({profit_closed_ratio_mean:.2%}) "
849
                                f"({profit_closed_percent} \N{GREEK CAPITAL LETTER SIGMA}%)`\n"
850
                                f"∙ `{round_coin_value(profit_closed_fiat, fiat_disp_cur)}`\n")
851
            else:
852
                markdown_msg = "`No closed trade` \n"
1✔
853

854
            markdown_msg += (
1✔
855
                f"*ROI:* All trades\n"
856
                f"∙ `{round_coin_value(profit_all_coin, stake_cur)} "
857
                f"({profit_all_ratio_mean:.2%}) "
858
                f"({profit_all_percent} \N{GREEK CAPITAL LETTER SIGMA}%)`\n"
859
                f"∙ `{round_coin_value(profit_all_fiat, fiat_disp_cur)}`\n"
860
                f"*Total Trade Count:* `{trade_count}`\n"
861
                f"*Bot started:* `{stats['bot_start_date']}`\n"
862
                f"*{'First Trade opened' if not timescale else 'Showing Profit since'}:* "
863
                f"`{first_trade_date}`\n"
864
                f"*Latest Trade opened:* `{latest_trade_date}`\n"
865
                f"*Win / Loss:* `{stats['winning_trades']} / {stats['losing_trades']}`\n"
866
                f"*Winrate:* `{winrate:.2%}`\n"
867
                f"*Expectancy (Ratio):* `{expectancy:.2f} ({expectancy_ratio:.2f})`"
868
            )
869
            if stats['closed_trade_count'] > 0:
1✔
870
                markdown_msg += (
1✔
871
                    f"\n*Avg. Duration:* `{avg_duration}`\n"
872
                    f"*Best Performing:* `{best_pair}: {best_pair_profit_ratio:.2%}`\n"
873
                    f"*Trading volume:* `{round_coin_value(stats['trading_volume'], stake_cur)}`\n"
874
                    f"*Profit factor:* `{stats['profit_factor']:.2f}`\n"
875
                    f"*Max Drawdown:* `{stats['max_drawdown']:.2%} "
876
                    f"({round_coin_value(stats['max_drawdown_abs'], stake_cur)})`\n"
877
                    f"    from `{stats['max_drawdown_start']} "
878
                    f"({round_coin_value(stats['drawdown_high'], stake_cur)})`\n"
879
                    f"    to `{stats['max_drawdown_end']} "
880
                    f"({round_coin_value(stats['drawdown_low'], stake_cur)})`\n"
881
                )
882
        await self._send_msg(markdown_msg, reload_able=True, callback_path="update_profit",
1✔
883
                             query=update.callback_query)
884

885
    @authorized_only
1✔
886
    async def _stats(self, update: Update, context: CallbackContext) -> None:
1✔
887
        """
888
        Handler for /stats
889
        Show stats of recent trades
890
        """
891
        stats = self._rpc._rpc_stats()
1✔
892

893
        reason_map = {
1✔
894
            'roi': 'ROI',
895
            'stop_loss': 'Stoploss',
896
            'trailing_stop_loss': 'Trail. Stop',
897
            'stoploss_on_exchange': 'Stoploss',
898
            'exit_signal': 'Exit Signal',
899
            'force_exit': 'Force Exit',
900
            'emergency_exit': 'Emergency Exit',
901
        }
902
        exit_reasons_tabulate = [
1✔
903
            [
904
                reason_map.get(reason, reason),
905
                sum(count.values()),
906
                count['wins'],
907
                count['losses']
908
            ] for reason, count in stats['exit_reasons'].items()
909
        ]
910
        exit_reasons_msg = 'No trades yet.'
1✔
911
        for reason in chunks(exit_reasons_tabulate, 25):
1✔
912
            exit_reasons_msg = tabulate(
1✔
913
                reason,
914
                headers=['Exit Reason', 'Exits', 'Wins', 'Losses']
915
            )
916
            if len(exit_reasons_tabulate) > 25:
1✔
917
                await self._send_msg(f"```\n{exit_reasons_msg}```", ParseMode.MARKDOWN)
×
918
                exit_reasons_msg = ''
×
919

920
        durations = stats['durations']
1✔
921
        duration_msg = tabulate(
1✔
922
            [
923
                ['Wins', str(timedelta(seconds=durations['wins']))
924
                 if durations['wins'] is not None else 'N/A'],
925
                ['Losses', str(timedelta(seconds=durations['losses']))
926
                 if durations['losses'] is not None else 'N/A']
927
            ],
928
            headers=['', 'Avg. Duration']
929
        )
930
        msg = (f"""```\n{exit_reasons_msg}```\n```\n{duration_msg}```""")
1✔
931

932
        await self._send_msg(msg, ParseMode.MARKDOWN)
1✔
933

934
    @authorized_only
1✔
935
    async def _balance(self, update: Update, context: CallbackContext) -> None:
1✔
936
        """ Handler for /balance """
937
        full_result = context.args and 'full' in context.args
1✔
938
        result = self._rpc._rpc_balance(self._config['stake_currency'],
1✔
939
                                        self._config.get('fiat_display_currency', ''))
940

941
        balance_dust_level = self._config['telegram'].get('balance_dust_level', 0.0)
1✔
942
        if not balance_dust_level:
1✔
943
            balance_dust_level = DUST_PER_COIN.get(self._config['stake_currency'], 1.0)
1✔
944

945
        output = ''
1✔
946
        if self._config['dry_run']:
1✔
947
            output += "*Warning:* Simulated balances in Dry Mode.\n"
1✔
948
        starting_cap = round_coin_value(result['starting_capital'], self._config['stake_currency'])
1✔
949
        output += f"Starting capital: `{starting_cap}`"
1✔
950
        starting_cap_fiat = round_coin_value(
1✔
951
            result['starting_capital_fiat'], self._config['fiat_display_currency']
952
        ) if result['starting_capital_fiat'] > 0 else ''
953
        output += (f" `, {starting_cap_fiat}`.\n"
1✔
954
                   ) if result['starting_capital_fiat'] > 0 else '.\n'
955

956
        total_dust_balance = 0
1✔
957
        total_dust_currencies = 0
1✔
958
        for curr in result['currencies']:
1✔
959
            curr_output = ''
1✔
960
            if (
1✔
961
                (curr['is_position'] or curr['est_stake'] > balance_dust_level)
962
                and (full_result or curr['is_bot_managed'])
963
            ):
964
                if curr['is_position']:
1✔
965
                    curr_output = (
×
966
                        f"*{curr['currency']}:*\n"
967
                        f"\t`{curr['side']}: {curr['position']:.8f}`\n"
968
                        f"\t`Leverage: {curr['leverage']:.1f}`\n"
969
                        f"\t`Est. {curr['stake']}: "
970
                        f"{round_coin_value(curr['est_stake'], curr['stake'], False)}`\n")
971
                else:
972
                    est_stake = round_coin_value(
1✔
973
                        curr['est_stake' if full_result else 'est_stake_bot'], curr['stake'], False)
974

975
                    curr_output = (
1✔
976
                        f"*{curr['currency']}:*\n"
977
                        f"\t`Available: {curr['free']:.8f}`\n"
978
                        f"\t`Balance: {curr['balance']:.8f}`\n"
979
                        f"\t`Pending: {curr['used']:.8f}`\n"
980
                        f"\t`Bot Owned: {curr['bot_owned']:.8f}`\n"
981
                        f"\t`Est. {curr['stake']}: {est_stake}`\n")
982

983
            elif curr['est_stake'] <= balance_dust_level:
1✔
984
                total_dust_balance += curr['est_stake']
1✔
985
                total_dust_currencies += 1
1✔
986

987
            # Handle overflowing message length
988
            if len(output + curr_output) >= MAX_MESSAGE_LENGTH:
1✔
989
                await self._send_msg(output)
1✔
990
                output = curr_output
1✔
991
            else:
992
                output += curr_output
1✔
993

994
        if total_dust_balance > 0:
1✔
995
            output += (
1✔
996
                f"*{total_dust_currencies} Other "
997
                f"{plural(total_dust_currencies, 'Currency', 'Currencies')} "
998
                f"(< {balance_dust_level} {result['stake']}):*\n"
999
                f"\t`Est. {result['stake']}: "
1000
                f"{round_coin_value(total_dust_balance, result['stake'], False)}`\n")
1001
        tc = result['trade_count'] > 0
1✔
1002
        stake_improve = f" `({result['starting_capital_ratio']:.2%})`" if tc else ''
1✔
1003
        fiat_val = f" `({result['starting_capital_fiat_ratio']:.2%})`" if tc else ''
1✔
1004
        value = round_coin_value(
1✔
1005
            result['value' if full_result else 'value_bot'], result['symbol'], False)
1006
        total_stake = round_coin_value(
1✔
1007
            result['total' if full_result else 'total_bot'], result['stake'], False)
1008
        output += (
1✔
1009
            f"\n*Estimated Value{' (Bot managed assets only)' if not full_result else ''}*:\n"
1010
            f"\t`{result['stake']}: {total_stake}`{stake_improve}\n"
1011
            f"\t`{result['symbol']}: {value}`{fiat_val}\n"
1012
        )
1013
        await self._send_msg(output, reload_able=True, callback_path="update_balance",
1✔
1014
                             query=update.callback_query)
1015

1016
    @authorized_only
1✔
1017
    async def _start(self, update: Update, context: CallbackContext) -> None:
1✔
1018
        """
1019
        Handler for /start.
1020
        Starts TradeThread
1021
        :param bot: telegram bot
1022
        :param update: message update
1023
        :return: None
1024
        """
1025
        msg = self._rpc._rpc_start()
1✔
1026
        await self._send_msg(f"Status: `{msg['status']}`")
1✔
1027

1028
    @authorized_only
1✔
1029
    async def _stop(self, update: Update, context: CallbackContext) -> None:
1✔
1030
        """
1031
        Handler for /stop.
1032
        Stops TradeThread
1033
        :param bot: telegram bot
1034
        :param update: message update
1035
        :return: None
1036
        """
1037
        msg = self._rpc._rpc_stop()
1✔
1038
        await self._send_msg(f"Status: `{msg['status']}`")
1✔
1039

1040
    @authorized_only
1✔
1041
    async def _reload_config(self, update: Update, context: CallbackContext) -> None:
1✔
1042
        """
1043
        Handler for /reload_config.
1044
        Triggers a config file reload
1045
        :param bot: telegram bot
1046
        :param update: message update
1047
        :return: None
1048
        """
1049
        msg = self._rpc._rpc_reload_config()
1✔
1050
        await self._send_msg(f"Status: `{msg['status']}`")
1✔
1051

1052
    @authorized_only
1✔
1053
    async def _stopentry(self, update: Update, context: CallbackContext) -> None:
1✔
1054
        """
1055
        Handler for /stop_buy.
1056
        Sets max_open_trades to 0 and gracefully sells all open trades
1057
        :param bot: telegram bot
1058
        :param update: message update
1059
        :return: None
1060
        """
1061
        msg = self._rpc._rpc_stopentry()
1✔
1062
        await self._send_msg(f"Status: `{msg['status']}`")
1✔
1063

1064
    @authorized_only
1✔
1065
    async def _reload_trade_from_exchange(self, update: Update, context: CallbackContext) -> None:
1✔
1066
        """
1067
        Handler for /reload_trade <tradeid>.
1068
        """
1069
        if not context.args or len(context.args) == 0:
1✔
1070
            raise RPCException("Trade-id not set.")
1✔
1071
        trade_id = int(context.args[0])
1✔
1072
        msg = self._rpc._rpc_reload_trade_from_exchange(trade_id)
1✔
1073
        await self._send_msg(f"Status: `{msg['status']}`")
1✔
1074

1075
    @authorized_only
1✔
1076
    async def _force_exit(self, update: Update, context: CallbackContext) -> None:
1✔
1077
        """
1078
        Handler for /forceexit <id>.
1079
        Sells the given trade at current price
1080
        :param bot: telegram bot
1081
        :param update: message update
1082
        :return: None
1083
        """
1084

1085
        if context.args:
1✔
1086
            trade_id = context.args[0]
1✔
1087
            await self._force_exit_action(trade_id)
1✔
1088
        else:
1089
            fiat_currency = self._config.get('fiat_display_currency', '')
1✔
1090
            try:
1✔
1091
                statlist, _, _ = self._rpc._rpc_status_table(
1✔
1092
                    self._config['stake_currency'], fiat_currency)
1093
            except RPCException:
1✔
1094
                await self._send_msg(msg='No open trade found.')
1✔
1095
                return
1✔
1096
            trades = []
1✔
1097
            for trade in statlist:
1✔
1098
                trades.append((trade[0], f"{trade[0]} {trade[1]} {trade[2]} {trade[3]}"))
1✔
1099

1100
            trade_buttons = [
1✔
1101
                InlineKeyboardButton(text=trade[1], callback_data=f"force_exit__{trade[0]}")
1102
                for trade in trades]
1103
            buttons_aligned = self._layout_inline_keyboard_onecol(trade_buttons)
1✔
1104

1105
            buttons_aligned.append([InlineKeyboardButton(
1✔
1106
                text='Cancel', callback_data='force_exit__cancel')])
1107
            await self._send_msg(msg="Which trade?", keyboard=buttons_aligned)
1✔
1108

1109
    async def _force_exit_action(self, trade_id):
1✔
1110
        if trade_id != 'cancel':
1✔
1111
            try:
1✔
1112
                loop = asyncio.get_running_loop()
1✔
1113
                # Workaround to avoid nested loops
1114
                await loop.run_in_executor(None, self._rpc._rpc_force_exit, trade_id)
1✔
1115
            except RPCException as e:
1✔
1116
                await self._send_msg(str(e))
1✔
1117

1118
    async def _force_exit_inline(self, update: Update, _: CallbackContext) -> None:
1✔
1119
        if update.callback_query:
1✔
1120
            query = update.callback_query
1✔
1121
            if query.data and '__' in query.data:
1✔
1122
                # Input data is "force_exit__<tradid|cancel>"
1123
                trade_id = query.data.split("__")[1].split(' ')[0]
1✔
1124
                if trade_id == 'cancel':
1✔
1125
                    await query.answer()
1✔
1126
                    await query.edit_message_text(text="Force exit canceled.")
1✔
1127
                    return
1✔
1128
                trade: Optional[Trade] = Trade.get_trades(trade_filter=Trade.id == trade_id).first()
1✔
1129
                await query.answer()
1✔
1130
                if trade:
1✔
1131
                    await query.edit_message_text(
1✔
1132
                        text=f"Manually exiting Trade #{trade_id}, {trade.pair}")
1133
                    await self._force_exit_action(trade_id)
1✔
1134
                else:
1135
                    await query.edit_message_text(text=f"Trade {trade_id} not found.")
×
1136

1137
    async def _force_enter_action(self, pair, price: Optional[float], order_side: SignalDirection):
1✔
1138
        if pair != 'cancel':
1✔
1139
            try:
1✔
1140
                def _force_enter():
1✔
1141
                    self._rpc._rpc_force_entry(pair, price, order_side=order_side)
1✔
1142
                loop = asyncio.get_running_loop()
1✔
1143
                # Workaround to avoid nested loops
1144
                await loop.run_in_executor(None, _force_enter)
1✔
1145
            except RPCException as e:
1✔
1146
                logger.exception("Forcebuy error!")
1✔
1147
                await self._send_msg(str(e), ParseMode.HTML)
1✔
1148

1149
    async def _force_enter_inline(self, update: Update, _: CallbackContext) -> None:
1✔
1150
        if update.callback_query:
1✔
1151
            query = update.callback_query
1✔
1152
            if query.data and '_||_' in query.data:
1✔
1153
                pair, side = query.data.split('_||_')
1✔
1154
                order_side = SignalDirection(side)
1✔
1155
                await query.answer()
1✔
1156
                await query.edit_message_text(text=f"Manually entering {order_side} for {pair}")
1✔
1157
                await self._force_enter_action(pair, None, order_side)
1✔
1158

1159
    @staticmethod
1✔
1160
    def _layout_inline_keyboard(
1✔
1161
            buttons: List[InlineKeyboardButton], cols=3) -> List[List[InlineKeyboardButton]]:
1162
        return [buttons[i:i + cols] for i in range(0, len(buttons), cols)]
1✔
1163

1164
    @staticmethod
1✔
1165
    def _layout_inline_keyboard_onecol(
1✔
1166
            buttons: List[InlineKeyboardButton], cols=1) -> List[List[InlineKeyboardButton]]:
1167
        return [buttons[i:i + cols] for i in range(0, len(buttons), cols)]
1✔
1168

1169
    @authorized_only
1✔
1170
    async def _force_enter(
1✔
1171
            self, update: Update, context: CallbackContext, order_side: SignalDirection) -> None:
1172
        """
1173
        Handler for /forcelong <asset> <price> and `/forceshort <asset> <price>
1174
        Buys a pair trade at the given or current price
1175
        :param bot: telegram bot
1176
        :param update: message update
1177
        :return: None
1178
        """
1179
        if context.args:
1✔
1180
            pair = context.args[0]
1✔
1181
            price = float(context.args[1]) if len(context.args) > 1 else None
1✔
1182
            await self._force_enter_action(pair, price, order_side)
1✔
1183
        else:
1184
            whitelist = self._rpc._rpc_whitelist()['whitelist']
1✔
1185
            pair_buttons = [
1✔
1186
                InlineKeyboardButton(text=pair, callback_data=f"{pair}_||_{order_side}")
1187
                for pair in sorted(whitelist)
1188
            ]
1189
            buttons_aligned = self._layout_inline_keyboard(pair_buttons)
1✔
1190

1191
            buttons_aligned.append([InlineKeyboardButton(text='Cancel', callback_data='cancel')])
1✔
1192
            await self._send_msg(msg="Which pair?",
1✔
1193
                                 keyboard=buttons_aligned,
1194
                                 query=update.callback_query)
1195

1196
    @authorized_only
1✔
1197
    async def _trades(self, update: Update, context: CallbackContext) -> None:
1✔
1198
        """
1199
        Handler for /trades <n>
1200
        Returns last n recent trades.
1201
        :param bot: telegram bot
1202
        :param update: message update
1203
        :return: None
1204
        """
1205
        stake_cur = self._config['stake_currency']
1✔
1206
        try:
1✔
1207
            nrecent = int(context.args[0]) if context.args else 10
1✔
1208
        except (TypeError, ValueError, IndexError):
1✔
1209
            nrecent = 10
1✔
1210
        trades = self._rpc._rpc_trade_history(
1✔
1211
            nrecent
1212
        )
1213
        trades_tab = tabulate(
1✔
1214
            [[dt_humanize(trade['close_date']),
1215
                trade['pair'] + " (#" + str(trade['trade_id']) + ")",
1216
                f"{(trade['close_profit']):.2%} ({trade['close_profit_abs']})"]
1217
                for trade in trades['trades']],
1218
            headers=[
1219
                'Close Date',
1220
                'Pair (ID)',
1221
                f'Profit ({stake_cur})',
1222
            ],
1223
            tablefmt='simple')
1224
        message = (f"<b>{min(trades['trades_count'], nrecent)} recent trades</b>:\n"
1✔
1225
                   + (f"<pre>{trades_tab}</pre>" if trades['trades_count'] > 0 else ''))
1226
        await self._send_msg(message, parse_mode=ParseMode.HTML)
1✔
1227

1228
    @authorized_only
1✔
1229
    async def _delete_trade(self, update: Update, context: CallbackContext) -> None:
1✔
1230
        """
1231
        Handler for /delete <id>.
1232
        Delete the given trade
1233
        :param bot: telegram bot
1234
        :param update: message update
1235
        :return: None
1236
        """
1237
        if not context.args or len(context.args) == 0:
1✔
1238
            raise RPCException("Trade-id not set.")
1✔
1239
        trade_id = int(context.args[0])
1✔
1240
        msg = self._rpc._rpc_delete(trade_id)
1✔
1241
        await self._send_msg(
1✔
1242
            f"`{msg['result_msg']}`\n"
1243
            'Please make sure to take care of this asset on the exchange manually.'
1244
        )
1245

1246
    @authorized_only
1✔
1247
    async def _cancel_open_order(self, update: Update, context: CallbackContext) -> None:
1✔
1248
        """
1249
        Handler for /cancel_open_order <id>.
1250
        Cancel open order for tradeid
1251
        :param bot: telegram bot
1252
        :param update: message update
1253
        :return: None
1254
        """
1255
        if not context.args or len(context.args) == 0:
1✔
1256
            raise RPCException("Trade-id not set.")
1✔
1257
        trade_id = int(context.args[0])
1✔
1258
        self._rpc._rpc_cancel_open_order(trade_id)
1✔
1259
        await self._send_msg('Open order canceled.')
1✔
1260

1261
    @authorized_only
1✔
1262
    async def _performance(self, update: Update, context: CallbackContext) -> None:
1✔
1263
        """
1264
        Handler for /performance.
1265
        Shows a performance statistic from finished trades
1266
        :param bot: telegram bot
1267
        :param update: message update
1268
        :return: None
1269
        """
1270
        trades = self._rpc._rpc_performance()
1✔
1271
        output = "<b>Performance:</b>\n"
1✔
1272
        for i, trade in enumerate(trades):
1✔
1273
            stat_line = (
1✔
1274
                f"{i+1}.\t <code>{trade['pair']}\t"
1275
                f"{round_coin_value(trade['profit_abs'], self._config['stake_currency'])} "
1276
                f"({trade['profit_ratio']:.2%}) "
1277
                f"({trade['count']})</code>\n")
1278

1279
            if len(output + stat_line) >= MAX_MESSAGE_LENGTH:
1✔
1280
                await self._send_msg(output, parse_mode=ParseMode.HTML)
×
1281
                output = stat_line
×
1282
            else:
1283
                output += stat_line
1✔
1284

1285
        await self._send_msg(output, parse_mode=ParseMode.HTML,
1✔
1286
                             reload_able=True, callback_path="update_performance",
1287
                             query=update.callback_query)
1288

1289
    @authorized_only
1✔
1290
    async def _enter_tag_performance(self, update: Update, context: CallbackContext) -> None:
1✔
1291
        """
1292
        Handler for /buys PAIR .
1293
        Shows a performance statistic from finished trades
1294
        :param bot: telegram bot
1295
        :param update: message update
1296
        :return: None
1297
        """
1298
        pair = None
1✔
1299
        if context.args and isinstance(context.args[0], str):
1✔
1300
            pair = context.args[0]
1✔
1301

1302
        trades = self._rpc._rpc_enter_tag_performance(pair)
1✔
1303
        output = "<b>Entry Tag Performance:</b>\n"
1✔
1304
        for i, trade in enumerate(trades):
1✔
1305
            stat_line = (
1✔
1306
                f"{i+1}.\t <code>{trade['enter_tag']}\t"
1307
                f"{round_coin_value(trade['profit_abs'], self._config['stake_currency'])} "
1308
                f"({trade['profit_ratio']:.2%}) "
1309
                f"({trade['count']})</code>\n")
1310

1311
            if len(output + stat_line) >= MAX_MESSAGE_LENGTH:
1✔
1312
                await self._send_msg(output, parse_mode=ParseMode.HTML)
×
1313
                output = stat_line
×
1314
            else:
1315
                output += stat_line
1✔
1316

1317
        await self._send_msg(output, parse_mode=ParseMode.HTML,
1✔
1318
                             reload_able=True, callback_path="update_enter_tag_performance",
1319
                             query=update.callback_query)
1320

1321
    @authorized_only
1✔
1322
    async def _exit_reason_performance(self, update: Update, context: CallbackContext) -> None:
1✔
1323
        """
1324
        Handler for /sells.
1325
        Shows a performance statistic from finished trades
1326
        :param bot: telegram bot
1327
        :param update: message update
1328
        :return: None
1329
        """
1330
        pair = None
1✔
1331
        if context.args and isinstance(context.args[0], str):
1✔
1332
            pair = context.args[0]
1✔
1333

1334
        trades = self._rpc._rpc_exit_reason_performance(pair)
1✔
1335
        output = "<b>Exit Reason Performance:</b>\n"
1✔
1336
        for i, trade in enumerate(trades):
1✔
1337
            stat_line = (
1✔
1338
                f"{i+1}.\t <code>{trade['exit_reason']}\t"
1339
                f"{round_coin_value(trade['profit_abs'], self._config['stake_currency'])} "
1340
                f"({trade['profit_ratio']:.2%}) "
1341
                f"({trade['count']})</code>\n")
1342

1343
            if len(output + stat_line) >= MAX_MESSAGE_LENGTH:
1✔
1344
                await self._send_msg(output, parse_mode=ParseMode.HTML)
×
1345
                output = stat_line
×
1346
            else:
1347
                output += stat_line
1✔
1348

1349
        await self._send_msg(output, parse_mode=ParseMode.HTML,
1✔
1350
                             reload_able=True, callback_path="update_exit_reason_performance",
1351
                             query=update.callback_query)
1352

1353
    @authorized_only
1✔
1354
    async def _mix_tag_performance(self, update: Update, context: CallbackContext) -> None:
1✔
1355
        """
1356
        Handler for /mix_tags.
1357
        Shows a performance statistic from finished trades
1358
        :param bot: telegram bot
1359
        :param update: message update
1360
        :return: None
1361
        """
1362
        pair = None
1✔
1363
        if context.args and isinstance(context.args[0], str):
1✔
1364
            pair = context.args[0]
1✔
1365

1366
        trades = self._rpc._rpc_mix_tag_performance(pair)
1✔
1367
        output = "<b>Mix Tag Performance:</b>\n"
1✔
1368
        for i, trade in enumerate(trades):
1✔
1369
            stat_line = (
1✔
1370
                f"{i+1}.\t <code>{trade['mix_tag']}\t"
1371
                f"{round_coin_value(trade['profit_abs'], self._config['stake_currency'])} "
1372
                f"({trade['profit']:.2%}) "
1373
                f"({trade['count']})</code>\n")
1374

1375
            if len(output + stat_line) >= MAX_MESSAGE_LENGTH:
1✔
1376
                await self._send_msg(output, parse_mode=ParseMode.HTML)
×
1377
                output = stat_line
×
1378
            else:
1379
                output += stat_line
1✔
1380

1381
        await self._send_msg(output, parse_mode=ParseMode.HTML,
1✔
1382
                             reload_able=True, callback_path="update_mix_tag_performance",
1383
                             query=update.callback_query)
1384

1385
    @authorized_only
1✔
1386
    async def _count(self, update: Update, context: CallbackContext) -> None:
1✔
1387
        """
1388
        Handler for /count.
1389
        Returns the number of trades running
1390
        :param bot: telegram bot
1391
        :param update: message update
1392
        :return: None
1393
        """
1394
        counts = self._rpc._rpc_count()
1✔
1395
        message = tabulate({k: [v] for k, v in counts.items()},
1✔
1396
                           headers=['current', 'max', 'total stake'],
1397
                           tablefmt='simple')
1398
        message = f"<pre>{message}</pre>"
1✔
1399
        logger.debug(message)
1✔
1400
        await self._send_msg(message, parse_mode=ParseMode.HTML,
1✔
1401
                             reload_able=True, callback_path="update_count",
1402
                             query=update.callback_query)
1403

1404
    @authorized_only
1✔
1405
    async def _locks(self, update: Update, context: CallbackContext) -> None:
1✔
1406
        """
1407
        Handler for /locks.
1408
        Returns the currently active locks
1409
        """
1410
        rpc_locks = self._rpc._rpc_locks()
1✔
1411
        if not rpc_locks['locks']:
1✔
1412
            await self._send_msg('No active locks.', parse_mode=ParseMode.HTML)
1✔
1413

1414
        for locks in chunks(rpc_locks['locks'], 25):
1✔
1415
            message = tabulate([[
1✔
1416
                lock['id'],
1417
                lock['pair'],
1418
                lock['lock_end_time'],
1419
                lock['reason']] for lock in locks],
1420
                headers=['ID', 'Pair', 'Until', 'Reason'],
1421
                tablefmt='simple')
1422
            message = f"<pre>{escape(message)}</pre>"
1✔
1423
            logger.debug(message)
1✔
1424
            await self._send_msg(message, parse_mode=ParseMode.HTML)
1✔
1425

1426
    @authorized_only
1✔
1427
    async def _delete_locks(self, update: Update, context: CallbackContext) -> None:
1✔
1428
        """
1429
        Handler for /delete_locks.
1430
        Returns the currently active locks
1431
        """
1432
        arg = context.args[0] if context.args and len(context.args) > 0 else None
1✔
1433
        lockid = None
1✔
1434
        pair = None
1✔
1435
        if arg:
1✔
1436
            try:
1✔
1437
                lockid = int(arg)
1✔
1438
            except ValueError:
1✔
1439
                pair = arg
1✔
1440

1441
        self._rpc._rpc_delete_lock(lockid=lockid, pair=pair)
1✔
1442
        await self._locks(update, context)
1✔
1443

1444
    @authorized_only
1✔
1445
    async def _whitelist(self, update: Update, context: CallbackContext) -> None:
1✔
1446
        """
1447
        Handler for /whitelist
1448
        Shows the currently active whitelist
1449
        """
1450
        whitelist = self._rpc._rpc_whitelist()
1✔
1451

1452
        if context.args:
1✔
1453
            if "sorted" in context.args:
1✔
1454
                whitelist['whitelist'] = sorted(whitelist['whitelist'])
1✔
1455
            if "baseonly" in context.args:
1✔
1456
                whitelist['whitelist'] = [pair.split("/")[0] for pair in whitelist['whitelist']]
1✔
1457

1458
        message = f"Using whitelist `{whitelist['method']}` with {whitelist['length']} pairs\n"
1✔
1459
        message += f"`{', '.join(whitelist['whitelist'])}`"
1✔
1460

1461
        logger.debug(message)
1✔
1462
        await self._send_msg(message)
1✔
1463

1464
    @authorized_only
1✔
1465
    async def _blacklist(self, update: Update, context: CallbackContext) -> None:
1✔
1466
        """
1467
        Handler for /blacklist
1468
        Shows the currently active blacklist
1469
        """
1470
        await self.send_blacklist_msg(self._rpc._rpc_blacklist(context.args))
1✔
1471

1472
    async def send_blacklist_msg(self, blacklist: Dict):
1✔
1473
        errmsgs = []
1✔
1474
        for pair, error in blacklist['errors'].items():
1✔
1475
            errmsgs.append(f"Error: {error['error_msg']}")
×
1476
        if errmsgs:
1✔
1477
            await self._send_msg('\n'.join(errmsgs))
×
1478

1479
        message = f"Blacklist contains {blacklist['length']} pairs\n"
1✔
1480
        message += f"`{', '.join(blacklist['blacklist'])}`"
1✔
1481

1482
        logger.debug(message)
1✔
1483
        await self._send_msg(message)
1✔
1484

1485
    @authorized_only
1✔
1486
    async def _blacklist_delete(self, update: Update, context: CallbackContext) -> None:
1✔
1487
        """
1488
        Handler for /bl_delete
1489
        Deletes pair(s) from current blacklist
1490
        """
1491
        await self.send_blacklist_msg(self._rpc._rpc_blacklist_delete(context.args or []))
1✔
1492

1493
    @authorized_only
1✔
1494
    async def _logs(self, update: Update, context: CallbackContext) -> None:
1✔
1495
        """
1496
        Handler for /logs
1497
        Shows the latest logs
1498
        """
1499
        try:
1✔
1500
            limit = int(context.args[0]) if context.args else 10
1✔
1501
        except (TypeError, ValueError, IndexError):
×
1502
            limit = 10
×
1503
        logs = RPC._rpc_get_logs(limit)['logs']
1✔
1504
        msgs = ''
1✔
1505
        msg_template = "*{}* {}: {} \\- `{}`"
1✔
1506
        for logrec in logs:
1✔
1507
            msg = msg_template.format(escape_markdown(logrec[0], version=2),
1✔
1508
                                      escape_markdown(logrec[2], version=2),
1509
                                      escape_markdown(logrec[3], version=2),
1510
                                      escape_markdown(logrec[4], version=2))
1511
            if len(msgs + msg) + 10 >= MAX_MESSAGE_LENGTH:
1✔
1512
                # Send message immediately if it would become too long
1513
                await self._send_msg(msgs, parse_mode=ParseMode.MARKDOWN_V2)
1✔
1514
                msgs = msg + '\n'
1✔
1515
            else:
1516
                # Append message to messages to send
1517
                msgs += msg + '\n'
1✔
1518

1519
        if msgs:
1✔
1520
            await self._send_msg(msgs, parse_mode=ParseMode.MARKDOWN_V2)
1✔
1521

1522
    @authorized_only
1✔
1523
    async def _edge(self, update: Update, context: CallbackContext) -> None:
1✔
1524
        """
1525
        Handler for /edge
1526
        Shows information related to Edge
1527
        """
1528
        edge_pairs = self._rpc._rpc_edge()
1✔
1529
        if not edge_pairs:
1✔
1530
            message = '<b>Edge only validated following pairs:</b>'
1✔
1531
            await self._send_msg(message, parse_mode=ParseMode.HTML)
1✔
1532

1533
        for chunk in chunks(edge_pairs, 25):
1✔
1534
            edge_pairs_tab = tabulate(chunk, headers='keys', tablefmt='simple')
1✔
1535
            message = (f'<b>Edge only validated following pairs:</b>\n'
1✔
1536
                       f'<pre>{edge_pairs_tab}</pre>')
1537

1538
            await self._send_msg(message, parse_mode=ParseMode.HTML)
1✔
1539

1540
    @authorized_only
1✔
1541
    async def _help(self, update: Update, context: CallbackContext) -> None:
1✔
1542
        """
1543
        Handler for /help.
1544
        Show commands of the bot
1545
        :param bot: telegram bot
1546
        :param update: message update
1547
        :return: None
1548
        """
1549
        force_enter_text = ("*/forcelong <pair> [<rate>]:* `Instantly buys the given pair. "
1✔
1550
                            "Optionally takes a rate at which to buy "
1551
                            "(only applies to limit orders).` \n"
1552
                            )
1553
        if self._rpc._freqtrade.trading_mode != TradingMode.SPOT:
1✔
1554
            force_enter_text += ("*/forceshort <pair> [<rate>]:* `Instantly shorts the given pair. "
×
1555
                                 "Optionally takes a rate at which to sell "
1556
                                 "(only applies to limit orders).` \n")
1557
        message = (
1✔
1558
            "_Bot Control_\n"
1559
            "------------\n"
1560
            "*/start:* `Starts the trader`\n"
1561
            "*/stop:* Stops the trader\n"
1562
            "*/stopentry:* `Stops entering, but handles open trades gracefully` \n"
1563
            "*/forceexit <trade_id>|all:* `Instantly exits the given trade or all trades, "
1564
            "regardless of profit`\n"
1565
            "*/fx <trade_id>|all:* `Alias to /forceexit`\n"
1566
            f"{force_enter_text if self._config.get('force_entry_enable', False) else ''}"
1567
            "*/delete <trade_id>:* `Instantly delete the given trade in the database`\n"
1568
            "*/reload_trade <trade_id>:* `Relade trade from exchange Orders`\n"
1569
            "*/cancel_open_order <trade_id>:* `Cancels open orders for trade. "
1570
            "Only valid when the trade has open orders.`\n"
1571
            "*/coo <trade_id>|all:* `Alias to /cancel_open_order`\n"
1572

1573
            "*/whitelist [sorted] [baseonly]:* `Show current whitelist. Optionally in "
1574
            "order and/or only displaying the base currency of each pairing.`\n"
1575
            "*/blacklist [pair]:* `Show current blacklist, or adds one or more pairs "
1576
            "to the blacklist.` \n"
1577
            "*/blacklist_delete [pairs]| /bl_delete [pairs]:* "
1578
            "`Delete pair / pattern from blacklist. Will reset on reload_conf.` \n"
1579
            "*/reload_config:* `Reload configuration file` \n"
1580
            "*/unlock <pair|id>:* `Unlock this Pair (or this lock id if it's numeric)`\n"
1581

1582
            "_Current state_\n"
1583
            "------------\n"
1584
            "*/show_config:* `Show running configuration` \n"
1585
            "*/locks:* `Show currently locked pairs`\n"
1586
            "*/balance:* `Show bot managed balance per currency`\n"
1587
            "*/balance total:* `Show account balance per currency`\n"
1588
            "*/logs [limit]:* `Show latest logs - defaults to 10` \n"
1589
            "*/count:* `Show number of active trades compared to allowed number of trades`\n"
1590
            "*/edge:* `Shows validated pairs by Edge if it is enabled` \n"
1591
            "*/health* `Show latest process timestamp - defaults to 1970-01-01 00:00:00` \n"
1592
            "*/marketdir [long | short | even | none]:* `Updates the user managed variable "
1593
            "that represents the current market direction. If no direction is provided `"
1594
            "`the currently set market direction will be output.` \n"
1595

1596
            "_Statistics_\n"
1597
            "------------\n"
1598
            "*/status <trade_id>|[table]:* `Lists all open trades`\n"
1599
            "         *<trade_id> :* `Lists one or more specific trades.`\n"
1600
            "                        `Separate multiple <trade_id> with a blank space.`\n"
1601
            "         *table :* `will display trades in a table`\n"
1602
            "                `pending buy orders are marked with an asterisk (*)`\n"
1603
            "                `pending sell orders are marked with a double asterisk (**)`\n"
1604
            "*/buys <pair|none>:* `Shows the enter_tag performance`\n"
1605
            "*/sells <pair|none>:* `Shows the exit reason performance`\n"
1606
            "*/mix_tags <pair|none>:* `Shows combined entry tag + exit reason performance`\n"
1607
            "*/trades [limit]:* `Lists last closed trades (limited to 10 by default)`\n"
1608
            "*/profit [<n>]:* `Lists cumulative profit from all finished trades, "
1609
            "over the last n days`\n"
1610
            "*/performance:* `Show performance of each finished trade grouped by pair`\n"
1611
            "*/daily <n>:* `Shows profit or loss per day, over the last n days`\n"
1612
            "*/weekly <n>:* `Shows statistics per week, over the last n weeks`\n"
1613
            "*/monthly <n>:* `Shows statistics per month, over the last n months`\n"
1614
            "*/stats:* `Shows Wins / losses by Sell reason as well as "
1615
            "Avg. holding durations for buys and sells.`\n"
1616
            "*/help:* `This help message`\n"
1617
            "*/version:* `Show version`"
1618
            )
1619

1620
        await self._send_msg(message, parse_mode=ParseMode.MARKDOWN)
1✔
1621

1622
    @authorized_only
1✔
1623
    async def _health(self, update: Update, context: CallbackContext) -> None:
1✔
1624
        """
1625
        Handler for /health
1626
        Shows the last process timestamp
1627
        """
1628
        health = self._rpc.health()
×
1629
        message = f"Last process: `{health['last_process_loc']}`"
×
1630
        await self._send_msg(message)
×
1631

1632
    @authorized_only
1✔
1633
    async def _version(self, update: Update, context: CallbackContext) -> None:
1✔
1634
        """
1635
        Handler for /version.
1636
        Show version information
1637
        :param bot: telegram bot
1638
        :param update: message update
1639
        :return: None
1640
        """
1641
        strategy_version = self._rpc._freqtrade.strategy.version()
1✔
1642
        version_string = f'*Version:* `{__version__}`'
1✔
1643
        if strategy_version is not None:
1✔
1644
            version_string += f'\n*Strategy version: * `{strategy_version}`'
1✔
1645

1646
        await self._send_msg(version_string)
1✔
1647

1648
    @authorized_only
1✔
1649
    async def _show_config(self, update: Update, context: CallbackContext) -> None:
1✔
1650
        """
1651
        Handler for /show_config.
1652
        Show config information information
1653
        :param bot: telegram bot
1654
        :param update: message update
1655
        :return: None
1656
        """
1657
        val = RPC._rpc_show_config(self._config, self._rpc._freqtrade.state)
1✔
1658

1659
        if val['trailing_stop']:
1✔
1660
            sl_info = (
1✔
1661
                f"*Initial Stoploss:* `{val['stoploss']}`\n"
1662
                f"*Trailing stop positive:* `{val['trailing_stop_positive']}`\n"
1663
                f"*Trailing stop offset:* `{val['trailing_stop_positive_offset']}`\n"
1664
                f"*Only trail above offset:* `{val['trailing_only_offset_is_reached']}`\n"
1665
            )
1666

1667
        else:
1668
            sl_info = f"*Stoploss:* `{val['stoploss']}`\n"
1✔
1669

1670
        if val['position_adjustment_enable']:
1✔
1671
            pa_info = (
×
1672
                f"*Position adjustment:* On\n"
1673
                f"*Max enter position adjustment:* `{val['max_entry_position_adjustment']}`\n"
1674
            )
1675
        else:
1676
            pa_info = "*Position adjustment:* Off\n"
1✔
1677

1678
        await self._send_msg(
1✔
1679
            f"*Mode:* `{'Dry-run' if val['dry_run'] else 'Live'}`\n"
1680
            f"*Exchange:* `{val['exchange']}`\n"
1681
            f"*Market: * `{val['trading_mode']}`\n"
1682
            f"*Stake per trade:* `{val['stake_amount']} {val['stake_currency']}`\n"
1683
            f"*Max open Trades:* `{val['max_open_trades']}`\n"
1684
            f"*Minimum ROI:* `{val['minimal_roi']}`\n"
1685
            f"*Entry strategy:* ```\n{json.dumps(val['entry_pricing'])}```\n"
1686
            f"*Exit strategy:* ```\n{json.dumps(val['exit_pricing'])}```\n"
1687
            f"{sl_info}"
1688
            f"{pa_info}"
1689
            f"*Timeframe:* `{val['timeframe']}`\n"
1690
            f"*Strategy:* `{val['strategy']}`\n"
1691
            f"*Current state:* `{val['state']}`"
1692
        )
1693

1694
    async def _update_msg(self, query: CallbackQuery, msg: str, callback_path: str = "",
1✔
1695
                          reload_able: bool = False, parse_mode: str = ParseMode.MARKDOWN) -> None:
1696
        if reload_able:
1✔
1697
            reply_markup = InlineKeyboardMarkup([
1✔
1698
                [InlineKeyboardButton("Refresh", callback_data=callback_path)],
1699
            ])
1700
        else:
1701
            reply_markup = InlineKeyboardMarkup([[]])
1✔
1702
        msg += f"\nUpdated: {datetime.now().ctime()}"
1✔
1703
        if not query.message:
1✔
1704
            return
×
1705
        chat_id = query.message.chat_id
1✔
1706
        message_id = query.message.message_id
1✔
1707

1708
        try:
1✔
1709
            await self._app.bot.edit_message_text(
1✔
1710
                chat_id=chat_id,
1711
                message_id=message_id,
1712
                text=msg,
1713
                parse_mode=parse_mode,
1714
                reply_markup=reply_markup
1715
            )
1716
        except BadRequest as e:
1✔
1717
            if 'not modified' in e.message.lower():
1✔
1718
                pass
1✔
1719
            else:
1720
                logger.warning('TelegramError: %s', e.message)
1✔
1721
        except TelegramError as telegram_err:
1✔
1722
            logger.warning('TelegramError: %s! Giving up on that message.', telegram_err.message)
1✔
1723

1724
    async def _send_msg(self, msg: str, parse_mode: str = ParseMode.MARKDOWN,
1✔
1725
                        disable_notification: bool = False,
1726
                        keyboard: Optional[List[List[InlineKeyboardButton]]] = None,
1727
                        callback_path: str = "",
1728
                        reload_able: bool = False,
1729
                        query: Optional[CallbackQuery] = None) -> None:
1730
        """
1731
        Send given markdown message
1732
        :param msg: message
1733
        :param bot: alternative bot
1734
        :param parse_mode: telegram parse mode
1735
        :return: None
1736
        """
1737
        reply_markup: Union[InlineKeyboardMarkup, ReplyKeyboardMarkup]
1738
        if query:
1✔
1739
            await self._update_msg(query=query, msg=msg, parse_mode=parse_mode,
1✔
1740
                                   callback_path=callback_path, reload_able=reload_able)
1741
            return
1✔
1742
        if reload_able and self._config['telegram'].get('reload', True):
1✔
1743
            reply_markup = InlineKeyboardMarkup([
×
1744
                [InlineKeyboardButton("Refresh", callback_data=callback_path)]])
1745
        else:
1746
            if keyboard is not None:
1✔
1747
                reply_markup = InlineKeyboardMarkup(keyboard)
×
1748
            else:
1749
                reply_markup = ReplyKeyboardMarkup(self._keyboard, resize_keyboard=True)
1✔
1750
        try:
1✔
1751
            try:
1✔
1752
                await self._app.bot.send_message(
1✔
1753
                    self._config['telegram']['chat_id'],
1754
                    text=msg,
1755
                    parse_mode=parse_mode,
1756
                    reply_markup=reply_markup,
1757
                    disable_notification=disable_notification,
1758
                )
1759
            except NetworkError as network_err:
1✔
1760
                # Sometimes the telegram server resets the current connection,
1761
                # if this is the case we send the message again.
1762
                logger.warning(
1✔
1763
                    'Telegram NetworkError: %s! Trying one more time.',
1764
                    network_err.message
1765
                )
1766
                await self._app.bot.send_message(
1✔
1767
                    self._config['telegram']['chat_id'],
1768
                    text=msg,
1769
                    parse_mode=parse_mode,
1770
                    reply_markup=reply_markup,
1771
                    disable_notification=disable_notification,
1772
                )
1773
        except TelegramError as telegram_err:
1✔
1774
            logger.warning(
1✔
1775
                'TelegramError: %s! Giving up on that message.',
1776
                telegram_err.message
1777
            )
1778

1779
    @authorized_only
1✔
1780
    async def _changemarketdir(self, update: Update, context: CallbackContext) -> None:
1✔
1781
        """
1782
        Handler for /marketdir.
1783
        Updates the bot's market_direction
1784
        :param bot: telegram bot
1785
        :param update: message update
1786
        :return: None
1787
        """
1788
        if context.args and len(context.args) == 1:
1✔
1789
            new_market_dir_arg = context.args[0]
1✔
1790
            old_market_dir = self._rpc._get_market_direction()
1✔
1791
            new_market_dir = None
1✔
1792
            if new_market_dir_arg == "long":
1✔
1793
                new_market_dir = MarketDirection.LONG
1✔
1794
            elif new_market_dir_arg == "short":
1✔
1795
                new_market_dir = MarketDirection.SHORT
×
1796
            elif new_market_dir_arg == "even":
1✔
1797
                new_market_dir = MarketDirection.EVEN
×
1798
            elif new_market_dir_arg == "none":
1✔
1799
                new_market_dir = MarketDirection.NONE
×
1800

1801
            if new_market_dir is not None:
1✔
1802
                self._rpc._update_market_direction(new_market_dir)
1✔
1803
                await self._send_msg("Successfully updated market direction"
1✔
1804
                                     f" from *{old_market_dir}* to *{new_market_dir}*.")
1805
            else:
1806
                raise RPCException("Invalid market direction provided. \n"
1✔
1807
                                   "Valid market directions: *long, short, even, none*")
1808
        elif context.args is not None and len(context.args) == 0:
×
1809
            old_market_dir = self._rpc._get_market_direction()
×
1810
            await self._send_msg(f"Currently set market direction: *{old_market_dir}*")
×
1811
        else:
1812
            raise RPCException("Invalid usage of command /marketdir. \n"
×
1813
                               "Usage: */marketdir [short |  long | even | none]*")
STATUS · Troubleshooting · Open an Issue · Sales · Support · CAREERS · ENTERPRISE · START FREE · SCHEDULE DEMO
ANNOUNCEMENTS · TWITTER · TOS & SLA · Supported CI Services · What's a CI service? · Automated Testing

© 2025 Coveralls, Inc