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

freqtrade / freqtrade / 9394559170

26 Apr 2024 06:36AM UTC coverage: 94.656% (-0.02%) from 94.674%
9394559170

push

github

xmatthias
Loader should be passed as kwarg for clarity

20280 of 21425 relevant lines covered (94.66%)

0.95 hits per line

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

94.37
/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, wraps
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, Literal, 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
1✔
33
from freqtrade.persistence import Trade
1✔
34
from freqtrade.rpc import RPC, RPCException, RPCHandler
1✔
35
from freqtrade.rpc.rpc_types import RPCEntryMsg, RPCExitMsg, RPCOrderMsg, RPCSendMsg
1✔
36
from freqtrade.util import dt_from_ts, dt_humanize_delta, fmt_coin, format_date, round_value
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
def safe_async_db(func: Callable[..., Any]):
1✔
48
    """
49
    Decorator to safely handle sessions when switching async context
50
    :param func: function to decorate
51
    :return: decorated function
52
    """
53
    @wraps(func)
1✔
54
    def wrapper(*args, **kwargs):
1✔
55
        """ Decorator logic """
56
        try:
1✔
57
            return func(*args, **kwargs)
1✔
58
        finally:
59
            Trade.session.remove()
1✔
60

61
    return wrapper
1✔
62

63

64
@dataclass
1✔
65
class TimeunitMappings:
1✔
66
    header: str
1✔
67
    message: str
1✔
68
    message2: str
1✔
69
    callback: str
1✔
70
    default: int
1✔
71
    dateformat: str
1✔
72

73

74
def authorized_only(command_handler: Callable[..., Coroutine[Any, Any, None]]):
1✔
75
    """
76
    Decorator to check if the message comes from the correct chat_id
77
    :param command_handler: Telegram CommandHandler
78
    :return: decorated function
79
    """
80

81
    @wraps(command_handler)
1✔
82
    async def wrapper(self, *args, **kwargs):
1✔
83
        """ Decorator logic """
84
        update = kwargs.get('update') or args[0]
1✔
85

86
        # Reject unauthorized messages
87
        if update.callback_query:
1✔
88
            cchat_id = int(update.callback_query.message.chat.id)
1✔
89
        else:
90
            cchat_id = int(update.message.chat_id)
1✔
91

92
        chat_id = int(self._config['telegram']['chat_id'])
1✔
93
        if cchat_id != chat_id:
1✔
94
            logger.info(f'Rejected unauthorized message from: {update.message.chat_id}')
1✔
95
            return wrapper
1✔
96
        # Rollback session to avoid getting data stored in a transaction.
97
        Trade.rollback()
1✔
98
        logger.debug(
1✔
99
            'Executing handler: %s for chat_id: %s',
100
            command_handler.__name__,
101
            chat_id
102
        )
103
        try:
1✔
104
            return await command_handler(self, *args, **kwargs)
1✔
105
        except RPCException as e:
1✔
106
            await self._send_msg(str(e))
1✔
107
        except BaseException:
1✔
108
            logger.exception('Exception occurred within Telegram module')
1✔
109
        finally:
110
            Trade.session.remove()
1✔
111

112
    return wrapper
1✔
113

114

115
class Telegram(RPCHandler):
1✔
116
    """  This class handles all telegram communication """
117

118
    def __init__(self, rpc: RPC, config: Config) -> None:
1✔
119
        """
120
        Init the Telegram call, and init the super class RPCHandler
121
        :param rpc: instance of RPC Helper class
122
        :param config: Configuration object
123
        :return: None
124
        """
125
        super().__init__(rpc, config)
1✔
126

127
        self._app: Application
1✔
128
        self._loop: asyncio.AbstractEventLoop
1✔
129
        self._init_keyboard()
1✔
130
        self._start_thread()
1✔
131

132
    def _start_thread(self):
1✔
133
        """
134
        Creates and starts the polling thread
135
        """
136
        self._thread = Thread(target=self._init, name='FTTelegram')
1✔
137
        self._thread.start()
1✔
138

139
    def _init_keyboard(self) -> None:
1✔
140
        """
141
        Validates the keyboard configuration from telegram config
142
        section.
143
        """
144
        self._keyboard: List[List[Union[str, KeyboardButton]]] = [
1✔
145
            ['/daily', '/profit', '/balance'],
146
            ['/status', '/status table', '/performance'],
147
            ['/count', '/start', '/stop', '/help']
148
        ]
149
        # do not allow commands with mandatory arguments and critical cmds
150
        # TODO: DRY! - its not good to list all valid cmds here. But otherwise
151
        #       this needs refactoring of the whole telegram module (same
152
        #       problem in _help()).
153
        valid_keys: List[str] = [
1✔
154
            r'/start$', r'/stop$', r'/status$', r'/status table$',
155
            r'/trades$', r'/performance$', r'/buys', r'/entries',
156
            r'/sells', r'/exits', r'/mix_tags',
157
            r'/daily$', r'/daily \d+$', r'/profit$', r'/profit \d+',
158
            r'/stats$', r'/count$', r'/locks$', r'/balance$',
159
            r'/stopbuy$', r'/stopentry$', r'/reload_config$', r'/show_config$',
160
            r'/logs$', r'/whitelist$', r'/whitelist(\ssorted|\sbaseonly)+$',
161
            r'/blacklist$', r'/bl_delete$',
162
            r'/weekly$', r'/weekly \d+$', r'/monthly$', r'/monthly \d+$',
163
            r'/forcebuy$', r'/forcelong$', r'/forceshort$',
164
            r'/forcesell$', r'/forceexit$',
165
            r'/edge$', r'/health$', r'/help$', r'/version$', r'/marketdir (long|short|even|none)$',
166
            r'/marketdir$'
167
        ]
168
        # Create keys for generation
169
        valid_keys_print = [k.replace('$', '') for k in valid_keys]
1✔
170

171
        # custom keyboard specified in config.json
172
        cust_keyboard = self._config['telegram'].get('keyboard', [])
1✔
173
        if cust_keyboard:
1✔
174
            combined = "(" + ")|(".join(valid_keys) + ")"
1✔
175
            # check for valid shortcuts
176
            invalid_keys = [b for b in chain.from_iterable(cust_keyboard)
1✔
177
                            if not re.match(combined, b)]
178
            if len(invalid_keys):
1✔
179
                err_msg = ('config.telegram.keyboard: Invalid commands for '
1✔
180
                           f'custom Telegram keyboard: {invalid_keys}'
181
                           f'\nvalid commands are: {valid_keys_print}')
182
                raise OperationalException(err_msg)
1✔
183
            else:
184
                self._keyboard = cust_keyboard
1✔
185
                logger.info('using custom keyboard from '
1✔
186
                            f'config.json: {self._keyboard}')
187

188
    def _init_telegram_app(self):
1✔
189
        return Application.builder().token(self._config['telegram']['token']).build()
×
190

191
    def _init(self) -> None:
1✔
192
        """
193
        Initializes this module with the given config,
194
        registers all known command handlers
195
        and starts polling for message updates
196
        Runs in a separate thread.
197
        """
198
        try:
1✔
199
            self._loop = asyncio.get_running_loop()
1✔
200
        except RuntimeError:
1✔
201
            self._loop = asyncio.new_event_loop()
1✔
202
            asyncio.set_event_loop(self._loop)
1✔
203

204
        self._app = self._init_telegram_app()
1✔
205

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

268
        for callback in callbacks:
1✔
269
            self._app.add_handler(callback)
1✔
270

271
        logger.info(
1✔
272
            'rpc.telegram is listening for following commands: %s',
273
            [[x for x in sorted(h.commands)] for h in handles]
274
        )
275
        self._loop.run_until_complete(self._startup_telegram())
1✔
276

277
    async def _startup_telegram(self) -> None:
1✔
278
        await self._app.initialize()
1✔
279
        await self._app.start()
1✔
280
        if self._app.updater:
1✔
281
            await self._app.updater.start_polling(
1✔
282
                bootstrap_retries=-1,
283
                timeout=20,
284
                # read_latency=60,  # Assumed transmission latency
285
                drop_pending_updates=True,
286
                # stop_signals=[],  # Necessary as we don't run on the main thread
287
            )
288
            while True:
1✔
289
                await asyncio.sleep(10)
1✔
290
                if not self._app.updater.running:
1✔
291
                    break
1✔
292

293
    async def _cleanup_telegram(self) -> None:
1✔
294
        if self._app.updater:
1✔
295
            await self._app.updater.stop()
1✔
296
        await self._app.stop()
1✔
297
        await self._app.shutdown()
1✔
298

299
    def cleanup(self) -> None:
1✔
300
        """
301
        Stops all running telegram threads.
302
        :return: None
303
        """
304
        # This can take up to `timeout` from the call to `start_polling`.
305
        asyncio.run_coroutine_threadsafe(self._cleanup_telegram(), self._loop)
1✔
306
        self._thread.join()
1✔
307

308
    def _exchange_from_msg(self, msg: RPCOrderMsg) -> str:
1✔
309
        """
310
        Extracts the exchange name from the given message.
311
        :param msg: The message to extract the exchange name from.
312
        :return: The exchange name.
313
        """
314
        return f"{msg['exchange']}{' (dry)' if self._config['dry_run'] else ''}"
1✔
315

316
    def _add_analyzed_candle(self, pair: str) -> str:
1✔
317
        candle_val = self._config['telegram'].get(
1✔
318
            'notification_settings', {}).get('show_candle', 'off')
319
        if candle_val != 'off':
1✔
320
            if candle_val == 'ohlc':
1✔
321
                analyzed_df, _ = self._rpc._freqtrade.dataprovider.get_analyzed_dataframe(
1✔
322
                    pair, self._config['timeframe'])
323
                candle = analyzed_df.iloc[-1].squeeze() if len(analyzed_df) > 0 else None
1✔
324
                if candle is not None:
1✔
325
                    return (
1✔
326
                        f"*Candle OHLC*: `{candle['open']}, {candle['high']}, "
327
                        f"{candle['low']}, {candle['close']}`\n"
328
                    )
329

330
        return ''
1✔
331

332
    def _format_entry_msg(self, msg: RPCEntryMsg) -> str:
1✔
333

334
        is_fill = msg['type'] in [RPCMessageType.ENTRY_FILL]
1✔
335
        emoji = '\N{CHECK MARK}' if is_fill else '\N{LARGE BLUE CIRCLE}'
1✔
336

337
        terminology = {
1✔
338
            '1_enter': 'New Trade',
339
            '1_entered': 'New Trade filled',
340
            'x_enter': 'Increasing position',
341
            'x_entered': 'Position increase filled',
342
        }
343

344
        key = f"{'x' if msg['sub_trade'] else '1'}_{'entered' if is_fill else 'enter'}"
1✔
345
        wording = terminology[key]
1✔
346

347
        message = (
1✔
348
            f"{emoji} *{self._exchange_from_msg(msg)}:*"
349
            f" {wording} (#{msg['trade_id']})\n"
350
            f"*Pair:* `{msg['pair']}`\n"
351
        )
352
        message += self._add_analyzed_candle(msg['pair'])
1✔
353
        message += f"*Enter Tag:* `{msg['enter_tag']}`\n" if msg.get('enter_tag') else ""
1✔
354
        message += f"*Amount:* `{round_value(msg['amount'], 8)}`\n"
1✔
355
        message += f"*Direction:* `{msg['direction']}"
1✔
356
        if msg.get('leverage') and msg.get('leverage', 1.0) != 1.0:
1✔
357
            message += f" ({msg['leverage']:.3g}x)"
1✔
358
        message += "`\n"
1✔
359
        message += f"*Open Rate:* `{round_value(msg['open_rate'], 8)} {msg['quote_currency']}`\n"
1✔
360
        if msg['type'] == RPCMessageType.ENTRY and msg['current_rate']:
1✔
361
            message += (
1✔
362
                f"*Current Rate:* `{round_value(msg['current_rate'], 8)} {msg['quote_currency']}`\n"
363
            )
364

365
        profit_fiat_extra = self.__format_profit_fiat(msg, 'stake_amount')  # type: ignore
1✔
366
        total = fmt_coin(msg['stake_amount'], msg['quote_currency'])
1✔
367

368
        message += f"*{'New ' if msg['sub_trade'] else ''}Total:* `{total}{profit_fiat_extra}`"
1✔
369

370
        return message
1✔
371

372
    def _format_exit_msg(self, msg: RPCExitMsg) -> str:
1✔
373
        duration = msg['close_date'].replace(
1✔
374
            microsecond=0) - msg['open_date'].replace(microsecond=0)
375
        duration_min = duration.total_seconds() / 60
1✔
376

377
        leverage_text = (f" ({msg['leverage']:.3g}x)"
1✔
378
                         if msg.get('leverage') and msg.get('leverage', 1.0) != 1.0
379
                         else "")
380

381
        profit_fiat_extra = self.__format_profit_fiat(msg, 'profit_amount')
1✔
382

383
        profit_extra = (
1✔
384
            f" ({msg['gain']}: {fmt_coin(msg['profit_amount'], msg['quote_currency'])}"
385
            f"{profit_fiat_extra})")
386

387
        is_fill = msg['type'] == RPCMessageType.EXIT_FILL
1✔
388
        is_sub_trade = msg.get('sub_trade')
1✔
389
        is_sub_profit = msg['profit_amount'] != msg.get('cumulative_profit')
1✔
390
        is_final_exit = msg.get('is_final_exit', False) and is_sub_profit
1✔
391
        profit_prefix = 'Sub ' if is_sub_trade else ''
1✔
392
        cp_extra = ''
1✔
393
        exit_wording = 'Exited' if is_fill else 'Exiting'
1✔
394
        if is_sub_trade or is_final_exit:
1✔
395
            cp_fiat = self.__format_profit_fiat(msg, 'cumulative_profit')
1✔
396

397
            if is_final_exit:
1✔
398
                profit_prefix = 'Sub '
×
399
                cp_extra = (
×
400
                    f"*Final Profit:* `{msg['final_profit_ratio']:.2%} "
401
                    f"({msg['cumulative_profit']:.8f} {msg['quote_currency']}{cp_fiat})`\n"
402
                )
403
            else:
404
                exit_wording = f"Partially {exit_wording.lower()}"
1✔
405
                if msg['cumulative_profit']:
1✔
406
                    cp_extra = (
1✔
407
                        f"*Cumulative Profit:* `"
408
                        f"{fmt_coin(msg['cumulative_profit'], msg['stake_currency'])}{cp_fiat}`\n"
409
                    )
410
        enter_tag = f"*Enter Tag:* `{msg['enter_tag']}`\n" if msg.get('enter_tag') else ""
1✔
411
        message = (
1✔
412
            f"{self._get_exit_emoji(msg)} *{self._exchange_from_msg(msg)}:* "
413
            f"{exit_wording} {msg['pair']} (#{msg['trade_id']})\n"
414
            f"{self._add_analyzed_candle(msg['pair'])}"
415
            f"*{f'{profit_prefix}Profit' if is_fill else f'Unrealized {profit_prefix}Profit'}:* "
416
            f"`{msg['profit_ratio']:.2%}{profit_extra}`\n"
417
            f"{cp_extra}"
418
            f"{enter_tag}"
419
            f"*Exit Reason:* `{msg['exit_reason']}`\n"
420
            f"*Direction:* `{msg['direction']}"
421
            f"{leverage_text}`\n"
422
            f"*Amount:* `{round_value(msg['amount'], 8)}`\n"
423
            f"*Open Rate:* `{fmt_coin(msg['open_rate'], msg['quote_currency'])}`\n"
424
        )
425
        if msg['type'] == RPCMessageType.EXIT and msg['current_rate']:
1✔
426
            message += f"*Current Rate:* `{fmt_coin(msg['current_rate'], msg['quote_currency'])}`\n"
1✔
427
            if msg['order_rate']:
1✔
428
                message += f"*Exit Rate:* `{fmt_coin(msg['order_rate'], msg['quote_currency'])}`"
1✔
429
        elif msg['type'] == RPCMessageType.EXIT_FILL:
1✔
430
            message += f"*Exit Rate:* `{fmt_coin(msg['close_rate'], msg['quote_currency'])}`"
1✔
431

432
        if is_sub_trade:
1✔
433
            stake_amount_fiat = self.__format_profit_fiat(msg, 'stake_amount')
1✔
434

435
            rem = fmt_coin(msg['stake_amount'], msg['quote_currency'])
1✔
436
            message += f"\n*Remaining:* `{rem}{stake_amount_fiat}`"
1✔
437
        else:
438
            message += f"\n*Duration:* `{duration} ({duration_min:.1f} min)`"
1✔
439
        return message
1✔
440

441
    def __format_profit_fiat(
1✔
442
            self,
443
            msg: RPCExitMsg,
444
            key: Literal['stake_amount', 'profit_amount', 'cumulative_profit']
445
    ) -> str:
446
        """
447
        Format Fiat currency to append to regular profit output
448
        """
449
        profit_fiat_extra = ''
1✔
450
        if self._rpc._fiat_converter and (fiat_currency := msg.get('fiat_currency')):
1✔
451
            profit_fiat = self._rpc._fiat_converter.convert_amount(
1✔
452
                    msg[key], msg['stake_currency'], fiat_currency)
453
            profit_fiat_extra = f" / {profit_fiat:.3f} {fiat_currency}"
1✔
454
        return profit_fiat_extra
1✔
455

456
    def compose_message(self, msg: RPCSendMsg) -> Optional[str]:
1✔
457
        if msg['type'] == RPCMessageType.ENTRY or msg['type'] == RPCMessageType.ENTRY_FILL:
1✔
458
            message = self._format_entry_msg(msg)
1✔
459

460
        elif msg['type'] == RPCMessageType.EXIT or msg['type'] == RPCMessageType.EXIT_FILL:
1✔
461
            message = self._format_exit_msg(msg)
1✔
462

463
        elif (
1✔
464
            msg['type'] == RPCMessageType.ENTRY_CANCEL
465
            or msg['type'] == RPCMessageType.EXIT_CANCEL
466
        ):
467
            message_side = 'enter' if msg['type'] == RPCMessageType.ENTRY_CANCEL else 'exit'
1✔
468
            message = (f"\N{WARNING SIGN} *{self._exchange_from_msg(msg)}:* "
1✔
469
                       f"Cancelling {'partial ' if msg.get('sub_trade') else ''}"
470
                       f"{message_side} Order for {msg['pair']} "
471
                       f"(#{msg['trade_id']}). Reason: {msg['reason']}.")
472

473
        elif msg['type'] == RPCMessageType.PROTECTION_TRIGGER:
1✔
474
            message = (
1✔
475
                f"*Protection* triggered due to {msg['reason']}. "
476
                f"`{msg['pair']}` will be locked until `{msg['lock_end_time']}`."
477
            )
478

479
        elif msg['type'] == RPCMessageType.PROTECTION_TRIGGER_GLOBAL:
1✔
480
            message = (
1✔
481
                f"*Protection* triggered due to {msg['reason']}. "
482
                f"*All pairs* will be locked until `{msg['lock_end_time']}`."
483
            )
484

485
        elif msg['type'] == RPCMessageType.STATUS:
1✔
486
            message = f"*Status:* `{msg['status']}`"
1✔
487

488
        elif msg['type'] == RPCMessageType.WARNING:
1✔
489
            message = f"\N{WARNING SIGN} *Warning:* `{msg['status']}`"
1✔
490
        elif msg['type'] == RPCMessageType.EXCEPTION:
1✔
491
            # Errors will contain exceptions, which are wrapped in triple ticks.
492
            message = f"\N{WARNING SIGN} *ERROR:* \n {msg['status']}"
×
493

494
        elif msg['type'] == RPCMessageType.STARTUP:
1✔
495
            message = f"{msg['status']}"
1✔
496
        elif msg['type'] == RPCMessageType.STRATEGY_MSG:
1✔
497
            message = f"{msg['msg']}"
1✔
498
        else:
499
            logger.debug("Unknown message type: %s", msg['type'])
1✔
500
            return None
1✔
501
        return message
1✔
502

503
    def send_msg(self, msg: RPCSendMsg) -> None:
1✔
504
        """ Send a message to telegram channel """
505

506
        default_noti = 'on'
1✔
507

508
        msg_type = msg['type']
1✔
509
        noti = ''
1✔
510
        if msg['type'] == RPCMessageType.EXIT:
1✔
511
            sell_noti = self._config['telegram'] \
1✔
512
                .get('notification_settings', {}).get(str(msg_type), {})
513
            # For backward compatibility sell still can be string
514
            if isinstance(sell_noti, str):
1✔
515
                noti = sell_noti
×
516
            else:
517
                noti = sell_noti.get(str(msg['exit_reason']), default_noti)
1✔
518
        else:
519
            noti = self._config['telegram'] \
1✔
520
                .get('notification_settings', {}).get(str(msg_type), default_noti)
521

522
        if noti == 'off':
1✔
523
            logger.info(f"Notification '{msg_type}' not sent.")
1✔
524
            # Notification disabled
525
            return
1✔
526

527
        message = self.compose_message(deepcopy(msg))
1✔
528
        if message:
1✔
529
            asyncio.run_coroutine_threadsafe(
1✔
530
                self._send_msg(message, disable_notification=(noti == 'silent')),
531
                self._loop)
532

533
    def _get_exit_emoji(self, msg):
1✔
534
        """
535
        Get emoji for exit-messages
536
        """
537

538
        if float(msg['profit_ratio']) >= 0.05:
1✔
539
            return "\N{ROCKET}"
1✔
540
        elif float(msg['profit_ratio']) >= 0.0:
1✔
541
            return "\N{EIGHT SPOKED ASTERISK}"
1✔
542
        elif msg['exit_reason'] == "stop_loss":
1✔
543
            return "\N{WARNING SIGN}"
1✔
544
        else:
545
            return "\N{CROSS MARK}"
1✔
546

547
    def _prepare_order_details(self, filled_orders: List, quote_currency: str, is_open: bool):
1✔
548
        """
549
        Prepare details of trade with entry adjustment enabled
550
        """
551
        lines_detail: List[str] = []
1✔
552
        if len(filled_orders) > 0:
1✔
553
            first_avg = filled_orders[0]["safe_price"]
1✔
554
        order_nr = 0
1✔
555
        for order in filled_orders:
1✔
556
            lines: List[str] = []
1✔
557
            if order['is_open'] is True:
1✔
558
                continue
1✔
559
            order_nr += 1
1✔
560
            wording = 'Entry' if order['ft_is_entry'] else 'Exit'
1✔
561

562
            cur_entry_amount = order["filled"] or order["amount"]
1✔
563
            cur_entry_average = order["safe_price"]
1✔
564
            lines.append("  ")
1✔
565
            lines.append(f"*{wording} #{order_nr}:*")
1✔
566
            if order_nr == 1:
1✔
567
                lines.append(
1✔
568
                    f"*Amount:* {round_value(cur_entry_amount, 8)} "
569
                    f"({fmt_coin(order['cost'], quote_currency)})"
570
                )
571
                lines.append(f"*Average Price:* {round_value(cur_entry_average, 8)}")
1✔
572
            else:
573
                # TODO: This calculation ignores fees.
574
                price_to_1st_entry = ((cur_entry_average - first_avg) / first_avg)
1✔
575
                if is_open:
1✔
576
                    lines.append("({})".format(dt_humanize_delta(order["order_filled_date"])))
1✔
577
                lines.append(f"*Amount:* {round_value(cur_entry_amount, 8)} "
1✔
578
                             f"({fmt_coin(order['cost'], quote_currency)})")
579
                lines.append(f"*Average {wording} Price:* {round_value(cur_entry_average, 8)} "
1✔
580
                             f"({price_to_1st_entry:.2%} from 1st entry rate)")
581
                lines.append(f"*Order Filled:* {order['order_filled_date']}")
1✔
582

583
            lines_detail.append("\n".join(lines))
1✔
584

585
        return lines_detail
1✔
586

587
    @authorized_only
1✔
588
    async def _order(self, update: Update, context: CallbackContext) -> None:
1✔
589
        """
590
        Handler for /order.
591
        Returns the orders of the trade
592
        :param bot: telegram bot
593
        :param update: message update
594
        :return: None
595
        """
596

597
        trade_ids = []
1✔
598
        if context.args and len(context.args) > 0:
1✔
599
            trade_ids = [int(i) for i in context.args if i.isnumeric()]
1✔
600

601
        results = self._rpc._rpc_trade_status(trade_ids=trade_ids)
1✔
602
        for r in results:
1✔
603
            lines = [
1✔
604
                "*Order List for Trade #*`{trade_id}`"
605
            ]
606

607
            lines_detail = self._prepare_order_details(
1✔
608
                r['orders'], r['quote_currency'], r['is_open'])
609
            lines.extend(lines_detail if lines_detail else "")
1✔
610
            await self.__send_order_msg(lines, r)
1✔
611

612
    async def __send_order_msg(self, lines: List[str], r: Dict[str, Any]) -> None:
1✔
613
        """
614
        Send status message.
615
        """
616
        msg = ''
1✔
617

618
        for line in lines:
1✔
619
            if line:
1✔
620
                if (len(msg) + len(line) + 1) < MAX_MESSAGE_LENGTH:
1✔
621
                    msg += line + '\n'
1✔
622
                else:
623
                    await self._send_msg(msg.format(**r))
1✔
624
                    msg = "*Order List for Trade #*`{trade_id}` - continued\n" + line + '\n'
1✔
625

626
        await self._send_msg(msg.format(**r))
1✔
627

628
    @authorized_only
1✔
629
    async def _status(self, update: Update, context: CallbackContext) -> None:
1✔
630
        """
631
        Handler for /status.
632
        Returns the current TradeThread status
633
        :param bot: telegram bot
634
        :param update: message update
635
        :return: None
636
        """
637

638
        if context.args and 'table' in context.args:
1✔
639
            await self._status_table(update, context)
1✔
640
            return
×
641
        else:
642
            await self._status_msg(update, context)
1✔
643

644
    async def _status_msg(self, update: Update, context: CallbackContext) -> None:
1✔
645
        """
646
        handler for `/status` and `/status <id>`.
647

648
        """
649
        # Check if there's at least one numerical ID provided.
650
        # If so, try to get only these trades.
651
        trade_ids = []
1✔
652
        if context.args and len(context.args) > 0:
1✔
653
            trade_ids = [int(i) for i in context.args if i.isnumeric()]
1✔
654

655
        results = self._rpc._rpc_trade_status(trade_ids=trade_ids)
1✔
656
        position_adjust = self._config.get('position_adjustment_enable', False)
1✔
657
        max_entries = self._config.get('max_entry_position_adjustment', -1)
1✔
658
        for r in results:
1✔
659
            r['open_date_hum'] = dt_humanize_delta(r['open_date'])
1✔
660
            r['num_entries'] = len([o for o in r['orders'] if o['ft_is_entry']])
1✔
661
            r['num_exits'] = len([o for o in r['orders'] if not o['ft_is_entry']
1✔
662
                                 and not o['ft_order_side'] == 'stoploss'])
663
            r['exit_reason'] = r.get('exit_reason', "")
1✔
664
            r['stake_amount_r'] = fmt_coin(r['stake_amount'], r['quote_currency'])
1✔
665
            r['max_stake_amount_r'] = fmt_coin(
1✔
666
                r['max_stake_amount'] or r['stake_amount'], r['quote_currency'])
667
            r['profit_abs_r'] = fmt_coin(r['profit_abs'], r['quote_currency'])
1✔
668
            r['realized_profit_r'] = fmt_coin(r['realized_profit'], r['quote_currency'])
1✔
669
            r['total_profit_abs_r'] = fmt_coin(
1✔
670
                r['total_profit_abs'], r['quote_currency'])
671
            lines = [
1✔
672
                "*Trade ID:* `{trade_id}`" +
673
                (" `(since {open_date_hum})`" if r['is_open'] else ""),
674
                "*Current Pair:* {pair}",
675
                f"*Direction:* {'`Short`' if r.get('is_short') else '`Long`'}"
676
                + " ` ({leverage}x)`" if r.get('leverage') else "",
677
                "*Amount:* `{amount} ({stake_amount_r})`",
678
                "*Total invested:* `{max_stake_amount_r}`" if position_adjust else "",
679
                "*Enter Tag:* `{enter_tag}`" if r['enter_tag'] else "",
680
                "*Exit Reason:* `{exit_reason}`" if r['exit_reason'] else "",
681
            ]
682

683
            if position_adjust:
1✔
684
                max_buy_str = (f"/{max_entries + 1}" if (max_entries > 0) else "")
1✔
685
                lines.extend([
1✔
686
                    "*Number of Entries:* `{num_entries}" + max_buy_str + "`",
687
                    "*Number of Exits:* `{num_exits}`"
688
                ])
689

690
            lines.extend([
1✔
691
                f"*Open Rate:* `{round_value(r['open_rate'], 8)}`",
692
                f"*Close Rate:* `{round_value(r['close_rate'], 8)}`" if r['close_rate'] else "",
693
                "*Open Date:* `{open_date}`",
694
                "*Close Date:* `{close_date}`" if r['close_date'] else "",
695
                f" \n*Current Rate:* `{round_value(r['current_rate'], 8)}`" if r['is_open'] else "",
696
                ("*Unrealized Profit:* " if r['is_open'] else "*Close Profit: *")
697
                + "`{profit_ratio:.2%}` `({profit_abs_r})`",
698
            ])
699

700
            if r['is_open']:
1✔
701
                if r.get('realized_profit'):
1✔
702
                    lines.extend([
×
703
                        "*Realized Profit:* `{realized_profit_ratio:.2%} ({realized_profit_r})`",
704
                        "*Total Profit:* `{total_profit_ratio:.2%} ({total_profit_abs_r})`"
705
                    ])
706

707
                # Append empty line to improve readability
708
                lines.append(" ")
1✔
709
                if (r['stop_loss_abs'] != r['initial_stop_loss_abs']
1✔
710
                        and r['initial_stop_loss_ratio'] is not None):
711
                    # Adding initial stoploss only if it is different from stoploss
712
                    lines.append("*Initial Stoploss:* `{initial_stop_loss_abs:.8f}` "
1✔
713
                                 "`({initial_stop_loss_ratio:.2%})`")
714

715
                # Adding stoploss and stoploss percentage only if it is not None
716
                lines.append(f"*Stoploss:* `{round_value(r['stop_loss_abs'], 8)}` " +
1✔
717
                             ("`({stop_loss_ratio:.2%})`" if r['stop_loss_ratio'] else ""))
718
                lines.append(f"*Stoploss distance:* `{round_value(r['stoploss_current_dist'], 8)}` "
1✔
719
                             "`({stoploss_current_dist_ratio:.2%})`")
720
                if r.get('open_orders'):
1✔
721
                    lines.append(
1✔
722
                        "*Open Order:* `{open_orders}`"
723
                        + ("- `{exit_order_status}`" if r['exit_order_status'] else ""))
724

725
            await self.__send_status_msg(lines, r)
1✔
726

727
    async def __send_status_msg(self, lines: List[str], r: Dict[str, Any]) -> None:
1✔
728
        """
729
        Send status message.
730
        """
731
        msg = ''
1✔
732

733
        for line in lines:
1✔
734
            if line:
1✔
735
                if (len(msg) + len(line) + 1) < MAX_MESSAGE_LENGTH:
1✔
736
                    msg += line + '\n'
1✔
737
                else:
738
                    await self._send_msg(msg.format(**r))
×
739
                    msg = "*Trade ID:* `{trade_id}` - continued\n" + line + '\n'
×
740

741
        await self._send_msg(msg.format(**r))
1✔
742

743
    @authorized_only
1✔
744
    async def _status_table(self, update: Update, context: CallbackContext) -> None:
1✔
745
        """
746
        Handler for /status table.
747
        Returns the current TradeThread status in table format
748
        :param bot: telegram bot
749
        :param update: message update
750
        :return: None
751
        """
752
        fiat_currency = self._config.get('fiat_display_currency', '')
1✔
753
        statlist, head, fiat_profit_sum = self._rpc._rpc_status_table(
1✔
754
            self._config['stake_currency'], fiat_currency)
755

756
        show_total = not isnan(fiat_profit_sum) and len(statlist) > 1
1✔
757
        max_trades_per_msg = 50
1✔
758
        """
1✔
759
        Calculate the number of messages of 50 trades per message
760
        0.99 is used to make sure that there are no extra (empty) messages
761
        As an example with 50 trades, there will be int(50/50 + 0.99) = 1 message
762
        """
763
        messages_count = max(int(len(statlist) / max_trades_per_msg + 0.99), 1)
1✔
764
        for i in range(0, messages_count):
1✔
765
            trades = statlist[i * max_trades_per_msg:(i + 1) * max_trades_per_msg]
1✔
766
            if show_total and i == messages_count - 1:
1✔
767
                # append total line
768
                trades.append(["Total", "", "", f"{fiat_profit_sum:.2f} {fiat_currency}"])
×
769

770
            message = tabulate(trades,
1✔
771
                               headers=head,
772
                               tablefmt='simple')
773
            if show_total and i == messages_count - 1:
1✔
774
                # insert separators line between Total
775
                lines = message.split("\n")
×
776
                message = "\n".join(lines[:-1] + [lines[1]] + [lines[-1]])
×
777
            await self._send_msg(f"<pre>{message}</pre>", parse_mode=ParseMode.HTML,
1✔
778
                                 reload_able=True, callback_path="update_status_table",
779
                                 query=update.callback_query)
780

781
    async def _timeunit_stats(self, update: Update, context: CallbackContext, unit: str) -> None:
1✔
782
        """
783
        Handler for /daily <n>
784
        Returns a daily profit (in BTC) over the last n days.
785
        :param bot: telegram bot
786
        :param update: message update
787
        :return: None
788
        """
789

790
        vals = {
1✔
791
            'days': TimeunitMappings('Day', 'Daily', 'days', 'update_daily', 7, '%Y-%m-%d'),
792
            'weeks': TimeunitMappings('Monday', 'Weekly', 'weeks (starting from Monday)',
793
                                      'update_weekly', 8, '%Y-%m-%d'),
794
            'months': TimeunitMappings('Month', 'Monthly', 'months', 'update_monthly', 6, '%Y-%m'),
795
        }
796
        val = vals[unit]
1✔
797

798
        stake_cur = self._config['stake_currency']
1✔
799
        fiat_disp_cur = self._config.get('fiat_display_currency', '')
1✔
800
        try:
1✔
801
            timescale = int(context.args[0]) if context.args else val.default
1✔
802
        except (TypeError, ValueError, IndexError):
1✔
803
            timescale = val.default
1✔
804
        stats = self._rpc._rpc_timeunit_profit(
1✔
805
            timescale,
806
            stake_cur,
807
            fiat_disp_cur,
808
            unit
809
        )
810
        stats_tab = tabulate(
1✔
811
            [[f"{period['date']:{val.dateformat}} ({period['trade_count']})",
812
              f"{fmt_coin(period['abs_profit'], stats['stake_currency'])}",
813
              f"{period['fiat_value']:.2f} {stats['fiat_display_currency']}",
814
              f"{period['rel_profit']:.2%}",
815
              ] for period in stats['data']],
816
            headers=[
817
                f"{val.header} (count)",
818
                f'{stake_cur}',
819
                f'{fiat_disp_cur}',
820
                'Profit %',
821
                'Trades',
822
            ],
823
            tablefmt='simple')
824
        message = (
1✔
825
            f'<b>{val.message} Profit over the last {timescale} {val.message2}</b>:\n'
826
            f'<pre>{stats_tab}</pre>'
827
        )
828
        await self._send_msg(message, parse_mode=ParseMode.HTML, reload_able=True,
1✔
829
                             callback_path=val.callback, query=update.callback_query)
830

831
    @authorized_only
1✔
832
    async def _daily(self, update: Update, context: CallbackContext) -> None:
1✔
833
        """
834
        Handler for /daily <n>
835
        Returns a daily profit (in BTC) over the last n days.
836
        :param bot: telegram bot
837
        :param update: message update
838
        :return: None
839
        """
840
        await self._timeunit_stats(update, context, 'days')
1✔
841

842
    @authorized_only
1✔
843
    async def _weekly(self, update: Update, context: CallbackContext) -> None:
1✔
844
        """
845
        Handler for /weekly <n>
846
        Returns a weekly profit (in BTC) over the last n weeks.
847
        :param bot: telegram bot
848
        :param update: message update
849
        :return: None
850
        """
851
        await self._timeunit_stats(update, context, 'weeks')
1✔
852

853
    @authorized_only
1✔
854
    async def _monthly(self, update: Update, context: CallbackContext) -> None:
1✔
855
        """
856
        Handler for /monthly <n>
857
        Returns a monthly profit (in BTC) over the last n months.
858
        :param bot: telegram bot
859
        :param update: message update
860
        :return: None
861
        """
862
        await self._timeunit_stats(update, context, 'months')
1✔
863

864
    @authorized_only
1✔
865
    async def _profit(self, update: Update, context: CallbackContext) -> None:
1✔
866
        """
867
        Handler for /profit.
868
        Returns a cumulative profit statistics.
869
        :param bot: telegram bot
870
        :param update: message update
871
        :return: None
872
        """
873
        stake_cur = self._config['stake_currency']
1✔
874
        fiat_disp_cur = self._config.get('fiat_display_currency', '')
1✔
875

876
        start_date = datetime.fromtimestamp(0)
1✔
877
        timescale = None
1✔
878
        try:
1✔
879
            if context.args:
1✔
880
                timescale = int(context.args[0]) - 1
1✔
881
                today_start = datetime.combine(date.today(), datetime.min.time())
1✔
882
                start_date = today_start - timedelta(days=timescale)
1✔
883
        except (TypeError, ValueError, IndexError):
1✔
884
            pass
1✔
885

886
        stats = self._rpc._rpc_trade_statistics(
1✔
887
            stake_cur,
888
            fiat_disp_cur,
889
            start_date)
890
        profit_closed_coin = stats['profit_closed_coin']
1✔
891
        profit_closed_ratio_mean = stats['profit_closed_ratio_mean']
1✔
892
        profit_closed_percent = stats['profit_closed_percent']
1✔
893
        profit_closed_fiat = stats['profit_closed_fiat']
1✔
894
        profit_all_coin = stats['profit_all_coin']
1✔
895
        profit_all_ratio_mean = stats['profit_all_ratio_mean']
1✔
896
        profit_all_percent = stats['profit_all_percent']
1✔
897
        profit_all_fiat = stats['profit_all_fiat']
1✔
898
        trade_count = stats['trade_count']
1✔
899
        first_trade_date = f"{stats['first_trade_humanized']} ({stats['first_trade_date']})"
1✔
900
        latest_trade_date = f"{stats['latest_trade_humanized']} ({stats['latest_trade_date']})"
1✔
901
        avg_duration = stats['avg_duration']
1✔
902
        best_pair = stats['best_pair']
1✔
903
        best_pair_profit_ratio = stats['best_pair_profit_ratio']
1✔
904
        winrate = stats['winrate']
1✔
905
        expectancy = stats['expectancy']
1✔
906
        expectancy_ratio = stats['expectancy_ratio']
1✔
907

908
        if stats['trade_count'] == 0:
1✔
909
            markdown_msg = f"No trades yet.\n*Bot started:* `{stats['bot_start_date']}`"
1✔
910
        else:
911
            # Message to display
912
            if stats['closed_trade_count'] > 0:
1✔
913
                markdown_msg = ("*ROI:* Closed trades\n"
1✔
914
                                f"∙ `{fmt_coin(profit_closed_coin, stake_cur)} "
915
                                f"({profit_closed_ratio_mean:.2%}) "
916
                                f"({profit_closed_percent} \N{GREEK CAPITAL LETTER SIGMA}%)`\n"
917
                                f"∙ `{fmt_coin(profit_closed_fiat, fiat_disp_cur)}`\n")
918
            else:
919
                markdown_msg = "`No closed trade` \n"
1✔
920

921
            markdown_msg += (
1✔
922
                f"*ROI:* All trades\n"
923
                f"∙ `{fmt_coin(profit_all_coin, stake_cur)} "
924
                f"({profit_all_ratio_mean:.2%}) "
925
                f"({profit_all_percent} \N{GREEK CAPITAL LETTER SIGMA}%)`\n"
926
                f"∙ `{fmt_coin(profit_all_fiat, fiat_disp_cur)}`\n"
927
                f"*Total Trade Count:* `{trade_count}`\n"
928
                f"*Bot started:* `{stats['bot_start_date']}`\n"
929
                f"*{'First Trade opened' if not timescale else 'Showing Profit since'}:* "
930
                f"`{first_trade_date}`\n"
931
                f"*Latest Trade opened:* `{latest_trade_date}`\n"
932
                f"*Win / Loss:* `{stats['winning_trades']} / {stats['losing_trades']}`\n"
933
                f"*Winrate:* `{winrate:.2%}`\n"
934
                f"*Expectancy (Ratio):* `{expectancy:.2f} ({expectancy_ratio:.2f})`"
935
            )
936
            if stats['closed_trade_count'] > 0:
1✔
937
                markdown_msg += (
1✔
938
                    f"\n*Avg. Duration:* `{avg_duration}`\n"
939
                    f"*Best Performing:* `{best_pair}: {best_pair_profit_ratio:.2%}`\n"
940
                    f"*Trading volume:* `{fmt_coin(stats['trading_volume'], stake_cur)}`\n"
941
                    f"*Profit factor:* `{stats['profit_factor']:.2f}`\n"
942
                    f"*Max Drawdown:* `{stats['max_drawdown']:.2%} "
943
                    f"({fmt_coin(stats['max_drawdown_abs'], stake_cur)})`\n"
944
                    f"    from `{stats['max_drawdown_start']} "
945
                    f"({fmt_coin(stats['drawdown_high'], stake_cur)})`\n"
946
                    f"    to `{stats['max_drawdown_end']} "
947
                    f"({fmt_coin(stats['drawdown_low'], stake_cur)})`\n"
948
                )
949
        await self._send_msg(markdown_msg, reload_able=True, callback_path="update_profit",
1✔
950
                             query=update.callback_query)
951

952
    @authorized_only
1✔
953
    async def _stats(self, update: Update, context: CallbackContext) -> None:
1✔
954
        """
955
        Handler for /stats
956
        Show stats of recent trades
957
        """
958
        stats = self._rpc._rpc_stats()
1✔
959

960
        reason_map = {
1✔
961
            'roi': 'ROI',
962
            'stop_loss': 'Stoploss',
963
            'trailing_stop_loss': 'Trail. Stop',
964
            'stoploss_on_exchange': 'Stoploss',
965
            'exit_signal': 'Exit Signal',
966
            'force_exit': 'Force Exit',
967
            'emergency_exit': 'Emergency Exit',
968
        }
969
        exit_reasons_tabulate = [
1✔
970
            [
971
                reason_map.get(reason, reason),
972
                sum(count.values()),
973
                count['wins'],
974
                count['losses']
975
            ] for reason, count in stats['exit_reasons'].items()
976
        ]
977
        exit_reasons_msg = 'No trades yet.'
1✔
978
        for reason in chunks(exit_reasons_tabulate, 25):
1✔
979
            exit_reasons_msg = tabulate(
1✔
980
                reason,
981
                headers=['Exit Reason', 'Exits', 'Wins', 'Losses']
982
            )
983
            if len(exit_reasons_tabulate) > 25:
1✔
984
                await self._send_msg(f"```\n{exit_reasons_msg}```", ParseMode.MARKDOWN)
×
985
                exit_reasons_msg = ''
×
986

987
        durations = stats['durations']
1✔
988
        duration_msg = tabulate(
1✔
989
            [
990
                ['Wins', str(timedelta(seconds=durations['wins']))
991
                 if durations['wins'] is not None else 'N/A'],
992
                ['Losses', str(timedelta(seconds=durations['losses']))
993
                 if durations['losses'] is not None else 'N/A']
994
            ],
995
            headers=['', 'Avg. Duration']
996
        )
997
        msg = (f"""```\n{exit_reasons_msg}```\n```\n{duration_msg}```""")
1✔
998

999
        await self._send_msg(msg, ParseMode.MARKDOWN)
1✔
1000

1001
    @authorized_only
1✔
1002
    async def _balance(self, update: Update, context: CallbackContext) -> None:
1✔
1003
        """ Handler for /balance """
1004
        full_result = context.args and 'full' in context.args
1✔
1005
        result = self._rpc._rpc_balance(self._config['stake_currency'],
1✔
1006
                                        self._config.get('fiat_display_currency', ''))
1007

1008
        balance_dust_level = self._config['telegram'].get('balance_dust_level', 0.0)
1✔
1009
        if not balance_dust_level:
1✔
1010
            balance_dust_level = DUST_PER_COIN.get(self._config['stake_currency'], 1.0)
1✔
1011

1012
        output = ''
1✔
1013
        if self._config['dry_run']:
1✔
1014
            output += "*Warning:* Simulated balances in Dry Mode.\n"
1✔
1015
        starting_cap = fmt_coin(result['starting_capital'], self._config['stake_currency'])
1✔
1016
        output += f"Starting capital: `{starting_cap}`"
1✔
1017
        starting_cap_fiat = fmt_coin(
1✔
1018
            result['starting_capital_fiat'], self._config['fiat_display_currency']
1019
        ) if result['starting_capital_fiat'] > 0 else ''
1020
        output += (f" `, {starting_cap_fiat}`.\n"
1✔
1021
                   ) if result['starting_capital_fiat'] > 0 else '.\n'
1022

1023
        total_dust_balance = 0
1✔
1024
        total_dust_currencies = 0
1✔
1025
        for curr in result['currencies']:
1✔
1026
            curr_output = ''
1✔
1027
            if (
1✔
1028
                (curr['is_position'] or curr['est_stake'] > balance_dust_level)
1029
                and (full_result or curr['is_bot_managed'])
1030
            ):
1031
                if curr['is_position']:
1✔
1032
                    curr_output = (
×
1033
                        f"*{curr['currency']}:*\n"
1034
                        f"\t`{curr['side']}: {curr['position']:.8f}`\n"
1035
                        f"\t`Leverage: {curr['leverage']:.1f}`\n"
1036
                        f"\t`Est. {curr['stake']}: "
1037
                        f"{fmt_coin(curr['est_stake'], curr['stake'], False)}`\n")
1038
                else:
1039
                    est_stake = fmt_coin(
1✔
1040
                        curr['est_stake' if full_result else 'est_stake_bot'], curr['stake'], False)
1041

1042
                    curr_output = (
1✔
1043
                        f"*{curr['currency']}:*\n"
1044
                        f"\t`Available: {curr['free']:.8f}`\n"
1045
                        f"\t`Balance: {curr['balance']:.8f}`\n"
1046
                        f"\t`Pending: {curr['used']:.8f}`\n"
1047
                        f"\t`Bot Owned: {curr['bot_owned']:.8f}`\n"
1048
                        f"\t`Est. {curr['stake']}: {est_stake}`\n")
1049

1050
            elif curr['est_stake'] <= balance_dust_level:
1✔
1051
                total_dust_balance += curr['est_stake']
1✔
1052
                total_dust_currencies += 1
1✔
1053

1054
            # Handle overflowing message length
1055
            if len(output + curr_output) >= MAX_MESSAGE_LENGTH:
1✔
1056
                await self._send_msg(output)
1✔
1057
                output = curr_output
1✔
1058
            else:
1059
                output += curr_output
1✔
1060

1061
        if total_dust_balance > 0:
1✔
1062
            output += (
1✔
1063
                f"*{total_dust_currencies} Other "
1064
                f"{plural(total_dust_currencies, 'Currency', 'Currencies')} "
1065
                f"(< {balance_dust_level} {result['stake']}):*\n"
1066
                f"\t`Est. {result['stake']}: "
1067
                f"{fmt_coin(total_dust_balance, result['stake'], False)}`\n")
1068
        tc = result['trade_count'] > 0
1✔
1069
        stake_improve = f" `({result['starting_capital_ratio']:.2%})`" if tc else ''
1✔
1070
        fiat_val = f" `({result['starting_capital_fiat_ratio']:.2%})`" if tc else ''
1✔
1071
        value = fmt_coin(
1✔
1072
            result['value' if full_result else 'value_bot'], result['symbol'], False)
1073
        total_stake = fmt_coin(
1✔
1074
            result['total' if full_result else 'total_bot'], result['stake'], False)
1075
        output += (
1✔
1076
            f"\n*Estimated Value{' (Bot managed assets only)' if not full_result else ''}*:\n"
1077
            f"\t`{result['stake']}: {total_stake}`{stake_improve}\n"
1078
            f"\t`{result['symbol']}: {value}`{fiat_val}\n"
1079
        )
1080
        await self._send_msg(output, reload_able=True, callback_path="update_balance",
1✔
1081
                             query=update.callback_query)
1082

1083
    @authorized_only
1✔
1084
    async def _start(self, update: Update, context: CallbackContext) -> None:
1✔
1085
        """
1086
        Handler for /start.
1087
        Starts TradeThread
1088
        :param bot: telegram bot
1089
        :param update: message update
1090
        :return: None
1091
        """
1092
        msg = self._rpc._rpc_start()
1✔
1093
        await self._send_msg(f"Status: `{msg['status']}`")
1✔
1094

1095
    @authorized_only
1✔
1096
    async def _stop(self, update: Update, context: CallbackContext) -> None:
1✔
1097
        """
1098
        Handler for /stop.
1099
        Stops TradeThread
1100
        :param bot: telegram bot
1101
        :param update: message update
1102
        :return: None
1103
        """
1104
        msg = self._rpc._rpc_stop()
1✔
1105
        await self._send_msg(f"Status: `{msg['status']}`")
1✔
1106

1107
    @authorized_only
1✔
1108
    async def _reload_config(self, update: Update, context: CallbackContext) -> None:
1✔
1109
        """
1110
        Handler for /reload_config.
1111
        Triggers a config file reload
1112
        :param bot: telegram bot
1113
        :param update: message update
1114
        :return: None
1115
        """
1116
        msg = self._rpc._rpc_reload_config()
1✔
1117
        await self._send_msg(f"Status: `{msg['status']}`")
1✔
1118

1119
    @authorized_only
1✔
1120
    async def _stopentry(self, update: Update, context: CallbackContext) -> None:
1✔
1121
        """
1122
        Handler for /stop_buy.
1123
        Sets max_open_trades to 0 and gracefully sells all open trades
1124
        :param bot: telegram bot
1125
        :param update: message update
1126
        :return: None
1127
        """
1128
        msg = self._rpc._rpc_stopentry()
1✔
1129
        await self._send_msg(f"Status: `{msg['status']}`")
1✔
1130

1131
    @authorized_only
1✔
1132
    async def _reload_trade_from_exchange(self, update: Update, context: CallbackContext) -> None:
1✔
1133
        """
1134
        Handler for /reload_trade <tradeid>.
1135
        """
1136
        if not context.args or len(context.args) == 0:
1✔
1137
            raise RPCException("Trade-id not set.")
1✔
1138
        trade_id = int(context.args[0])
1✔
1139
        msg = self._rpc._rpc_reload_trade_from_exchange(trade_id)
1✔
1140
        await self._send_msg(f"Status: `{msg['status']}`")
1✔
1141

1142
    @authorized_only
1✔
1143
    async def _force_exit(self, update: Update, context: CallbackContext) -> None:
1✔
1144
        """
1145
        Handler for /forceexit <id>.
1146
        Sells the given trade at current price
1147
        :param bot: telegram bot
1148
        :param update: message update
1149
        :return: None
1150
        """
1151

1152
        if context.args:
1✔
1153
            trade_id = context.args[0]
1✔
1154
            await self._force_exit_action(trade_id)
1✔
1155
        else:
1156
            fiat_currency = self._config.get('fiat_display_currency', '')
1✔
1157
            try:
1✔
1158
                statlist, _, _ = self._rpc._rpc_status_table(
1✔
1159
                    self._config['stake_currency'], fiat_currency)
1160
            except RPCException:
1✔
1161
                await self._send_msg(msg='No open trade found.')
1✔
1162
                return
1✔
1163
            trades = []
1✔
1164
            for trade in statlist:
1✔
1165
                trades.append((trade[0], f"{trade[0]} {trade[1]} {trade[2]} {trade[3]}"))
1✔
1166

1167
            trade_buttons = [
1✔
1168
                InlineKeyboardButton(text=trade[1], callback_data=f"force_exit__{trade[0]}")
1169
                for trade in trades]
1170
            buttons_aligned = self._layout_inline_keyboard_onecol(trade_buttons)
1✔
1171

1172
            buttons_aligned.append([InlineKeyboardButton(
1✔
1173
                text='Cancel', callback_data='force_exit__cancel')])
1174
            await self._send_msg(msg="Which trade?", keyboard=buttons_aligned)
1✔
1175

1176
    async def _force_exit_action(self, trade_id: str):
1✔
1177
        if trade_id != 'cancel':
1✔
1178
            try:
1✔
1179
                loop = asyncio.get_running_loop()
1✔
1180
                # Workaround to avoid nested loops
1181
                await loop.run_in_executor(None, safe_async_db(self._rpc._rpc_force_exit), trade_id)
1✔
1182
            except RPCException as e:
1✔
1183
                await self._send_msg(str(e))
1✔
1184

1185
    async def _force_exit_inline(self, update: Update, _: CallbackContext) -> None:
1✔
1186
        if update.callback_query:
1✔
1187
            query = update.callback_query
1✔
1188
            if query.data and '__' in query.data:
1✔
1189
                # Input data is "force_exit__<tradid|cancel>"
1190
                trade_id = query.data.split("__")[1].split(' ')[0]
1✔
1191
                if trade_id == 'cancel':
1✔
1192
                    await query.answer()
1✔
1193
                    await query.edit_message_text(text="Force exit canceled.")
1✔
1194
                    return
1✔
1195
                trade: Optional[Trade] = Trade.get_trades(trade_filter=Trade.id == trade_id).first()
1✔
1196
                await query.answer()
1✔
1197
                if trade:
1✔
1198
                    await query.edit_message_text(
1✔
1199
                        text=f"Manually exiting Trade #{trade_id}, {trade.pair}")
1200
                    await self._force_exit_action(trade_id)
1✔
1201
                else:
1202
                    await query.edit_message_text(text=f"Trade {trade_id} not found.")
×
1203

1204
    async def _force_enter_action(self, pair, price: Optional[float], order_side: SignalDirection):
1✔
1205
        if pair != 'cancel':
1✔
1206
            try:
1✔
1207
                @safe_async_db
1✔
1208
                def _force_enter():
1✔
1209
                    self._rpc._rpc_force_entry(pair, price, order_side=order_side)
1✔
1210
                loop = asyncio.get_running_loop()
1✔
1211
                # Workaround to avoid nested loops
1212
                await loop.run_in_executor(None, _force_enter)
1✔
1213
            except RPCException as e:
1✔
1214
                logger.exception("Forcebuy error!")
1✔
1215
                await self._send_msg(str(e), ParseMode.HTML)
1✔
1216

1217
    async def _force_enter_inline(self, update: Update, _: CallbackContext) -> None:
1✔
1218
        if update.callback_query:
1✔
1219
            query = update.callback_query
1✔
1220
            if query.data and '__' in query.data:
1✔
1221
                # Input data is "force_enter__<pair|cancel>_<side>"
1222
                payload = query.data.split("__")[1]
1✔
1223
                if payload == 'cancel':
1✔
1224
                    await query.answer()
1✔
1225
                    await query.edit_message_text(text="Force enter canceled.")
1✔
1226
                    return
1✔
1227
                if payload and '_||_' in payload:
1✔
1228
                    pair, side = payload.split('_||_')
1✔
1229
                    order_side = SignalDirection(side)
1✔
1230
                    await query.answer()
1✔
1231
                    await query.edit_message_text(text=f"Manually entering {order_side} for {pair}")
1✔
1232
                    await self._force_enter_action(pair, None, order_side)
1✔
1233

1234
    @staticmethod
1✔
1235
    def _layout_inline_keyboard(
1✔
1236
            buttons: List[InlineKeyboardButton], cols=3) -> List[List[InlineKeyboardButton]]:
1237
        return [buttons[i:i + cols] for i in range(0, len(buttons), cols)]
1✔
1238

1239
    @staticmethod
1✔
1240
    def _layout_inline_keyboard_onecol(
1✔
1241
            buttons: List[InlineKeyboardButton], cols=1) -> List[List[InlineKeyboardButton]]:
1242
        return [buttons[i:i + cols] for i in range(0, len(buttons), cols)]
1✔
1243

1244
    @authorized_only
1✔
1245
    async def _force_enter(
1✔
1246
            self, update: Update, context: CallbackContext, order_side: SignalDirection) -> None:
1247
        """
1248
        Handler for /forcelong <asset> <price> and `/forceshort <asset> <price>
1249
        Buys a pair trade at the given or current price
1250
        :param bot: telegram bot
1251
        :param update: message update
1252
        :return: None
1253
        """
1254
        if context.args:
1✔
1255
            pair = context.args[0]
1✔
1256
            price = float(context.args[1]) if len(context.args) > 1 else None
1✔
1257
            await self._force_enter_action(pair, price, order_side)
1✔
1258
        else:
1259
            whitelist = self._rpc._rpc_whitelist()['whitelist']
1✔
1260
            pair_buttons = [
1✔
1261
                InlineKeyboardButton(
1262
                    text=pair, callback_data=f"force_enter__{pair}_||_{order_side}"
1263
                ) for pair in sorted(whitelist)
1264
            ]
1265
            buttons_aligned = self._layout_inline_keyboard(pair_buttons)
1✔
1266

1267
            buttons_aligned.append([InlineKeyboardButton(text='Cancel',
1✔
1268
                                                         callback_data='force_enter__cancel')])
1269
            await self._send_msg(msg="Which pair?",
1✔
1270
                                 keyboard=buttons_aligned,
1271
                                 query=update.callback_query)
1272

1273
    @authorized_only
1✔
1274
    async def _trades(self, update: Update, context: CallbackContext) -> None:
1✔
1275
        """
1276
        Handler for /trades <n>
1277
        Returns last n recent trades.
1278
        :param bot: telegram bot
1279
        :param update: message update
1280
        :return: None
1281
        """
1282
        stake_cur = self._config['stake_currency']
1✔
1283
        try:
1✔
1284
            nrecent = int(context.args[0]) if context.args else 10
1✔
1285
        except (TypeError, ValueError, IndexError):
1✔
1286
            nrecent = 10
1✔
1287
        trades = self._rpc._rpc_trade_history(
1✔
1288
            nrecent
1289
        )
1290
        trades_tab = tabulate(
1✔
1291
            [[dt_humanize_delta(dt_from_ts(trade['close_timestamp'])),
1292
                trade['pair'] + " (#" + str(trade['trade_id']) + ")",
1293
                f"{(trade['close_profit']):.2%} ({trade['close_profit_abs']})"]
1294
                for trade in trades['trades']],
1295
            headers=[
1296
                'Close Date',
1297
                'Pair (ID)',
1298
                f'Profit ({stake_cur})',
1299
            ],
1300
            tablefmt='simple')
1301
        message = (f"<b>{min(trades['trades_count'], nrecent)} recent trades</b>:\n"
1✔
1302
                   + (f"<pre>{trades_tab}</pre>" if trades['trades_count'] > 0 else ''))
1303
        await self._send_msg(message, parse_mode=ParseMode.HTML)
1✔
1304

1305
    @authorized_only
1✔
1306
    async def _delete_trade(self, update: Update, context: CallbackContext) -> None:
1✔
1307
        """
1308
        Handler for /delete <id>.
1309
        Delete the given trade
1310
        :param bot: telegram bot
1311
        :param update: message update
1312
        :return: None
1313
        """
1314
        if not context.args or len(context.args) == 0:
1✔
1315
            raise RPCException("Trade-id not set.")
1✔
1316
        trade_id = int(context.args[0])
1✔
1317
        msg = self._rpc._rpc_delete(trade_id)
1✔
1318
        await self._send_msg(
1✔
1319
            f"`{msg['result_msg']}`\n"
1320
            'Please make sure to take care of this asset on the exchange manually.'
1321
        )
1322

1323
    @authorized_only
1✔
1324
    async def _cancel_open_order(self, update: Update, context: CallbackContext) -> None:
1✔
1325
        """
1326
        Handler for /cancel_open_order <id>.
1327
        Cancel open order for tradeid
1328
        :param bot: telegram bot
1329
        :param update: message update
1330
        :return: None
1331
        """
1332
        if not context.args or len(context.args) == 0:
1✔
1333
            raise RPCException("Trade-id not set.")
1✔
1334
        trade_id = int(context.args[0])
1✔
1335
        self._rpc._rpc_cancel_open_order(trade_id)
1✔
1336
        await self._send_msg('Open order canceled.')
1✔
1337

1338
    @authorized_only
1✔
1339
    async def _performance(self, update: Update, context: CallbackContext) -> None:
1✔
1340
        """
1341
        Handler for /performance.
1342
        Shows a performance statistic from finished trades
1343
        :param bot: telegram bot
1344
        :param update: message update
1345
        :return: None
1346
        """
1347
        trades = self._rpc._rpc_performance()
1✔
1348
        output = "<b>Performance:</b>\n"
1✔
1349
        for i, trade in enumerate(trades):
1✔
1350
            stat_line = (
1✔
1351
                f"{i + 1}.\t <code>{trade['pair']}\t"
1352
                f"{fmt_coin(trade['profit_abs'], self._config['stake_currency'])} "
1353
                f"({trade['profit_ratio']:.2%}) "
1354
                f"({trade['count']})</code>\n")
1355

1356
            if len(output + stat_line) >= MAX_MESSAGE_LENGTH:
1✔
1357
                await self._send_msg(output, parse_mode=ParseMode.HTML)
×
1358
                output = stat_line
×
1359
            else:
1360
                output += stat_line
1✔
1361

1362
        await self._send_msg(output, parse_mode=ParseMode.HTML,
1✔
1363
                             reload_able=True, callback_path="update_performance",
1364
                             query=update.callback_query)
1365

1366
    @authorized_only
1✔
1367
    async def _enter_tag_performance(self, update: Update, context: CallbackContext) -> None:
1✔
1368
        """
1369
        Handler for /entries PAIR .
1370
        Shows a performance statistic from finished trades
1371
        :param bot: telegram bot
1372
        :param update: message update
1373
        :return: None
1374
        """
1375
        pair = None
1✔
1376
        if context.args and isinstance(context.args[0], str):
1✔
1377
            pair = context.args[0]
1✔
1378

1379
        trades = self._rpc._rpc_enter_tag_performance(pair)
1✔
1380
        output = "*Entry Tag Performance:*\n"
1✔
1381
        for i, trade in enumerate(trades):
1✔
1382
            stat_line = (
1✔
1383
                f"{i + 1}.\t `{trade['enter_tag']}\t"
1384
                f"{fmt_coin(trade['profit_abs'], self._config['stake_currency'])} "
1385
                f"({trade['profit_ratio']:.2%}) "
1386
                f"({trade['count']})`\n")
1387

1388
            if len(output + stat_line) >= MAX_MESSAGE_LENGTH:
1✔
1389
                await self._send_msg(output, parse_mode=ParseMode.MARKDOWN)
×
1390
                output = stat_line
×
1391
            else:
1392
                output += stat_line
1✔
1393

1394
        await self._send_msg(output, parse_mode=ParseMode.MARKDOWN,
1✔
1395
                             reload_able=True, callback_path="update_enter_tag_performance",
1396
                             query=update.callback_query)
1397

1398
    @authorized_only
1✔
1399
    async def _exit_reason_performance(self, update: Update, context: CallbackContext) -> None:
1✔
1400
        """
1401
        Handler for /exits.
1402
        Shows a performance statistic from finished trades
1403
        :param bot: telegram bot
1404
        :param update: message update
1405
        :return: None
1406
        """
1407
        pair = None
1✔
1408
        if context.args and isinstance(context.args[0], str):
1✔
1409
            pair = context.args[0]
1✔
1410

1411
        trades = self._rpc._rpc_exit_reason_performance(pair)
1✔
1412
        output = "*Exit Reason Performance:*\n"
1✔
1413
        for i, trade in enumerate(trades):
1✔
1414
            stat_line = (
1✔
1415
                f"{i + 1}.\t `{trade['exit_reason']}\t"
1416
                f"{fmt_coin(trade['profit_abs'], self._config['stake_currency'])} "
1417
                f"({trade['profit_ratio']:.2%}) "
1418
                f"({trade['count']})`\n")
1419

1420
            if len(output + stat_line) >= MAX_MESSAGE_LENGTH:
1✔
1421
                await self._send_msg(output, parse_mode=ParseMode.MARKDOWN)
×
1422
                output = stat_line
×
1423
            else:
1424
                output += stat_line
1✔
1425

1426
        await self._send_msg(output, parse_mode=ParseMode.MARKDOWN,
1✔
1427
                             reload_able=True, callback_path="update_exit_reason_performance",
1428
                             query=update.callback_query)
1429

1430
    @authorized_only
1✔
1431
    async def _mix_tag_performance(self, update: Update, context: CallbackContext) -> None:
1✔
1432
        """
1433
        Handler for /mix_tags.
1434
        Shows a performance statistic from finished trades
1435
        :param bot: telegram bot
1436
        :param update: message update
1437
        :return: None
1438
        """
1439
        pair = None
1✔
1440
        if context.args and isinstance(context.args[0], str):
1✔
1441
            pair = context.args[0]
1✔
1442

1443
        trades = self._rpc._rpc_mix_tag_performance(pair)
1✔
1444
        output = "*Mix Tag Performance:*\n"
1✔
1445
        for i, trade in enumerate(trades):
1✔
1446
            stat_line = (
1✔
1447
                f"{i + 1}.\t `{trade['mix_tag']}\t"
1448
                f"{fmt_coin(trade['profit_abs'], self._config['stake_currency'])} "
1449
                f"({trade['profit_ratio']:.2%}) "
1450
                f"({trade['count']})`\n")
1451

1452
            if len(output + stat_line) >= MAX_MESSAGE_LENGTH:
1✔
1453
                await self._send_msg(output, parse_mode=ParseMode.MARKDOWN)
×
1454
                output = stat_line
×
1455
            else:
1456
                output += stat_line
1✔
1457

1458
        await self._send_msg(output, parse_mode=ParseMode.MARKDOWN,
1✔
1459
                             reload_able=True, callback_path="update_mix_tag_performance",
1460
                             query=update.callback_query)
1461

1462
    @authorized_only
1✔
1463
    async def _count(self, update: Update, context: CallbackContext) -> None:
1✔
1464
        """
1465
        Handler for /count.
1466
        Returns the number of trades running
1467
        :param bot: telegram bot
1468
        :param update: message update
1469
        :return: None
1470
        """
1471
        counts = self._rpc._rpc_count()
1✔
1472
        message = tabulate({k: [v] for k, v in counts.items()},
1✔
1473
                           headers=['current', 'max', 'total stake'],
1474
                           tablefmt='simple')
1475
        message = f"<pre>{message}</pre>"
1✔
1476
        logger.debug(message)
1✔
1477
        await self._send_msg(message, parse_mode=ParseMode.HTML,
1✔
1478
                             reload_able=True, callback_path="update_count",
1479
                             query=update.callback_query)
1480

1481
    @authorized_only
1✔
1482
    async def _locks(self, update: Update, context: CallbackContext) -> None:
1✔
1483
        """
1484
        Handler for /locks.
1485
        Returns the currently active locks
1486
        """
1487
        rpc_locks = self._rpc._rpc_locks()
1✔
1488
        if not rpc_locks['locks']:
1✔
1489
            await self._send_msg('No active locks.', parse_mode=ParseMode.HTML)
1✔
1490

1491
        for locks in chunks(rpc_locks['locks'], 25):
1✔
1492
            message = tabulate([[
1✔
1493
                lock['id'],
1494
                lock['pair'],
1495
                lock['lock_end_time'],
1496
                lock['reason']] for lock in locks],
1497
                headers=['ID', 'Pair', 'Until', 'Reason'],
1498
                tablefmt='simple')
1499
            message = f"<pre>{escape(message)}</pre>"
1✔
1500
            logger.debug(message)
1✔
1501
            await self._send_msg(message, parse_mode=ParseMode.HTML)
1✔
1502

1503
    @authorized_only
1✔
1504
    async def _delete_locks(self, update: Update, context: CallbackContext) -> None:
1✔
1505
        """
1506
        Handler for /delete_locks.
1507
        Returns the currently active locks
1508
        """
1509
        arg = context.args[0] if context.args and len(context.args) > 0 else None
1✔
1510
        lockid = None
1✔
1511
        pair = None
1✔
1512
        if arg:
1✔
1513
            try:
1✔
1514
                lockid = int(arg)
1✔
1515
            except ValueError:
1✔
1516
                pair = arg
1✔
1517

1518
        self._rpc._rpc_delete_lock(lockid=lockid, pair=pair)
1✔
1519
        await self._locks(update, context)
1✔
1520

1521
    @authorized_only
1✔
1522
    async def _whitelist(self, update: Update, context: CallbackContext) -> None:
1✔
1523
        """
1524
        Handler for /whitelist
1525
        Shows the currently active whitelist
1526
        """
1527
        whitelist = self._rpc._rpc_whitelist()
1✔
1528

1529
        if context.args:
1✔
1530
            if "sorted" in context.args:
1✔
1531
                whitelist['whitelist'] = sorted(whitelist['whitelist'])
1✔
1532
            if "baseonly" in context.args:
1✔
1533
                whitelist['whitelist'] = [pair.split("/")[0] for pair in whitelist['whitelist']]
1✔
1534

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

1538
        logger.debug(message)
1✔
1539
        await self._send_msg(message)
1✔
1540

1541
    @authorized_only
1✔
1542
    async def _blacklist(self, update: Update, context: CallbackContext) -> None:
1✔
1543
        """
1544
        Handler for /blacklist
1545
        Shows the currently active blacklist
1546
        """
1547
        await self.send_blacklist_msg(self._rpc._rpc_blacklist(context.args))
1✔
1548

1549
    async def send_blacklist_msg(self, blacklist: Dict):
1✔
1550
        errmsgs = []
1✔
1551
        for _, error in blacklist['errors'].items():
1✔
1552
            errmsgs.append(f"Error: {error['error_msg']}")
×
1553
        if errmsgs:
1✔
1554
            await self._send_msg('\n'.join(errmsgs))
×
1555

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

1559
        logger.debug(message)
1✔
1560
        await self._send_msg(message)
1✔
1561

1562
    @authorized_only
1✔
1563
    async def _blacklist_delete(self, update: Update, context: CallbackContext) -> None:
1✔
1564
        """
1565
        Handler for /bl_delete
1566
        Deletes pair(s) from current blacklist
1567
        """
1568
        await self.send_blacklist_msg(self._rpc._rpc_blacklist_delete(context.args or []))
1✔
1569

1570
    @authorized_only
1✔
1571
    async def _logs(self, update: Update, context: CallbackContext) -> None:
1✔
1572
        """
1573
        Handler for /logs
1574
        Shows the latest logs
1575
        """
1576
        try:
1✔
1577
            limit = int(context.args[0]) if context.args else 10
1✔
1578
        except (TypeError, ValueError, IndexError):
×
1579
            limit = 10
×
1580
        logs = RPC._rpc_get_logs(limit)['logs']
1✔
1581
        msgs = ''
1✔
1582
        msg_template = "*{}* {}: {} \\- `{}`"
1✔
1583
        for logrec in logs:
1✔
1584
            msg = msg_template.format(escape_markdown(logrec[0], version=2),
1✔
1585
                                      escape_markdown(logrec[2], version=2),
1586
                                      escape_markdown(logrec[3], version=2),
1587
                                      escape_markdown(logrec[4], version=2))
1588
            if len(msgs + msg) + 10 >= MAX_MESSAGE_LENGTH:
1✔
1589
                # Send message immediately if it would become too long
1590
                await self._send_msg(msgs, parse_mode=ParseMode.MARKDOWN_V2)
1✔
1591
                msgs = msg + '\n'
1✔
1592
            else:
1593
                # Append message to messages to send
1594
                msgs += msg + '\n'
1✔
1595

1596
        if msgs:
1✔
1597
            await self._send_msg(msgs, parse_mode=ParseMode.MARKDOWN_V2)
1✔
1598

1599
    @authorized_only
1✔
1600
    async def _edge(self, update: Update, context: CallbackContext) -> None:
1✔
1601
        """
1602
        Handler for /edge
1603
        Shows information related to Edge
1604
        """
1605
        edge_pairs = self._rpc._rpc_edge()
1✔
1606
        if not edge_pairs:
1✔
1607
            message = '<b>Edge only validated following pairs:</b>'
1✔
1608
            await self._send_msg(message, parse_mode=ParseMode.HTML)
1✔
1609

1610
        for chunk in chunks(edge_pairs, 25):
1✔
1611
            edge_pairs_tab = tabulate(chunk, headers='keys', tablefmt='simple')
1✔
1612
            message = (f'<b>Edge only validated following pairs:</b>\n'
1✔
1613
                       f'<pre>{edge_pairs_tab}</pre>')
1614

1615
            await self._send_msg(message, parse_mode=ParseMode.HTML)
1✔
1616

1617
    @authorized_only
1✔
1618
    async def _help(self, update: Update, context: CallbackContext) -> None:
1✔
1619
        """
1620
        Handler for /help.
1621
        Show commands of the bot
1622
        :param bot: telegram bot
1623
        :param update: message update
1624
        :return: None
1625
        """
1626
        force_enter_text = ("*/forcelong <pair> [<rate>]:* `Instantly buys the given pair. "
1✔
1627
                            "Optionally takes a rate at which to buy "
1628
                            "(only applies to limit orders).` \n"
1629
                            )
1630
        if self._rpc._freqtrade.trading_mode != TradingMode.SPOT:
1✔
1631
            force_enter_text += ("*/forceshort <pair> [<rate>]:* `Instantly shorts the given pair. "
×
1632
                                 "Optionally takes a rate at which to sell "
1633
                                 "(only applies to limit orders).` \n")
1634
        message = (
1✔
1635
            "_Bot Control_\n"
1636
            "------------\n"
1637
            "*/start:* `Starts the trader`\n"
1638
            "*/stop:* Stops the trader\n"
1639
            "*/stopentry:* `Stops entering, but handles open trades gracefully` \n"
1640
            "*/forceexit <trade_id>|all:* `Instantly exits the given trade or all trades, "
1641
            "regardless of profit`\n"
1642
            "*/fx <trade_id>|all:* `Alias to /forceexit`\n"
1643
            f"{force_enter_text if self._config.get('force_entry_enable', False) else ''}"
1644
            "*/delete <trade_id>:* `Instantly delete the given trade in the database`\n"
1645
            "*/reload_trade <trade_id>:* `Relade trade from exchange Orders`\n"
1646
            "*/cancel_open_order <trade_id>:* `Cancels open orders for trade. "
1647
            "Only valid when the trade has open orders.`\n"
1648
            "*/coo <trade_id>|all:* `Alias to /cancel_open_order`\n"
1649

1650
            "*/whitelist [sorted] [baseonly]:* `Show current whitelist. Optionally in "
1651
            "order and/or only displaying the base currency of each pairing.`\n"
1652
            "*/blacklist [pair]:* `Show current blacklist, or adds one or more pairs "
1653
            "to the blacklist.` \n"
1654
            "*/blacklist_delete [pairs]| /bl_delete [pairs]:* "
1655
            "`Delete pair / pattern from blacklist. Will reset on reload_conf.` \n"
1656
            "*/reload_config:* `Reload configuration file` \n"
1657
            "*/unlock <pair|id>:* `Unlock this Pair (or this lock id if it's numeric)`\n"
1658

1659
            "_Current state_\n"
1660
            "------------\n"
1661
            "*/show_config:* `Show running configuration` \n"
1662
            "*/locks:* `Show currently locked pairs`\n"
1663
            "*/balance:* `Show bot managed balance per currency`\n"
1664
            "*/balance total:* `Show account balance per currency`\n"
1665
            "*/logs [limit]:* `Show latest logs - defaults to 10` \n"
1666
            "*/count:* `Show number of active trades compared to allowed number of trades`\n"
1667
            "*/edge:* `Shows validated pairs by Edge if it is enabled` \n"
1668
            "*/health* `Show latest process timestamp - defaults to 1970-01-01 00:00:00` \n"
1669
            "*/marketdir [long | short | even | none]:* `Updates the user managed variable "
1670
            "that represents the current market direction. If no direction is provided `"
1671
            "`the currently set market direction will be output.` \n"
1672
            "*/list_custom_data <trade_id> <key>:* `List custom_data for Trade ID & Key combo.`\n"
1673
            "`If no Key is supplied it will list all key-value pairs found for that Trade ID.`"
1674

1675
            "_Statistics_\n"
1676
            "------------\n"
1677
            "*/status <trade_id>|[table]:* `Lists all open trades`\n"
1678
            "         *<trade_id> :* `Lists one or more specific trades.`\n"
1679
            "                        `Separate multiple <trade_id> with a blank space.`\n"
1680
            "         *table :* `will display trades in a table`\n"
1681
            "                `pending buy orders are marked with an asterisk (*)`\n"
1682
            "                `pending sell orders are marked with a double asterisk (**)`\n"
1683
            "*/entries <pair|none>:* `Shows the enter_tag performance`\n"
1684
            "*/exits <pair|none>:* `Shows the exit reason performance`\n"
1685
            "*/mix_tags <pair|none>:* `Shows combined entry tag + exit reason performance`\n"
1686
            "*/trades [limit]:* `Lists last closed trades (limited to 10 by default)`\n"
1687
            "*/profit [<n>]:* `Lists cumulative profit from all finished trades, "
1688
            "over the last n days`\n"
1689
            "*/performance:* `Show performance of each finished trade grouped by pair`\n"
1690
            "*/daily <n>:* `Shows profit or loss per day, over the last n days`\n"
1691
            "*/weekly <n>:* `Shows statistics per week, over the last n weeks`\n"
1692
            "*/monthly <n>:* `Shows statistics per month, over the last n months`\n"
1693
            "*/stats:* `Shows Wins / losses by Sell reason as well as "
1694
            "Avg. holding durations for buys and sells.`\n"
1695
            "*/help:* `This help message`\n"
1696
            "*/version:* `Show version`\n"
1697
            )
1698

1699
        await self._send_msg(message, parse_mode=ParseMode.MARKDOWN)
1✔
1700

1701
    @authorized_only
1✔
1702
    async def _health(self, update: Update, context: CallbackContext) -> None:
1✔
1703
        """
1704
        Handler for /health
1705
        Shows the last process timestamp
1706
        """
1707
        health = self._rpc.health()
×
1708
        message = f"Last process: `{health['last_process_loc']}`\n"
×
1709
        message += f"Initial bot start: `{health['bot_start_loc']}`\n"
×
1710
        message += f"Last bot restart: `{health['bot_startup_loc']}`"
×
1711
        await self._send_msg(message)
×
1712

1713
    @authorized_only
1✔
1714
    async def _version(self, update: Update, context: CallbackContext) -> None:
1✔
1715
        """
1716
        Handler for /version.
1717
        Show version information
1718
        :param bot: telegram bot
1719
        :param update: message update
1720
        :return: None
1721
        """
1722
        strategy_version = self._rpc._freqtrade.strategy.version()
1✔
1723
        version_string = f'*Version:* `{__version__}`'
1✔
1724
        if strategy_version is not None:
1✔
1725
            version_string += f'\n*Strategy version: * `{strategy_version}`'
1✔
1726

1727
        await self._send_msg(version_string)
1✔
1728

1729
    @authorized_only
1✔
1730
    async def _show_config(self, update: Update, context: CallbackContext) -> None:
1✔
1731
        """
1732
        Handler for /show_config.
1733
        Show config information information
1734
        :param bot: telegram bot
1735
        :param update: message update
1736
        :return: None
1737
        """
1738
        val = RPC._rpc_show_config(self._config, self._rpc._freqtrade.state)
1✔
1739

1740
        if val['trailing_stop']:
1✔
1741
            sl_info = (
1✔
1742
                f"*Initial Stoploss:* `{val['stoploss']}`\n"
1743
                f"*Trailing stop positive:* `{val['trailing_stop_positive']}`\n"
1744
                f"*Trailing stop offset:* `{val['trailing_stop_positive_offset']}`\n"
1745
                f"*Only trail above offset:* `{val['trailing_only_offset_is_reached']}`\n"
1746
            )
1747

1748
        else:
1749
            sl_info = f"*Stoploss:* `{val['stoploss']}`\n"
1✔
1750

1751
        if val['position_adjustment_enable']:
1✔
1752
            pa_info = (
×
1753
                f"*Position adjustment:* On\n"
1754
                f"*Max enter position adjustment:* `{val['max_entry_position_adjustment']}`\n"
1755
            )
1756
        else:
1757
            pa_info = "*Position adjustment:* Off\n"
1✔
1758

1759
        await self._send_msg(
1✔
1760
            f"*Mode:* `{'Dry-run' if val['dry_run'] else 'Live'}`\n"
1761
            f"*Exchange:* `{val['exchange']}`\n"
1762
            f"*Market: * `{val['trading_mode']}`\n"
1763
            f"*Stake per trade:* `{val['stake_amount']} {val['stake_currency']}`\n"
1764
            f"*Max open Trades:* `{val['max_open_trades']}`\n"
1765
            f"*Minimum ROI:* `{val['minimal_roi']}`\n"
1766
            f"*Entry strategy:* ```\n{json.dumps(val['entry_pricing'])}```\n"
1767
            f"*Exit strategy:* ```\n{json.dumps(val['exit_pricing'])}```\n"
1768
            f"{sl_info}"
1769
            f"{pa_info}"
1770
            f"*Timeframe:* `{val['timeframe']}`\n"
1771
            f"*Strategy:* `{val['strategy']}`\n"
1772
            f"*Current state:* `{val['state']}`"
1773
        )
1774

1775
    @authorized_only
1✔
1776
    async def _list_custom_data(self, update: Update, context: CallbackContext) -> None:
1✔
1777
        """
1778
        Handler for /list_custom_data <id> <key>.
1779
        List custom_data for specified trade (and key if supplied).
1780
        :param bot: telegram bot
1781
        :param update: message update
1782
        :return: None
1783
        """
1784
        try:
1✔
1785
            if not context.args or len(context.args) == 0:
1✔
1786
                raise RPCException("Trade-id not set.")
1✔
1787
            trade_id = int(context.args[0])
1✔
1788
            key = None if len(context.args) < 2 else str(context.args[1])
1✔
1789

1790
            results = self._rpc._rpc_list_custom_data(trade_id, key)
1✔
1791
            messages = []
1✔
1792
            if len(results) > 0:
1✔
1793
                messages.append(
1✔
1794
                    'Found custom-data entr' + ('ies: ' if len(results) > 1 else 'y: ')
1795
                )
1796
                for result in results:
1✔
1797
                    lines = [
1✔
1798
                        f"*Key:* `{result['cd_key']}`",
1799
                        f"*ID:* `{result['id']}`",
1800
                        f"*Trade ID:* `{result['ft_trade_id']}`",
1801
                        f"*Type:* `{result['cd_type']}`",
1802
                        f"*Value:* `{result['cd_value']}`",
1803
                        f"*Create Date:* `{format_date(result['created_at'])}`",
1804
                        f"*Update Date:* `{format_date(result['updated_at'])}`"
1805
                    ]
1806
                    # Filter empty lines using list-comprehension
1807
                    messages.append("\n".join([line for line in lines if line]))
1✔
1808
                for msg in messages:
1✔
1809
                    if len(msg) > MAX_MESSAGE_LENGTH:
1✔
1810
                        msg = "Message dropped because length exceeds "
×
1811
                        msg += f"maximum allowed characters: {MAX_MESSAGE_LENGTH}"
×
1812
                        logger.warning(msg)
×
1813
                    await self._send_msg(msg)
1✔
1814
            else:
1815
                message = f"Didn't find any custom-data entries for Trade ID: `{trade_id}`"
1✔
1816
                message += f" and Key: `{key}`." if key is not None else ""
1✔
1817
                await self._send_msg(message)
1✔
1818

1819
        except RPCException as e:
1✔
1820
            await self._send_msg(str(e))
1✔
1821

1822
    async def _update_msg(self, query: CallbackQuery, msg: str, callback_path: str = "",
1✔
1823
                          reload_able: bool = False, parse_mode: str = ParseMode.MARKDOWN) -> None:
1824
        if reload_able:
1✔
1825
            reply_markup = InlineKeyboardMarkup([
1✔
1826
                [InlineKeyboardButton("Refresh", callback_data=callback_path)],
1827
            ])
1828
        else:
1829
            reply_markup = InlineKeyboardMarkup([[]])
1✔
1830
        msg += f"\nUpdated: {datetime.now().ctime()}"
1✔
1831
        if not query.message:
1✔
1832
            return
×
1833

1834
        try:
1✔
1835
            await query.edit_message_text(
1✔
1836
                text=msg,
1837
                parse_mode=parse_mode,
1838
                reply_markup=reply_markup
1839
            )
1840
        except BadRequest as e:
1✔
1841
            if 'not modified' in e.message.lower():
1✔
1842
                pass
1✔
1843
            else:
1844
                logger.warning('TelegramError: %s', e.message)
1✔
1845
        except TelegramError as telegram_err:
1✔
1846
            logger.warning('TelegramError: %s! Giving up on that message.', telegram_err.message)
1✔
1847

1848
    async def _send_msg(self, msg: str, parse_mode: str = ParseMode.MARKDOWN,
1✔
1849
                        disable_notification: bool = False,
1850
                        keyboard: Optional[List[List[InlineKeyboardButton]]] = None,
1851
                        callback_path: str = "",
1852
                        reload_able: bool = False,
1853
                        query: Optional[CallbackQuery] = None) -> None:
1854
        """
1855
        Send given markdown message
1856
        :param msg: message
1857
        :param bot: alternative bot
1858
        :param parse_mode: telegram parse mode
1859
        :return: None
1860
        """
1861
        reply_markup: Union[InlineKeyboardMarkup, ReplyKeyboardMarkup]
1862
        if query:
1✔
1863
            await self._update_msg(query=query, msg=msg, parse_mode=parse_mode,
1✔
1864
                                   callback_path=callback_path, reload_able=reload_able)
1865
            return
1✔
1866
        if reload_able and self._config['telegram'].get('reload', True):
1✔
1867
            reply_markup = InlineKeyboardMarkup([
×
1868
                [InlineKeyboardButton("Refresh", callback_data=callback_path)]])
1869
        else:
1870
            if keyboard is not None:
1✔
1871
                reply_markup = InlineKeyboardMarkup(keyboard)
×
1872
            else:
1873
                reply_markup = ReplyKeyboardMarkup(self._keyboard, resize_keyboard=True)
1✔
1874
        try:
1✔
1875
            try:
1✔
1876
                await self._app.bot.send_message(
1✔
1877
                    self._config['telegram']['chat_id'],
1878
                    text=msg,
1879
                    parse_mode=parse_mode,
1880
                    reply_markup=reply_markup,
1881
                    disable_notification=disable_notification,
1882
                )
1883
            except NetworkError as network_err:
1✔
1884
                # Sometimes the telegram server resets the current connection,
1885
                # if this is the case we send the message again.
1886
                logger.warning(
1✔
1887
                    'Telegram NetworkError: %s! Trying one more time.',
1888
                    network_err.message
1889
                )
1890
                await self._app.bot.send_message(
1✔
1891
                    self._config['telegram']['chat_id'],
1892
                    text=msg,
1893
                    parse_mode=parse_mode,
1894
                    reply_markup=reply_markup,
1895
                    disable_notification=disable_notification,
1896
                )
1897
        except TelegramError as telegram_err:
1✔
1898
            logger.warning(
1✔
1899
                'TelegramError: %s! Giving up on that message.',
1900
                telegram_err.message
1901
            )
1902

1903
    @authorized_only
1✔
1904
    async def _changemarketdir(self, update: Update, context: CallbackContext) -> None:
1✔
1905
        """
1906
        Handler for /marketdir.
1907
        Updates the bot's market_direction
1908
        :param bot: telegram bot
1909
        :param update: message update
1910
        :return: None
1911
        """
1912
        if context.args and len(context.args) == 1:
1✔
1913
            new_market_dir_arg = context.args[0]
1✔
1914
            old_market_dir = self._rpc._get_market_direction()
1✔
1915
            new_market_dir = None
1✔
1916
            if new_market_dir_arg == "long":
1✔
1917
                new_market_dir = MarketDirection.LONG
1✔
1918
            elif new_market_dir_arg == "short":
1✔
1919
                new_market_dir = MarketDirection.SHORT
×
1920
            elif new_market_dir_arg == "even":
1✔
1921
                new_market_dir = MarketDirection.EVEN
×
1922
            elif new_market_dir_arg == "none":
1✔
1923
                new_market_dir = MarketDirection.NONE
×
1924

1925
            if new_market_dir is not None:
1✔
1926
                self._rpc._update_market_direction(new_market_dir)
1✔
1927
                await self._send_msg("Successfully updated market direction"
1✔
1928
                                     f" from *{old_market_dir}* to *{new_market_dir}*.")
1929
            else:
1930
                raise RPCException("Invalid market direction provided. \n"
1✔
1931
                                   "Valid market directions: *long, short, even, none*")
1932
        elif context.args is not None and len(context.args) == 0:
×
1933
            old_market_dir = self._rpc._get_market_direction()
×
1934
            await self._send_msg(f"Currently set market direction: *{old_market_dir}*")
×
1935
        else:
1936
            raise RPCException("Invalid usage of command /marketdir. \n"
×
1937
                               "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