From afd8e85835c27a051b188296fc078a22bb0af5ef Mon Sep 17 00:00:00 2001 From: Anuj Shah Date: Wed, 1 Jun 2022 15:54:32 +0530 Subject: [PATCH 01/46] feat: add support for discord notification --- freqtrade/freqtradebot.py | 131 +++++++++++++++++++++++++++----------- 1 file changed, 94 insertions(+), 37 deletions(-) diff --git a/freqtrade/freqtradebot.py b/freqtrade/freqtradebot.py index fba63459b..f7e022987 100644 --- a/freqtrade/freqtradebot.py +++ b/freqtrade/freqtradebot.py @@ -2,6 +2,7 @@ Freqtrade is the main module of this bot. It contains the class Freqtrade() """ import copy +import json import logging import traceback from datetime import datetime, time, timezone @@ -9,6 +10,7 @@ from math import isclose from threading import Lock from typing import Any, Dict, List, Optional, Tuple +import requests from schedule import Scheduler from freqtrade import __version__, constants @@ -34,7 +36,6 @@ from freqtrade.strategy.interface import IStrategy from freqtrade.strategy.strategy_wrapper import strategy_safe_wrapper from freqtrade.wallets import Wallets - logger = logging.getLogger(__name__) @@ -379,9 +380,9 @@ class FreqtradeBot(LoggingMixin): except ExchangeError: logger.warning(f"Error updating {order.order_id}.") -# -# BUY / enter positions / open trades logic and methods -# + # + # BUY / enter positions / open trades logic and methods + # def enter_positions(self) -> int: """ @@ -489,9 +490,9 @@ class FreqtradeBot(LoggingMixin): else: return False -# -# BUY / increase positions / DCA logic and methods -# + # + # BUY / increase positions / DCA logic and methods + # def process_open_trade_positions(self): """ Tries to execute additional buy or sell orders for open trades (positions) @@ -579,16 +580,16 @@ class FreqtradeBot(LoggingMixin): return False def execute_entry( - self, - pair: str, - stake_amount: float, - price: Optional[float] = None, - *, - is_short: bool = False, - ordertype: Optional[str] = None, - enter_tag: Optional[str] = None, - trade: Optional[Trade] = None, - order_adjust: bool = False + self, + pair: str, + stake_amount: float, + price: Optional[float] = None, + *, + is_short: bool = False, + ordertype: Optional[str] = None, + enter_tag: Optional[str] = None, + trade: Optional[Trade] = None, + order_adjust: bool = False ) -> bool: """ Executes a limit buy for the given pair @@ -622,9 +623,9 @@ class FreqtradeBot(LoggingMixin): if not pos_adjust and not strategy_safe_wrapper( self.strategy.confirm_trade_entry, default_retval=True)( - pair=pair, order_type=order_type, amount=amount, rate=enter_limit_requested, - time_in_force=time_in_force, current_time=datetime.now(timezone.utc), - entry_tag=enter_tag, side=trade_side): + pair=pair, order_type=order_type, amount=amount, rate=enter_limit_requested, + time_in_force=time_in_force, current_time=datetime.now(timezone.utc), + entry_tag=enter_tag, side=trade_side): logger.info(f"User requested abortion of buying {pair}") return False order = self.exchange.create_order( @@ -746,11 +747,11 @@ class FreqtradeBot(LoggingMixin): return trade def get_valid_enter_price_and_stake( - self, pair: str, price: Optional[float], stake_amount: float, - trade_side: LongShort, - entry_tag: Optional[str], - trade: Optional[Trade], - order_adjust: bool, + self, pair: str, price: Optional[float], stake_amount: float, + trade_side: LongShort, + entry_tag: Optional[str], + trade: Optional[Trade], + order_adjust: bool, ) -> Tuple[float, float, float]: if price: @@ -885,9 +886,9 @@ class FreqtradeBot(LoggingMixin): # Send the message self.rpc.send_msg(msg) -# -# SELL / exit positions / close trades logic and methods -# + # + # SELL / exit positions / close trades logic and methods + # def exit_positions(self, trades: List[Any]) -> int: """ @@ -1059,10 +1060,10 @@ class FreqtradeBot(LoggingMixin): # Finally we check if stoploss on exchange should be moved up because of trailing. # Triggered Orders are now real orders - so don't replace stoploss anymore if ( - trade.is_open and stoploss_order - and stoploss_order.get('status_stop') != 'triggered' - and (self.config.get('trailing_stop', False) - or self.config.get('use_custom_stoploss', False)) + trade.is_open and stoploss_order + and stoploss_order.get('status_stop') != 'triggered' + and (self.config.get('trailing_stop', False) + or self.config.get('use_custom_stoploss', False)) ): # if trailing stoploss is enabled we check if stoploss value has changed # in which case we cancel stoploss order and put another one with new @@ -1145,7 +1146,7 @@ class FreqtradeBot(LoggingMixin): if not_closed: if fully_cancelled or (order_obj and self.strategy.ft_check_timed_out( - trade, order_obj, datetime.now(timezone.utc))): + trade, order_obj, datetime.now(timezone.utc))): self.handle_timedout_order(order, trade) else: self.replace_order(order, order_obj, trade) @@ -1424,7 +1425,7 @@ class FreqtradeBot(LoggingMixin): # if stoploss is on exchange and we are on dry_run mode, # we consider the sell price stop price if (self.config['dry_run'] and exit_type == 'stoploss' - and self.strategy.order_types['stoploss_on_exchange']): + and self.strategy.order_types['stoploss_on_exchange']): limit = trade.stop_loss # set custom_exit_price if available @@ -1543,6 +1544,43 @@ class FreqtradeBot(LoggingMixin): # Send the message self.rpc.send_msg(msg) + open_date = trade.open_date.strftime('%Y-%m-%d %H:%M:%S') + close_date = trade.close_date.strftime('%Y-%m-%d %H:%M:%S') if trade.close_date else None + + # Send the message to the discord bot + embeds = [{ + 'title': '{} Trade: {}'.format( + 'Profit' if profit_ratio > 0 else 'Loss', + trade.pair), + 'color': (0x00FF00 if profit_ratio > 0 else 0xFF0000), + 'fields': [ + {'name': 'Trade ID', 'value': trade.id, 'inline': True}, + {'name': 'Exchange', 'value': trade.exchange.capitalize(), 'inline': True}, + {'name': 'Pair', 'value': trade.pair, 'inline': True}, + {'name': 'Direction', 'value': 'Short' if trade.is_short else 'Long', 'inline': True}, + {'name': 'Open rate', 'value': trade.open_rate, 'inline': True}, + {'name': 'Close rate', 'value': trade.close_rate, 'inline': True}, + {'name': 'Amount', 'value': trade.amount, 'inline': True}, + {'name': 'Open order', 'value': trade.open_order_id, 'inline': True}, + {'name': 'Open date', 'value': open_date, 'inline': True}, + {'name': 'Close date', 'value': close_date, 'inline': True}, + {'name': 'Profit', 'value': profit_trade, 'inline': True}, + {'name': 'Profitability', 'value': '{:.2f}%'.format(profit_ratio * 100), 'inline': True}, + {'name': 'Stake currency', 'value': self.config['stake_currency'], 'inline': True}, + {'name': 'Fiat currency', 'value': self.config.get('fiat_display_currency', None), 'inline': True}, + {'name': 'Buy Tag', 'value': trade.enter_tag, 'inline': True}, + {'name': 'Sell Reason', 'value': trade.exit_reason, 'inline': True}, + {'name': 'Strategy', 'value': trade.strategy, 'inline': True}, + {'name': 'Timeframe', 'value': trade.timeframe, 'inline': True}, + ], + }] + # convert all value in fields to string + for embed in embeds: + for field in embed['fields']: + field['value'] = str(field['value']) + if fill: + self.discord_send(embeds) + def _notify_exit_cancel(self, trade: Trade, order_type: str, reason: str) -> None: """ Sends rpc notification when a sell cancel occurred. @@ -1593,9 +1631,9 @@ class FreqtradeBot(LoggingMixin): # Send the message self.rpc.send_msg(msg) -# -# Common update trade state methods -# + # + # Common update trade state methods + # def update_trade_state(self, trade: Trade, order_id: str, action_order: Dict[str, Any] = None, stoploss_order: bool = False, send_msg: bool = True) -> bool: @@ -1818,3 +1856,22 @@ class FreqtradeBot(LoggingMixin): return max( min(valid_custom_price, max_custom_price_allowed), min_custom_price_allowed) + + def discord_send(self, embeds): + if not 'discord' in self.config or self.config['discord']['enabled'] == False: + return + if self.config['runmode'].value in ('dry_run', 'live'): + webhook_url = self.config['discord']['webhook_url'] + + payload = { + "embeds": embeds + } + + headers = { + "Content-Type": "application/json" + } + + try: + requests.post(webhook_url, data=json.dumps(payload), headers=headers) + except Exception as e: + logger.error(f"Error sending discord message: {e}") From 45c47bda6000b2b57026fdedffaaa69f8fc1797e Mon Sep 17 00:00:00 2001 From: Anuj Shah Date: Wed, 1 Jun 2022 21:14:48 +0530 Subject: [PATCH 02/46] refactor into discord rpc module --- freqtrade/freqtradebot.py | 131 ++++++++++------------------------- freqtrade/rpc/discord.py | 101 +++++++++++++++++++++++++++ freqtrade/rpc/rpc_manager.py | 6 ++ 3 files changed, 144 insertions(+), 94 deletions(-) create mode 100644 freqtrade/rpc/discord.py diff --git a/freqtrade/freqtradebot.py b/freqtrade/freqtradebot.py index f7e022987..fba63459b 100644 --- a/freqtrade/freqtradebot.py +++ b/freqtrade/freqtradebot.py @@ -2,7 +2,6 @@ Freqtrade is the main module of this bot. It contains the class Freqtrade() """ import copy -import json import logging import traceback from datetime import datetime, time, timezone @@ -10,7 +9,6 @@ from math import isclose from threading import Lock from typing import Any, Dict, List, Optional, Tuple -import requests from schedule import Scheduler from freqtrade import __version__, constants @@ -36,6 +34,7 @@ from freqtrade.strategy.interface import IStrategy from freqtrade.strategy.strategy_wrapper import strategy_safe_wrapper from freqtrade.wallets import Wallets + logger = logging.getLogger(__name__) @@ -380,9 +379,9 @@ class FreqtradeBot(LoggingMixin): except ExchangeError: logger.warning(f"Error updating {order.order_id}.") - # - # BUY / enter positions / open trades logic and methods - # +# +# BUY / enter positions / open trades logic and methods +# def enter_positions(self) -> int: """ @@ -490,9 +489,9 @@ class FreqtradeBot(LoggingMixin): else: return False - # - # BUY / increase positions / DCA logic and methods - # +# +# BUY / increase positions / DCA logic and methods +# def process_open_trade_positions(self): """ Tries to execute additional buy or sell orders for open trades (positions) @@ -580,16 +579,16 @@ class FreqtradeBot(LoggingMixin): return False def execute_entry( - self, - pair: str, - stake_amount: float, - price: Optional[float] = None, - *, - is_short: bool = False, - ordertype: Optional[str] = None, - enter_tag: Optional[str] = None, - trade: Optional[Trade] = None, - order_adjust: bool = False + self, + pair: str, + stake_amount: float, + price: Optional[float] = None, + *, + is_short: bool = False, + ordertype: Optional[str] = None, + enter_tag: Optional[str] = None, + trade: Optional[Trade] = None, + order_adjust: bool = False ) -> bool: """ Executes a limit buy for the given pair @@ -623,9 +622,9 @@ class FreqtradeBot(LoggingMixin): if not pos_adjust and not strategy_safe_wrapper( self.strategy.confirm_trade_entry, default_retval=True)( - pair=pair, order_type=order_type, amount=amount, rate=enter_limit_requested, - time_in_force=time_in_force, current_time=datetime.now(timezone.utc), - entry_tag=enter_tag, side=trade_side): + pair=pair, order_type=order_type, amount=amount, rate=enter_limit_requested, + time_in_force=time_in_force, current_time=datetime.now(timezone.utc), + entry_tag=enter_tag, side=trade_side): logger.info(f"User requested abortion of buying {pair}") return False order = self.exchange.create_order( @@ -747,11 +746,11 @@ class FreqtradeBot(LoggingMixin): return trade def get_valid_enter_price_and_stake( - self, pair: str, price: Optional[float], stake_amount: float, - trade_side: LongShort, - entry_tag: Optional[str], - trade: Optional[Trade], - order_adjust: bool, + self, pair: str, price: Optional[float], stake_amount: float, + trade_side: LongShort, + entry_tag: Optional[str], + trade: Optional[Trade], + order_adjust: bool, ) -> Tuple[float, float, float]: if price: @@ -886,9 +885,9 @@ class FreqtradeBot(LoggingMixin): # Send the message self.rpc.send_msg(msg) - # - # SELL / exit positions / close trades logic and methods - # +# +# SELL / exit positions / close trades logic and methods +# def exit_positions(self, trades: List[Any]) -> int: """ @@ -1060,10 +1059,10 @@ class FreqtradeBot(LoggingMixin): # Finally we check if stoploss on exchange should be moved up because of trailing. # Triggered Orders are now real orders - so don't replace stoploss anymore if ( - trade.is_open and stoploss_order - and stoploss_order.get('status_stop') != 'triggered' - and (self.config.get('trailing_stop', False) - or self.config.get('use_custom_stoploss', False)) + trade.is_open and stoploss_order + and stoploss_order.get('status_stop') != 'triggered' + and (self.config.get('trailing_stop', False) + or self.config.get('use_custom_stoploss', False)) ): # if trailing stoploss is enabled we check if stoploss value has changed # in which case we cancel stoploss order and put another one with new @@ -1146,7 +1145,7 @@ class FreqtradeBot(LoggingMixin): if not_closed: if fully_cancelled or (order_obj and self.strategy.ft_check_timed_out( - trade, order_obj, datetime.now(timezone.utc))): + trade, order_obj, datetime.now(timezone.utc))): self.handle_timedout_order(order, trade) else: self.replace_order(order, order_obj, trade) @@ -1425,7 +1424,7 @@ class FreqtradeBot(LoggingMixin): # if stoploss is on exchange and we are on dry_run mode, # we consider the sell price stop price if (self.config['dry_run'] and exit_type == 'stoploss' - and self.strategy.order_types['stoploss_on_exchange']): + and self.strategy.order_types['stoploss_on_exchange']): limit = trade.stop_loss # set custom_exit_price if available @@ -1544,43 +1543,6 @@ class FreqtradeBot(LoggingMixin): # Send the message self.rpc.send_msg(msg) - open_date = trade.open_date.strftime('%Y-%m-%d %H:%M:%S') - close_date = trade.close_date.strftime('%Y-%m-%d %H:%M:%S') if trade.close_date else None - - # Send the message to the discord bot - embeds = [{ - 'title': '{} Trade: {}'.format( - 'Profit' if profit_ratio > 0 else 'Loss', - trade.pair), - 'color': (0x00FF00 if profit_ratio > 0 else 0xFF0000), - 'fields': [ - {'name': 'Trade ID', 'value': trade.id, 'inline': True}, - {'name': 'Exchange', 'value': trade.exchange.capitalize(), 'inline': True}, - {'name': 'Pair', 'value': trade.pair, 'inline': True}, - {'name': 'Direction', 'value': 'Short' if trade.is_short else 'Long', 'inline': True}, - {'name': 'Open rate', 'value': trade.open_rate, 'inline': True}, - {'name': 'Close rate', 'value': trade.close_rate, 'inline': True}, - {'name': 'Amount', 'value': trade.amount, 'inline': True}, - {'name': 'Open order', 'value': trade.open_order_id, 'inline': True}, - {'name': 'Open date', 'value': open_date, 'inline': True}, - {'name': 'Close date', 'value': close_date, 'inline': True}, - {'name': 'Profit', 'value': profit_trade, 'inline': True}, - {'name': 'Profitability', 'value': '{:.2f}%'.format(profit_ratio * 100), 'inline': True}, - {'name': 'Stake currency', 'value': self.config['stake_currency'], 'inline': True}, - {'name': 'Fiat currency', 'value': self.config.get('fiat_display_currency', None), 'inline': True}, - {'name': 'Buy Tag', 'value': trade.enter_tag, 'inline': True}, - {'name': 'Sell Reason', 'value': trade.exit_reason, 'inline': True}, - {'name': 'Strategy', 'value': trade.strategy, 'inline': True}, - {'name': 'Timeframe', 'value': trade.timeframe, 'inline': True}, - ], - }] - # convert all value in fields to string - for embed in embeds: - for field in embed['fields']: - field['value'] = str(field['value']) - if fill: - self.discord_send(embeds) - def _notify_exit_cancel(self, trade: Trade, order_type: str, reason: str) -> None: """ Sends rpc notification when a sell cancel occurred. @@ -1631,9 +1593,9 @@ class FreqtradeBot(LoggingMixin): # Send the message self.rpc.send_msg(msg) - # - # Common update trade state methods - # +# +# Common update trade state methods +# def update_trade_state(self, trade: Trade, order_id: str, action_order: Dict[str, Any] = None, stoploss_order: bool = False, send_msg: bool = True) -> bool: @@ -1856,22 +1818,3 @@ class FreqtradeBot(LoggingMixin): return max( min(valid_custom_price, max_custom_price_allowed), min_custom_price_allowed) - - def discord_send(self, embeds): - if not 'discord' in self.config or self.config['discord']['enabled'] == False: - return - if self.config['runmode'].value in ('dry_run', 'live'): - webhook_url = self.config['discord']['webhook_url'] - - payload = { - "embeds": embeds - } - - headers = { - "Content-Type": "application/json" - } - - try: - requests.post(webhook_url, data=json.dumps(payload), headers=headers) - except Exception as e: - logger.error(f"Error sending discord message: {e}") diff --git a/freqtrade/rpc/discord.py b/freqtrade/rpc/discord.py new file mode 100644 index 000000000..ee9970dc5 --- /dev/null +++ b/freqtrade/rpc/discord.py @@ -0,0 +1,101 @@ +import json +import logging +from typing import Dict, Any + +import requests + +from freqtrade.enums import RPCMessageType +from freqtrade.rpc import RPCHandler, RPC + + +class Discord(RPCHandler): + def __init__(self, rpc: 'RPC', config: Dict[str, Any]): + super().__init__(rpc, config) + self.logger = logging.getLogger(__name__) + self.strategy = config.get('strategy', '') + self.timeframe = config.get('timeframe', '') + self.config = config + + def send_msg(self, msg: Dict[str, str]) -> None: + self._send_msg(msg) + + def _send_msg(self, msg): + """ + msg = { + 'type': (RPCMessageType.EXIT_FILL if fill + else RPCMessageType.EXIT), + 'trade_id': trade.id, + 'exchange': trade.exchange.capitalize(), + 'pair': trade.pair, + 'leverage': trade.leverage, + 'direction': 'Short' if trade.is_short else 'Long', + 'gain': gain, + 'limit': profit_rate, + 'order_type': order_type, + 'amount': trade.amount, + 'open_rate': trade.open_rate, + 'close_rate': trade.close_rate, + 'current_rate': current_rate, + 'profit_amount': profit_trade, + 'profit_ratio': profit_ratio, + 'buy_tag': trade.enter_tag, + 'enter_tag': trade.enter_tag, + 'sell_reason': trade.exit_reason, # Deprecated + 'exit_reason': trade.exit_reason, + 'open_date': trade.open_date, + 'close_date': trade.close_date or datetime.utcnow(), + 'stake_currency': self.config['stake_currency'], + 'fiat_currency': self.config.get('fiat_display_currency', None), + } + """ + self.logger.info(f"Sending discord message: {msg}") + + # TODO: handle other message types + if msg['type'] == RPCMessageType.EXIT_FILL: + profit_ratio = msg.get('profit_ratio') + open_date = msg.get('open_date').strftime('%Y-%m-%d %H:%M:%S') + close_date = msg.get('close_date').strftime('%Y-%m-%d %H:%M:%S') if msg.get('close_date') else '' + + embeds = [{ + 'title': '{} Trade: {}'.format( + 'Profit' if profit_ratio > 0 else 'Loss', + msg.get('pair')), + 'color': (0x00FF00 if profit_ratio > 0 else 0xFF0000), + 'fields': [ + {'name': 'Trade ID', 'value': msg.get('id'), 'inline': True}, + {'name': 'Exchange', 'value': msg.get('exchange').capitalize(), 'inline': True}, + {'name': 'Pair', 'value': msg.get('pair'), 'inline': True}, + {'name': 'Direction', 'value': 'Short' if msg.get('is_short') else 'Long', 'inline': True}, + {'name': 'Open rate', 'value': msg.get('open_rate'), 'inline': True}, + {'name': 'Close rate', 'value': msg.get('close_rate'), 'inline': True}, + {'name': 'Amount', 'value': msg.get('amount'), 'inline': True}, + {'name': 'Open order', 'value': msg.get('open_order_id'), 'inline': True}, + {'name': 'Open date', 'value': open_date, 'inline': True}, + {'name': 'Close date', 'value': close_date, 'inline': True}, + {'name': 'Profit', 'value': msg.get('profit_amount'), 'inline': True}, + {'name': 'Profitability', 'value': '{:.2f}%'.format(profit_ratio * 100), 'inline': True}, + {'name': 'Stake currency', 'value': msg.get('stake_currency'), 'inline': True}, + {'name': 'Fiat currency', 'value': msg.get('fiat_display_currency'), 'inline': True}, + {'name': 'Buy Tag', 'value': msg.get('enter_tag'), 'inline': True}, + {'name': 'Sell Reason', 'value': msg.get('exit_reason'), 'inline': True}, + {'name': 'Strategy', 'value': self.strategy, 'inline': True}, + {'name': 'Timeframe', 'value': self.timeframe, 'inline': True}, + ], + }] + + # convert all value in fields to string for discord + for embed in embeds: + for field in embed['fields']: + field['value'] = str(field['value']) + + # Send the message to discord channel + payload = { + 'embeds': embeds, + } + headers = { + 'Content-Type': 'application/json', + } + try: + requests.post(self.config['discord']['webhook_url'], data=json.dumps(payload), headers=headers) + except Exception as e: + self.logger.error(f"Failed to send discord message: {e}") diff --git a/freqtrade/rpc/rpc_manager.py b/freqtrade/rpc/rpc_manager.py index d97d1df5f..66e84029f 100644 --- a/freqtrade/rpc/rpc_manager.py +++ b/freqtrade/rpc/rpc_manager.py @@ -27,6 +27,12 @@ class RPCManager: from freqtrade.rpc.telegram import Telegram self.registered_modules.append(Telegram(self._rpc, config)) + # Enable discord + if config.get('discord', {}).get('enabled', False): + logger.info('Enabling rpc.discord ...') + from freqtrade.rpc.discord import Discord + self.registered_modules.append(Discord(self._rpc, config)) + # Enable Webhook if config.get('webhook', {}).get('enabled', False): logger.info('Enabling rpc.webhook ...') From eb4adeab4d7511fe084924e72c14065c6c106ebf Mon Sep 17 00:00:00 2001 From: Anuj Shah Date: Thu, 2 Jun 2022 11:19:29 +0530 Subject: [PATCH 03/46] fix flake8 issues --- freqtrade/rpc/discord.py | 17 ++++++++++++----- 1 file changed, 12 insertions(+), 5 deletions(-) diff --git a/freqtrade/rpc/discord.py b/freqtrade/rpc/discord.py index ee9970dc5..43a8e9a05 100644 --- a/freqtrade/rpc/discord.py +++ b/freqtrade/rpc/discord.py @@ -54,7 +54,8 @@ class Discord(RPCHandler): if msg['type'] == RPCMessageType.EXIT_FILL: profit_ratio = msg.get('profit_ratio') open_date = msg.get('open_date').strftime('%Y-%m-%d %H:%M:%S') - close_date = msg.get('close_date').strftime('%Y-%m-%d %H:%M:%S') if msg.get('close_date') else '' + close_date = msg.get('close_date').strftime( + '%Y-%m-%d %H:%M:%S') if msg.get('close_date') else '' embeds = [{ 'title': '{} Trade: {}'.format( @@ -65,7 +66,8 @@ class Discord(RPCHandler): {'name': 'Trade ID', 'value': msg.get('id'), 'inline': True}, {'name': 'Exchange', 'value': msg.get('exchange').capitalize(), 'inline': True}, {'name': 'Pair', 'value': msg.get('pair'), 'inline': True}, - {'name': 'Direction', 'value': 'Short' if msg.get('is_short') else 'Long', 'inline': True}, + {'name': 'Direction', 'value': 'Short' if msg.get( + 'is_short') else 'Long', 'inline': True}, {'name': 'Open rate', 'value': msg.get('open_rate'), 'inline': True}, {'name': 'Close rate', 'value': msg.get('close_rate'), 'inline': True}, {'name': 'Amount', 'value': msg.get('amount'), 'inline': True}, @@ -73,9 +75,11 @@ class Discord(RPCHandler): {'name': 'Open date', 'value': open_date, 'inline': True}, {'name': 'Close date', 'value': close_date, 'inline': True}, {'name': 'Profit', 'value': msg.get('profit_amount'), 'inline': True}, - {'name': 'Profitability', 'value': '{:.2f}%'.format(profit_ratio * 100), 'inline': True}, + {'name': 'Profitability', 'value': '{:.2f}%'.format( + profit_ratio * 100), 'inline': True}, {'name': 'Stake currency', 'value': msg.get('stake_currency'), 'inline': True}, - {'name': 'Fiat currency', 'value': msg.get('fiat_display_currency'), 'inline': True}, + {'name': 'Fiat currency', 'value': msg.get( + 'fiat_display_currency'), 'inline': True}, {'name': 'Buy Tag', 'value': msg.get('enter_tag'), 'inline': True}, {'name': 'Sell Reason', 'value': msg.get('exit_reason'), 'inline': True}, {'name': 'Strategy', 'value': self.strategy, 'inline': True}, @@ -96,6 +100,9 @@ class Discord(RPCHandler): 'Content-Type': 'application/json', } try: - requests.post(self.config['discord']['webhook_url'], data=json.dumps(payload), headers=headers) + requests.post( + self.config['discord']['webhook_url'], + data=json.dumps(payload), + headers=headers) except Exception as e: self.logger.error(f"Failed to send discord message: {e}") From ac40ae89b9d50fba5bb088ba902e1ac6bce0f6a1 Mon Sep 17 00:00:00 2001 From: gautier pialat Date: Wed, 8 Jun 2022 00:20:33 +0200 Subject: [PATCH 04/46] give extra info on rate origin for confirm_trade_* Documentation : Take into consideration the market buy/sell rates use case for the confirm_trade_entry and confirm_trade_exit callback function --- docs/strategy-callbacks.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/docs/strategy-callbacks.md b/docs/strategy-callbacks.md index 656f206a4..b897453e7 100644 --- a/docs/strategy-callbacks.md +++ b/docs/strategy-callbacks.md @@ -550,7 +550,7 @@ class AwesomeStrategy(IStrategy): :param pair: Pair that's about to be bought/shorted. :param order_type: Order type (as configured in order_types). usually limit or market. :param amount: Amount in target (base) currency that's going to be traded. - :param rate: Rate that's going to be used when using limit orders + :param rate: Rate that's going to be used when using limit orders or current rate for market orders. :param time_in_force: Time in force. Defaults to GTC (Good-til-cancelled). :param current_time: datetime object, containing the current datetime :param entry_tag: Optional entry_tag (buy_tag) if provided with the buy signal. @@ -599,7 +599,7 @@ class AwesomeStrategy(IStrategy): :param trade: trade object. :param order_type: Order type (as configured in order_types). usually limit or market. :param amount: Amount in base currency. - :param rate: Rate that's going to be used when using limit orders + :param rate: Rate that's going to be used when using limit orders or current rate for market orders. :param time_in_force: Time in force. Defaults to GTC (Good-til-cancelled). :param exit_reason: Exit reason. Can be any of ['roi', 'stop_loss', 'stoploss_on_exchange', 'trailing_stop_loss', From 3cb15a2a5470e8a915aa5f39123808882b4b93eb Mon Sep 17 00:00:00 2001 From: Matthias Date: Wed, 8 Jun 2022 07:08:01 +0200 Subject: [PATCH 05/46] Combine weekly and daily profit methods --- freqtrade/rpc/rpc.py | 67 ++++++++++----------------------------- freqtrade/rpc/telegram.py | 5 +-- 2 files changed, 20 insertions(+), 52 deletions(-) diff --git a/freqtrade/rpc/rpc.py b/freqtrade/rpc/rpc.py index a98e3f96d..571438059 100644 --- a/freqtrade/rpc/rpc.py +++ b/freqtrade/rpc/rpc.py @@ -285,23 +285,33 @@ class RPC: def _rpc_daily_profit( self, timescale: int, - stake_currency: str, fiat_display_currency: str) -> Dict[str, Any]: - today = datetime.now(timezone.utc).date() - profit_days: Dict[date, Dict] = {} + stake_currency: str, fiat_display_currency: str, + timeunit: str = 'days') -> Dict[str, Any]: + """ + :param timeunit: Valid entries are 'days', 'weeks', 'months' + """ + start_date = datetime.now(timezone.utc).date() + if timeunit == 'weeks': + # weekly + start_date = start_date - timedelta(days=start_date.weekday()) # Monday + if timeunit == 'months': + start_date = start_date.replace(day=1) + + profit_units: Dict[date, Dict] = {} if not (isinstance(timescale, int) and timescale > 0): raise RPCException('timescale must be an integer greater than 0') for day in range(0, timescale): - profitday = today - timedelta(days=day) + profitday = start_date - timedelta(**{timeunit: day}) trades = Trade.get_trades(trade_filter=[ Trade.is_open.is_(False), Trade.close_date >= profitday, - Trade.close_date < (profitday + timedelta(days=1)) + Trade.close_date < (profitday + timedelta(**{timeunit: 1})) ]).order_by(Trade.close_date).all() curdayprofit = sum( trade.close_profit_abs for trade in trades if trade.close_profit_abs is not None) - profit_days[profitday] = { + profit_units[profitday] = { 'amount': curdayprofit, 'trades': len(trades) } @@ -317,50 +327,7 @@ class RPC: ) if self._fiat_converter else 0, 'trade_count': value["trades"], } - for key, value in profit_days.items() - ] - return { - 'stake_currency': stake_currency, - 'fiat_display_currency': fiat_display_currency, - 'data': data - } - - def _rpc_weekly_profit( - self, timescale: int, - stake_currency: str, fiat_display_currency: str) -> Dict[str, Any]: - today = datetime.now(timezone.utc).date() - first_iso_day_of_week = today - timedelta(days=today.weekday()) # Monday - profit_weeks: Dict[date, Dict] = {} - - if not (isinstance(timescale, int) and timescale > 0): - raise RPCException('timescale must be an integer greater than 0') - - for week in range(0, timescale): - profitweek = first_iso_day_of_week - timedelta(weeks=week) - trades = Trade.get_trades(trade_filter=[ - Trade.is_open.is_(False), - Trade.close_date >= profitweek, - Trade.close_date < (profitweek + timedelta(weeks=1)) - ]).order_by(Trade.close_date).all() - curweekprofit = sum( - trade.close_profit_abs for trade in trades if trade.close_profit_abs is not None) - profit_weeks[profitweek] = { - 'amount': curweekprofit, - 'trades': len(trades) - } - - data = [ - { - 'date': key, - 'abs_profit': value["amount"], - 'fiat_value': self._fiat_converter.convert_amount( - value['amount'], - stake_currency, - fiat_display_currency - ) if self._fiat_converter else 0, - 'trade_count': value["trades"], - } - for key, value in profit_weeks.items() + for key, value in profit_units.items() ] return { 'stake_currency': stake_currency, diff --git a/freqtrade/rpc/telegram.py b/freqtrade/rpc/telegram.py index e456b1eef..cfbd3949f 100644 --- a/freqtrade/rpc/telegram.py +++ b/freqtrade/rpc/telegram.py @@ -618,10 +618,11 @@ class Telegram(RPCHandler): except (TypeError, ValueError, IndexError): timescale = 8 try: - stats = self._rpc._rpc_weekly_profit( + stats = self._rpc._rpc_daily_profit( timescale, stake_cur, - fiat_disp_cur + fiat_disp_cur, + 'weeks' ) stats_tab = tabulate( [[week['date'], From d4dd026310b411ee78d7857dde4bec974226bb60 Mon Sep 17 00:00:00 2001 From: Matthias Date: Wed, 8 Jun 2022 19:52:05 +0200 Subject: [PATCH 06/46] Consolidate monthly stats to common method --- freqtrade/rpc/api_server/api_v1.py | 4 +-- freqtrade/rpc/rpc.py | 55 +++++------------------------- freqtrade/rpc/telegram.py | 12 ++++--- 3 files changed, 18 insertions(+), 53 deletions(-) diff --git a/freqtrade/rpc/api_server/api_v1.py b/freqtrade/rpc/api_server/api_v1.py index a8b9873d7..271e3de1b 100644 --- a/freqtrade/rpc/api_server/api_v1.py +++ b/freqtrade/rpc/api_server/api_v1.py @@ -86,8 +86,8 @@ def stats(rpc: RPC = Depends(get_rpc)): @router.get('/daily', response_model=Daily, tags=['info']) def daily(timescale: int = 7, rpc: RPC = Depends(get_rpc), config=Depends(get_config)): - return rpc._rpc_daily_profit(timescale, config['stake_currency'], - config.get('fiat_display_currency', '')) + return rpc._rpc_timeunit_profit(timescale, config['stake_currency'], + config.get('fiat_display_currency', '')) @router.get('/status', response_model=List[OpenTradeSchema], tags=['info']) diff --git a/freqtrade/rpc/rpc.py b/freqtrade/rpc/rpc.py index 571438059..a6290bd5a 100644 --- a/freqtrade/rpc/rpc.py +++ b/freqtrade/rpc/rpc.py @@ -283,7 +283,7 @@ class RPC: columns.append('# Entries') return trades_list, columns, fiat_profit_sum - def _rpc_daily_profit( + def _rpc_timeunit_profit( self, timescale: int, stake_currency: str, fiat_display_currency: str, timeunit: str = 'days') -> Dict[str, Any]: @@ -297,17 +297,22 @@ class RPC: if timeunit == 'months': start_date = start_date.replace(day=1) + def time_offset(step: int): + if timeunit == 'months': + return relativedelta(months=step) + return timedelta(**{timeunit: step}) + profit_units: Dict[date, Dict] = {} if not (isinstance(timescale, int) and timescale > 0): raise RPCException('timescale must be an integer greater than 0') for day in range(0, timescale): - profitday = start_date - timedelta(**{timeunit: day}) + profitday = start_date - time_offset(day) trades = Trade.get_trades(trade_filter=[ Trade.is_open.is_(False), Trade.close_date >= profitday, - Trade.close_date < (profitday + timedelta(**{timeunit: 1})) + Trade.close_date < (profitday + time_offset(1)) ]).order_by(Trade.close_date).all() curdayprofit = sum( trade.close_profit_abs for trade in trades if trade.close_profit_abs is not None) @@ -318,7 +323,7 @@ class RPC: data = [ { - 'date': key, + 'date': f"{key.year}-{key.month:02d}" if timeunit == 'months' else key, 'abs_profit': value["amount"], 'fiat_value': self._fiat_converter.convert_amount( value['amount'], @@ -335,48 +340,6 @@ class RPC: 'data': data } - def _rpc_monthly_profit( - self, timescale: int, - stake_currency: str, fiat_display_currency: str) -> Dict[str, Any]: - first_day_of_month = datetime.now(timezone.utc).date().replace(day=1) - profit_months: Dict[date, Dict] = {} - - if not (isinstance(timescale, int) and timescale > 0): - raise RPCException('timescale must be an integer greater than 0') - - for month in range(0, timescale): - profitmonth = first_day_of_month - relativedelta(months=month) - trades = Trade.get_trades(trade_filter=[ - Trade.is_open.is_(False), - Trade.close_date >= profitmonth, - Trade.close_date < (profitmonth + relativedelta(months=1)) - ]).order_by(Trade.close_date).all() - curmonthprofit = sum( - trade.close_profit_abs for trade in trades if trade.close_profit_abs is not None) - profit_months[profitmonth] = { - 'amount': curmonthprofit, - 'trades': len(trades) - } - - data = [ - { - 'date': f"{key.year}-{key.month:02d}", - 'abs_profit': value["amount"], - 'fiat_value': self._fiat_converter.convert_amount( - value['amount'], - stake_currency, - fiat_display_currency - ) if self._fiat_converter else 0, - 'trade_count': value["trades"], - } - for key, value in profit_months.items() - ] - return { - 'stake_currency': stake_currency, - 'fiat_display_currency': fiat_display_currency, - 'data': data - } - def _rpc_trade_history(self, limit: int, offset: int = 0, order_by_id: bool = False) -> Dict: """ Returns the X last trades """ order_by = Trade.id if order_by_id else Trade.close_date.desc() diff --git a/freqtrade/rpc/telegram.py b/freqtrade/rpc/telegram.py index cfbd3949f..5efdcdbed 100644 --- a/freqtrade/rpc/telegram.py +++ b/freqtrade/rpc/telegram.py @@ -579,10 +579,11 @@ class Telegram(RPCHandler): except (TypeError, ValueError, IndexError): timescale = 7 try: - stats = self._rpc._rpc_daily_profit( + stats = self._rpc._rpc_timeunit_profit( timescale, stake_cur, - fiat_disp_cur + fiat_disp_cur, + 'days' ) stats_tab = tabulate( [[day['date'], @@ -618,7 +619,7 @@ class Telegram(RPCHandler): except (TypeError, ValueError, IndexError): timescale = 8 try: - stats = self._rpc._rpc_daily_profit( + stats = self._rpc._rpc_timeunit_profit( timescale, stake_cur, fiat_disp_cur, @@ -659,10 +660,11 @@ class Telegram(RPCHandler): except (TypeError, ValueError, IndexError): timescale = 6 try: - stats = self._rpc._rpc_monthly_profit( + stats = self._rpc._rpc_timeunit_profit( timescale, stake_cur, - fiat_disp_cur + fiat_disp_cur, + 'months' ) stats_tab = tabulate( [[month['date'], From a547001601f785f5c6d2171edc8a52159241e07d Mon Sep 17 00:00:00 2001 From: Matthias Date: Wed, 8 Jun 2022 20:09:51 +0200 Subject: [PATCH 07/46] Reduce Telegram "unit" stats --- freqtrade/rpc/telegram.py | 158 ++++++++++++++++---------------------- 1 file changed, 65 insertions(+), 93 deletions(-) diff --git a/freqtrade/rpc/telegram.py b/freqtrade/rpc/telegram.py index 5efdcdbed..e64ab7b8a 100644 --- a/freqtrade/rpc/telegram.py +++ b/freqtrade/rpc/telegram.py @@ -6,6 +6,7 @@ This module manage Telegram communication import json import logging import re +from dataclasses import dataclass from datetime import date, datetime, timedelta from functools import partial from html import escape @@ -37,6 +38,15 @@ logger.debug('Included module rpc.telegram ...') MAX_TELEGRAM_MESSAGE_LENGTH = 4096 +@dataclass +class TimeunitMappings: + header: str + message: str + message2: str + callback: str + default: int + + def authorized_only(command_handler: Callable[..., None]) -> Callable[..., Any]: """ Decorator to check if the message comes from the correct chat_id @@ -563,6 +573,58 @@ class Telegram(RPCHandler): except RPCException as e: self._send_msg(str(e)) + @authorized_only + def _timeunit_stats(self, update: Update, context: CallbackContext, unit: str) -> None: + """ + Handler for /daily + Returns a daily profit (in BTC) over the last n days. + :param bot: telegram bot + :param update: message update + :return: None + """ + + vals = { + 'days': TimeunitMappings('Day', 'Daily', 'days', 'update_daily', 7), + 'weeks': TimeunitMappings('Monday', 'Weekly', 'weeks (starting from Monday)', + 'update_weekly', 8), + 'months': TimeunitMappings('Month', 'Monthly', 'months', 'update_monthly', 6), + } + val = vals[unit] + + stake_cur = self._config['stake_currency'] + fiat_disp_cur = self._config.get('fiat_display_currency', '') + try: + timescale = int(context.args[0]) if context.args else val.default + except (TypeError, ValueError, IndexError): + timescale = val.default + try: + stats = self._rpc._rpc_timeunit_profit( + timescale, + stake_cur, + fiat_disp_cur, + unit + ) + stats_tab = tabulate( + [[day['date'], + f"{round_coin_value(day['abs_profit'], stats['stake_currency'])}", + f"{day['fiat_value']:.3f} {stats['fiat_display_currency']}", + f"{day['trade_count']} trades"] for day in stats['data']], + headers=[ + val.header, + f'Profit {stake_cur}', + f'Profit {fiat_disp_cur}', + 'Trades', + ], + tablefmt='simple') + message = ( + f'{val.message} Profit over the last {timescale} {val.message2}:\n' + f'
{stats_tab}
' + ) + self._send_msg(message, parse_mode=ParseMode.HTML, reload_able=True, + callback_path=val.callback, query=update.callback_query) + except RPCException as e: + self._send_msg(str(e)) + @authorized_only def _daily(self, update: Update, context: CallbackContext) -> None: """ @@ -572,36 +634,7 @@ class Telegram(RPCHandler): :param update: message update :return: None """ - stake_cur = self._config['stake_currency'] - fiat_disp_cur = self._config.get('fiat_display_currency', '') - try: - timescale = int(context.args[0]) if context.args else 7 - except (TypeError, ValueError, IndexError): - timescale = 7 - try: - stats = self._rpc._rpc_timeunit_profit( - timescale, - stake_cur, - fiat_disp_cur, - 'days' - ) - stats_tab = tabulate( - [[day['date'], - f"{round_coin_value(day['abs_profit'], stats['stake_currency'])}", - f"{day['fiat_value']:.3f} {stats['fiat_display_currency']}", - f"{day['trade_count']} trades"] for day in stats['data']], - headers=[ - 'Day', - f'Profit {stake_cur}', - f'Profit {fiat_disp_cur}', - 'Trades', - ], - tablefmt='simple') - message = f'Daily Profit over the last {timescale} days:\n
{stats_tab}
' - self._send_msg(message, parse_mode=ParseMode.HTML, reload_able=True, - callback_path="update_daily", query=update.callback_query) - except RPCException as e: - self._send_msg(str(e)) + self._timeunit_stats(update, context, 'days') @authorized_only def _weekly(self, update: Update, context: CallbackContext) -> None: @@ -612,37 +645,7 @@ class Telegram(RPCHandler): :param update: message update :return: None """ - stake_cur = self._config['stake_currency'] - fiat_disp_cur = self._config.get('fiat_display_currency', '') - try: - timescale = int(context.args[0]) if context.args else 8 - except (TypeError, ValueError, IndexError): - timescale = 8 - try: - stats = self._rpc._rpc_timeunit_profit( - timescale, - stake_cur, - fiat_disp_cur, - 'weeks' - ) - stats_tab = tabulate( - [[week['date'], - f"{round_coin_value(week['abs_profit'], stats['stake_currency'])}", - f"{week['fiat_value']:.3f} {stats['fiat_display_currency']}", - f"{week['trade_count']} trades"] for week in stats['data']], - headers=[ - 'Monday', - f'Profit {stake_cur}', - f'Profit {fiat_disp_cur}', - 'Trades', - ], - tablefmt='simple') - message = f'Weekly Profit over the last {timescale} weeks ' \ - f'(starting from Monday):\n
{stats_tab}
' - self._send_msg(message, parse_mode=ParseMode.HTML, reload_able=True, - callback_path="update_weekly", query=update.callback_query) - except RPCException as e: - self._send_msg(str(e)) + self._timeunit_stats(update, context, 'weeks') @authorized_only def _monthly(self, update: Update, context: CallbackContext) -> None: @@ -653,38 +656,7 @@ class Telegram(RPCHandler): :param update: message update :return: None """ - stake_cur = self._config['stake_currency'] - fiat_disp_cur = self._config.get('fiat_display_currency', '') - try: - timescale = int(context.args[0]) if context.args else 6 - except (TypeError, ValueError, IndexError): - timescale = 6 - try: - stats = self._rpc._rpc_timeunit_profit( - timescale, - stake_cur, - fiat_disp_cur, - 'months' - ) - stats_tab = tabulate( - [[month['date'], - f"{round_coin_value(month['abs_profit'], stats['stake_currency'])}", - f"{month['fiat_value']:.3f} {stats['fiat_display_currency']}", - f"{month['trade_count']} trades"] for month in stats['data']], - headers=[ - 'Month', - f'Profit {stake_cur}', - f'Profit {fiat_disp_cur}', - 'Trades', - ], - tablefmt='simple') - message = f'Monthly Profit over the last {timescale} months' \ - f':\n
{stats_tab}
' - self._send_msg(message, parse_mode=ParseMode.HTML, reload_able=True, - callback_path="update_monthly", query=update.callback_query) - except RPCException as e: - self._send_msg(str(e)) - + self._timeunit_stats(update, context, 'months') @authorized_only def _profit(self, update: Update, context: CallbackContext) -> None: """ From b211a5156f5b7e92a652369ed1f6be19d3535b69 Mon Sep 17 00:00:00 2001 From: Matthias Date: Thu, 9 Jun 2022 19:36:15 +0200 Subject: [PATCH 08/46] Add test for strategy_wrapper lazy loading --- tests/strategy/test_interface.py | 25 ++++++++++++++++++++++++- 1 file changed, 24 insertions(+), 1 deletion(-) diff --git a/tests/strategy/test_interface.py b/tests/strategy/test_interface.py index b7b73bdcf..dca87e724 100644 --- a/tests/strategy/test_interface.py +++ b/tests/strategy/test_interface.py @@ -20,7 +20,8 @@ from freqtrade.strategy.hyper import detect_parameters from freqtrade.strategy.parameters import (BaseParameter, BooleanParameter, CategoricalParameter, DecimalParameter, IntParameter, RealParameter) from freqtrade.strategy.strategy_wrapper import strategy_safe_wrapper -from tests.conftest import CURRENT_TEST_STRATEGY, TRADE_SIDES, log_has, log_has_re +from tests.conftest import (CURRENT_TEST_STRATEGY, TRADE_SIDES, create_mock_trades, log_has, + log_has_re) from .strats.strategy_test_v3 import StrategyTestV3 @@ -812,6 +813,28 @@ def test_strategy_safe_wrapper(value): assert ret == value +@pytest.mark.usefixtures("init_persistence") +def test_strategy_safe_wrapper_trade_copy(fee): + create_mock_trades(fee) + + def working_method(trade): + assert len(trade.orders) > 0 + assert trade.orders + trade.orders = [] + assert len(trade.orders) == 0 + return trade + + trade = Trade.get_open_trades()[0] + # Don't assert anything before strategy_wrapper. + # This ensures that relationship loading works correctly. + ret = strategy_safe_wrapper(working_method, message='DeadBeef')(trade=trade) + assert isinstance(ret, Trade) + assert id(trade) != id(ret) + # Did not modify the original order + assert len(trade.orders) > 0 + assert len(ret.orders) == 0 + + def test_hyperopt_parameters(): from skopt.space import Categorical, Integer, Real with pytest.raises(OperationalException, match=r"Name is determined.*"): From 88f8cbe17278f21d459a323d66d85cbe6c03db48 Mon Sep 17 00:00:00 2001 From: Matthias Date: Thu, 9 Jun 2022 06:45:22 +0200 Subject: [PATCH 09/46] Update tests to reflect new naming --- freqtrade/rpc/telegram.py | 1 + tests/rpc/test_rpc.py | 8 ++++---- 2 files changed, 5 insertions(+), 4 deletions(-) diff --git a/freqtrade/rpc/telegram.py b/freqtrade/rpc/telegram.py index e64ab7b8a..27eb04b89 100644 --- a/freqtrade/rpc/telegram.py +++ b/freqtrade/rpc/telegram.py @@ -657,6 +657,7 @@ class Telegram(RPCHandler): :return: None """ self._timeunit_stats(update, context, 'months') + @authorized_only def _profit(self, update: Update, context: CallbackContext) -> None: """ diff --git a/tests/rpc/test_rpc.py b/tests/rpc/test_rpc.py index 95645c8ba..e1f40bcd2 100644 --- a/tests/rpc/test_rpc.py +++ b/tests/rpc/test_rpc.py @@ -284,8 +284,8 @@ def test_rpc_status_table(default_conf, ticker, fee, mocker) -> None: assert isnan(fiat_profit_sum) -def test_rpc_daily_profit(default_conf, update, ticker, fee, - limit_buy_order, limit_sell_order, markets, mocker) -> None: +def test__rpc_timeunit_profit(default_conf, update, ticker, fee, + limit_buy_order, limit_sell_order, markets, mocker) -> None: mocker.patch('freqtrade.rpc.telegram.Telegram', MagicMock()) mocker.patch.multiple( 'freqtrade.exchange.Exchange', @@ -316,7 +316,7 @@ def test_rpc_daily_profit(default_conf, update, ticker, fee, # Try valid data update.message.text = '/daily 2' - days = rpc._rpc_daily_profit(7, stake_currency, fiat_display_currency) + days = rpc._rpc_timeunit_profit(7, stake_currency, fiat_display_currency) assert len(days['data']) == 7 assert days['stake_currency'] == default_conf['stake_currency'] assert days['fiat_display_currency'] == default_conf['fiat_display_currency'] @@ -332,7 +332,7 @@ def test_rpc_daily_profit(default_conf, update, ticker, fee, # Try invalid data with pytest.raises(RPCException, match=r'.*must be an integer greater than 0*'): - rpc._rpc_daily_profit(0, stake_currency, fiat_display_currency) + rpc._rpc_timeunit_profit(0, stake_currency, fiat_display_currency) @pytest.mark.parametrize('is_short', [True, False]) From 1ddd5f1901d08073dd7d8c9cc3b819c728a20350 Mon Sep 17 00:00:00 2001 From: Matthias Date: Thu, 9 Jun 2022 19:41:08 +0200 Subject: [PATCH 10/46] Update docstring throughout the bot. --- docs/strategy-callbacks.md | 6 ++++-- freqtrade/strategy/interface.py | 2 ++ .../templates/subtemplates/strategy_methods_advanced.j2 | 2 ++ 3 files changed, 8 insertions(+), 2 deletions(-) diff --git a/docs/strategy-callbacks.md b/docs/strategy-callbacks.md index b897453e7..410641f44 100644 --- a/docs/strategy-callbacks.md +++ b/docs/strategy-callbacks.md @@ -550,7 +550,8 @@ class AwesomeStrategy(IStrategy): :param pair: Pair that's about to be bought/shorted. :param order_type: Order type (as configured in order_types). usually limit or market. :param amount: Amount in target (base) currency that's going to be traded. - :param rate: Rate that's going to be used when using limit orders or current rate for market orders. + :param rate: Rate that's going to be used when using limit orders + or current rate for market orders. :param time_in_force: Time in force. Defaults to GTC (Good-til-cancelled). :param current_time: datetime object, containing the current datetime :param entry_tag: Optional entry_tag (buy_tag) if provided with the buy signal. @@ -599,7 +600,8 @@ class AwesomeStrategy(IStrategy): :param trade: trade object. :param order_type: Order type (as configured in order_types). usually limit or market. :param amount: Amount in base currency. - :param rate: Rate that's going to be used when using limit orders or current rate for market orders. + :param rate: Rate that's going to be used when using limit orders + or current rate for market orders. :param time_in_force: Time in force. Defaults to GTC (Good-til-cancelled). :param exit_reason: Exit reason. Can be any of ['roi', 'stop_loss', 'stoploss_on_exchange', 'trailing_stop_loss', diff --git a/freqtrade/strategy/interface.py b/freqtrade/strategy/interface.py index 3b3d326ff..d4ccfc5db 100644 --- a/freqtrade/strategy/interface.py +++ b/freqtrade/strategy/interface.py @@ -289,6 +289,7 @@ class IStrategy(ABC, HyperStrategyMixin): :param order_type: Order type (as configured in order_types). usually limit or market. :param amount: Amount in target (base) currency that's going to be traded. :param rate: Rate that's going to be used when using limit orders + or current rate for market orders. :param time_in_force: Time in force. Defaults to GTC (Good-til-cancelled). :param current_time: datetime object, containing the current datetime :param entry_tag: Optional entry_tag (buy_tag) if provided with the buy signal. @@ -316,6 +317,7 @@ class IStrategy(ABC, HyperStrategyMixin): :param order_type: Order type (as configured in order_types). usually limit or market. :param amount: Amount in base currency. :param rate: Rate that's going to be used when using limit orders + or current rate for market orders. :param time_in_force: Time in force. Defaults to GTC (Good-til-cancelled). :param exit_reason: Exit reason. Can be any of ['roi', 'stop_loss', 'stoploss_on_exchange', 'trailing_stop_loss', diff --git a/freqtrade/templates/subtemplates/strategy_methods_advanced.j2 b/freqtrade/templates/subtemplates/strategy_methods_advanced.j2 index acefd0363..815ca7cd3 100644 --- a/freqtrade/templates/subtemplates/strategy_methods_advanced.j2 +++ b/freqtrade/templates/subtemplates/strategy_methods_advanced.j2 @@ -161,6 +161,7 @@ def confirm_trade_entry(self, pair: str, order_type: str, amount: float, rate: f :param order_type: Order type (as configured in order_types). usually limit or market. :param amount: Amount in target (base) currency that's going to be traded. :param rate: Rate that's going to be used when using limit orders + or current rate for market orders. :param time_in_force: Time in force. Defaults to GTC (Good-til-cancelled). :param current_time: datetime object, containing the current datetime :param entry_tag: Optional entry_tag (buy_tag) if provided with the buy signal. @@ -188,6 +189,7 @@ def confirm_trade_exit(self, pair: str, trade: 'Trade', order_type: str, amount: :param order_type: Order type (as configured in order_types). usually limit or market. :param amount: Amount in base currency. :param rate: Rate that's going to be used when using limit orders + or current rate for market orders. :param time_in_force: Time in force. Defaults to GTC (Good-til-cancelled). :param exit_reason: Exit reason. Can be any of ['roi', 'stop_loss', 'stoploss_on_exchange', 'trailing_stop_loss', From a9c7ad8a0fcbf00063beba6a2b59809b99a97218 Mon Sep 17 00:00:00 2001 From: Matthias Date: Thu, 9 Jun 2022 19:51:21 +0200 Subject: [PATCH 11/46] Add warning about sqlite disabled foreign keys --- docs/sql_cheatsheet.md | 3 +++ 1 file changed, 3 insertions(+) diff --git a/docs/sql_cheatsheet.md b/docs/sql_cheatsheet.md index 49372b002..c9fcba557 100644 --- a/docs/sql_cheatsheet.md +++ b/docs/sql_cheatsheet.md @@ -100,6 +100,9 @@ DELETE FROM trades WHERE id = 31; !!! Warning This will remove this trade from the database. Please make sure you got the correct id and **NEVER** run this query without the `where` clause. +!!! Danger + Some systems (Ubuntu) disable foreign keys in their sqlite3 implementation. When using sqlite3 - please ensure that foreign keys are on by running `PRAGMA foreign_keys = ON` before the above query. + ## Use a different database system !!! Warning From 3c2ba99fc480d028f8c6c86db68cfa5813b2b0e5 Mon Sep 17 00:00:00 2001 From: Matthias Date: Thu, 9 Jun 2022 19:57:56 +0200 Subject: [PATCH 12/46] Improve sql cheatsheet docs --- docs/sql_cheatsheet.md | 23 ++++++++++++++--------- 1 file changed, 14 insertions(+), 9 deletions(-) diff --git a/docs/sql_cheatsheet.md b/docs/sql_cheatsheet.md index c9fcba557..c42cb5575 100644 --- a/docs/sql_cheatsheet.md +++ b/docs/sql_cheatsheet.md @@ -89,29 +89,34 @@ WHERE id=31; If you'd still like to remove a trade from the database directly, you can use the below query. -```sql -DELETE FROM trades WHERE id = ; -``` +!!! Danger + Some systems (Ubuntu) disable foreign keys in their sqlite3 packaging. When using sqlite - please ensure that foreign keys are on by running `PRAGMA foreign_keys = ON` before the above query. ```sql +DELETE FROM trades WHERE id = ; + DELETE FROM trades WHERE id = 31; ``` !!! Warning This will remove this trade from the database. Please make sure you got the correct id and **NEVER** run this query without the `where` clause. -!!! Danger - Some systems (Ubuntu) disable foreign keys in their sqlite3 implementation. When using sqlite3 - please ensure that foreign keys are on by running `PRAGMA foreign_keys = ON` before the above query. - ## Use a different database system +Freqtrade is using SQLAlchemy, which supports multiple different database systems. As such, a multitude of database systems should be supported. +Freqtrade does not depend or install any additional database driver. Please refer to the [SQLAlchemy docs](https://docs.sqlalchemy.org/en/14/core/engines.html#database-urls) on installation instructions for the respective database systems. + +The following systems have been tested and are known to work with freqtrade: + +* sqlite (default) +* PostgreSQL) +* MariaDB + !!! Warning - By using one of the below database systems, you acknowledge that you know how to manage such a system. Freqtrade will not provide any support with setup or maintenance (or backups) of the below database systems. + By using one of the below database systems, you acknowledge that you know how to manage such a system. The freqtrade team will not provide any support with setup or maintenance (or backups) of the below database systems. ### PostgreSQL -Freqtrade supports PostgreSQL by using SQLAlchemy, which supports multiple different database systems. - Installation: `pip install psycopg2-binary` From 8fb743b91d33d7187c32765a6c6f3c2c5d7fd2eb Mon Sep 17 00:00:00 2001 From: Matthias Date: Thu, 9 Jun 2022 20:13:26 +0200 Subject: [PATCH 13/46] improve variable wording --- freqtrade/rpc/telegram.py | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/freqtrade/rpc/telegram.py b/freqtrade/rpc/telegram.py index 27eb04b89..106a5f011 100644 --- a/freqtrade/rpc/telegram.py +++ b/freqtrade/rpc/telegram.py @@ -605,10 +605,10 @@ class Telegram(RPCHandler): unit ) stats_tab = tabulate( - [[day['date'], - f"{round_coin_value(day['abs_profit'], stats['stake_currency'])}", - f"{day['fiat_value']:.3f} {stats['fiat_display_currency']}", - f"{day['trade_count']} trades"] for day in stats['data']], + [[period['date'], + f"{round_coin_value(period['abs_profit'], stats['stake_currency'])}", + f"{period['fiat_value']:.3f} {stats['fiat_display_currency']}", + f"{period['trade_count']} trades"] for period in stats['data']], headers=[ val.header, f'Profit {stake_cur}', From dce9fdd0e4717559862b85df0850d5a1608e62fd Mon Sep 17 00:00:00 2001 From: Italo <45588475+italodamato@users.noreply.github.com> Date: Thu, 9 Jun 2022 20:06:23 +0100 Subject: [PATCH 14/46] don't overwrite is_random this should fix issue #6746 --- freqtrade/optimize/hyperopt.py | 13 +++++++------ 1 file changed, 7 insertions(+), 6 deletions(-) diff --git a/freqtrade/optimize/hyperopt.py b/freqtrade/optimize/hyperopt.py index d1697709b..ac1b7b8ba 100644 --- a/freqtrade/optimize/hyperopt.py +++ b/freqtrade/optimize/hyperopt.py @@ -429,18 +429,19 @@ class Hyperopt: return new_list i = 0 asked_non_tried: List[List[Any]] = [] - is_random: List[bool] = [] + is_random_non_tried: List[bool] = [] while i < 5 and len(asked_non_tried) < n_points: if i < 3: self.opt.cache_ = {} asked = unique_list(self.opt.ask(n_points=n_points * 5)) is_random = [False for _ in range(len(asked))] else: - asked = unique_list(self.opt.space.rvs(n_samples=n_points * 5)) + asked = unique_list(self.opt.space.rvs( + n_samples=n_points * 5, random_state=self.random_state + i)) is_random = [True for _ in range(len(asked))] - is_random += [rand for x, rand in zip(asked, is_random) - if x not in self.opt.Xi - and x not in asked_non_tried] + is_random_non_tried += [rand for x, rand in zip(asked, is_random) + if x not in self.opt.Xi + and x not in asked_non_tried] asked_non_tried += [x for x in asked if x not in self.opt.Xi and x not in asked_non_tried] @@ -449,7 +450,7 @@ class Hyperopt: if asked_non_tried: return ( asked_non_tried[:min(len(asked_non_tried), n_points)], - is_random[:min(len(asked_non_tried), n_points)] + is_random_non_tried[:min(len(asked_non_tried), n_points)] ) else: return self.opt.ask(n_points=n_points), [False for _ in range(n_points)] From ad3c01736e74f4986cba86f685c2999fd202883f Mon Sep 17 00:00:00 2001 From: Matthias Date: Fri, 10 Jun 2022 07:26:53 +0200 Subject: [PATCH 15/46] time aggregate to only query for data necessary improves the query by not creating a full trade object. --- freqtrade/rpc/rpc.py | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/freqtrade/rpc/rpc.py b/freqtrade/rpc/rpc.py index a6290bd5a..64584382a 100644 --- a/freqtrade/rpc/rpc.py +++ b/freqtrade/rpc/rpc.py @@ -309,16 +309,18 @@ class RPC: for day in range(0, timescale): profitday = start_date - time_offset(day) - trades = Trade.get_trades(trade_filter=[ + # Only query for necessary columns for performance reasons. + trades = Trade.query.session.query(Trade.close_profit_abs).filter( Trade.is_open.is_(False), Trade.close_date >= profitday, Trade.close_date < (profitday + time_offset(1)) - ]).order_by(Trade.close_date).all() + ).order_by(Trade.close_date).all() + curdayprofit = sum( trade.close_profit_abs for trade in trades if trade.close_profit_abs is not None) profit_units[profitday] = { 'amount': curdayprofit, - 'trades': len(trades) + 'trades': len(trades), } data = [ From 7142394121abc4d511f110d805dd848989eb9126 Mon Sep 17 00:00:00 2001 From: Italo <45588475+italodamato@users.noreply.github.com> Date: Fri, 10 Jun 2022 09:46:45 +0100 Subject: [PATCH 16/46] remove random_state condition otherwise the random sample always draws the same set of points --- freqtrade/optimize/hyperopt.py | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/freqtrade/optimize/hyperopt.py b/freqtrade/optimize/hyperopt.py index ac1b7b8ba..cb0d788da 100644 --- a/freqtrade/optimize/hyperopt.py +++ b/freqtrade/optimize/hyperopt.py @@ -436,8 +436,7 @@ class Hyperopt: asked = unique_list(self.opt.ask(n_points=n_points * 5)) is_random = [False for _ in range(len(asked))] else: - asked = unique_list(self.opt.space.rvs( - n_samples=n_points * 5, random_state=self.random_state + i)) + asked = unique_list(self.opt.space.rvs(n_samples=n_points * 5)) is_random = [True for _ in range(len(asked))] is_random_non_tried += [rand for x, rand in zip(asked, is_random) if x not in self.opt.Xi From 76f87377ba542a106476828cd04846e29c0cfb88 Mon Sep 17 00:00:00 2001 From: Matthias Date: Fri, 10 Jun 2022 20:18:33 +0200 Subject: [PATCH 17/46] Reduce decimals on FIAT daily column --- freqtrade/rpc/telegram.py | 2 +- tests/rpc/test_rpc_telegram.py | 18 +++++++++--------- 2 files changed, 10 insertions(+), 10 deletions(-) diff --git a/freqtrade/rpc/telegram.py b/freqtrade/rpc/telegram.py index 106a5f011..61b73553f 100644 --- a/freqtrade/rpc/telegram.py +++ b/freqtrade/rpc/telegram.py @@ -607,7 +607,7 @@ class Telegram(RPCHandler): stats_tab = tabulate( [[period['date'], f"{round_coin_value(period['abs_profit'], stats['stake_currency'])}", - f"{period['fiat_value']:.3f} {stats['fiat_display_currency']}", + f"{period['fiat_value']:.2f} {stats['fiat_display_currency']}", f"{period['trade_count']} trades"] for period in stats['data']], headers=[ val.header, diff --git a/tests/rpc/test_rpc_telegram.py b/tests/rpc/test_rpc_telegram.py index 2bc4fc5c3..5271c5a30 100644 --- a/tests/rpc/test_rpc_telegram.py +++ b/tests/rpc/test_rpc_telegram.py @@ -447,7 +447,7 @@ def test_daily_handle(default_conf, update, ticker, limit_buy_order, fee, assert 'Day ' in msg_mock.call_args_list[0][0][0] assert str(datetime.utcnow().date()) in msg_mock.call_args_list[0][0][0] assert str(' 0.00006217 BTC') in msg_mock.call_args_list[0][0][0] - assert str(' 0.933 USD') in msg_mock.call_args_list[0][0][0] + assert str(' 0.93 USD') in msg_mock.call_args_list[0][0][0] assert str(' 1 trade') in msg_mock.call_args_list[0][0][0] assert str(' 0 trade') in msg_mock.call_args_list[0][0][0] @@ -459,7 +459,7 @@ def test_daily_handle(default_conf, update, ticker, limit_buy_order, fee, assert "Daily Profit over the last 7 days:" in msg_mock.call_args_list[0][0][0] assert str(datetime.utcnow().date()) in msg_mock.call_args_list[0][0][0] assert str(' 0.00006217 BTC') in msg_mock.call_args_list[0][0][0] - assert str(' 0.933 USD') in msg_mock.call_args_list[0][0][0] + assert str(' 0.93 USD') in msg_mock.call_args_list[0][0][0] assert str(' 1 trade') in msg_mock.call_args_list[0][0][0] assert str(' 0 trade') in msg_mock.call_args_list[0][0][0] @@ -482,7 +482,7 @@ def test_daily_handle(default_conf, update, ticker, limit_buy_order, fee, context.args = ["1"] telegram._daily(update=update, context=context) assert str(' 0.00018651 BTC') in msg_mock.call_args_list[0][0][0] - assert str(' 2.798 USD') in msg_mock.call_args_list[0][0][0] + assert str(' 2.80 USD') in msg_mock.call_args_list[0][0][0] assert str(' 3 trades') in msg_mock.call_args_list[0][0][0] @@ -561,7 +561,7 @@ def test_weekly_handle(default_conf, update, ticker, limit_buy_order, fee, first_iso_day_of_current_week = today - timedelta(days=today.weekday()) assert str(first_iso_day_of_current_week) in msg_mock.call_args_list[0][0][0] assert str(' 0.00006217 BTC') in msg_mock.call_args_list[0][0][0] - assert str(' 0.933 USD') in msg_mock.call_args_list[0][0][0] + assert str(' 0.93 USD') in msg_mock.call_args_list[0][0][0] assert str(' 1 trade') in msg_mock.call_args_list[0][0][0] assert str(' 0 trade') in msg_mock.call_args_list[0][0][0] @@ -574,7 +574,7 @@ def test_weekly_handle(default_conf, update, ticker, limit_buy_order, fee, in msg_mock.call_args_list[0][0][0] assert 'Weekly' in msg_mock.call_args_list[0][0][0] assert str(' 0.00006217 BTC') in msg_mock.call_args_list[0][0][0] - assert str(' 0.933 USD') in msg_mock.call_args_list[0][0][0] + assert str(' 0.93 USD') in msg_mock.call_args_list[0][0][0] assert str(' 1 trade') in msg_mock.call_args_list[0][0][0] assert str(' 0 trade') in msg_mock.call_args_list[0][0][0] @@ -599,7 +599,7 @@ def test_weekly_handle(default_conf, update, ticker, limit_buy_order, fee, context.args = ["1"] telegram._weekly(update=update, context=context) assert str(' 0.00018651 BTC') in msg_mock.call_args_list[0][0][0] - assert str(' 2.798 USD') in msg_mock.call_args_list[0][0][0] + assert str(' 2.80 USD') in msg_mock.call_args_list[0][0][0] assert str(' 3 trades') in msg_mock.call_args_list[0][0][0] @@ -678,7 +678,7 @@ def test_monthly_handle(default_conf, update, ticker, limit_buy_order, fee, current_month = f"{today.year}-{today.month:02} " assert current_month in msg_mock.call_args_list[0][0][0] assert str(' 0.00006217 BTC') in msg_mock.call_args_list[0][0][0] - assert str(' 0.933 USD') in msg_mock.call_args_list[0][0][0] + assert str(' 0.93 USD') in msg_mock.call_args_list[0][0][0] assert str(' 1 trade') in msg_mock.call_args_list[0][0][0] assert str(' 0 trade') in msg_mock.call_args_list[0][0][0] @@ -692,7 +692,7 @@ def test_monthly_handle(default_conf, update, ticker, limit_buy_order, fee, assert 'Month ' in msg_mock.call_args_list[0][0][0] assert current_month in msg_mock.call_args_list[0][0][0] assert str(' 0.00006217 BTC') in msg_mock.call_args_list[0][0][0] - assert str(' 0.933 USD') in msg_mock.call_args_list[0][0][0] + assert str(' 0.93 USD') in msg_mock.call_args_list[0][0][0] assert str(' 1 trade') in msg_mock.call_args_list[0][0][0] assert str(' 0 trade') in msg_mock.call_args_list[0][0][0] @@ -717,7 +717,7 @@ def test_monthly_handle(default_conf, update, ticker, limit_buy_order, fee, assert msg_mock.call_count == 1 assert 'Monthly Profit over the last 12 months:' in msg_mock.call_args_list[0][0][0] assert str(' 0.00018651 BTC') in msg_mock.call_args_list[0][0][0] - assert str(' 2.798 USD') in msg_mock.call_args_list[0][0][0] + assert str(' 2.80 USD') in msg_mock.call_args_list[0][0][0] assert str(' 3 trades') in msg_mock.call_args_list[0][0][0] # The one-digit months should contain a zero, Eg: September 2021 = "2021-09" From 2c7c5f9a6e0815760d1bafed9a96e8804c15b7b4 Mon Sep 17 00:00:00 2001 From: Matthias Date: Fri, 10 Jun 2022 20:34:17 +0200 Subject: [PATCH 18/46] Update mock_usdt trade method --- tests/conftest.py | 19 +++-- tests/conftest_trades_usdt.py | 151 +++++++++++++++++++-------------- tests/plugins/test_pairlist.py | 4 +- 3 files changed, 100 insertions(+), 74 deletions(-) diff --git a/tests/conftest.py b/tests/conftest.py index 02738b0e9..b4b98cbeb 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -325,7 +325,7 @@ def create_mock_trades_with_leverage(fee, use_db: bool = True): Trade.query.session.flush() -def create_mock_trades_usdt(fee, use_db: bool = True): +def create_mock_trades_usdt(fee, is_short: Optional[bool] = False, use_db: bool = True): """ Create some fake trades ... """ @@ -335,26 +335,29 @@ def create_mock_trades_usdt(fee, use_db: bool = True): else: LocalTrade.add_bt_trade(trade) + is_short1 = is_short if is_short is not None else True + is_short2 = is_short if is_short is not None else False + # Simulate dry_run entries - trade = mock_trade_usdt_1(fee) + trade = mock_trade_usdt_1(fee, is_short1) add_trade(trade) - trade = mock_trade_usdt_2(fee) + trade = mock_trade_usdt_2(fee, is_short1) add_trade(trade) - trade = mock_trade_usdt_3(fee) + trade = mock_trade_usdt_3(fee, is_short1) add_trade(trade) - trade = mock_trade_usdt_4(fee) + trade = mock_trade_usdt_4(fee, is_short2) add_trade(trade) - trade = mock_trade_usdt_5(fee) + trade = mock_trade_usdt_5(fee, is_short2) add_trade(trade) - trade = mock_trade_usdt_6(fee) + trade = mock_trade_usdt_6(fee, is_short1) add_trade(trade) - trade = mock_trade_usdt_7(fee) + trade = mock_trade_usdt_7(fee, is_short1) add_trade(trade) if use_db: Trade.commit() diff --git a/tests/conftest_trades_usdt.py b/tests/conftest_trades_usdt.py index 59e7f0457..6f83bb8be 100644 --- a/tests/conftest_trades_usdt.py +++ b/tests/conftest_trades_usdt.py @@ -6,12 +6,24 @@ from freqtrade.persistence.models import Order, Trade MOCK_TRADE_COUNT = 6 -def mock_order_usdt_1(): +def entry_side(is_short: bool): + return "sell" if is_short else "buy" + + +def exit_side(is_short: bool): + return "buy" if is_short else "sell" + + +def direc(is_short: bool): + return "short" if is_short else "long" + + +def mock_order_usdt_1(is_short: bool): return { - 'id': '1234', + 'id': f'1234_{direc(is_short)}', 'symbol': 'ADA/USDT', 'status': 'closed', - 'side': 'buy', + 'side': entry_side(is_short), 'type': 'limit', 'price': 2.0, 'amount': 10.0, @@ -20,7 +32,7 @@ def mock_order_usdt_1(): } -def mock_trade_usdt_1(fee): +def mock_trade_usdt_1(fee, is_short: bool): trade = Trade( pair='ADA/USDT', stake_amount=20.0, @@ -32,21 +44,22 @@ def mock_trade_usdt_1(fee): open_date=datetime.now(tz=timezone.utc) - timedelta(minutes=17), open_rate=2.0, exchange='binance', - open_order_id='dry_run_buy_12345', + open_order_id=f'1234_{direc(is_short)}', strategy='StrategyTestV2', timeframe=5, + is_short=is_short, ) - o = Order.parse_from_ccxt_object(mock_order_usdt_1(), 'ADA/USDT', 'buy') + o = Order.parse_from_ccxt_object(mock_order_usdt_1(is_short), 'ADA/USDT', entry_side(is_short)) trade.orders.append(o) return trade -def mock_order_usdt_2(): +def mock_order_usdt_2(is_short: bool): return { - 'id': '1235', + 'id': f'1235_{direc(is_short)}', 'symbol': 'ETC/USDT', 'status': 'closed', - 'side': 'buy', + 'side': entry_side(is_short), 'type': 'limit', 'price': 2.0, 'amount': 100.0, @@ -55,12 +68,12 @@ def mock_order_usdt_2(): } -def mock_order_usdt_2_sell(): +def mock_order_usdt_2_exit(is_short: bool): return { - 'id': '12366', + 'id': f'12366_{direc(is_short)}', 'symbol': 'ETC/USDT', 'status': 'closed', - 'side': 'sell', + 'side': exit_side(is_short), 'type': 'limit', 'price': 2.05, 'amount': 100.0, @@ -69,7 +82,7 @@ def mock_order_usdt_2_sell(): } -def mock_trade_usdt_2(fee): +def mock_trade_usdt_2(fee, is_short: bool): """ Closed trade... """ @@ -86,26 +99,28 @@ def mock_trade_usdt_2(fee): close_profit_abs=3.9875, exchange='binance', is_open=False, - open_order_id='dry_run_sell_12345', + open_order_id=f'12366_{direc(is_short)}', strategy='StrategyTestV2', timeframe=5, - exit_reason='sell_signal', + exit_reason='exit_signal', open_date=datetime.now(tz=timezone.utc) - timedelta(minutes=20), close_date=datetime.now(tz=timezone.utc) - timedelta(minutes=2), + is_short=is_short, ) - o = Order.parse_from_ccxt_object(mock_order_usdt_2(), 'ETC/USDT', 'buy') + o = Order.parse_from_ccxt_object(mock_order_usdt_2(is_short), 'ETC/USDT', entry_side(is_short)) trade.orders.append(o) - o = Order.parse_from_ccxt_object(mock_order_usdt_2_sell(), 'ETC/USDT', 'sell') + o = Order.parse_from_ccxt_object( + mock_order_usdt_2_exit(is_short), 'ETC/USDT', exit_side(is_short)) trade.orders.append(o) return trade -def mock_order_usdt_3(): +def mock_order_usdt_3(is_short: bool): return { - 'id': '41231a12a', + 'id': f'41231a12a_{direc(is_short)}', 'symbol': 'XRP/USDT', 'status': 'closed', - 'side': 'buy', + 'side': entry_side(is_short), 'type': 'limit', 'price': 1.0, 'amount': 30.0, @@ -114,12 +129,12 @@ def mock_order_usdt_3(): } -def mock_order_usdt_3_sell(): +def mock_order_usdt_3_exit(is_short: bool): return { - 'id': '41231a666a', + 'id': f'41231a666a_{direc(is_short)}', 'symbol': 'XRP/USDT', 'status': 'closed', - 'side': 'sell', + 'side': exit_side(is_short), 'type': 'stop_loss_limit', 'price': 1.1, 'average': 1.1, @@ -129,7 +144,7 @@ def mock_order_usdt_3_sell(): } -def mock_trade_usdt_3(fee): +def mock_trade_usdt_3(fee, is_short: bool): """ Closed trade """ @@ -151,20 +166,22 @@ def mock_trade_usdt_3(fee): exit_reason='roi', open_date=datetime.now(tz=timezone.utc) - timedelta(minutes=20), close_date=datetime.now(tz=timezone.utc), + is_short=is_short, ) - o = Order.parse_from_ccxt_object(mock_order_usdt_3(), 'XRP/USDT', 'buy') + o = Order.parse_from_ccxt_object(mock_order_usdt_3(is_short), 'XRP/USDT', entry_side(is_short)) trade.orders.append(o) - o = Order.parse_from_ccxt_object(mock_order_usdt_3_sell(), 'XRP/USDT', 'sell') + o = Order.parse_from_ccxt_object(mock_order_usdt_3_exit(is_short), + 'XRP/USDT', exit_side(is_short)) trade.orders.append(o) return trade -def mock_order_usdt_4(): +def mock_order_usdt_4(is_short: bool): return { - 'id': 'prod_buy_12345', + 'id': f'prod_buy_12345_{direc(is_short)}', 'symbol': 'ETC/USDT', 'status': 'open', - 'side': 'buy', + 'side': entry_side(is_short), 'type': 'limit', 'price': 2.0, 'amount': 10.0, @@ -173,7 +190,7 @@ def mock_order_usdt_4(): } -def mock_trade_usdt_4(fee): +def mock_trade_usdt_4(fee, is_short: bool): """ Simulate prod entry """ @@ -188,21 +205,22 @@ def mock_trade_usdt_4(fee): is_open=True, open_rate=2.0, exchange='binance', - open_order_id='prod_buy_12345', + open_order_id=f'prod_buy_12345_{direc(is_short)}', strategy='StrategyTestV2', timeframe=5, + is_short=is_short, ) - o = Order.parse_from_ccxt_object(mock_order_usdt_4(), 'ETC/USDT', 'buy') + o = Order.parse_from_ccxt_object(mock_order_usdt_4(is_short), 'ETC/USDT', entry_side(is_short)) trade.orders.append(o) return trade -def mock_order_usdt_5(): +def mock_order_usdt_5(is_short: bool): return { - 'id': 'prod_buy_3455', + 'id': f'prod_buy_3455_{direc(is_short)}', 'symbol': 'XRP/USDT', 'status': 'closed', - 'side': 'buy', + 'side': entry_side(is_short), 'type': 'limit', 'price': 2.0, 'amount': 10.0, @@ -211,12 +229,12 @@ def mock_order_usdt_5(): } -def mock_order_usdt_5_stoploss(): +def mock_order_usdt_5_stoploss(is_short: bool): return { - 'id': 'prod_stoploss_3455', + 'id': f'prod_stoploss_3455_{direc(is_short)}', 'symbol': 'XRP/USDT', 'status': 'open', - 'side': 'sell', + 'side': exit_side(is_short), 'type': 'stop_loss_limit', 'price': 2.0, 'amount': 10.0, @@ -225,7 +243,7 @@ def mock_order_usdt_5_stoploss(): } -def mock_trade_usdt_5(fee): +def mock_trade_usdt_5(fee, is_short: bool): """ Simulate prod entry with stoploss """ @@ -241,22 +259,23 @@ def mock_trade_usdt_5(fee): open_rate=2.0, exchange='binance', strategy='SampleStrategy', - stoploss_order_id='prod_stoploss_3455', + stoploss_order_id=f'prod_stoploss_3455_{direc(is_short)}', timeframe=5, + is_short=is_short, ) - o = Order.parse_from_ccxt_object(mock_order_usdt_5(), 'XRP/USDT', 'buy') + o = Order.parse_from_ccxt_object(mock_order_usdt_5(is_short), 'XRP/USDT', entry_side(is_short)) trade.orders.append(o) - o = Order.parse_from_ccxt_object(mock_order_usdt_5_stoploss(), 'XRP/USDT', 'stoploss') + o = Order.parse_from_ccxt_object(mock_order_usdt_5_stoploss(is_short), 'XRP/USDT', 'stoploss') trade.orders.append(o) return trade -def mock_order_usdt_6(): +def mock_order_usdt_6(is_short: bool): return { - 'id': 'prod_buy_6', + 'id': f'prod_entry_6_{direc(is_short)}', 'symbol': 'LTC/USDT', 'status': 'closed', - 'side': 'buy', + 'side': entry_side(is_short), 'type': 'limit', 'price': 10.0, 'amount': 2.0, @@ -265,12 +284,12 @@ def mock_order_usdt_6(): } -def mock_order_usdt_6_sell(): +def mock_order_usdt_6_exit(is_short: bool): return { - 'id': 'prod_sell_6', + 'id': f'prod_exit_6_{direc(is_short)}', 'symbol': 'LTC/USDT', 'status': 'open', - 'side': 'sell', + 'side': exit_side(is_short), 'type': 'limit', 'price': 12.0, 'amount': 2.0, @@ -279,7 +298,7 @@ def mock_order_usdt_6_sell(): } -def mock_trade_usdt_6(fee): +def mock_trade_usdt_6(fee, is_short: bool): """ Simulate prod entry with open sell order """ @@ -295,22 +314,24 @@ def mock_trade_usdt_6(fee): open_rate=10.0, exchange='binance', strategy='SampleStrategy', - open_order_id="prod_sell_6", + open_order_id=f'prod_exit_6_{direc(is_short)}', timeframe=5, + is_short=is_short, ) - o = Order.parse_from_ccxt_object(mock_order_usdt_6(), 'LTC/USDT', 'buy') + o = Order.parse_from_ccxt_object(mock_order_usdt_6(is_short), 'LTC/USDT', entry_side(is_short)) trade.orders.append(o) - o = Order.parse_from_ccxt_object(mock_order_usdt_6_sell(), 'LTC/USDT', 'sell') + o = Order.parse_from_ccxt_object(mock_order_usdt_6_exit(is_short), + 'LTC/USDT', exit_side(is_short)) trade.orders.append(o) return trade -def mock_order_usdt_7(): +def mock_order_usdt_7(is_short: bool): return { - 'id': 'prod_buy_7', + 'id': f'prod_entry_7_{direc(is_short)}', 'symbol': 'LTC/USDT', 'status': 'closed', - 'side': 'buy', + 'side': entry_side(is_short), 'type': 'limit', 'price': 10.0, 'amount': 2.0, @@ -319,12 +340,12 @@ def mock_order_usdt_7(): } -def mock_order_usdt_7_sell(): +def mock_order_usdt_7_exit(is_short: bool): return { - 'id': 'prod_sell_7', + 'id': f'prod_exit_7_{direc(is_short)}', 'symbol': 'LTC/USDT', 'status': 'closed', - 'side': 'sell', + 'side': exit_side(is_short), 'type': 'limit', 'price': 8.0, 'amount': 2.0, @@ -333,7 +354,7 @@ def mock_order_usdt_7_sell(): } -def mock_trade_usdt_7(fee): +def mock_trade_usdt_7(fee, is_short: bool): """ Simulate prod entry with open sell order """ @@ -342,8 +363,8 @@ def mock_trade_usdt_7(fee): stake_amount=20.0, amount=2.0, amount_requested=2.0, - open_date=datetime.now(tz=timezone.utc) - timedelta(minutes=20), - close_date=datetime.now(tz=timezone.utc) - timedelta(minutes=5), + open_date=datetime.now(tz=timezone.utc) - timedelta(days=2, minutes=20), + close_date=datetime.now(tz=timezone.utc) - timedelta(days=2, minutes=5), fee_open=fee.return_value, fee_close=fee.return_value, is_open=False, @@ -353,11 +374,13 @@ def mock_trade_usdt_7(fee): close_profit_abs=-4.0, exchange='binance', strategy='SampleStrategy', - open_order_id="prod_sell_6", + open_order_id=f'prod_exit_7_{direc(is_short)}', timeframe=5, + is_short=is_short, ) - o = Order.parse_from_ccxt_object(mock_order_usdt_7(), 'LTC/USDT', 'buy') + o = Order.parse_from_ccxt_object(mock_order_usdt_7(is_short), 'LTC/USDT', entry_side(is_short)) trade.orders.append(o) - o = Order.parse_from_ccxt_object(mock_order_usdt_7_sell(), 'LTC/USDT', 'sell') + o = Order.parse_from_ccxt_object(mock_order_usdt_7_exit(is_short), + 'LTC/USDT', exit_side(is_short)) trade.orders.append(o) return trade diff --git a/tests/plugins/test_pairlist.py b/tests/plugins/test_pairlist.py index c29e619b1..c56f405e2 100644 --- a/tests/plugins/test_pairlist.py +++ b/tests/plugins/test_pairlist.py @@ -762,8 +762,8 @@ def test_PerformanceFilter_keep_mid_order(mocker, default_conf_usdt, fee, caplog with time_machine.travel("2021-09-01 05:00:00 +00:00") as t: create_mock_trades_usdt(fee) pm.refresh_pairlist() - assert pm.whitelist == ['XRP/USDT', 'ETC/USDT', 'ETH/USDT', - 'NEO/USDT', 'TKN/USDT', 'ADA/USDT', 'LTC/USDT'] + assert pm.whitelist == ['XRP/USDT', 'ETC/USDT', 'ETH/USDT', 'LTC/USDT', + 'NEO/USDT', 'TKN/USDT', 'ADA/USDT', ] # assert log_has_re(r'Removing pair .* since .* is below .*', caplog) # Move to "outside" of lookback window, so original sorting is restored. From ab6a306e074da244c3798670cf00760a3c3c44aa Mon Sep 17 00:00:00 2001 From: Matthias Date: Fri, 10 Jun 2022 20:52:05 +0200 Subject: [PATCH 19/46] Update daily test to USDT --- tests/rpc/test_rpc_telegram.py | 59 ++++++++++------------------------ 1 file changed, 17 insertions(+), 42 deletions(-) diff --git a/tests/rpc/test_rpc_telegram.py b/tests/rpc/test_rpc_telegram.py index 5271c5a30..3cafb2d7d 100644 --- a/tests/rpc/test_rpc_telegram.py +++ b/tests/rpc/test_rpc_telegram.py @@ -27,8 +27,9 @@ from freqtrade.persistence.models import Order from freqtrade.rpc import RPC from freqtrade.rpc.rpc import RPCException from freqtrade.rpc.telegram import Telegram, authorized_only -from tests.conftest import (CURRENT_TEST_STRATEGY, create_mock_trades, get_patched_freqtradebot, - log_has, log_has_re, patch_exchange, patch_get_signal, patch_whitelist) +from tests.conftest import (CURRENT_TEST_STRATEGY, create_mock_trades, create_mock_trades_usdt, + get_patched_freqtradebot, log_has, log_has_re, patch_exchange, + patch_get_signal, patch_whitelist) class DummyCls(Telegram): @@ -404,12 +405,10 @@ def test_status_table_handle(default_conf, update, ticker, fee, mocker) -> None: assert msg_mock.call_count == 1 -def test_daily_handle(default_conf, update, ticker, limit_buy_order, fee, - limit_sell_order, mocker) -> None: - default_conf['max_open_trades'] = 1 +def test_daily_handle(default_conf_usdt, update, ticker, fee, mocker) -> None: mocker.patch( 'freqtrade.rpc.rpc.CryptoToFiatConverter._find_price', - return_value=15000.0 + return_value=1.1 ) mocker.patch.multiple( 'freqtrade.exchange.Exchange', @@ -417,25 +416,10 @@ def test_daily_handle(default_conf, update, ticker, limit_buy_order, fee, get_fee=fee, ) - telegram, freqtradebot, msg_mock = get_telegram_testobject(mocker, default_conf) - - patch_get_signal(freqtradebot) + telegram, freqtradebot, msg_mock = get_telegram_testobject(mocker, default_conf_usdt) # Create some test data - freqtradebot.enter_positions() - trade = Trade.query.first() - assert trade - - # Simulate fulfilled LIMIT_BUY order for trade - oobj = Order.parse_from_ccxt_object(limit_buy_order, limit_buy_order['symbol'], 'buy') - trade.update_trade(oobj) - - # Simulate fulfilled LIMIT_SELL order for trade - oobjs = Order.parse_from_ccxt_object(limit_sell_order, limit_sell_order['symbol'], 'sell') - trade.update_trade(oobjs) - - trade.close_date = datetime.utcnow() - trade.is_open = False + create_mock_trades_usdt(fee) # Try valid data # /daily 2 @@ -446,9 +430,9 @@ def test_daily_handle(default_conf, update, ticker, limit_buy_order, fee, assert "Daily Profit over the last 2 days:" in msg_mock.call_args_list[0][0][0] assert 'Day ' in msg_mock.call_args_list[0][0][0] assert str(datetime.utcnow().date()) in msg_mock.call_args_list[0][0][0] - assert str(' 0.00006217 BTC') in msg_mock.call_args_list[0][0][0] - assert str(' 0.93 USD') in msg_mock.call_args_list[0][0][0] - assert str(' 1 trade') in msg_mock.call_args_list[0][0][0] + assert str(' 13.83 USDT') in msg_mock.call_args_list[0][0][0] + assert str(' 15.21 USD') in msg_mock.call_args_list[0][0][0] + assert str(' 2 trade') in msg_mock.call_args_list[0][0][0] assert str(' 0 trade') in msg_mock.call_args_list[0][0][0] # Reset msg_mock @@ -458,32 +442,23 @@ def test_daily_handle(default_conf, update, ticker, limit_buy_order, fee, assert msg_mock.call_count == 1 assert "Daily Profit over the last 7 days:" in msg_mock.call_args_list[0][0][0] assert str(datetime.utcnow().date()) in msg_mock.call_args_list[0][0][0] - assert str(' 0.00006217 BTC') in msg_mock.call_args_list[0][0][0] - assert str(' 0.93 USD') in msg_mock.call_args_list[0][0][0] + assert str((datetime.utcnow() - timedelta(days=5)).date()) in msg_mock.call_args_list[0][0][0] + assert str(' 13.83 USDT') in msg_mock.call_args_list[0][0][0] + assert str(' 15.21 USD') in msg_mock.call_args_list[0][0][0] + assert str(' 2 trade') in msg_mock.call_args_list[0][0][0] assert str(' 1 trade') in msg_mock.call_args_list[0][0][0] assert str(' 0 trade') in msg_mock.call_args_list[0][0][0] # Reset msg_mock msg_mock.reset_mock() - freqtradebot.config['max_open_trades'] = 2 - # Add two other trades - n = freqtradebot.enter_positions() - assert n == 2 - - trades = Trade.query.all() - for trade in trades: - trade.update_trade(oobj) - trade.update_trade(oobjs) - trade.close_date = datetime.utcnow() - trade.is_open = False # /daily 1 context = MagicMock() context.args = ["1"] telegram._daily(update=update, context=context) - assert str(' 0.00018651 BTC') in msg_mock.call_args_list[0][0][0] - assert str(' 2.80 USD') in msg_mock.call_args_list[0][0][0] - assert str(' 3 trades') in msg_mock.call_args_list[0][0][0] + assert str(' 13.83 USDT') in msg_mock.call_args_list[0][0][0] + assert str(' 15.21 USD') in msg_mock.call_args_list[0][0][0] + assert str(' 2 trade') in msg_mock.call_args_list[0][0][0] def test_daily_wrong_input(default_conf, update, ticker, mocker) -> None: From 1a5c3c587d4936b9e6978197b2e257a7040ba5bc Mon Sep 17 00:00:00 2001 From: Matthias Date: Sat, 11 Jun 2022 08:38:30 +0200 Subject: [PATCH 20/46] Simplify weekly/monthly tests, convert to usdt --- tests/rpc/test_rpc.py | 3 +- tests/rpc/test_rpc_telegram.py | 178 +++++++++------------------------ 2 files changed, 47 insertions(+), 134 deletions(-) diff --git a/tests/rpc/test_rpc.py b/tests/rpc/test_rpc.py index e1f40bcd2..da477edf4 100644 --- a/tests/rpc/test_rpc.py +++ b/tests/rpc/test_rpc.py @@ -284,7 +284,7 @@ def test_rpc_status_table(default_conf, ticker, fee, mocker) -> None: assert isnan(fiat_profit_sum) -def test__rpc_timeunit_profit(default_conf, update, ticker, fee, +def test__rpc_timeunit_profit(default_conf, ticker, fee, limit_buy_order, limit_sell_order, markets, mocker) -> None: mocker.patch('freqtrade.rpc.telegram.Telegram', MagicMock()) mocker.patch.multiple( @@ -315,7 +315,6 @@ def test__rpc_timeunit_profit(default_conf, update, ticker, fee, trade.is_open = False # Try valid data - update.message.text = '/daily 2' days = rpc._rpc_timeunit_profit(7, stake_currency, fiat_display_currency) assert len(days['data']) == 7 assert days['stake_currency'] == default_conf['stake_currency'] diff --git a/tests/rpc/test_rpc_telegram.py b/tests/rpc/test_rpc_telegram.py index 3cafb2d7d..404fdd2b0 100644 --- a/tests/rpc/test_rpc_telegram.py +++ b/tests/rpc/test_rpc_telegram.py @@ -430,10 +430,11 @@ def test_daily_handle(default_conf_usdt, update, ticker, fee, mocker) -> None: assert "Daily Profit over the last 2 days:" in msg_mock.call_args_list[0][0][0] assert 'Day ' in msg_mock.call_args_list[0][0][0] assert str(datetime.utcnow().date()) in msg_mock.call_args_list[0][0][0] - assert str(' 13.83 USDT') in msg_mock.call_args_list[0][0][0] - assert str(' 15.21 USD') in msg_mock.call_args_list[0][0][0] - assert str(' 2 trade') in msg_mock.call_args_list[0][0][0] - assert str(' 0 trade') in msg_mock.call_args_list[0][0][0] + assert ' 13.83 USDT' in msg_mock.call_args_list[0][0][0] + assert ' 15.21 USD' in msg_mock.call_args_list[0][0][0] + assert ' 2 trade' in msg_mock.call_args_list[0][0][0] + assert '13.83 USDT 15.21 USD 2 trades' in msg_mock.call_args_list[0][0][0] + assert ' 0 trade' in msg_mock.call_args_list[0][0][0] # Reset msg_mock msg_mock.reset_mock() @@ -443,11 +444,11 @@ def test_daily_handle(default_conf_usdt, update, ticker, fee, mocker) -> None: assert "Daily Profit over the last 7 days:" in msg_mock.call_args_list[0][0][0] assert str(datetime.utcnow().date()) in msg_mock.call_args_list[0][0][0] assert str((datetime.utcnow() - timedelta(days=5)).date()) in msg_mock.call_args_list[0][0][0] - assert str(' 13.83 USDT') in msg_mock.call_args_list[0][0][0] - assert str(' 15.21 USD') in msg_mock.call_args_list[0][0][0] - assert str(' 2 trade') in msg_mock.call_args_list[0][0][0] - assert str(' 1 trade') in msg_mock.call_args_list[0][0][0] - assert str(' 0 trade') in msg_mock.call_args_list[0][0][0] + assert ' 13.83 USDT' in msg_mock.call_args_list[0][0][0] + assert ' 15.21 USD' in msg_mock.call_args_list[0][0][0] + assert ' 2 trade' in msg_mock.call_args_list[0][0][0] + assert ' 1 trade' in msg_mock.call_args_list[0][0][0] + assert ' 0 trade' in msg_mock.call_args_list[0][0][0] # Reset msg_mock msg_mock.reset_mock() @@ -456,9 +457,9 @@ def test_daily_handle(default_conf_usdt, update, ticker, fee, mocker) -> None: context = MagicMock() context.args = ["1"] telegram._daily(update=update, context=context) - assert str(' 13.83 USDT') in msg_mock.call_args_list[0][0][0] - assert str(' 15.21 USD') in msg_mock.call_args_list[0][0][0] - assert str(' 2 trade') in msg_mock.call_args_list[0][0][0] + assert ' 13.83 USDT' in msg_mock.call_args_list[0][0][0] + assert ' 15.21 USD' in msg_mock.call_args_list[0][0][0] + assert ' 2 trade' in msg_mock.call_args_list[0][0][0] def test_daily_wrong_input(default_conf, update, ticker, mocker) -> None: @@ -487,15 +488,14 @@ def test_daily_wrong_input(default_conf, update, ticker, mocker) -> None: context = MagicMock() context.args = ["today"] telegram._daily(update=update, context=context) - assert str('Daily Profit over the last 7 days:') in msg_mock.call_args_list[0][0][0] + assert 'Daily Profit over the last 7 days:' in msg_mock.call_args_list[0][0][0] -def test_weekly_handle(default_conf, update, ticker, limit_buy_order, fee, - limit_sell_order, mocker) -> None: - default_conf['max_open_trades'] = 1 +def test_weekly_handle(default_conf_usdt, update, ticker, fee, mocker) -> None: + default_conf_usdt['max_open_trades'] = 1 mocker.patch( 'freqtrade.rpc.rpc.CryptoToFiatConverter._find_price', - return_value=15000.0 + return_value=1.1 ) mocker.patch.multiple( 'freqtrade.exchange.Exchange', @@ -503,25 +503,9 @@ def test_weekly_handle(default_conf, update, ticker, limit_buy_order, fee, get_fee=fee, ) - telegram, freqtradebot, msg_mock = get_telegram_testobject(mocker, default_conf) + telegram, freqtradebot, msg_mock = get_telegram_testobject(mocker, default_conf_usdt) - patch_get_signal(freqtradebot) - - # Create some test data - freqtradebot.enter_positions() - trade = Trade.query.first() - assert trade - - # Simulate fulfilled LIMIT_BUY order for trade - oobj = Order.parse_from_ccxt_object(limit_buy_order, limit_buy_order['symbol'], 'buy') - trade.update_trade(oobj) - - # Simulate fulfilled LIMIT_SELL order for trade - oobjs = Order.parse_from_ccxt_object(limit_sell_order, limit_sell_order['symbol'], 'sell') - trade.update_trade(oobjs) - - trade.close_date = datetime.utcnow() - trade.is_open = False + create_mock_trades_usdt(fee) # Try valid data # /weekly 2 @@ -535,10 +519,10 @@ def test_weekly_handle(default_conf, update, ticker, limit_buy_order, fee, today = datetime.utcnow().date() first_iso_day_of_current_week = today - timedelta(days=today.weekday()) assert str(first_iso_day_of_current_week) in msg_mock.call_args_list[0][0][0] - assert str(' 0.00006217 BTC') in msg_mock.call_args_list[0][0][0] - assert str(' 0.93 USD') in msg_mock.call_args_list[0][0][0] - assert str(' 1 trade') in msg_mock.call_args_list[0][0][0] - assert str(' 0 trade') in msg_mock.call_args_list[0][0][0] + assert ' 9.83 USDT' in msg_mock.call_args_list[0][0][0] + assert ' 10.81 USD' in msg_mock.call_args_list[0][0][0] + assert ' 3 trade' in msg_mock.call_args_list[0][0][0] + assert ' 0 trade' in msg_mock.call_args_list[0][0][0] # Reset msg_mock msg_mock.reset_mock() @@ -548,44 +532,10 @@ def test_weekly_handle(default_conf, update, ticker, limit_buy_order, fee, assert "Weekly Profit over the last 8 weeks (starting from Monday):" \ in msg_mock.call_args_list[0][0][0] assert 'Weekly' in msg_mock.call_args_list[0][0][0] - assert str(' 0.00006217 BTC') in msg_mock.call_args_list[0][0][0] - assert str(' 0.93 USD') in msg_mock.call_args_list[0][0][0] - assert str(' 1 trade') in msg_mock.call_args_list[0][0][0] - assert str(' 0 trade') in msg_mock.call_args_list[0][0][0] - - # Reset msg_mock - msg_mock.reset_mock() - freqtradebot.config['max_open_trades'] = 2 - # Add two other trades - n = freqtradebot.enter_positions() - assert n == 2 - - trades = Trade.query.all() - for trade in trades: - trade.update_trade(oobj) - trade.update_trade(oobjs) - trade.close_date = datetime.utcnow() - trade.is_open = False - - # /weekly 1 - # By default, the 8 previous weeks are shown - # So the previous modified trade should be excluded from the stats - context = MagicMock() - context.args = ["1"] - telegram._weekly(update=update, context=context) - assert str(' 0.00018651 BTC') in msg_mock.call_args_list[0][0][0] - assert str(' 2.80 USD') in msg_mock.call_args_list[0][0][0] - assert str(' 3 trades') in msg_mock.call_args_list[0][0][0] - - -def test_weekly_wrong_input(default_conf, update, ticker, mocker) -> None: - mocker.patch.multiple( - 'freqtrade.exchange.Exchange', - fetch_ticker=ticker - ) - - telegram, freqtradebot, msg_mock = get_telegram_testobject(mocker, default_conf) - patch_get_signal(freqtradebot) + assert ' 9.83 USDT' in msg_mock.call_args_list[0][0][0] + assert ' 10.81 USD' in msg_mock.call_args_list[0][0][0] + assert ' 3 trade' in msg_mock.call_args_list[0][0][0] + assert ' 0 trade' in msg_mock.call_args_list[0][0][0] # Try invalid data msg_mock.reset_mock() @@ -604,16 +554,17 @@ def test_weekly_wrong_input(default_conf, update, ticker, mocker) -> None: context = MagicMock() context.args = ["this week"] telegram._weekly(update=update, context=context) - assert str('Weekly Profit over the last 8 weeks (starting from Monday):') \ + assert ( + 'Weekly Profit over the last 8 weeks (starting from Monday):' in msg_mock.call_args_list[0][0][0] + ) -def test_monthly_handle(default_conf, update, ticker, limit_buy_order, fee, - limit_sell_order, mocker) -> None: - default_conf['max_open_trades'] = 1 +def test_monthly_handle(default_conf_usdt, update, ticker, fee, mocker) -> None: + default_conf_usdt['max_open_trades'] = 1 mocker.patch( 'freqtrade.rpc.rpc.CryptoToFiatConverter._find_price', - return_value=15000.0 + return_value=1.1 ) mocker.patch.multiple( 'freqtrade.exchange.Exchange', @@ -621,25 +572,9 @@ def test_monthly_handle(default_conf, update, ticker, limit_buy_order, fee, get_fee=fee, ) - telegram, freqtradebot, msg_mock = get_telegram_testobject(mocker, default_conf) + telegram, freqtradebot, msg_mock = get_telegram_testobject(mocker, default_conf_usdt) - patch_get_signal(freqtradebot) - - # Create some test data - freqtradebot.enter_positions() - trade = Trade.query.first() - assert trade - - # Simulate fulfilled LIMIT_BUY order for trade - oobj = Order.parse_from_ccxt_object(limit_buy_order, limit_buy_order['symbol'], 'buy') - trade.update_trade(oobj) - - # Simulate fulfilled LIMIT_SELL order for trade - oobjs = Order.parse_from_ccxt_object(limit_sell_order, limit_sell_order['symbol'], 'sell') - trade.update_trade(oobjs) - - trade.close_date = datetime.utcnow() - trade.is_open = False + create_mock_trades_usdt(fee) # Try valid data # /monthly 2 @@ -652,10 +587,10 @@ def test_monthly_handle(default_conf, update, ticker, limit_buy_order, fee, today = datetime.utcnow().date() current_month = f"{today.year}-{today.month:02} " assert current_month in msg_mock.call_args_list[0][0][0] - assert str(' 0.00006217 BTC') in msg_mock.call_args_list[0][0][0] - assert str(' 0.93 USD') in msg_mock.call_args_list[0][0][0] - assert str(' 1 trade') in msg_mock.call_args_list[0][0][0] - assert str(' 0 trade') in msg_mock.call_args_list[0][0][0] + assert ' 9.83 USDT' in msg_mock.call_args_list[0][0][0] + assert ' 10.81 USD' in msg_mock.call_args_list[0][0][0] + assert ' 3 trade' in msg_mock.call_args_list[0][0][0] + assert ' 0 trade' in msg_mock.call_args_list[0][0][0] # Reset msg_mock msg_mock.reset_mock() @@ -666,24 +601,13 @@ def test_monthly_handle(default_conf, update, ticker, limit_buy_order, fee, assert 'Monthly Profit over the last 6 months:' in msg_mock.call_args_list[0][0][0] assert 'Month ' in msg_mock.call_args_list[0][0][0] assert current_month in msg_mock.call_args_list[0][0][0] - assert str(' 0.00006217 BTC') in msg_mock.call_args_list[0][0][0] - assert str(' 0.93 USD') in msg_mock.call_args_list[0][0][0] - assert str(' 1 trade') in msg_mock.call_args_list[0][0][0] - assert str(' 0 trade') in msg_mock.call_args_list[0][0][0] + assert ' 9.83 USDT' in msg_mock.call_args_list[0][0][0] + assert ' 10.81 USD' in msg_mock.call_args_list[0][0][0] + assert ' 3 trade' in msg_mock.call_args_list[0][0][0] + assert ' 0 trade' in msg_mock.call_args_list[0][0][0] # Reset msg_mock msg_mock.reset_mock() - freqtradebot.config['max_open_trades'] = 2 - # Add two other trades - n = freqtradebot.enter_positions() - assert n == 2 - - trades = Trade.query.all() - for trade in trades: - trade.update_trade(oobj) - trade.update_trade(oobjs) - trade.close_date = datetime.utcnow() - trade.is_open = False # /monthly 12 context = MagicMock() @@ -691,24 +615,14 @@ def test_monthly_handle(default_conf, update, ticker, limit_buy_order, fee, telegram._monthly(update=update, context=context) assert msg_mock.call_count == 1 assert 'Monthly Profit over the last 12 months:' in msg_mock.call_args_list[0][0][0] - assert str(' 0.00018651 BTC') in msg_mock.call_args_list[0][0][0] - assert str(' 2.80 USD') in msg_mock.call_args_list[0][0][0] - assert str(' 3 trades') in msg_mock.call_args_list[0][0][0] + assert ' 9.83 USDT' in msg_mock.call_args_list[0][0][0] + assert ' 10.81 USD' in msg_mock.call_args_list[0][0][0] + assert ' 3 trade' in msg_mock.call_args_list[0][0][0] # The one-digit months should contain a zero, Eg: September 2021 = "2021-09" # Since we loaded the last 12 months, any month should appear assert str('-09') in msg_mock.call_args_list[0][0][0] - -def test_monthly_wrong_input(default_conf, update, ticker, mocker) -> None: - mocker.patch.multiple( - 'freqtrade.exchange.Exchange', - fetch_ticker=ticker - ) - - telegram, freqtradebot, msg_mock = get_telegram_testobject(mocker, default_conf) - patch_get_signal(freqtradebot) - # Try invalid data msg_mock.reset_mock() freqtradebot.state = State.RUNNING From 0a801c022316eb5a944f7690cc191d90a3364939 Mon Sep 17 00:00:00 2001 From: Matthias Date: Sat, 11 Jun 2022 08:58:36 +0200 Subject: [PATCH 21/46] Simplify daily RPC test --- tests/rpc/test_rpc.py | 40 +++++++++++++++------------------------- 1 file changed, 15 insertions(+), 25 deletions(-) diff --git a/tests/rpc/test_rpc.py b/tests/rpc/test_rpc.py index da477edf4..982ac65d7 100644 --- a/tests/rpc/test_rpc.py +++ b/tests/rpc/test_rpc.py @@ -15,7 +15,8 @@ from freqtrade.persistence.models import Order from freqtrade.persistence.pairlock_middleware import PairLocks from freqtrade.rpc import RPC, RPCException from freqtrade.rpc.fiat_convert import CryptoToFiatConverter -from tests.conftest import create_mock_trades, get_patched_freqtradebot, patch_get_signal +from tests.conftest import (create_mock_trades, create_mock_trades_usdt, get_patched_freqtradebot, + patch_get_signal) # Functions for recurrent object patching @@ -284,7 +285,7 @@ def test_rpc_status_table(default_conf, ticker, fee, mocker) -> None: assert isnan(fiat_profit_sum) -def test__rpc_timeunit_profit(default_conf, ticker, fee, +def test__rpc_timeunit_profit(default_conf_usdt, ticker, fee, limit_buy_order, limit_sell_order, markets, mocker) -> None: mocker.patch('freqtrade.rpc.telegram.Telegram', MagicMock()) mocker.patch.multiple( @@ -294,38 +295,27 @@ def test__rpc_timeunit_profit(default_conf, ticker, fee, markets=PropertyMock(return_value=markets) ) - freqtradebot = get_patched_freqtradebot(mocker, default_conf) - patch_get_signal(freqtradebot) - stake_currency = default_conf['stake_currency'] - fiat_display_currency = default_conf['fiat_display_currency'] + freqtradebot = get_patched_freqtradebot(mocker, default_conf_usdt) + create_mock_trades_usdt(fee) + + stake_currency = default_conf_usdt['stake_currency'] + fiat_display_currency = default_conf_usdt['fiat_display_currency'] rpc = RPC(freqtradebot) rpc._fiat_converter = CryptoToFiatConverter() - # Create some test data - freqtradebot.enter_positions() - trade = Trade.query.first() - assert trade - - # Simulate buy & sell - oobj = Order.parse_from_ccxt_object(limit_buy_order, limit_buy_order['symbol'], 'buy') - trade.update_trade(oobj) - oobj = Order.parse_from_ccxt_object(limit_sell_order, limit_sell_order['symbol'], 'sell') - trade.update_trade(oobj) - trade.close_date = datetime.utcnow() - trade.is_open = False # Try valid data days = rpc._rpc_timeunit_profit(7, stake_currency, fiat_display_currency) assert len(days['data']) == 7 - assert days['stake_currency'] == default_conf['stake_currency'] - assert days['fiat_display_currency'] == default_conf['fiat_display_currency'] + assert days['stake_currency'] == default_conf_usdt['stake_currency'] + assert days['fiat_display_currency'] == default_conf_usdt['fiat_display_currency'] for day in days['data']: - # [datetime.date(2018, 1, 11), '0.00000000 BTC', '0.000 USD'] - assert (day['abs_profit'] == 0.0 or - day['abs_profit'] == 0.00006217) + # {'date': datetime.date(2022, 6, 11), 'abs_profit': 13.8299999, + # 'fiat_value': 0.0, 'trade_count': 2} + assert day['abs_profit'] in (0.0, pytest.approx(13.8299999), pytest.approx(-4.0)) + assert day['trade_count'] in (0, 1, 2) - assert (day['fiat_value'] == 0.0 or - day['fiat_value'] == 0.76748865) + assert day['fiat_value'] in (0.0, ) # ensure first day is current date assert str(days['data'][0]['date']) == str(datetime.utcnow().date()) From 76827b31a9c59d0d7344ff379f5ef7f0fc1a56f4 Mon Sep 17 00:00:00 2001 From: Matthias Date: Sat, 11 Jun 2022 11:18:21 +0200 Subject: [PATCH 22/46] Add relative profit to daily/weekly commands --- freqtrade/rpc/rpc.py | 11 +++++++++-- freqtrade/rpc/telegram.py | 12 +++++++----- tests/rpc/test_rpc.py | 4 +++- 3 files changed, 19 insertions(+), 8 deletions(-) diff --git a/freqtrade/rpc/rpc.py b/freqtrade/rpc/rpc.py index 64584382a..da5144dab 100644 --- a/freqtrade/rpc/rpc.py +++ b/freqtrade/rpc/rpc.py @@ -302,11 +302,12 @@ class RPC: return relativedelta(months=step) return timedelta(**{timeunit: step}) - profit_units: Dict[date, Dict] = {} - if not (isinstance(timescale, int) and timescale > 0): raise RPCException('timescale must be an integer greater than 0') + profit_units: Dict[date, Dict] = {} + daily_stake = self._freqtrade.wallets.get_total_stake_amount() + for day in range(0, timescale): profitday = start_date - time_offset(day) # Only query for necessary columns for performance reasons. @@ -318,8 +319,12 @@ class RPC: curdayprofit = sum( trade.close_profit_abs for trade in trades if trade.close_profit_abs is not None) + # Calculate this periods starting balance + daily_stake = daily_stake - curdayprofit profit_units[profitday] = { 'amount': curdayprofit, + 'daily_stake': daily_stake, + 'rel_profit': round(curdayprofit / daily_stake, 8) if daily_stake > 0 else 0, 'trades': len(trades), } @@ -327,6 +332,8 @@ class RPC: { 'date': f"{key.year}-{key.month:02d}" if timeunit == 'months' else key, 'abs_profit': value["amount"], + 'starting_balance': value["daily_stake"], + 'rel_profit': value["rel_profit"], 'fiat_value': self._fiat_converter.convert_amount( value['amount'], stake_currency, diff --git a/freqtrade/rpc/telegram.py b/freqtrade/rpc/telegram.py index 61b73553f..c3e4c1152 100644 --- a/freqtrade/rpc/telegram.py +++ b/freqtrade/rpc/telegram.py @@ -605,14 +605,16 @@ class Telegram(RPCHandler): unit ) stats_tab = tabulate( - [[period['date'], + [[f"{period['date']} ({period['trade_count']})", f"{round_coin_value(period['abs_profit'], stats['stake_currency'])}", f"{period['fiat_value']:.2f} {stats['fiat_display_currency']}", - f"{period['trade_count']} trades"] for period in stats['data']], + f"{period['rel_profit']:.2%}", + ] for period in stats['data']], headers=[ - val.header, - f'Profit {stake_cur}', - f'Profit {fiat_disp_cur}', + f"{val.header} (trades)", + f'Prof {stake_cur}', + f'Prof {fiat_disp_cur}', + 'Profit %', 'Trades', ], tablefmt='simple') diff --git a/tests/rpc/test_rpc.py b/tests/rpc/test_rpc.py index 982ac65d7..0273b8237 100644 --- a/tests/rpc/test_rpc.py +++ b/tests/rpc/test_rpc.py @@ -311,10 +311,12 @@ def test__rpc_timeunit_profit(default_conf_usdt, ticker, fee, assert days['fiat_display_currency'] == default_conf_usdt['fiat_display_currency'] for day in days['data']: # {'date': datetime.date(2022, 6, 11), 'abs_profit': 13.8299999, + # 'starting_balance': 1055.37, 'rel_profit': 0.0131044, # 'fiat_value': 0.0, 'trade_count': 2} assert day['abs_profit'] in (0.0, pytest.approx(13.8299999), pytest.approx(-4.0)) + assert day['rel_profit'] in (0.0, pytest.approx(0.01310441), pytest.approx(-0.00377583)) assert day['trade_count'] in (0, 1, 2) - + assert day['starting_balance'] in (pytest.approx(1059.37), pytest.approx(1055.37)) assert day['fiat_value'] in (0.0, ) # ensure first day is current date assert str(days['data'][0]['date']) == str(datetime.utcnow().date()) From 9ba11f7bcc0481bbc6db6c3faf3a9e25b8a0edd3 Mon Sep 17 00:00:00 2001 From: Matthias Date: Sat, 11 Jun 2022 11:26:49 +0200 Subject: [PATCH 23/46] Update docs and tests for new daily command --- docs/telegram-usage.md | 30 +++++++++++++++--------------- freqtrade/rpc/telegram.py | 6 +++--- tests/rpc/test_rpc_telegram.py | 32 ++++++++++++++++---------------- 3 files changed, 34 insertions(+), 34 deletions(-) diff --git a/docs/telegram-usage.md b/docs/telegram-usage.md index 27f5f91b6..6e21d3689 100644 --- a/docs/telegram-usage.md +++ b/docs/telegram-usage.md @@ -328,11 +328,11 @@ Per default `/daily` will return the 7 last days. The example below if for `/dai > **Daily Profit over the last 3 days:** ``` -Day Profit BTC Profit USD ----------- -------------- ------------ -2018-01-03 0.00224175 BTC 29,142 USD -2018-01-02 0.00033131 BTC 4,307 USD -2018-01-01 0.00269130 BTC 34.986 USD +Day (count) USDT USD Profit % +-------------- ------------ ---------- ---------- +2022-06-11 (1) -0.746 USDT -0.75 USD -0.08% +2022-06-10 (0) 0 USDT 0.00 USD 0.00% +2022-06-09 (5) 20 USDT 20.10 USD 5.00% ``` ### /weekly @@ -342,11 +342,11 @@ from Monday. The example below if for `/weekly 3`: > **Weekly Profit over the last 3 weeks (starting from Monday):** ``` -Monday Profit BTC Profit USD ----------- -------------- ------------ -2018-01-03 0.00224175 BTC 29,142 USD -2017-12-27 0.00033131 BTC 4,307 USD -2017-12-20 0.00269130 BTC 34.986 USD +Monday (count) Profit BTC Profit USD Profit % +------------- -------------- ------------ ---------- +2018-01-03 (5) 0.00224175 BTC 29,142 USD 4.98% +2017-12-27 (1) 0.00033131 BTC 4,307 USD 0.00% +2017-12-20 (4) 0.00269130 BTC 34.986 USD 5.12% ``` ### /monthly @@ -356,11 +356,11 @@ if for `/monthly 3`: > **Monthly Profit over the last 3 months:** ``` -Month Profit BTC Profit USD ----------- -------------- ------------ -2018-01 0.00224175 BTC 29,142 USD -2017-12 0.00033131 BTC 4,307 USD -2017-11 0.00269130 BTC 34.986 USD +Month (count) Profit BTC Profit USD Profit % +------------- -------------- ------------ ---------- +2018-01 (20) 0.00224175 BTC 29,142 USD 4.98% +2017-12 (5) 0.00033131 BTC 4,307 USD 0.00% +2017-11 (10) 0.00269130 BTC 34.986 USD 5.10% ``` ### /whitelist diff --git a/freqtrade/rpc/telegram.py b/freqtrade/rpc/telegram.py index c3e4c1152..2e1d23621 100644 --- a/freqtrade/rpc/telegram.py +++ b/freqtrade/rpc/telegram.py @@ -611,9 +611,9 @@ class Telegram(RPCHandler): f"{period['rel_profit']:.2%}", ] for period in stats['data']], headers=[ - f"{val.header} (trades)", - f'Prof {stake_cur}', - f'Prof {fiat_disp_cur}', + f"{val.header} (count)", + f'{stake_cur}', + f'{fiat_disp_cur}', 'Profit %', 'Trades', ], diff --git a/tests/rpc/test_rpc_telegram.py b/tests/rpc/test_rpc_telegram.py index 404fdd2b0..11a783f3a 100644 --- a/tests/rpc/test_rpc_telegram.py +++ b/tests/rpc/test_rpc_telegram.py @@ -432,9 +432,9 @@ def test_daily_handle(default_conf_usdt, update, ticker, fee, mocker) -> None: assert str(datetime.utcnow().date()) in msg_mock.call_args_list[0][0][0] assert ' 13.83 USDT' in msg_mock.call_args_list[0][0][0] assert ' 15.21 USD' in msg_mock.call_args_list[0][0][0] - assert ' 2 trade' in msg_mock.call_args_list[0][0][0] - assert '13.83 USDT 15.21 USD 2 trades' in msg_mock.call_args_list[0][0][0] - assert ' 0 trade' in msg_mock.call_args_list[0][0][0] + assert '(2)' in msg_mock.call_args_list[0][0][0] + assert '(2) 13.83 USDT 15.21 USD 1.31%' in msg_mock.call_args_list[0][0][0] + assert '(0)' in msg_mock.call_args_list[0][0][0] # Reset msg_mock msg_mock.reset_mock() @@ -446,9 +446,9 @@ def test_daily_handle(default_conf_usdt, update, ticker, fee, mocker) -> None: assert str((datetime.utcnow() - timedelta(days=5)).date()) in msg_mock.call_args_list[0][0][0] assert ' 13.83 USDT' in msg_mock.call_args_list[0][0][0] assert ' 15.21 USD' in msg_mock.call_args_list[0][0][0] - assert ' 2 trade' in msg_mock.call_args_list[0][0][0] - assert ' 1 trade' in msg_mock.call_args_list[0][0][0] - assert ' 0 trade' in msg_mock.call_args_list[0][0][0] + assert '(2)' in msg_mock.call_args_list[0][0][0] + assert '(1)' in msg_mock.call_args_list[0][0][0] + assert '(0)' in msg_mock.call_args_list[0][0][0] # Reset msg_mock msg_mock.reset_mock() @@ -459,7 +459,7 @@ def test_daily_handle(default_conf_usdt, update, ticker, fee, mocker) -> None: telegram._daily(update=update, context=context) assert ' 13.83 USDT' in msg_mock.call_args_list[0][0][0] assert ' 15.21 USD' in msg_mock.call_args_list[0][0][0] - assert ' 2 trade' in msg_mock.call_args_list[0][0][0] + assert '(2)' in msg_mock.call_args_list[0][0][0] def test_daily_wrong_input(default_conf, update, ticker, mocker) -> None: @@ -521,8 +521,8 @@ def test_weekly_handle(default_conf_usdt, update, ticker, fee, mocker) -> None: assert str(first_iso_day_of_current_week) in msg_mock.call_args_list[0][0][0] assert ' 9.83 USDT' in msg_mock.call_args_list[0][0][0] assert ' 10.81 USD' in msg_mock.call_args_list[0][0][0] - assert ' 3 trade' in msg_mock.call_args_list[0][0][0] - assert ' 0 trade' in msg_mock.call_args_list[0][0][0] + assert '(3)' in msg_mock.call_args_list[0][0][0] + assert '(0)' in msg_mock.call_args_list[0][0][0] # Reset msg_mock msg_mock.reset_mock() @@ -534,8 +534,8 @@ def test_weekly_handle(default_conf_usdt, update, ticker, fee, mocker) -> None: assert 'Weekly' in msg_mock.call_args_list[0][0][0] assert ' 9.83 USDT' in msg_mock.call_args_list[0][0][0] assert ' 10.81 USD' in msg_mock.call_args_list[0][0][0] - assert ' 3 trade' in msg_mock.call_args_list[0][0][0] - assert ' 0 trade' in msg_mock.call_args_list[0][0][0] + assert '(3)' in msg_mock.call_args_list[0][0][0] + assert '(0)' in msg_mock.call_args_list[0][0][0] # Try invalid data msg_mock.reset_mock() @@ -589,8 +589,8 @@ def test_monthly_handle(default_conf_usdt, update, ticker, fee, mocker) -> None: assert current_month in msg_mock.call_args_list[0][0][0] assert ' 9.83 USDT' in msg_mock.call_args_list[0][0][0] assert ' 10.81 USD' in msg_mock.call_args_list[0][0][0] - assert ' 3 trade' in msg_mock.call_args_list[0][0][0] - assert ' 0 trade' in msg_mock.call_args_list[0][0][0] + assert '(3)' in msg_mock.call_args_list[0][0][0] + assert '(0)' in msg_mock.call_args_list[0][0][0] # Reset msg_mock msg_mock.reset_mock() @@ -603,8 +603,8 @@ def test_monthly_handle(default_conf_usdt, update, ticker, fee, mocker) -> None: assert current_month in msg_mock.call_args_list[0][0][0] assert ' 9.83 USDT' in msg_mock.call_args_list[0][0][0] assert ' 10.81 USD' in msg_mock.call_args_list[0][0][0] - assert ' 3 trade' in msg_mock.call_args_list[0][0][0] - assert ' 0 trade' in msg_mock.call_args_list[0][0][0] + assert '(3)' in msg_mock.call_args_list[0][0][0] + assert '(0)' in msg_mock.call_args_list[0][0][0] # Reset msg_mock msg_mock.reset_mock() @@ -617,7 +617,7 @@ def test_monthly_handle(default_conf_usdt, update, ticker, fee, mocker) -> None: assert 'Monthly Profit over the last 12 months:' in msg_mock.call_args_list[0][0][0] assert ' 9.83 USDT' in msg_mock.call_args_list[0][0][0] assert ' 10.81 USD' in msg_mock.call_args_list[0][0][0] - assert ' 3 trade' in msg_mock.call_args_list[0][0][0] + assert '(3)' in msg_mock.call_args_list[0][0][0] # The one-digit months should contain a zero, Eg: September 2021 = "2021-09" # Since we loaded the last 12 months, any month should appear From 3a06337601b1ff4ca0609010635fb95b7eee7aa7 Mon Sep 17 00:00:00 2001 From: Matthias Date: Sat, 11 Jun 2022 11:28:45 +0200 Subject: [PATCH 24/46] Update API to provide new values. --- freqtrade/rpc/api_server/api_schemas.py | 2 ++ freqtrade/rpc/api_server/api_v1.py | 3 ++- 2 files changed, 4 insertions(+), 1 deletion(-) diff --git a/freqtrade/rpc/api_server/api_schemas.py b/freqtrade/rpc/api_server/api_schemas.py index a31c74c2e..11fdc0121 100644 --- a/freqtrade/rpc/api_server/api_schemas.py +++ b/freqtrade/rpc/api_server/api_schemas.py @@ -120,6 +120,8 @@ class Stats(BaseModel): class DailyRecord(BaseModel): date: date abs_profit: float + rel_profit: float + starting_balance: float fiat_value: float trade_count: int diff --git a/freqtrade/rpc/api_server/api_v1.py b/freqtrade/rpc/api_server/api_v1.py index 271e3de1b..225fe66b9 100644 --- a/freqtrade/rpc/api_server/api_v1.py +++ b/freqtrade/rpc/api_server/api_v1.py @@ -36,7 +36,8 @@ logger = logging.getLogger(__name__) # versions 2.xx -> futures/short branch # 2.14: Add entry/exit orders to trade response # 2.15: Add backtest history endpoints -API_VERSION = 2.15 +# 2.16: Additional daily metrics +API_VERSION = 2.16 # Public API, requires no auth. router_public = APIRouter() From f816c15e1eb1452b332aa39bbd5d59b105a5324e Mon Sep 17 00:00:00 2001 From: Matthias Date: Sat, 11 Jun 2022 12:02:41 +0200 Subject: [PATCH 25/46] Update discord message format --- freqtrade/rpc/discord.py | 91 ++++++++++++++-------------------------- 1 file changed, 31 insertions(+), 60 deletions(-) diff --git a/freqtrade/rpc/discord.py b/freqtrade/rpc/discord.py index 43a8e9a05..41185a090 100644 --- a/freqtrade/rpc/discord.py +++ b/freqtrade/rpc/discord.py @@ -1,61 +1,44 @@ -import json import logging -from typing import Dict, Any - -import requests +from typing import Any, Dict +from freqtrade.constants import DATETIME_PRINT_FORMAT from freqtrade.enums import RPCMessageType -from freqtrade.rpc import RPCHandler, RPC +from freqtrade.rpc import RPC +from freqtrade.rpc.webhook import Webhook -class Discord(RPCHandler): +logger = logging.getLogger(__name__) + + +class Discord(Webhook): def __init__(self, rpc: 'RPC', config: Dict[str, Any]): - super().__init__(rpc, config) - self.logger = logging.getLogger(__name__) + # super().__init__(rpc, config) + self.rpc = rpc + self.config = config self.strategy = config.get('strategy', '') self.timeframe = config.get('timeframe', '') - self.config = config - def send_msg(self, msg: Dict[str, str]) -> None: - self._send_msg(msg) + self._url = self.config['discord']['webhook_url'] + self._format = 'json' + self._retries = 1 + self._retry_delay = 0.1 - def _send_msg(self, msg): + def cleanup(self) -> None: """ - msg = { - 'type': (RPCMessageType.EXIT_FILL if fill - else RPCMessageType.EXIT), - 'trade_id': trade.id, - 'exchange': trade.exchange.capitalize(), - 'pair': trade.pair, - 'leverage': trade.leverage, - 'direction': 'Short' if trade.is_short else 'Long', - 'gain': gain, - 'limit': profit_rate, - 'order_type': order_type, - 'amount': trade.amount, - 'open_rate': trade.open_rate, - 'close_rate': trade.close_rate, - 'current_rate': current_rate, - 'profit_amount': profit_trade, - 'profit_ratio': profit_ratio, - 'buy_tag': trade.enter_tag, - 'enter_tag': trade.enter_tag, - 'sell_reason': trade.exit_reason, # Deprecated - 'exit_reason': trade.exit_reason, - 'open_date': trade.open_date, - 'close_date': trade.close_date or datetime.utcnow(), - 'stake_currency': self.config['stake_currency'], - 'fiat_currency': self.config.get('fiat_display_currency', None), - } + Cleanup pending module resources. + This will do nothing for webhooks, they will simply not be called anymore """ - self.logger.info(f"Sending discord message: {msg}") + pass + + def send_msg(self, msg) -> None: + logger.info(f"Sending discord message: {msg}") # TODO: handle other message types if msg['type'] == RPCMessageType.EXIT_FILL: profit_ratio = msg.get('profit_ratio') - open_date = msg.get('open_date').strftime('%Y-%m-%d %H:%M:%S') + open_date = msg.get('open_date').strftime(DATETIME_PRINT_FORMAT) close_date = msg.get('close_date').strftime( - '%Y-%m-%d %H:%M:%S') if msg.get('close_date') else '' + DATETIME_PRINT_FORMAT) if msg.get('close_date') else '' embeds = [{ 'title': '{} Trade: {}'.format( @@ -63,7 +46,7 @@ class Discord(RPCHandler): msg.get('pair')), 'color': (0x00FF00 if profit_ratio > 0 else 0xFF0000), 'fields': [ - {'name': 'Trade ID', 'value': msg.get('id'), 'inline': True}, + {'name': 'Trade ID', 'value': msg.get('trade_id'), 'inline': True}, {'name': 'Exchange', 'value': msg.get('exchange').capitalize(), 'inline': True}, {'name': 'Pair', 'value': msg.get('pair'), 'inline': True}, {'name': 'Direction', 'value': 'Short' if msg.get( @@ -75,11 +58,10 @@ class Discord(RPCHandler): {'name': 'Open date', 'value': open_date, 'inline': True}, {'name': 'Close date', 'value': close_date, 'inline': True}, {'name': 'Profit', 'value': msg.get('profit_amount'), 'inline': True}, - {'name': 'Profitability', 'value': '{:.2f}%'.format( - profit_ratio * 100), 'inline': True}, + {'name': 'Profitability', 'value': f'{profit_ratio:.2%}', 'inline': True}, {'name': 'Stake currency', 'value': msg.get('stake_currency'), 'inline': True}, - {'name': 'Fiat currency', 'value': msg.get( - 'fiat_display_currency'), 'inline': True}, + {'name': 'Fiat currency', 'value': msg.get('fiat_display_currency'), + 'inline': True}, {'name': 'Buy Tag', 'value': msg.get('enter_tag'), 'inline': True}, {'name': 'Sell Reason', 'value': msg.get('exit_reason'), 'inline': True}, {'name': 'Strategy', 'value': self.strategy, 'inline': True}, @@ -89,20 +71,9 @@ class Discord(RPCHandler): # convert all value in fields to string for discord for embed in embeds: - for field in embed['fields']: + for field in embed['fields']: # type: ignore field['value'] = str(field['value']) # Send the message to discord channel - payload = { - 'embeds': embeds, - } - headers = { - 'Content-Type': 'application/json', - } - try: - requests.post( - self.config['discord']['webhook_url'], - data=json.dumps(payload), - headers=headers) - except Exception as e: - self.logger.error(f"Failed to send discord message: {e}") + payload = {'embeds': embeds} + self._send_msg(payload) From fdfa94bcc31b5fc751873ccfa943dc962a24a030 Mon Sep 17 00:00:00 2001 From: Matthias Date: Sat, 11 Jun 2022 17:30:56 +0200 Subject: [PATCH 26/46] make discord notifications fully configurable. --- docs/assets/discord_notification.png | Bin 0 -> 48861 bytes docs/webhook-config.md | 49 +++++++++++++++++++++++ freqtrade/constants.py | 41 +++++++++++++++++++ freqtrade/rpc/discord.py | 57 +++++++++------------------ 4 files changed, 108 insertions(+), 39 deletions(-) create mode 100644 docs/assets/discord_notification.png diff --git a/docs/assets/discord_notification.png b/docs/assets/discord_notification.png new file mode 100644 index 0000000000000000000000000000000000000000..05a7705d7f599de625de31782344f98d93ccb17b GIT binary patch literal 48861 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 V+b%kpPX|ChQeyI=72gei{tsZlJYoO< literal 0 HcmV?d00001 diff --git a/docs/webhook-config.md b/docs/webhook-config.md index 5f5933b47..3677ebe89 100644 --- a/docs/webhook-config.md +++ b/docs/webhook-config.md @@ -239,3 +239,52 @@ Possible parameters are: The fields in `webhook.webhookstatus` are used for regular status messages (Started / Stopped / ...). Parameters are filled using string.format. The only possible value here is `{status}`. + +## Discord + +A special form of webhooks is available for discord. +You can configure this as follows: + +```json +"discord": { + "enabled": true, + "webhook_url": "https://discord.com/api/webhooks/", + "exit_fill": [ + {"Trade ID": "{trade_id}"}, + {"Exchange": "{exchange}"}, + {"Pair": "{pair}"}, + {"Direction": "{direction}"}, + {"Open rate": "{open_rate}"}, + {"Close rate": "{close_rate}"}, + {"Amount": "{amount}"}, + {"Open date": "{open_date:%Y-%m-%d %H:%M:%S}"}, + {"Close date": "{close_date:%Y-%m-%d %H:%M:%S}"}, + {"Profit": "{profit_amount} {stake_currency}"}, + {"Profitability": "{profit_ratio:.2%}"}, + {"Enter tag": "{enter_tag}"}, + {"Exit Reason": "{exit_reason}"}, + {"Strategy": "{strategy}"}, + {"Timeframe": "{timeframe}"}, + ], + "entry_fill": [ + {"Trade ID": "{trade_id}"}, + {"Exchange": "{exchange}"}, + {"Pair": "{pair}"}, + {"Direction": "{direction}"}, + {"Open rate": "{open_rate}"}, + {"Amount": "{amount}"}, + {"Open date": "{open_date:%Y-%m-%d %H:%M:%S}"}, + {"Enter tag": "{enter_tag}"}, + {"Strategy": "{strategy} {timeframe}"}, + ] +} +``` + + +The above represents the default (`exit_fill` and `entry_fill` are optional and will default to the above configuration) - modifications are obviously possible. + +Available fields correspond to the fields for webhooks and are documented in the corresponding webhook sections. + +The notifications will look as follows by default. + +![discord-notification](assets/discord_notification.png) diff --git a/freqtrade/constants.py b/freqtrade/constants.py index 9fbd70e42..18dbea259 100644 --- a/freqtrade/constants.py +++ b/freqtrade/constants.py @@ -336,6 +336,47 @@ CONF_SCHEMA = { 'webhookstatus': {'type': 'object'}, }, }, + 'discord': { + 'type': 'object', + 'properties': { + 'enabled': {'type': 'boolean'}, + 'webhook_url': {'type': 'string'}, + "exit_fill": { + 'type': 'array', 'items': {'type': 'object'}, + 'default': [ + {"Trade ID": "{trade_id}"}, + {"Exchange": "{exchange}"}, + {"Pair": "{pair}"}, + {"Direction": "{direction}"}, + {"Open rate": "{open_rate}"}, + {"Close rate": "{close_rate}"}, + {"Amount": "{amount}"}, + {"Open date": "{open_date:%Y-%m-%d %H:%M:%S}"}, + {"Close date": "{close_date:%Y-%m-%d %H:%M:%S}"}, + {"Profit": "{profit_amount} {stake_currency}"}, + {"Profitability": "{profit_ratio:.2%}"}, + {"Enter tag": "{enter_tag}"}, + {"Exit Reason": "{exit_reason}"}, + {"Strategy": "{strategy}"}, + {"Timeframe": "{timeframe}"}, + ] + }, + "entry_fill": { + 'type': 'array', 'items': {'type': 'object'}, + 'default': [ + {"Trade ID": "{trade_id}"}, + {"Exchange": "{exchange}"}, + {"Pair": "{pair}"}, + {"Direction": "{direction}"}, + {"Open rate": "{open_rate}"}, + {"Amount": "{amount}"}, + {"Open date": "{open_date:%Y-%m-%d %H:%M:%S}"}, + {"Enter tag": "{enter_tag}"}, + {"Strategy": "{strategy} {timeframe}"}, + ] + }, + } + }, 'api_server': { 'type': 'object', 'properties': { diff --git a/freqtrade/rpc/discord.py b/freqtrade/rpc/discord.py index 41185a090..9509b4f23 100644 --- a/freqtrade/rpc/discord.py +++ b/freqtrade/rpc/discord.py @@ -1,8 +1,7 @@ import logging from typing import Any, Dict -from freqtrade.constants import DATETIME_PRINT_FORMAT -from freqtrade.enums import RPCMessageType +from freqtrade.enums.rpcmessagetype import RPCMessageType from freqtrade.rpc import RPC from freqtrade.rpc.webhook import Webhook @@ -33,46 +32,26 @@ class Discord(Webhook): def send_msg(self, msg) -> None: logger.info(f"Sending discord message: {msg}") - # TODO: handle other message types - if msg['type'] == RPCMessageType.EXIT_FILL: - profit_ratio = msg.get('profit_ratio') - open_date = msg.get('open_date').strftime(DATETIME_PRINT_FORMAT) - close_date = msg.get('close_date').strftime( - DATETIME_PRINT_FORMAT) if msg.get('close_date') else '' + if msg['type'].value in self.config['discord']: + + msg['strategy'] = self.strategy + msg['timeframe'] = self.timeframe + fields = self.config['discord'].get(msg['type'].value) + color = 0x0000FF + if msg['type'] in (RPCMessageType.EXIT, RPCMessageType.EXIT_FILL): + profit_ratio = msg.get('profit_ratio') + color = (0x00FF00 if profit_ratio > 0 else 0xFF0000) embeds = [{ - 'title': '{} Trade: {}'.format( - 'Profit' if profit_ratio > 0 else 'Loss', - msg.get('pair')), - 'color': (0x00FF00 if profit_ratio > 0 else 0xFF0000), - 'fields': [ - {'name': 'Trade ID', 'value': msg.get('trade_id'), 'inline': True}, - {'name': 'Exchange', 'value': msg.get('exchange').capitalize(), 'inline': True}, - {'name': 'Pair', 'value': msg.get('pair'), 'inline': True}, - {'name': 'Direction', 'value': 'Short' if msg.get( - 'is_short') else 'Long', 'inline': True}, - {'name': 'Open rate', 'value': msg.get('open_rate'), 'inline': True}, - {'name': 'Close rate', 'value': msg.get('close_rate'), 'inline': True}, - {'name': 'Amount', 'value': msg.get('amount'), 'inline': True}, - {'name': 'Open order', 'value': msg.get('open_order_id'), 'inline': True}, - {'name': 'Open date', 'value': open_date, 'inline': True}, - {'name': 'Close date', 'value': close_date, 'inline': True}, - {'name': 'Profit', 'value': msg.get('profit_amount'), 'inline': True}, - {'name': 'Profitability', 'value': f'{profit_ratio:.2%}', 'inline': True}, - {'name': 'Stake currency', 'value': msg.get('stake_currency'), 'inline': True}, - {'name': 'Fiat currency', 'value': msg.get('fiat_display_currency'), - 'inline': True}, - {'name': 'Buy Tag', 'value': msg.get('enter_tag'), 'inline': True}, - {'name': 'Sell Reason', 'value': msg.get('exit_reason'), 'inline': True}, - {'name': 'Strategy', 'value': self.strategy, 'inline': True}, - {'name': 'Timeframe', 'value': self.timeframe, 'inline': True}, - ], - }] + 'title': f"Trade: {msg['pair']} {msg['type'].value}", + 'color': color, + 'fields': [], - # convert all value in fields to string for discord - for embed in embeds: - for field in embed['fields']: # type: ignore - field['value'] = str(field['value']) + }] + for f in fields: + for k, v in f.items(): + v = v.format(**msg) + embeds[0]['fields'].append({'name': k, 'value': v, 'inline': True}) # Send the message to discord channel payload = {'embeds': embeds} From 4b70e03daadc8cc3213656c6d8eaa32815096dd1 Mon Sep 17 00:00:00 2001 From: Matthias Date: Sat, 11 Jun 2022 17:45:37 +0200 Subject: [PATCH 27/46] Add some rudimentary tsts for discord webhook integration --- freqtrade/rpc/discord.py | 3 ++- tests/rpc/test_rpc_webhook.py | 41 +++++++++++++++++++++++++++++++++++ 2 files changed, 43 insertions(+), 1 deletion(-) diff --git a/freqtrade/rpc/discord.py b/freqtrade/rpc/discord.py index 9509b4f23..5991f7126 100644 --- a/freqtrade/rpc/discord.py +++ b/freqtrade/rpc/discord.py @@ -51,7 +51,8 @@ class Discord(Webhook): for f in fields: for k, v in f.items(): v = v.format(**msg) - embeds[0]['fields'].append({'name': k, 'value': v, 'inline': True}) + embeds[0]['fields'].append( # type: ignore + {'name': k, 'value': v, 'inline': True}) # Send the message to discord channel payload = {'embeds': embeds} diff --git a/tests/rpc/test_rpc_webhook.py b/tests/rpc/test_rpc_webhook.py index db357f80f..4d65b4966 100644 --- a/tests/rpc/test_rpc_webhook.py +++ b/tests/rpc/test_rpc_webhook.py @@ -1,5 +1,6 @@ # pragma pylint: disable=missing-docstring, C0103, protected-access +from datetime import datetime, timedelta from unittest.mock import MagicMock import pytest @@ -7,6 +8,7 @@ from requests import RequestException from freqtrade.enums import ExitType, RPCMessageType from freqtrade.rpc import RPC +from freqtrade.rpc.discord import Discord from freqtrade.rpc.webhook import Webhook from tests.conftest import get_patched_freqtradebot, log_has @@ -406,3 +408,42 @@ def test__send_msg_with_raw_format(default_conf, mocker, caplog): webhook._send_msg(msg) assert post.call_args[1] == {'data': msg['data'], 'headers': {'Content-Type': 'text/plain'}} + + +def test_send_msg_discord(default_conf, mocker): + + default_conf["discord"] = { + 'enabled': True, + 'webhook_url': "https://webhookurl..." + } + msg_mock = MagicMock() + mocker.patch("freqtrade.rpc.webhook.Webhook._send_msg", msg_mock) + discord = Discord(RPC(get_patched_freqtradebot(mocker, default_conf)), default_conf) + + msg = { + 'type': RPCMessageType.EXIT_FILL, + 'trade_id': 1, + 'exchange': 'Binance', + 'pair': 'ETH/BTC', + 'direction': 'Long', + 'gain': "profit", + 'close_rate': 0.005, + 'amount': 0.8, + 'order_type': 'limit', + 'open_date': datetime.now() - timedelta(days=1), + 'close_date': datetime.now(), + 'open_rate': 0.004, + 'current_rate': 0.005, + 'profit_amount': 0.001, + 'profit_ratio': 0.20, + 'stake_currency': 'BTC', + 'enter_tag': 'enter_tagggg', + 'exit_reason': ExitType.STOP_LOSS.value, + } + discord.send_msg(msg=msg) + + assert msg_mock.call_count == 1 + assert 'embeds' in msg_mock.call_args_list[0][0][0] + assert 'title' in msg_mock.call_args_list[0][0][0]['embeds'][0] + assert 'color' in msg_mock.call_args_list[0][0][0]['embeds'][0] + assert 'fields' in msg_mock.call_args_list[0][0][0]['embeds'][0] From c9761f47361203eafbb08f9a5413e88e0e80159b Mon Sep 17 00:00:00 2001 From: Matthias Date: Sat, 11 Jun 2022 18:02:03 +0200 Subject: [PATCH 28/46] FreqUI should be installed by default when running setup.sh --- setup.sh | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/setup.sh b/setup.sh index bb51c3a2f..202cb70c7 100755 --- a/setup.sh +++ b/setup.sh @@ -87,6 +87,10 @@ function updateenv() { echo "Failed installing Freqtrade" exit 1 fi + + echo "Installing freqUI" + freqtrade install-ui + echo "pip install completed" echo if [[ $dev =~ ^[Yy]$ ]]; then From 56652c2b391fa1714bf706ed156df72910b7dad5 Mon Sep 17 00:00:00 2001 From: Matthias Date: Sun, 12 Jun 2022 17:09:47 +0200 Subject: [PATCH 29/46] Improve test resiliance --- tests/test_freqtradebot.py | 18 +++++++++++------- 1 file changed, 11 insertions(+), 7 deletions(-) diff --git a/tests/test_freqtradebot.py b/tests/test_freqtradebot.py index cd7459cbe..7f9bc6a46 100644 --- a/tests/test_freqtradebot.py +++ b/tests/test_freqtradebot.py @@ -210,13 +210,14 @@ def test_edge_overrides_stoploss(limit_order, fee, caplog, mocker, # # mocking the ticker: price is falling ... enter_price = limit_order['buy']['price'] + ticker_val = { + 'bid': enter_price, + 'ask': enter_price, + 'last': enter_price, + } mocker.patch.multiple( 'freqtrade.exchange.Exchange', - fetch_ticker=MagicMock(return_value={ - 'bid': enter_price * buy_price_mult, - 'ask': enter_price * buy_price_mult, - 'last': enter_price * buy_price_mult, - }), + fetch_ticker=MagicMock(return_value=ticker_val), get_fee=fee, ) ############################################# @@ -229,9 +230,12 @@ def test_edge_overrides_stoploss(limit_order, fee, caplog, mocker, freqtrade.enter_positions() trade = Trade.query.first() caplog.clear() - oobj = Order.parse_from_ccxt_object(limit_order['buy'], 'ADA/USDT', 'buy') - trade.update_trade(oobj) ############################################# + ticker_val.update({ + 'bid': enter_price * buy_price_mult, + 'ask': enter_price * buy_price_mult, + 'last': enter_price * buy_price_mult, + }) # stoploss shoud be hit assert freqtrade.handle_trade(trade) is not ignore_strat_sl From dff83ef62045c2b006702d5bc855cc9051a3bc80 Mon Sep 17 00:00:00 2001 From: Matthias Date: Sun, 12 Jun 2022 17:30:01 +0200 Subject: [PATCH 30/46] Update telegram profit test to USDT --- tests/rpc/test_rpc_telegram.py | 30 +++++++++++++----------------- 1 file changed, 13 insertions(+), 17 deletions(-) diff --git a/tests/rpc/test_rpc_telegram.py b/tests/rpc/test_rpc_telegram.py index 11a783f3a..355a8b078 100644 --- a/tests/rpc/test_rpc_telegram.py +++ b/tests/rpc/test_rpc_telegram.py @@ -643,16 +643,16 @@ def test_monthly_handle(default_conf_usdt, update, ticker, fee, mocker) -> None: assert str('Monthly Profit over the last 6 months:') in msg_mock.call_args_list[0][0][0] -def test_profit_handle(default_conf, update, ticker, ticker_sell_up, fee, - limit_buy_order, limit_sell_order, mocker) -> None: - mocker.patch('freqtrade.rpc.rpc.CryptoToFiatConverter._find_price', return_value=15000.0) +def test_profit_handle(default_conf_usdt, update, ticker_usdt, ticker_sell_up, fee, + limit_sell_order_usdt, mocker) -> None: + mocker.patch('freqtrade.rpc.rpc.CryptoToFiatConverter._find_price', return_value=1.1) mocker.patch.multiple( 'freqtrade.exchange.Exchange', - fetch_ticker=ticker, + fetch_ticker=ticker_usdt, get_fee=fee, ) - telegram, freqtradebot, msg_mock = get_telegram_testobject(mocker, default_conf) + telegram, freqtradebot, msg_mock = get_telegram_testobject(mocker, default_conf_usdt) patch_get_signal(freqtradebot) telegram._profit(update=update, context=MagicMock()) @@ -664,10 +664,6 @@ def test_profit_handle(default_conf, update, ticker, ticker_sell_up, fee, freqtradebot.enter_positions() trade = Trade.query.first() - # Simulate fulfilled LIMIT_BUY order for trade - oobj = Order.parse_from_ccxt_object(limit_buy_order, limit_buy_order['symbol'], 'buy') - trade.update_trade(oobj) - context = MagicMock() # Test with invalid 2nd argument (should silently pass) context.args = ["aaa"] @@ -675,15 +671,15 @@ def test_profit_handle(default_conf, update, ticker, ticker_sell_up, fee, assert msg_mock.call_count == 1 assert 'No closed trade' in msg_mock.call_args_list[-1][0][0] assert '*ROI:* All trades' in msg_mock.call_args_list[-1][0][0] - mocker.patch('freqtrade.wallets.Wallets.get_starting_balance', return_value=0.01) - assert ('∙ `-0.000005 BTC (-0.50%) (-0.0 \N{GREEK CAPITAL LETTER SIGMA}%)`' + mocker.patch('freqtrade.wallets.Wallets.get_starting_balance', return_value=1000) + assert ('∙ `0.298 USDT (0.50%) (0.03 \N{GREEK CAPITAL LETTER SIGMA}%)`' in msg_mock.call_args_list[-1][0][0]) msg_mock.reset_mock() # Update the ticker with a market going up mocker.patch('freqtrade.exchange.Exchange.fetch_ticker', ticker_sell_up) # Simulate fulfilled LIMIT_SELL order for trade - oobj = Order.parse_from_ccxt_object(limit_sell_order, limit_sell_order['symbol'], 'sell') + oobj = Order.parse_from_ccxt_object(limit_sell_order_usdt, limit_sell_order_usdt['symbol'], 'sell') trade.update_trade(oobj) trade.close_date = datetime.now(timezone.utc) @@ -694,15 +690,15 @@ def test_profit_handle(default_conf, update, ticker, ticker_sell_up, fee, telegram._profit(update=update, context=context) assert msg_mock.call_count == 1 assert '*ROI:* Closed trades' in msg_mock.call_args_list[-1][0][0] - assert ('∙ `0.00006217 BTC (6.20%) (0.62 \N{GREEK CAPITAL LETTER SIGMA}%)`' + assert ('∙ `5.685 USDT (9.45%) (0.57 \N{GREEK CAPITAL LETTER SIGMA}%)`' in msg_mock.call_args_list[-1][0][0]) - assert '∙ `0.933 USD`' in msg_mock.call_args_list[-1][0][0] + assert '∙ `6.253 USD`' in msg_mock.call_args_list[-1][0][0] assert '*ROI:* All trades' in msg_mock.call_args_list[-1][0][0] - assert ('∙ `0.00006217 BTC (6.20%) (0.62 \N{GREEK CAPITAL LETTER SIGMA}%)`' + assert ('∙ `5.685 USDT (9.45%) (0.57 \N{GREEK CAPITAL LETTER SIGMA}%)`' in msg_mock.call_args_list[-1][0][0]) - assert '∙ `0.933 USD`' in msg_mock.call_args_list[-1][0][0] + assert '∙ `6.253 USD`' in msg_mock.call_args_list[-1][0][0] - assert '*Best Performing:* `ETH/BTC: 6.20%`' in msg_mock.call_args_list[-1][0][0] + assert '*Best Performing:* `ETH/USDT: 9.45%`' in msg_mock.call_args_list[-1][0][0] @pytest.mark.parametrize('is_short', [True, False]) From 7619fd08d65e4cafee6e5a9f227987392dfe8fe2 Mon Sep 17 00:00:00 2001 From: Matthias Date: Sun, 12 Jun 2022 19:31:32 +0200 Subject: [PATCH 31/46] Update telegram tests to use mock_trades --- tests/conftest_trades_usdt.py | 6 +- tests/rpc/test_rpc_telegram.py | 102 ++++++++------------------------- 2 files changed, 27 insertions(+), 81 deletions(-) diff --git a/tests/conftest_trades_usdt.py b/tests/conftest_trades_usdt.py index 6f83bb8be..cc1b1a206 100644 --- a/tests/conftest_trades_usdt.py +++ b/tests/conftest_trades_usdt.py @@ -95,13 +95,14 @@ def mock_trade_usdt_2(fee, is_short: bool): fee_close=fee.return_value, open_rate=2.0, close_rate=2.05, - close_profit=5.0, + close_profit=0.05, close_profit_abs=3.9875, exchange='binance', is_open=False, open_order_id=f'12366_{direc(is_short)}', strategy='StrategyTestV2', timeframe=5, + enter_tag='TEST1', exit_reason='exit_signal', open_date=datetime.now(tz=timezone.utc) - timedelta(minutes=20), close_date=datetime.now(tz=timezone.utc) - timedelta(minutes=2), @@ -157,12 +158,13 @@ def mock_trade_usdt_3(fee, is_short: bool): fee_close=fee.return_value, open_rate=1.0, close_rate=1.1, - close_profit=10.0, + close_profit=0.1, close_profit_abs=9.8425, exchange='binance', is_open=False, strategy='StrategyTestV2', timeframe=5, + enter_tag='TEST3', exit_reason='roi', open_date=datetime.now(tz=timezone.utc) - timedelta(minutes=20), close_date=datetime.now(tz=timezone.utc), diff --git a/tests/rpc/test_rpc_telegram.py b/tests/rpc/test_rpc_telegram.py index 355a8b078..48acda47e 100644 --- a/tests/rpc/test_rpc_telegram.py +++ b/tests/rpc/test_rpc_telegram.py @@ -679,7 +679,8 @@ def test_profit_handle(default_conf_usdt, update, ticker_usdt, ticker_sell_up, f # Update the ticker with a market going up mocker.patch('freqtrade.exchange.Exchange.fetch_ticker', ticker_sell_up) # Simulate fulfilled LIMIT_SELL order for trade - oobj = Order.parse_from_ccxt_object(limit_sell_order_usdt, limit_sell_order_usdt['symbol'], 'sell') + oobj = Order.parse_from_ccxt_object( + limit_sell_order_usdt, limit_sell_order_usdt['symbol'], 'sell') trade.update_trade(oobj) trade.close_date = datetime.now(timezone.utc) @@ -1235,71 +1236,43 @@ def test_force_enter_no_pair(default_conf, update, mocker) -> None: assert fbuy_mock.call_count == 1 -def test_telegram_performance_handle(default_conf, update, ticker, fee, - limit_buy_order, limit_sell_order, mocker) -> None: +def test_telegram_performance_handle(default_conf_usdt, update, ticker, fee, mocker) -> None: mocker.patch.multiple( 'freqtrade.exchange.Exchange', fetch_ticker=ticker, get_fee=fee, ) - telegram, freqtradebot, msg_mock = get_telegram_testobject(mocker, default_conf) - patch_get_signal(freqtradebot) + telegram, freqtradebot, msg_mock = get_telegram_testobject(mocker, default_conf_usdt) # Create some test data - freqtradebot.enter_positions() - trade = Trade.query.first() - assert trade + create_mock_trades_usdt(fee) - # Simulate fulfilled LIMIT_BUY order for trade - oobj = Order.parse_from_ccxt_object(limit_buy_order, limit_buy_order['symbol'], 'buy') - trade.update_trade(oobj) - - # Simulate fulfilled LIMIT_SELL order for trade - oobj = Order.parse_from_ccxt_object(limit_sell_order, limit_sell_order['symbol'], 'sell') - trade.update_trade(oobj) - - trade.close_date = datetime.utcnow() - trade.is_open = False telegram._performance(update=update, context=MagicMock()) assert msg_mock.call_count == 1 assert 'Performance' in msg_mock.call_args_list[0][0][0] - assert 'ETH/BTC\t0.00006217 BTC (6.20%) (1)' in msg_mock.call_args_list[0][0][0] + assert 'XRP/USDT\t9.842 USDT (10.00%) (1)' in msg_mock.call_args_list[0][0][0] def test_telegram_entry_tag_performance_handle( - default_conf, update, ticker, fee, limit_buy_order, limit_sell_order, mocker) -> None: + default_conf_usdt, update, ticker, fee, mocker) -> None: mocker.patch.multiple( 'freqtrade.exchange.Exchange', fetch_ticker=ticker, get_fee=fee, ) - telegram, freqtradebot, msg_mock = get_telegram_testobject(mocker, default_conf) + telegram, freqtradebot, msg_mock = get_telegram_testobject(mocker, default_conf_usdt) patch_get_signal(freqtradebot) - # Create some test data - freqtradebot.enter_positions() - trade = Trade.query.first() - assert trade + create_mock_trades_usdt(fee) - # Simulate fulfilled LIMIT_BUY order for trade - oobj = Order.parse_from_ccxt_object(limit_buy_order, limit_buy_order['symbol'], 'buy') - trade.update_trade(oobj) - - trade.enter_tag = "TESTBUY" - # Simulate fulfilled LIMIT_SELL order for trade - oobj = Order.parse_from_ccxt_object(limit_sell_order, limit_sell_order['symbol'], 'sell') - trade.update_trade(oobj) - - trade.close_date = datetime.utcnow() - trade.is_open = False context = MagicMock() telegram._enter_tag_performance(update=update, context=context) assert msg_mock.call_count == 1 assert 'Entry Tag Performance' in msg_mock.call_args_list[0][0][0] - assert 'TESTBUY\t0.00006217 BTC (6.20%) (1)' in msg_mock.call_args_list[0][0][0] + assert 'TEST1\t3.987 USDT (5.00%) (1)' in msg_mock.call_args_list[0][0][0] - context.args = [trade.pair] + context.args = ['XRP/USDT'] telegram._enter_tag_performance(update=update, context=context) assert msg_mock.call_count == 2 @@ -1312,37 +1285,24 @@ def test_telegram_entry_tag_performance_handle( assert "Error" in msg_mock.call_args_list[0][0][0] -def test_telegram_exit_reason_performance_handle(default_conf, update, ticker, fee, - limit_buy_order, limit_sell_order, mocker) -> None: +def test_telegram_exit_reason_performance_handle(default_conf_usdt, update, ticker, fee, + mocker) -> None: mocker.patch.multiple( 'freqtrade.exchange.Exchange', fetch_ticker=ticker, get_fee=fee, ) - telegram, freqtradebot, msg_mock = get_telegram_testobject(mocker, default_conf) + telegram, freqtradebot, msg_mock = get_telegram_testobject(mocker, default_conf_usdt) patch_get_signal(freqtradebot) - # Create some test data - freqtradebot.enter_positions() - trade = Trade.query.first() - assert trade - trade.exit_reason = 'TESTSELL' - # Simulate fulfilled LIMIT_BUY order for trade - oobj = Order.parse_from_ccxt_object(limit_buy_order, limit_buy_order['symbol'], 'buy') - trade.update_trade(oobj) + create_mock_trades_usdt(fee) - # Simulate fulfilled LIMIT_SELL order for trade - oobj = Order.parse_from_ccxt_object(limit_sell_order, limit_sell_order['symbol'], 'sell') - trade.update_trade(oobj) - - trade.close_date = datetime.utcnow() - trade.is_open = False context = MagicMock() telegram._exit_reason_performance(update=update, context=context) assert msg_mock.call_count == 1 assert 'Exit Reason Performance' in msg_mock.call_args_list[0][0][0] - assert 'TESTSELL\t0.00006217 BTC (6.20%) (1)' in msg_mock.call_args_list[0][0][0] - context.args = [trade.pair] + assert 'roi\t9.842 USDT (10.00%) (1)' in msg_mock.call_args_list[0][0][0] + context.args = ['XRP/USDT'] telegram._exit_reason_performance(update=update, context=context) assert msg_mock.call_count == 2 @@ -1356,43 +1316,27 @@ def test_telegram_exit_reason_performance_handle(default_conf, update, ticker, f assert "Error" in msg_mock.call_args_list[0][0][0] -def test_telegram_mix_tag_performance_handle(default_conf, update, ticker, fee, - limit_buy_order, limit_sell_order, mocker) -> None: +def test_telegram_mix_tag_performance_handle(default_conf_usdt, update, ticker, fee, + mocker) -> None: mocker.patch.multiple( 'freqtrade.exchange.Exchange', fetch_ticker=ticker, get_fee=fee, ) - telegram, freqtradebot, msg_mock = get_telegram_testobject(mocker, default_conf) + telegram, freqtradebot, msg_mock = get_telegram_testobject(mocker, default_conf_usdt) patch_get_signal(freqtradebot) # Create some test data - freqtradebot.enter_positions() - trade = Trade.query.first() - assert trade - - trade.enter_tag = "TESTBUY" - trade.exit_reason = "TESTSELL" - - # Simulate fulfilled LIMIT_BUY order for trade - oobj = Order.parse_from_ccxt_object(limit_buy_order, limit_buy_order['symbol'], 'buy') - trade.update_trade(oobj) - - # Simulate fulfilled LIMIT_SELL order for trade - oobj = Order.parse_from_ccxt_object(limit_sell_order, limit_sell_order['symbol'], 'sell') - trade.update_trade(oobj) - - trade.close_date = datetime.utcnow() - trade.is_open = False + create_mock_trades_usdt(fee) context = MagicMock() telegram._mix_tag_performance(update=update, context=context) assert msg_mock.call_count == 1 assert 'Mix Tag Performance' in msg_mock.call_args_list[0][0][0] - assert ('TESTBUY TESTSELL\t0.00006217 BTC (6.20%) (1)' + assert ('TEST3 roi\t9.842 USDT (10.00%) (1)' in msg_mock.call_args_list[0][0][0]) - context.args = [trade.pair] + context.args = ['XRP/USDT'] telegram._mix_tag_performance(update=update, context=context) assert msg_mock.call_count == 2 From 40c7caac16279c9d1e34ef50fe2fc8178b01d886 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Mon, 13 Jun 2022 03:01:53 +0000 Subject: [PATCH 32/46] Bump types-filelock from 3.2.6 to 3.2.7 Bumps [types-filelock](https://github.com/python/typeshed) from 3.2.6 to 3.2.7. - [Release notes](https://github.com/python/typeshed/releases) - [Commits](https://github.com/python/typeshed/commits) --- updated-dependencies: - dependency-name: types-filelock dependency-type: direct:development update-type: version-update:semver-patch ... Signed-off-by: dependabot[bot] --- requirements-dev.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/requirements-dev.txt b/requirements-dev.txt index 4eb157aae..e7d64a2b6 100644 --- a/requirements-dev.txt +++ b/requirements-dev.txt @@ -23,7 +23,7 @@ nbconvert==6.5.0 # mypy types types-cachetools==5.0.1 -types-filelock==3.2.6 +types-filelock==3.2.7 types-requests==2.27.30 types-tabulate==0.8.9 types-python-dateutil==2.8.17 From 390e600f55cfe868bee74d9d74fc03e323575359 Mon Sep 17 00:00:00 2001 From: Matthias Date: Mon, 13 Jun 2022 06:46:34 +0200 Subject: [PATCH 33/46] Update statistics output --- tests/conftest_trades_usdt.py | 104 +++++++++++++++++----------------- tests/rpc/test_rpc.py | 87 +++++++++------------------- 2 files changed, 78 insertions(+), 113 deletions(-) diff --git a/tests/conftest_trades_usdt.py b/tests/conftest_trades_usdt.py index cc1b1a206..41d705c01 100644 --- a/tests/conftest_trades_usdt.py +++ b/tests/conftest_trades_usdt.py @@ -20,36 +20,60 @@ def direc(is_short: bool): def mock_order_usdt_1(is_short: bool): return { - 'id': f'1234_{direc(is_short)}', - 'symbol': 'ADA/USDT', + 'id': f'prod_entry_1_{direc(is_short)}', + 'symbol': 'LTC/USDT', 'status': 'closed', 'side': entry_side(is_short), 'type': 'limit', - 'price': 2.0, - 'amount': 10.0, - 'filled': 10.0, + 'price': 10.0, + 'amount': 2.0, + 'filled': 2.0, + 'remaining': 0.0, + } + + +def mock_order_usdt_1_exit(is_short: bool): + return { + 'id': f'prod_exit_1_{direc(is_short)}', + 'symbol': 'LTC/USDT', + 'status': 'closed', + 'side': exit_side(is_short), + 'type': 'limit', + 'price': 8.0, + 'amount': 2.0, + 'filled': 2.0, 'remaining': 0.0, } def mock_trade_usdt_1(fee, is_short: bool): + """ + Simulate prod entry with open sell order + """ trade = Trade( - pair='ADA/USDT', + pair='LTC/USDT', stake_amount=20.0, - amount=10.0, - amount_requested=10.0, + amount=2.0, + amount_requested=2.0, + open_date=datetime.now(tz=timezone.utc) - timedelta(days=2, minutes=20), + close_date=datetime.now(tz=timezone.utc) - timedelta(days=2, minutes=5), fee_open=fee.return_value, fee_close=fee.return_value, - is_open=True, - open_date=datetime.now(tz=timezone.utc) - timedelta(minutes=17), - open_rate=2.0, + is_open=False, + open_rate=10.0, + close_rate=8.0, + close_profit=-0.2, + close_profit_abs=-4.0, exchange='binance', - open_order_id=f'1234_{direc(is_short)}', - strategy='StrategyTestV2', + strategy='SampleStrategy', + open_order_id=f'prod_exit_1_{direc(is_short)}', timeframe=5, is_short=is_short, ) - o = Order.parse_from_ccxt_object(mock_order_usdt_1(is_short), 'ADA/USDT', entry_side(is_short)) + o = Order.parse_from_ccxt_object(mock_order_usdt_1(is_short), 'LTC/USDT', entry_side(is_short)) + trade.orders.append(o) + o = Order.parse_from_ccxt_object(mock_order_usdt_1_exit(is_short), + 'LTC/USDT', exit_side(is_short)) trade.orders.append(o) return trade @@ -330,59 +354,35 @@ def mock_trade_usdt_6(fee, is_short: bool): def mock_order_usdt_7(is_short: bool): return { - 'id': f'prod_entry_7_{direc(is_short)}', - 'symbol': 'LTC/USDT', + 'id': f'1234_{direc(is_short)}', + 'symbol': 'ADA/USDT', 'status': 'closed', 'side': entry_side(is_short), 'type': 'limit', - 'price': 10.0, - 'amount': 2.0, - 'filled': 2.0, - 'remaining': 0.0, - } - - -def mock_order_usdt_7_exit(is_short: bool): - return { - 'id': f'prod_exit_7_{direc(is_short)}', - 'symbol': 'LTC/USDT', - 'status': 'closed', - 'side': exit_side(is_short), - 'type': 'limit', - 'price': 8.0, - 'amount': 2.0, - 'filled': 2.0, + 'price': 2.0, + 'amount': 10.0, + 'filled': 10.0, 'remaining': 0.0, } def mock_trade_usdt_7(fee, is_short: bool): - """ - Simulate prod entry with open sell order - """ trade = Trade( - pair='LTC/USDT', + pair='ADA/USDT', stake_amount=20.0, - amount=2.0, - amount_requested=2.0, - open_date=datetime.now(tz=timezone.utc) - timedelta(days=2, minutes=20), - close_date=datetime.now(tz=timezone.utc) - timedelta(days=2, minutes=5), + amount=10.0, + amount_requested=10.0, fee_open=fee.return_value, fee_close=fee.return_value, - is_open=False, - open_rate=10.0, - close_rate=8.0, - close_profit=-0.2, - close_profit_abs=-4.0, + is_open=True, + open_date=datetime.now(tz=timezone.utc) - timedelta(minutes=17), + open_rate=2.0, exchange='binance', - strategy='SampleStrategy', - open_order_id=f'prod_exit_7_{direc(is_short)}', + open_order_id=f'1234_{direc(is_short)}', + strategy='StrategyTestV2', timeframe=5, is_short=is_short, ) - o = Order.parse_from_ccxt_object(mock_order_usdt_7(is_short), 'LTC/USDT', entry_side(is_short)) - trade.orders.append(o) - o = Order.parse_from_ccxt_object(mock_order_usdt_7_exit(is_short), - 'LTC/USDT', exit_side(is_short)) + o = Order.parse_from_ccxt_object(mock_order_usdt_7(is_short), 'ADA/USDT', entry_side(is_short)) trade.orders.append(o) return trade diff --git a/tests/rpc/test_rpc.py b/tests/rpc/test_rpc.py index 0273b8237..339a6382f 100644 --- a/tests/rpc/test_rpc.py +++ b/tests/rpc/test_rpc.py @@ -407,13 +407,9 @@ def test_rpc_delete_trade(mocker, default_conf, fee, markets, caplog, is_short): assert stoploss_mock.call_count == 0 -def test_rpc_trade_statistics(default_conf, ticker, ticker_sell_up, fee, - limit_buy_order, limit_sell_order, mocker) -> None: - mocker.patch.multiple( - 'freqtrade.rpc.fiat_convert.CoinGeckoAPI', - get_price=MagicMock(return_value={'bitcoin': {'usd': 15000.0}}), - ) - mocker.patch('freqtrade.rpc.rpc.CryptoToFiatConverter._find_price', return_value=15000.0) +def test_rpc_trade_statistics11(default_conf_usdt, ticker, fee, + mocker) -> None: + mocker.patch('freqtrade.rpc.rpc.CryptoToFiatConverter._find_price', return_value=1.1) mocker.patch('freqtrade.rpc.telegram.Telegram', MagicMock()) mocker.patch.multiple( 'freqtrade.exchange.Exchange', @@ -421,10 +417,9 @@ def test_rpc_trade_statistics(default_conf, ticker, ticker_sell_up, fee, get_fee=fee, ) - freqtradebot = get_patched_freqtradebot(mocker, default_conf) - patch_get_signal(freqtradebot) - stake_currency = default_conf['stake_currency'] - fiat_display_currency = default_conf['fiat_display_currency'] + freqtradebot = get_patched_freqtradebot(mocker, default_conf_usdt) + stake_currency = default_conf_usdt['stake_currency'] + fiat_display_currency = default_conf_usdt['fiat_display_currency'] rpc = RPC(freqtradebot) rpc._fiat_converter = CryptoToFiatConverter() @@ -437,62 +432,32 @@ def test_rpc_trade_statistics(default_conf, ticker, ticker_sell_up, fee, assert res['latest_trade_timestamp'] == 0 # Create some test data - freqtradebot.enter_positions() - trade = Trade.query.first() - # Simulate fulfilled LIMIT_BUY order for trade - oobj = Order.parse_from_ccxt_object(limit_buy_order, limit_buy_order['symbol'], 'sell') - trade.update_trade(oobj) - - # Update the ticker with a market going up - mocker.patch.multiple( - 'freqtrade.exchange.Exchange', - fetch_ticker=ticker_sell_up - ) - oobj = Order.parse_from_ccxt_object(limit_sell_order, limit_sell_order['symbol'], 'sell') - trade.update_trade(oobj) - trade.close_date = datetime.utcnow() - trade.is_open = False - - freqtradebot.enter_positions() - trade = Trade.query.first() - # Simulate fulfilled LIMIT_BUY order for trade - oobj = Order.parse_from_ccxt_object(limit_buy_order, limit_buy_order['symbol'], 'buy') - trade.update_trade(oobj) - - # Update the ticker with a market going up - mocker.patch.multiple( - 'freqtrade.exchange.Exchange', - fetch_ticker=ticker_sell_up - ) - oobj = Order.parse_from_ccxt_object(limit_sell_order, limit_sell_order['symbol'], 'sell') - trade.update_trade(oobj) - trade.close_date = datetime.utcnow() - trade.is_open = False + create_mock_trades_usdt(fee) stats = rpc._rpc_trade_statistics(stake_currency, fiat_display_currency) - assert prec_satoshi(stats['profit_closed_coin'], 6.217e-05) - assert prec_satoshi(stats['profit_closed_percent_mean'], 6.2) - assert prec_satoshi(stats['profit_closed_fiat'], 0.93255) - assert prec_satoshi(stats['profit_all_coin'], 5.802e-05) - assert prec_satoshi(stats['profit_all_percent_mean'], 2.89) - assert prec_satoshi(stats['profit_all_fiat'], 0.8703) - assert stats['trade_count'] == 2 - assert stats['first_trade_date'] == 'just now' - assert stats['latest_trade_date'] == 'just now' - assert stats['avg_duration'] in ('0:00:00', '0:00:01', '0:00:02') - assert stats['best_pair'] == 'ETH/BTC' - assert prec_satoshi(stats['best_rate'], 6.2) + assert pytest.approx(stats['profit_closed_coin']) == 9.83 + assert pytest.approx(stats['profit_closed_percent_mean']) == -1.67 + assert pytest.approx(stats['profit_closed_fiat']) == 10.813 + assert pytest.approx(stats['profit_all_coin']) == -77.45964918 + assert pytest.approx(stats['profit_all_percent_mean']) == -57.86 + assert pytest.approx(stats['profit_all_fiat']) == -85.205614098 + assert stats['trade_count'] == 7 + assert stats['first_trade_date'] == '2 days ago' + assert stats['latest_trade_date'] == '17 minutes ago' + assert stats['avg_duration'] in ('0:17:40') + assert stats['best_pair'] == 'XRP/USDT' + assert stats['best_rate'] == 10.0 # Test non-available pair mocker.patch('freqtrade.exchange.Exchange.get_rate', - MagicMock(side_effect=ExchangeError("Pair 'ETH/BTC' not available"))) + MagicMock(side_effect=ExchangeError("Pair 'XRP/USDT' not available"))) stats = rpc._rpc_trade_statistics(stake_currency, fiat_display_currency) - assert stats['trade_count'] == 2 - assert stats['first_trade_date'] == 'just now' - assert stats['latest_trade_date'] == 'just now' - assert stats['avg_duration'] in ('0:00:00', '0:00:01', '0:00:02') - assert stats['best_pair'] == 'ETH/BTC' - assert prec_satoshi(stats['best_rate'], 6.2) + assert stats['trade_count'] == 7 + assert stats['first_trade_date'] == '2 days ago' + assert stats['latest_trade_date'] == '17 minutes ago' + assert stats['avg_duration'] in ('0:17:40') + assert stats['best_pair'] == 'XRP/USDT' + assert stats['best_rate'] == 10.0 assert isnan(stats['profit_all_coin']) From 43c871f2f4e4b7022befea6e4dd8c3b8871231a8 Mon Sep 17 00:00:00 2001 From: Matthias Date: Mon, 13 Jun 2022 06:49:31 +0200 Subject: [PATCH 34/46] Use time-machine to stabilize time-sensitive tests --- tests/rpc/test_rpc_telegram.py | 14 +++++++++----- 1 file changed, 9 insertions(+), 5 deletions(-) diff --git a/tests/rpc/test_rpc_telegram.py b/tests/rpc/test_rpc_telegram.py index 48acda47e..3bd817ac7 100644 --- a/tests/rpc/test_rpc_telegram.py +++ b/tests/rpc/test_rpc_telegram.py @@ -405,7 +405,7 @@ def test_status_table_handle(default_conf, update, ticker, fee, mocker) -> None: assert msg_mock.call_count == 1 -def test_daily_handle(default_conf_usdt, update, ticker, fee, mocker) -> None: +def test_daily_handle(default_conf_usdt, update, ticker, fee, mocker, time_machine) -> None: mocker.patch( 'freqtrade.rpc.rpc.CryptoToFiatConverter._find_price', return_value=1.1 @@ -418,6 +418,8 @@ def test_daily_handle(default_conf_usdt, update, ticker, fee, mocker) -> None: telegram, freqtradebot, msg_mock = get_telegram_testobject(mocker, default_conf_usdt) + # Move date to within day + time_machine.move_to('2022-06-11 08:00:00+00:00') # Create some test data create_mock_trades_usdt(fee) @@ -491,7 +493,7 @@ def test_daily_wrong_input(default_conf, update, ticker, mocker) -> None: assert 'Daily Profit over the last 7 days:' in msg_mock.call_args_list[0][0][0] -def test_weekly_handle(default_conf_usdt, update, ticker, fee, mocker) -> None: +def test_weekly_handle(default_conf_usdt, update, ticker, fee, mocker, time_machine) -> None: default_conf_usdt['max_open_trades'] = 1 mocker.patch( 'freqtrade.rpc.rpc.CryptoToFiatConverter._find_price', @@ -504,7 +506,8 @@ def test_weekly_handle(default_conf_usdt, update, ticker, fee, mocker) -> None: ) telegram, freqtradebot, msg_mock = get_telegram_testobject(mocker, default_conf_usdt) - + # Move to saturday - so all trades are within that week + time_machine.move_to('2022-06-11') create_mock_trades_usdt(fee) # Try valid data @@ -560,7 +563,7 @@ def test_weekly_handle(default_conf_usdt, update, ticker, fee, mocker) -> None: ) -def test_monthly_handle(default_conf_usdt, update, ticker, fee, mocker) -> None: +def test_monthly_handle(default_conf_usdt, update, ticker, fee, mocker, time_machine) -> None: default_conf_usdt['max_open_trades'] = 1 mocker.patch( 'freqtrade.rpc.rpc.CryptoToFiatConverter._find_price', @@ -573,7 +576,8 @@ def test_monthly_handle(default_conf_usdt, update, ticker, fee, mocker) -> None: ) telegram, freqtradebot, msg_mock = get_telegram_testobject(mocker, default_conf_usdt) - + # Move to day within the month so all mock trades fall into this week. + time_machine.move_to('2022-06-11') create_mock_trades_usdt(fee) # Try valid data From 8fd245c28b6d41be9b45a8c9f5aeb6d5ab7d277c Mon Sep 17 00:00:00 2001 From: Matthias Date: Mon, 13 Jun 2022 06:58:06 +0200 Subject: [PATCH 35/46] Update pre-commit filelocktypes --- .pre-commit-config.yaml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 685d789ec..f5c1a36f5 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -14,7 +14,7 @@ repos: exclude: build_helpers additional_dependencies: - types-cachetools==5.0.1 - - types-filelock==3.2.6 + - types-filelock==3.2.7 - types-requests==2.27.30 - types-tabulate==0.8.9 - types-python-dateutil==2.8.17 From 70966c8a8f0782b1e4d3f94c64b8cecb0e34b71b Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Mon, 13 Jun 2022 05:08:12 +0000 Subject: [PATCH 36/46] Bump actions/setup-python from 3 to 4 Bumps [actions/setup-python](https://github.com/actions/setup-python) from 3 to 4. - [Release notes](https://github.com/actions/setup-python/releases) - [Commits](https://github.com/actions/setup-python/compare/v3...v4) --- updated-dependencies: - dependency-name: actions/setup-python dependency-type: direct:production update-type: version-update:semver-major ... Signed-off-by: dependabot[bot] --- .github/workflows/ci.yml | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index bbe0bcf6e..551268af7 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -30,7 +30,7 @@ jobs: - uses: actions/checkout@v3 - name: Set up Python - uses: actions/setup-python@v3 + uses: actions/setup-python@v4 with: python-version: ${{ matrix.python-version }} @@ -127,7 +127,7 @@ jobs: - uses: actions/checkout@v3 - name: Set up Python - uses: actions/setup-python@v3 + uses: actions/setup-python@v4 with: python-version: ${{ matrix.python-version }} @@ -211,7 +211,7 @@ jobs: - uses: actions/checkout@v3 - name: Set up Python - uses: actions/setup-python@v3 + uses: actions/setup-python@v4 with: python-version: ${{ matrix.python-version }} @@ -263,7 +263,7 @@ jobs: - uses: actions/checkout@v3 - name: Set up Python - uses: actions/setup-python@v3 + uses: actions/setup-python@v4 with: python-version: "3.10" @@ -282,7 +282,7 @@ jobs: ./tests/test_docs.sh - name: Set up Python - uses: actions/setup-python@v3 + uses: actions/setup-python@v4 with: python-version: "3.10" @@ -336,7 +336,7 @@ jobs: - uses: actions/checkout@v3 - name: Set up Python - uses: actions/setup-python@v3 + uses: actions/setup-python@v4 with: python-version: "3.9" From e67d29cd2f85ac2eb029b1c7904ece2cc7cc35a1 Mon Sep 17 00:00:00 2001 From: Matthias Date: Mon, 13 Jun 2022 07:10:47 +0200 Subject: [PATCH 37/46] Update more trades to use create_mock_trades --- tests/rpc/test_rpc.py | 179 +++++++++++------------------------------- 1 file changed, 46 insertions(+), 133 deletions(-) diff --git a/tests/rpc/test_rpc.py b/tests/rpc/test_rpc.py index 339a6382f..d20646e60 100644 --- a/tests/rpc/test_rpc.py +++ b/tests/rpc/test_rpc.py @@ -11,7 +11,6 @@ from freqtrade.edge import PairInfo from freqtrade.enums import SignalDirection, State, TradingMode from freqtrade.exceptions import ExchangeError, InvalidOrderException, TemporaryError from freqtrade.persistence import Trade -from freqtrade.persistence.models import Order from freqtrade.persistence.pairlock_middleware import PairLocks from freqtrade.rpc import RPC, RPCException from freqtrade.rpc.fiat_convert import CryptoToFiatConverter @@ -407,8 +406,7 @@ def test_rpc_delete_trade(mocker, default_conf, fee, markets, caplog, is_short): assert stoploss_mock.call_count == 0 -def test_rpc_trade_statistics11(default_conf_usdt, ticker, fee, - mocker) -> None: +def test_rpc_trade_statistics(default_conf_usdt, ticker, fee, mocker) -> None: mocker.patch('freqtrade.rpc.rpc.CryptoToFiatConverter._find_price', return_value=1.1) mocker.patch('freqtrade.rpc.telegram.Telegram', MagicMock()) mocker.patch.multiple( @@ -463,14 +461,9 @@ def test_rpc_trade_statistics11(default_conf_usdt, ticker, fee, # Test that rpc_trade_statistics can handle trades that lacks # trade.open_rate (it is set to None) -def test_rpc_trade_statistics_closed(mocker, default_conf, ticker, fee, - ticker_sell_up, limit_buy_order, limit_sell_order): - mocker.patch.multiple( - 'freqtrade.rpc.fiat_convert.CoinGeckoAPI', - get_price=MagicMock(return_value={'bitcoin': {'usd': 15000.0}}), - ) +def test_rpc_trade_statistics_closed(mocker, default_conf_usdt, ticker, fee): mocker.patch('freqtrade.rpc.fiat_convert.CryptoToFiatConverter._find_price', - return_value=15000.0) + return_value=1.1) mocker.patch('freqtrade.rpc.telegram.Telegram', MagicMock()) mocker.patch.multiple( 'freqtrade.exchange.Exchange', @@ -478,46 +471,32 @@ def test_rpc_trade_statistics_closed(mocker, default_conf, ticker, fee, get_fee=fee, ) - freqtradebot = get_patched_freqtradebot(mocker, default_conf) + freqtradebot = get_patched_freqtradebot(mocker, default_conf_usdt) patch_get_signal(freqtradebot) - stake_currency = default_conf['stake_currency'] - fiat_display_currency = default_conf['fiat_display_currency'] + stake_currency = default_conf_usdt['stake_currency'] + fiat_display_currency = default_conf_usdt['fiat_display_currency'] rpc = RPC(freqtradebot) # Create some test data - freqtradebot.enter_positions() - trade = Trade.query.first() - # Simulate fulfilled LIMIT_BUY order for trade - oobj = Order.parse_from_ccxt_object(limit_buy_order, limit_buy_order['symbol'], 'buy') - trade.update_trade(oobj) - # Update the ticker with a market going up - mocker.patch.multiple( - 'freqtrade.exchange.Exchange', - fetch_ticker=ticker_sell_up, - get_fee=fee - ) - oobj = Order.parse_from_ccxt_object(limit_sell_order, limit_sell_order['symbol'], 'sell') - trade.update_trade(oobj) - trade.close_date = datetime.utcnow() - trade.is_open = False + create_mock_trades_usdt(fee) for trade in Trade.query.order_by(Trade.id).all(): trade.open_rate = None stats = rpc._rpc_trade_statistics(stake_currency, fiat_display_currency) - assert prec_satoshi(stats['profit_closed_coin'], 0) - assert prec_satoshi(stats['profit_closed_percent_mean'], 0) - assert prec_satoshi(stats['profit_closed_fiat'], 0) - assert prec_satoshi(stats['profit_all_coin'], 0) - assert prec_satoshi(stats['profit_all_percent_mean'], 0) - assert prec_satoshi(stats['profit_all_fiat'], 0) - assert stats['trade_count'] == 1 - assert stats['first_trade_date'] == 'just now' - assert stats['latest_trade_date'] == 'just now' + assert stats['profit_closed_coin'] == 0 + assert stats['profit_closed_percent_mean'] == 0 + assert stats['profit_closed_fiat'] == 0 + assert stats['profit_all_coin'] == 0 + assert stats['profit_all_percent_mean'] == 0 + assert stats['profit_all_fiat'] == 0 + assert stats['trade_count'] == 7 + assert stats['first_trade_date'] == '2 days ago' + assert stats['latest_trade_date'] == '17 minutes ago' assert stats['avg_duration'] == '0:00:00' - assert stats['best_pair'] == 'ETH/BTC' - assert prec_satoshi(stats['best_rate'], 6.2) + assert stats['best_pair'] == 'XRP/USDT' + assert stats['best_rate'] == 10.0 def test_rpc_balance_handle_error(default_conf, mocker): @@ -869,8 +848,7 @@ def test_rpc_force_exit(default_conf, ticker, fee, mocker) -> None: assert cancel_order_mock.call_count == 3 -def test_performance_handle(default_conf, ticker, limit_buy_order, fee, - limit_sell_order, mocker) -> None: +def test_performance_handle(default_conf_usdt, ticker, fee, mocker) -> None: mocker.patch('freqtrade.rpc.telegram.Telegram', MagicMock()) mocker.patch.multiple( 'freqtrade.exchange.Exchange', @@ -879,34 +857,21 @@ def test_performance_handle(default_conf, ticker, limit_buy_order, fee, get_fee=fee, ) - freqtradebot = get_patched_freqtradebot(mocker, default_conf) + freqtradebot = get_patched_freqtradebot(mocker, default_conf_usdt) patch_get_signal(freqtradebot) rpc = RPC(freqtradebot) - # Create some test data - freqtradebot.enter_positions() - trade = Trade.query.first() - assert trade + create_mock_trades_usdt(fee) - # Simulate fulfilled LIMIT_BUY order for trade - oobj = Order.parse_from_ccxt_object(limit_buy_order, limit_buy_order['symbol'], 'buy') - trade.update_trade(oobj) - - # Simulate fulfilled LIMIT_SELL order for trade - oobj = Order.parse_from_ccxt_object(limit_sell_order, limit_sell_order['symbol'], 'sell') - trade.update_trade(oobj) - - trade.close_date = datetime.utcnow() - trade.is_open = False res = rpc._rpc_performance() - assert len(res) == 1 - assert res[0]['pair'] == 'ETH/BTC' + assert len(res) == 3 + assert res[0]['pair'] == 'XRP/USDT' assert res[0]['count'] == 1 - assert prec_satoshi(res[0]['profit_pct'], 6.2) + assert res[0]['profit_pct'] == 10.0 -def test_enter_tag_performance_handle(default_conf, ticker, limit_buy_order, fee, - limit_sell_order, mocker) -> None: +def test_enter_tag_performance_handle(default_conf, ticker, fee, mocker) -> None: + mocker.patch('freqtrade.rpc.telegram.Telegram', MagicMock()) mocker.patch.multiple( 'freqtrade.exchange.Exchange', @@ -920,34 +885,22 @@ def test_enter_tag_performance_handle(default_conf, ticker, limit_buy_order, fee rpc = RPC(freqtradebot) # Create some test data + create_mock_trades_usdt(fee) freqtradebot.enter_positions() - trade = Trade.query.first() - assert trade - # Simulate fulfilled LIMIT_BUY order for trade - oobj = Order.parse_from_ccxt_object(limit_buy_order, limit_buy_order['symbol'], 'buy') - trade.update_trade(oobj) - - # Simulate fulfilled LIMIT_SELL order for trade - oobj = Order.parse_from_ccxt_object(limit_sell_order, limit_sell_order['symbol'], 'sell') - trade.update_trade(oobj) - - trade.close_date = datetime.utcnow() - trade.is_open = False res = rpc._rpc_enter_tag_performance(None) - assert len(res) == 1 - assert res[0]['enter_tag'] == 'Other' + assert len(res) == 3 + assert res[0]['enter_tag'] == 'TEST3' assert res[0]['count'] == 1 - assert prec_satoshi(res[0]['profit_pct'], 6.2) + assert res[0]['profit_pct'] == 10.0 - trade.enter_tag = "TEST_TAG" res = rpc._rpc_enter_tag_performance(None) - assert len(res) == 1 - assert res[0]['enter_tag'] == 'TEST_TAG' + assert len(res) == 3 + assert res[0]['enter_tag'] == 'TEST3' assert res[0]['count'] == 1 - assert prec_satoshi(res[0]['profit_pct'], 6.2) + assert res[0]['profit_pct'] == 10.0 def test_enter_tag_performance_handle_2(mocker, default_conf, markets, fee): @@ -979,8 +932,7 @@ def test_enter_tag_performance_handle_2(mocker, default_conf, markets, fee): assert prec_satoshi(res[0]['profit_pct'], 0.5) -def test_exit_reason_performance_handle(default_conf, ticker, limit_buy_order, fee, - limit_sell_order, mocker) -> None: +def test_exit_reason_performance_handle(default_conf_usdt, ticker, fee, mocker) -> None: mocker.patch('freqtrade.rpc.telegram.Telegram', MagicMock()) mocker.patch.multiple( 'freqtrade.exchange.Exchange', @@ -989,39 +941,22 @@ def test_exit_reason_performance_handle(default_conf, ticker, limit_buy_order, f get_fee=fee, ) - freqtradebot = get_patched_freqtradebot(mocker, default_conf) + freqtradebot = get_patched_freqtradebot(mocker, default_conf_usdt) patch_get_signal(freqtradebot) rpc = RPC(freqtradebot) # Create some test data - freqtradebot.enter_positions() - trade = Trade.query.first() - assert trade + create_mock_trades_usdt(fee) - # Simulate fulfilled LIMIT_BUY order for trade - oobj = Order.parse_from_ccxt_object(limit_buy_order, limit_buy_order['symbol'], 'buy') - trade.update_trade(oobj) - - # Simulate fulfilled LIMIT_SELL order for trade - oobj = Order.parse_from_ccxt_object(limit_sell_order, limit_sell_order['symbol'], 'sell') - trade.update_trade(oobj) - - trade.close_date = datetime.utcnow() - trade.is_open = False res = rpc._rpc_exit_reason_performance(None) - assert len(res) == 1 - assert res[0]['exit_reason'] == 'Other' + assert len(res) == 3 + assert res[0]['exit_reason'] == 'roi' assert res[0]['count'] == 1 - assert prec_satoshi(res[0]['profit_pct'], 6.2) + assert res[0]['profit_pct'] == 10.0 - trade.exit_reason = "TEST1" - res = rpc._rpc_exit_reason_performance(None) - - assert len(res) == 1 - assert res[0]['exit_reason'] == 'TEST1' - assert res[0]['count'] == 1 - assert prec_satoshi(res[0]['profit_pct'], 6.2) + assert res[1]['exit_reason'] == 'exit_signal' + assert res[2]['exit_reason'] == 'Other' def test_exit_reason_performance_handle_2(mocker, default_conf, markets, fee): @@ -1053,8 +988,7 @@ def test_exit_reason_performance_handle_2(mocker, default_conf, markets, fee): assert prec_satoshi(res[0]['profit_pct'], 0.5) -def test_mix_tag_performance_handle(default_conf, ticker, limit_buy_order, fee, - limit_sell_order, mocker) -> None: +def test_mix_tag_performance_handle(default_conf, ticker, fee, mocker) -> None: mocker.patch('freqtrade.rpc.telegram.Telegram', MagicMock()) mocker.patch.multiple( 'freqtrade.exchange.Exchange', @@ -1068,35 +1002,14 @@ def test_mix_tag_performance_handle(default_conf, ticker, limit_buy_order, fee, rpc = RPC(freqtradebot) # Create some test data - freqtradebot.enter_positions() - trade = Trade.query.first() - assert trade + create_mock_trades_usdt(fee) - # Simulate fulfilled LIMIT_BUY order for trade - oobj = Order.parse_from_ccxt_object(limit_buy_order, limit_buy_order['symbol'], 'buy') - trade.update_trade(oobj) - - # Simulate fulfilled LIMIT_SELL order for trade - oobj = Order.parse_from_ccxt_object(limit_sell_order, limit_sell_order['symbol'], 'sell') - trade.update_trade(oobj) - - trade.close_date = datetime.utcnow() - trade.is_open = False res = rpc._rpc_mix_tag_performance(None) - assert len(res) == 1 - assert res[0]['mix_tag'] == 'Other Other' + assert len(res) == 3 + assert res[0]['mix_tag'] == 'TEST3 roi' assert res[0]['count'] == 1 - assert prec_satoshi(res[0]['profit_pct'], 6.2) - - trade.enter_tag = "TESTBUY" - trade.exit_reason = "TESTSELL" - res = rpc._rpc_mix_tag_performance(None) - - assert len(res) == 1 - assert res[0]['mix_tag'] == 'TESTBUY TESTSELL' - assert res[0]['count'] == 1 - assert prec_satoshi(res[0]['profit_pct'], 6.2) + assert res[0]['profit_pct'] == 10.0 def test_mix_tag_performance_handle_2(mocker, default_conf, markets, fee): From ee0b9e3a5c3aa907d8901db89e5c375ccb42b406 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Mon, 13 Jun 2022 06:25:18 +0000 Subject: [PATCH 38/46] Bump mkdocs-material from 8.3.2 to 8.3.4 Bumps [mkdocs-material](https://github.com/squidfunk/mkdocs-material) from 8.3.2 to 8.3.4. - [Release notes](https://github.com/squidfunk/mkdocs-material/releases) - [Changelog](https://github.com/squidfunk/mkdocs-material/blob/master/CHANGELOG) - [Commits](https://github.com/squidfunk/mkdocs-material/compare/8.3.2...8.3.4) --- updated-dependencies: - dependency-name: mkdocs-material dependency-type: direct:production update-type: version-update:semver-patch ... Signed-off-by: dependabot[bot] --- docs/requirements-docs.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/requirements-docs.txt b/docs/requirements-docs.txt index f351151ab..1b4403b97 100644 --- a/docs/requirements-docs.txt +++ b/docs/requirements-docs.txt @@ -1,5 +1,5 @@ mkdocs==1.3.0 -mkdocs-material==8.3.2 +mkdocs-material==8.3.4 mdx_truly_sane_lists==1.2 pymdown-extensions==9.4 jinja2==3.1.2 From 71f314d4c45b39a91380c6b1a02876506b6430af Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Mon, 13 Jun 2022 06:25:35 +0000 Subject: [PATCH 39/46] Bump ccxt from 1.85.57 to 1.87.12 Bumps [ccxt](https://github.com/ccxt/ccxt) from 1.85.57 to 1.87.12. - [Release notes](https://github.com/ccxt/ccxt/releases) - [Changelog](https://github.com/ccxt/ccxt/blob/master/exchanges.cfg) - [Commits](https://github.com/ccxt/ccxt/compare/1.85.57...1.87.12) --- updated-dependencies: - dependency-name: ccxt dependency-type: direct:production update-type: version-update:semver-minor ... Signed-off-by: dependabot[bot] --- requirements.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/requirements.txt b/requirements.txt index 05d5a10db..4ebcdaa8b 100644 --- a/requirements.txt +++ b/requirements.txt @@ -2,7 +2,7 @@ numpy==1.22.4 pandas==1.4.2 pandas-ta==0.3.14b -ccxt==1.85.57 +ccxt==1.87.12 # Pin cryptography for now due to rust build errors with piwheels cryptography==37.0.2 aiohttp==3.8.1 From 43b8b0a083d527318f6033b26a395d8c5dbc7e86 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Mon, 13 Jun 2022 06:25:53 +0000 Subject: [PATCH 40/46] Bump mypy from 0.960 to 0.961 Bumps [mypy](https://github.com/python/mypy) from 0.960 to 0.961. - [Release notes](https://github.com/python/mypy/releases) - [Commits](https://github.com/python/mypy/compare/v0.960...v0.961) --- updated-dependencies: - dependency-name: mypy dependency-type: direct:development update-type: version-update:semver-minor ... Signed-off-by: dependabot[bot] --- requirements-dev.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/requirements-dev.txt b/requirements-dev.txt index e7d64a2b6..19912d59c 100644 --- a/requirements-dev.txt +++ b/requirements-dev.txt @@ -7,7 +7,7 @@ coveralls==3.3.1 flake8==4.0.1 flake8-tidy-imports==4.8.0 -mypy==0.960 +mypy==0.961 pre-commit==2.19.0 pytest==7.1.2 pytest-asyncio==0.18.3 From cb2f89bca63a73aea5b35f5a6b8d2ae48d5455c6 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Mon, 13 Jun 2022 06:26:23 +0000 Subject: [PATCH 41/46] Bump requests from 2.27.1 to 2.28.0 Bumps [requests](https://github.com/psf/requests) from 2.27.1 to 2.28.0. - [Release notes](https://github.com/psf/requests/releases) - [Changelog](https://github.com/psf/requests/blob/main/HISTORY.md) - [Commits](https://github.com/psf/requests/compare/v2.27.1...v2.28.0) --- updated-dependencies: - dependency-name: requests dependency-type: direct:production update-type: version-update:semver-minor ... Signed-off-by: dependabot[bot] --- requirements.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/requirements.txt b/requirements.txt index 05d5a10db..ba9cecafd 100644 --- a/requirements.txt +++ b/requirements.txt @@ -10,7 +10,7 @@ SQLAlchemy==1.4.37 python-telegram-bot==13.12 arrow==1.2.2 cachetools==4.2.2 -requests==2.27.1 +requests==2.28.0 urllib3==1.26.9 jsonschema==4.6.0 TA-Lib==0.4.24 From fdca583c6760a6ba76f04b076e373d09accf8291 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Mon, 13 Jun 2022 07:07:39 +0000 Subject: [PATCH 42/46] Bump pymdown-extensions from 9.4 to 9.5 Bumps [pymdown-extensions](https://github.com/facelessuser/pymdown-extensions) from 9.4 to 9.5. - [Release notes](https://github.com/facelessuser/pymdown-extensions/releases) - [Commits](https://github.com/facelessuser/pymdown-extensions/compare/9.4...9.5) --- updated-dependencies: - dependency-name: pymdown-extensions dependency-type: direct:production update-type: version-update:semver-minor ... Signed-off-by: dependabot[bot] --- docs/requirements-docs.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/requirements-docs.txt b/docs/requirements-docs.txt index 1b4403b97..1f342ca02 100644 --- a/docs/requirements-docs.txt +++ b/docs/requirements-docs.txt @@ -1,5 +1,5 @@ mkdocs==1.3.0 mkdocs-material==8.3.4 mdx_truly_sane_lists==1.2 -pymdown-extensions==9.4 +pymdown-extensions==9.5 jinja2==3.1.2 From 850f5d3842008406c9a24611fdb6e40e6c138ae1 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Mon, 13 Jun 2022 07:32:39 +0000 Subject: [PATCH 43/46] Bump orjson from 3.7.1 to 3.7.2 Bumps [orjson](https://github.com/ijl/orjson) from 3.7.1 to 3.7.2. - [Release notes](https://github.com/ijl/orjson/releases) - [Changelog](https://github.com/ijl/orjson/blob/master/CHANGELOG.md) - [Commits](https://github.com/ijl/orjson/compare/3.7.1...3.7.2) --- updated-dependencies: - dependency-name: orjson dependency-type: direct:production update-type: version-update:semver-patch ... Signed-off-by: dependabot[bot] --- requirements.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/requirements.txt b/requirements.txt index 9b7d87e02..b2dbd921e 100644 --- a/requirements.txt +++ b/requirements.txt @@ -28,7 +28,7 @@ py_find_1st==1.1.5 # Load ticker files 30% faster python-rapidjson==1.6 # Properly format api responses -orjson==3.7.1 +orjson==3.7.2 # Notify systemd sdnotify==0.3.2 From 35adeb64122a02d52f2515b53ea3bd125c2a8d31 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Mon, 13 Jun 2022 07:33:30 +0000 Subject: [PATCH 44/46] Bump plotly from 5.8.0 to 5.8.2 Bumps [plotly](https://github.com/plotly/plotly.py) from 5.8.0 to 5.8.2. - [Release notes](https://github.com/plotly/plotly.py/releases) - [Changelog](https://github.com/plotly/plotly.py/blob/master/CHANGELOG.md) - [Commits](https://github.com/plotly/plotly.py/compare/v5.8.0...v5.8.2) --- updated-dependencies: - dependency-name: plotly dependency-type: direct:production update-type: version-update:semver-patch ... Signed-off-by: dependabot[bot] --- requirements-plot.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/requirements-plot.txt b/requirements-plot.txt index e17efbc71..a2a894c57 100644 --- a/requirements-plot.txt +++ b/requirements-plot.txt @@ -1,4 +1,4 @@ # Include all requirements to run the bot. -r requirements.txt -plotly==5.8.0 +plotly==5.8.2 From 848a5d85c63f7655f958c700e1e82caaa69d2b9f Mon Sep 17 00:00:00 2001 From: Matthias Date: Mon, 13 Jun 2022 08:50:12 +0000 Subject: [PATCH 45/46] Add small stability fix to test --- tests/test_freqtradebot.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/tests/test_freqtradebot.py b/tests/test_freqtradebot.py index 7f9bc6a46..3fd16f925 100644 --- a/tests/test_freqtradebot.py +++ b/tests/test_freqtradebot.py @@ -3775,6 +3775,7 @@ def test_exit_profit_only( trade = Trade.query.first() assert trade.is_short == is_short oobj = Order.parse_from_ccxt_object(limit_order[eside], limit_order[eside]['symbol'], eside) + trade.update_order(limit_order[eside]) trade.update_trade(oobj) freqtrade.wallets.update() if profit_only: @@ -4063,6 +4064,7 @@ def test_trailing_stop_loss_positive( trade = Trade.query.first() assert trade.is_short == is_short oobj = Order.parse_from_ccxt_object(limit_order[eside], limit_order[eside]['symbol'], eside) + trade.update_order(limit_order[eside]) trade.update_trade(oobj) caplog.set_level(logging.DEBUG) # stop-loss not reached From d5fd1f9c3848469b4ce1fa1039ef533acc09c0f2 Mon Sep 17 00:00:00 2001 From: Matthias Date: Mon, 13 Jun 2022 13:24:27 +0000 Subject: [PATCH 46/46] Improve order filled handling --- freqtrade/persistence/trade_model.py | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/freqtrade/persistence/trade_model.py b/freqtrade/persistence/trade_model.py index 0be9d22c1..79f58591d 100644 --- a/freqtrade/persistence/trade_model.py +++ b/freqtrade/persistence/trade_model.py @@ -74,7 +74,7 @@ class Order(_DECL_BASE): @property def safe_filled(self) -> float: - return self.filled or self.amount or 0.0 + return self.filled if self.filled is not None else self.amount or 0.0 @property def safe_fee_base(self) -> float: @@ -847,8 +847,6 @@ class LocalTrade(): tmp_amount = o.safe_amount_after_fee tmp_price = o.average or o.price - if o.filled is not None: - tmp_amount = o.filled if tmp_amount > 0.0 and tmp_price is not None: total_amount += tmp_amount total_stake += tmp_price * tmp_amount