diff --git a/freqtrade/enums/signaltype.py b/freqtrade/enums/signaltype.py index d2995d57a..4437f49e3 100644 --- a/freqtrade/enums/signaltype.py +++ b/freqtrade/enums/signaltype.py @@ -14,3 +14,4 @@ class SignalTagType(Enum): Enum for signal columns """ BUY_TAG = "buy_tag" + EXIT_TAG = "exit_tag" diff --git a/freqtrade/freqtradebot.py b/freqtrade/freqtradebot.py index bf4742fdc..d23ba270d 100644 --- a/freqtrade/freqtradebot.py +++ b/freqtrade/freqtradebot.py @@ -201,11 +201,11 @@ class FreqtradeBot(LoggingMixin): if len(open_trades) != 0: msg = { 'type': RPCMessageType.WARNING, - 'status': f"{len(open_trades)} open trades active.\n\n" - f"Handle these trades manually on {self.exchange.name}, " - f"or '/start' the bot again and use '/stopbuy' " - f"to handle open trades gracefully. \n" - f"{'Trades are simulated.' if self.config['dry_run'] else ''}", + 'status': f"{len(open_trades)} open trades active.\n\n" + f"Handle these trades manually on {self.exchange.name}, " + f"or '/start' the bot again and use '/stopbuy' " + f"to handle open trades gracefully. \n" + f"{'Trades are simulated.' if self.config['dry_run'] else ''}", } self.rpc.send_msg(msg) @@ -420,7 +420,7 @@ class FreqtradeBot(LoggingMixin): return False # running get_signal on historical data fetched - (buy, sell, buy_tag) = self.strategy.get_signal( + (buy, sell, buy_tag, _) = self.strategy.get_signal( pair, self.strategy.timeframe, analyzed_df @@ -700,21 +700,22 @@ class FreqtradeBot(LoggingMixin): logger.debug('Handling %s ...', trade) (buy, sell) = (False, False) + exit_tag = None if (self.config.get('use_sell_signal', True) or self.config.get('ignore_roi_if_buy_signal', False)): analyzed_df, _ = self.dataprovider.get_analyzed_dataframe(trade.pair, self.strategy.timeframe) - (buy, sell, _) = self.strategy.get_signal( + (buy, sell, _, exit_tag) = self.strategy.get_signal( trade.pair, self.strategy.timeframe, analyzed_df ) logger.debug('checking sell') - exit_rate = self.exchange.get_rate(trade.pair, refresh=True, side="sell") - if self._check_and_execute_exit(trade, exit_rate, buy, sell): + sell_rate = self.exchange.get_rate(trade.pair, refresh=True, side="sell") + if self._check_and_execute_exit(trade, sell_rate, buy, sell, exit_tag): return True logger.debug('Found no sell signal for %s.', trade) @@ -852,18 +853,21 @@ class FreqtradeBot(LoggingMixin): f"for pair {trade.pair}.") def _check_and_execute_exit(self, trade: Trade, exit_rate: float, - buy: bool, sell: bool) -> bool: + buy: bool, sell: bool, exit_tag: Optional[str]) -> bool: """ Check and execute exit """ + should_sell = self.strategy.should_sell( trade, exit_rate, datetime.now(timezone.utc), buy, sell, force_stoploss=self.edge.stoploss(trade.pair) if self.edge else 0 ) if should_sell.sell_flag: - logger.info(f'Executing Sell for {trade.pair}. Reason: {should_sell.sell_type}') - self.execute_trade_exit(trade, exit_rate, should_sell) + logger.info( + f'Executing Sell for {trade.pair}. Reason: {should_sell.sell_type}. ' + f'Tag: {exit_tag if exit_tag is not None else "None"}') + self.execute_trade_exit(trade, exit_rate, should_sell, exit_tag) return True return False @@ -1064,7 +1068,12 @@ class FreqtradeBot(LoggingMixin): raise DependencyException( f"Not enough amount to sell. Trade-amount: {amount}, Wallet: {wallet_amount}") - def execute_trade_exit(self, trade: Trade, limit: float, sell_reason: SellCheckTuple) -> bool: + def execute_trade_exit( + self, + trade: Trade, + limit: float, + sell_reason: SellCheckTuple, + exit_tag: Optional[str] = None) -> bool: """ Executes a trade exit for the given trade and limit :param trade: Trade instance @@ -1140,7 +1149,7 @@ class FreqtradeBot(LoggingMixin): trade.open_order_id = order['id'] trade.sell_order_status = '' trade.close_rate_requested = limit - trade.sell_reason = sell_reason.sell_reason + trade.sell_reason = exit_tag or sell_reason.sell_reason # In case of market sell orders the order can be closed immediately if order.get('status', 'unknown') in ('closed', 'expired'): self.update_trade_state(trade, trade.open_order_id, order) @@ -1181,6 +1190,7 @@ class FreqtradeBot(LoggingMixin): 'current_rate': current_rate, 'profit_amount': profit_trade, 'profit_ratio': profit_ratio, + 'buy_tag': trade.buy_tag, 'sell_reason': trade.sell_reason, 'open_date': trade.open_date, 'close_date': trade.close_date or datetime.utcnow(), @@ -1224,6 +1234,7 @@ class FreqtradeBot(LoggingMixin): 'current_rate': current_rate, 'profit_amount': profit_trade, 'profit_ratio': profit_ratio, + 'buy_tag': trade.buy_tag, 'sell_reason': trade.sell_reason, 'open_date': trade.open_date, 'close_date': trade.close_date or datetime.now(timezone.utc), diff --git a/freqtrade/optimize/backtesting.py b/freqtrade/optimize/backtesting.py index 82c072867..2f074500f 100644 --- a/freqtrade/optimize/backtesting.py +++ b/freqtrade/optimize/backtesting.py @@ -44,6 +44,7 @@ SELL_IDX = 4 LOW_IDX = 5 HIGH_IDX = 6 BUY_TAG_IDX = 7 +EXIT_TAG_IDX = 8 class Backtesting: @@ -247,7 +248,7 @@ class Backtesting: """ # Every change to this headers list must evaluate further usages of the resulting tuple # and eventually change the constants for indexes at the top - headers = ['date', 'buy', 'open', 'close', 'sell', 'low', 'high', 'buy_tag'] + headers = ['date', 'buy', 'open', 'close', 'sell', 'low', 'high', 'buy_tag', 'exit_tag'] data: Dict = {} self.progress.init_step(BacktestState.CONVERT, len(processed)) @@ -259,6 +260,7 @@ class Backtesting: pair_data.loc[:, 'buy'] = 0 # cleanup if buy_signal is exist pair_data.loc[:, 'sell'] = 0 # cleanup if sell_signal is exist pair_data.loc[:, 'buy_tag'] = None # cleanup if buy_tag is exist + pair_data.loc[:, 'exit_tag'] = None # cleanup if exit_tag is exist df_analyzed = self.strategy.advise_sell( self.strategy.advise_buy(pair_data, {'pair': pair}), {'pair': pair}).copy() @@ -270,6 +272,7 @@ class Backtesting: df_analyzed.loc[:, 'buy'] = df_analyzed.loc[:, 'buy'].shift(1) df_analyzed.loc[:, 'sell'] = df_analyzed.loc[:, 'sell'].shift(1) df_analyzed.loc[:, 'buy_tag'] = df_analyzed.loc[:, 'buy_tag'].shift(1) + df_analyzed.loc[:, 'exit_tag'] = df_analyzed.loc[:, 'exit_tag'].shift(1) # Update dataprovider cache self.dataprovider._set_cached_df(pair, self.timeframe, df_analyzed) @@ -360,6 +363,16 @@ class Backtesting: if sell.sell_flag: trade.close_date = sell_candle_time trade.sell_reason = sell.sell_reason + + # Checks and adds an exit tag, after checking that the length of the + # sell_row has the length for an exit tag column + if( + len(sell_row) > EXIT_TAG_IDX + and sell_row[EXIT_TAG_IDX] is not None + and len(sell_row[EXIT_TAG_IDX]) > 0 + ): + trade.sell_reason = sell_row[EXIT_TAG_IDX] + trade_dur = int((trade.close_date_utc - trade.open_date_utc).total_seconds() // 60) closerate = self._get_close_rate(sell_row, trade, sell, trade_dur) @@ -387,7 +400,7 @@ class Backtesting: detail_data = detail_data.loc[ (detail_data['date'] >= sell_candle_time) & (detail_data['date'] < sell_candle_end) - ].copy() + ].copy() if len(detail_data) == 0: # Fall back to "regular" data if no detail data was found for this candle return self._get_sell_trade_entry_for_candle(trade, sell_row) diff --git a/freqtrade/optimize/optimize_reports.py b/freqtrade/optimize/optimize_reports.py index 09de655ef..a3a1aa5ce 100644 --- a/freqtrade/optimize/optimize_reports.py +++ b/freqtrade/optimize/optimize_reports.py @@ -55,6 +55,15 @@ def _get_line_header(first_column: str, stake_currency: str) -> List[str]: 'Win Draw Loss Win%'] +def _get_line_header_sell(first_column: str, stake_currency: str) -> List[str]: + """ + Generate header lines (goes in line with _generate_result_line()) + """ + return [first_column, 'Sells', 'Avg Profit %', 'Cum Profit %', + f'Tot Profit {stake_currency}', 'Tot Profit %', 'Avg Duration', + 'Win Draw Loss Win%'] + + def _generate_wins_draws_losses(wins, draws, losses): if wins > 0 and losses == 0: wl_ratio = '100' @@ -127,6 +136,71 @@ def generate_pair_metrics(data: Dict[str, Dict], stake_currency: str, starting_b return tabular_data +def generate_tag_metrics(tag_type: str, + starting_balance: int, + results: DataFrame, + skip_nan: bool = False) -> List[Dict]: + """ + Generates and returns a list of metrics for the given tag trades and the results dataframe + :param starting_balance: Starting balance + :param results: Dataframe containing the backtest results + :param skip_nan: Print "left open" open trades + :return: List of Dicts containing the metrics per pair + """ + + tabular_data = [] + + if tag_type in results.columns: + for tag, count in results[tag_type].value_counts().iteritems(): + result = results[results[tag_type] == tag] + if skip_nan and result['profit_abs'].isnull().all(): + continue + + tabular_data.append(_generate_tag_result_line(result, starting_balance, tag)) + + # Sort by total profit %: + tabular_data = sorted(tabular_data, key=lambda k: k['profit_total_abs'], reverse=True) + + # Append Total + tabular_data.append(_generate_result_line(results, starting_balance, 'TOTAL')) + return tabular_data + else: + return [] + + +def _generate_tag_result_line(result: DataFrame, starting_balance: int, first_column: str) -> Dict: + """ + Generate one result dict, with "first_column" as key. + """ + profit_sum = result['profit_ratio'].sum() + # (end-capital - starting capital) / starting capital + profit_total = result['profit_abs'].sum() / starting_balance + + return { + 'key': first_column, + 'trades': len(result), + 'profit_mean': result['profit_ratio'].mean() if len(result) > 0 else 0.0, + 'profit_mean_pct': result['profit_ratio'].mean() * 100.0 if len(result) > 0 else 0.0, + 'profit_sum': profit_sum, + 'profit_sum_pct': round(profit_sum * 100.0, 2), + 'profit_total_abs': result['profit_abs'].sum(), + 'profit_total': profit_total, + 'profit_total_pct': round(profit_total * 100.0, 2), + 'duration_avg': str(timedelta( + minutes=round(result['trade_duration'].mean())) + ) if not result.empty else '0:00', + # 'duration_max': str(timedelta( + # minutes=round(result['trade_duration'].max())) + # ) if not result.empty else '0:00', + # 'duration_min': str(timedelta( + # minutes=round(result['trade_duration'].min())) + # ) if not result.empty else '0:00', + 'wins': len(result[result['profit_abs'] > 0]), + 'draws': len(result[result['profit_abs'] == 0]), + 'losses': len(result[result['profit_abs'] < 0]), + } + + def generate_sell_reason_stats(max_open_trades: int, results: DataFrame) -> List[Dict]: """ Generate small table outlining Backtest results @@ -347,6 +421,10 @@ def generate_strategy_stats(btdata: Dict[str, DataFrame], pair_results = generate_pair_metrics(btdata, stake_currency=stake_currency, starting_balance=starting_balance, results=results, skip_nan=False) + + buy_tag_results = generate_tag_metrics("buy_tag", starting_balance=starting_balance, + results=results, skip_nan=False) + sell_reason_stats = generate_sell_reason_stats(max_open_trades=max_open_trades, results=results) left_open_results = generate_pair_metrics(btdata, stake_currency=stake_currency, @@ -370,6 +448,7 @@ def generate_strategy_stats(btdata: Dict[str, DataFrame], 'best_pair': best_pair, 'worst_pair': worst_pair, 'results_per_pair': pair_results, + 'results_per_buy_tag': buy_tag_results, 'sell_reason_summary': sell_reason_stats, 'left_open_trades': left_open_results, # 'days_breakdown_stats': days_breakdown_stats, @@ -542,6 +621,37 @@ def text_table_sell_reason(sell_reason_stats: List[Dict[str, Any]], stake_curren return tabulate(output, headers=headers, tablefmt="orgtbl", stralign="right") +def text_table_tags(tag_type: str, tag_results: List[Dict[str, Any]], stake_currency: str) -> str: + """ + Generates and returns a text table for the given backtest data and the results dataframe + :param pair_results: List of Dictionaries - one entry per pair + final TOTAL row + :param stake_currency: stake-currency - used to correctly name headers + :return: pretty printed table with tabulate as string + """ + if(tag_type == "buy_tag"): + headers = _get_line_header("TAG", stake_currency) + else: + headers = _get_line_header_sell("TAG", stake_currency) + floatfmt = _get_line_floatfmt(stake_currency) + output = [ + [ + t['key'] if t['key'] is not None and len( + t['key']) > 0 else "OTHER", + t['trades'], + t['profit_mean_pct'], + t['profit_sum_pct'], + t['profit_total_abs'], + t['profit_total_pct'], + t['duration_avg'], + _generate_wins_draws_losses( + t['wins'], + t['draws'], + t['losses'])] for t in tag_results] + # Ignore type as floatfmt does allow tuples but mypy does not know that + return tabulate(output, headers=headers, + floatfmt=floatfmt, tablefmt="orgtbl", stralign="right") + + def text_table_periodic_breakdown(days_breakdown_stats: List[Dict[str, Any]], stake_currency: str, period: str) -> str: """ @@ -687,6 +797,16 @@ def show_backtest_result(strategy: str, results: Dict[str, Any], stake_currency: print(' BACKTESTING REPORT '.center(len(table.splitlines()[0]), '=')) print(table) + if(results['results_per_buy_tag'] is not None): + table = text_table_tags( + "buy_tag", + results['results_per_buy_tag'], + stake_currency=stake_currency) + + if isinstance(table, str) and len(table) > 0: + print(' BUY TAG STATS '.center(len(table.splitlines()[0]), '=')) + print(table) + table = text_table_sell_reason(sell_reason_stats=results['sell_reason_summary'], stake_currency=stake_currency) if isinstance(table, str) and len(table) > 0: @@ -714,6 +834,7 @@ def show_backtest_result(strategy: str, results: Dict[str, Any], stake_currency: if isinstance(table, str) and len(table) > 0: print('=' * len(table.splitlines()[0])) + print() diff --git a/freqtrade/persistence/models.py b/freqtrade/persistence/models.py index 04f1d67b2..b3518c228 100644 --- a/freqtrade/persistence/models.py +++ b/freqtrade/persistence/models.py @@ -853,13 +853,131 @@ class Trade(_DECL_BASE, LocalTrade): return [ { 'pair': pair, - 'profit': profit, + 'profit_ratio': profit, + 'profit': round(profit * 100, 2), # Compatibility mode + 'profit_pct': round(profit * 100, 2), 'profit_abs': profit_abs, 'count': count } for pair, profit, profit_abs, count in pair_rates ] + @staticmethod + def get_buy_tag_performance(pair: Optional[str]) -> List[Dict[str, Any]]: + """ + Returns List of dicts containing all Trades, based on buy tag performance + Can either be average for all pairs or a specific pair provided + NOTE: Not supported in Backtesting. + """ + + filters = [Trade.is_open.is_(False)] + if(pair is not None): + filters.append(Trade.pair == pair) + + buy_tag_perf = Trade.query.with_entities( + Trade.buy_tag, + func.sum(Trade.close_profit).label('profit_sum'), + func.sum(Trade.close_profit_abs).label('profit_sum_abs'), + func.count(Trade.pair).label('count') + ).filter(*filters)\ + .group_by(Trade.buy_tag) \ + .order_by(desc('profit_sum_abs')) \ + .all() + + return [ + { + 'buy_tag': buy_tag if buy_tag is not None else "Other", + 'profit_ratio': profit, + 'profit_pct': round(profit * 100, 2), + 'profit_abs': profit_abs, + 'count': count + } + for buy_tag, profit, profit_abs, count in buy_tag_perf + ] + + @staticmethod + def get_sell_reason_performance(pair: Optional[str]) -> List[Dict[str, Any]]: + """ + Returns List of dicts containing all Trades, based on sell reason performance + Can either be average for all pairs or a specific pair provided + NOTE: Not supported in Backtesting. + """ + + filters = [Trade.is_open.is_(False)] + if(pair is not None): + filters.append(Trade.pair == pair) + + sell_tag_perf = Trade.query.with_entities( + Trade.sell_reason, + func.sum(Trade.close_profit).label('profit_sum'), + func.sum(Trade.close_profit_abs).label('profit_sum_abs'), + func.count(Trade.pair).label('count') + ).filter(*filters)\ + .group_by(Trade.sell_reason) \ + .order_by(desc('profit_sum_abs')) \ + .all() + + return [ + { + 'sell_reason': sell_reason if sell_reason is not None else "Other", + 'profit_ratio': profit, + 'profit_pct': round(profit * 100, 2), + 'profit_abs': profit_abs, + 'count': count + } + for sell_reason, profit, profit_abs, count in sell_tag_perf + ] + + @staticmethod + def get_mix_tag_performance(pair: Optional[str]) -> List[Dict[str, Any]]: + """ + Returns List of dicts containing all Trades, based on buy_tag + sell_reason performance + Can either be average for all pairs or a specific pair provided + NOTE: Not supported in Backtesting. + """ + + filters = [Trade.is_open.is_(False)] + if(pair is not None): + filters.append(Trade.pair == pair) + + mix_tag_perf = Trade.query.with_entities( + Trade.id, + Trade.buy_tag, + Trade.sell_reason, + func.sum(Trade.close_profit).label('profit_sum'), + func.sum(Trade.close_profit_abs).label('profit_sum_abs'), + func.count(Trade.pair).label('count') + ).filter(*filters)\ + .group_by(Trade.id) \ + .order_by(desc('profit_sum_abs')) \ + .all() + + return_list: List[Dict] = [] + for id, buy_tag, sell_reason, profit, profit_abs, count in mix_tag_perf: + buy_tag = buy_tag if buy_tag is not None else "Other" + sell_reason = sell_reason if sell_reason is not None else "Other" + + if(sell_reason is not None and buy_tag is not None): + mix_tag = buy_tag + " " + sell_reason + i = 0 + if not any(item["mix_tag"] == mix_tag for item in return_list): + return_list.append({'mix_tag': mix_tag, + 'profit': profit, + 'profit_abs': profit_abs, + 'count': count}) + else: + while i < len(return_list): + if return_list[i]["mix_tag"] == mix_tag: + return_list[i] = { + 'mix_tag': mix_tag, + 'profit': profit + return_list[i]["profit"], + 'profit_abs': profit_abs + return_list[i]["profit_abs"], + 'count': 1 + return_list[i]["count"]} + i += 1 + + [x.update({'profit': round(x['profit'] * 100, 2)}) for x in return_list] + return return_list + @staticmethod def get_best_pair(start_date: datetime = datetime.fromtimestamp(0)): """ diff --git a/freqtrade/rpc/api_server/api_schemas.py b/freqtrade/rpc/api_server/api_schemas.py index e9985c3c6..ff1915fca 100644 --- a/freqtrade/rpc/api_server/api_schemas.py +++ b/freqtrade/rpc/api_server/api_schemas.py @@ -63,6 +63,8 @@ class Count(BaseModel): class PerformanceEntry(BaseModel): pair: str profit: float + profit_ratio: float + profit_pct: float profit_abs: float count: int diff --git a/freqtrade/rpc/rpc.py b/freqtrade/rpc/rpc.py index d0858350c..da8d23b7a 100644 --- a/freqtrade/rpc/rpc.py +++ b/freqtrade/rpc/rpc.py @@ -105,7 +105,7 @@ class RPC: val = { 'dry_run': config['dry_run'], 'stake_currency': config['stake_currency'], - 'stake_currency_decimals': decimals_per_coin(config['stake_currency']), + 'stake_currency_decimals': decimals_per_coin(config['stake_currency']), 'stake_amount': config['stake_amount'], 'available_capital': config.get('available_capital'), 'max_open_trades': (config['max_open_trades'] @@ -682,10 +682,36 @@ class RPC: Shows a performance statistic from finished trades """ pair_rates = Trade.get_overall_performance() - # Round and convert to % - [x.update({'profit': round(x['profit'] * 100, 2)}) for x in pair_rates] + return pair_rates + def _rpc_buy_tag_performance(self, pair: Optional[str]) -> List[Dict[str, Any]]: + """ + Handler for buy tag performance. + Shows a performance statistic from finished trades + """ + buy_tags = Trade.get_buy_tag_performance(pair) + + return buy_tags + + def _rpc_sell_reason_performance(self, pair: Optional[str]) -> List[Dict[str, Any]]: + """ + Handler for sell reason performance. + Shows a performance statistic from finished trades + """ + sell_reasons = Trade.get_sell_reason_performance(pair) + + return sell_reasons + + def _rpc_mix_tag_performance(self, pair: Optional[str]) -> List[Dict[str, Any]]: + """ + Handler for mix tag (buy_tag + sell_reason) performance. + Shows a performance statistic from finished trades + """ + mix_tags = Trade.get_mix_tag_performance(pair) + + return mix_tags + def _rpc_count(self) -> Dict[str, float]: """ Returns the number of trades running """ if self._freqtrade.state != State.RUNNING: diff --git a/freqtrade/rpc/telegram.py b/freqtrade/rpc/telegram.py index 073583940..77d5f06e2 100644 --- a/freqtrade/rpc/telegram.py +++ b/freqtrade/rpc/telegram.py @@ -107,8 +107,8 @@ class Telegram(RPCHandler): # this needs refactoring of the whole telegram module (same # problem in _help()). valid_keys: List[str] = [r'/start$', r'/stop$', r'/status$', r'/status table$', - r'/trades$', r'/performance$', r'/daily$', r'/daily \d+$', - r'/profit$', r'/profit \d+', + r'/trades$', r'/performance$', r'/buys', r'/sells', r'/mix_tags', + r'/daily$', r'/daily \d+$', r'/profit$', r'/profit \d+', r'/stats$', r'/count$', r'/locks$', r'/balance$', r'/stopbuy$', r'/reload_config$', r'/show_config$', r'/logs$', r'/whitelist$', r'/blacklist$', r'/edge$', @@ -154,6 +154,9 @@ class Telegram(RPCHandler): CommandHandler('trades', self._trades), CommandHandler('delete', self._delete_trade), CommandHandler('performance', self._performance), + CommandHandler('buys', self._buy_tag_performance), + CommandHandler('sells', self._sell_reason_performance), + CommandHandler('mix_tags', self._mix_tag_performance), CommandHandler('stats', self._stats), CommandHandler('daily', self._daily), CommandHandler('count', self._count), @@ -175,6 +178,10 @@ class Telegram(RPCHandler): CallbackQueryHandler(self._profit, pattern='update_profit'), CallbackQueryHandler(self._balance, pattern='update_balance'), CallbackQueryHandler(self._performance, pattern='update_performance'), + CallbackQueryHandler(self._buy_tag_performance, pattern='update_buy_tag_performance'), + CallbackQueryHandler(self._sell_reason_performance, + pattern='update_sell_reason_performance'), + CallbackQueryHandler(self._mix_tag_performance, pattern='update_mix_tag_performance'), CallbackQueryHandler(self._count, pattern='update_count'), CallbackQueryHandler(self._forcebuy_inline), ] @@ -238,6 +245,7 @@ class Telegram(RPCHandler): microsecond=0) - msg['open_date'].replace(microsecond=0) msg['duration_min'] = msg['duration'].total_seconds() / 60 + msg['buy_tag'] = msg['buy_tag'] if "buy_tag" in msg.keys() else None msg['emoji'] = self._get_sell_emoji(msg) # Check if all sell properties are available. @@ -253,6 +261,7 @@ class Telegram(RPCHandler): message = ("{emoji} *{exchange}:* Selling {pair} (#{trade_id})\n" "*Profit:* `{profit_percent:.2f}%{profit_extra}`\n" + "*Buy Tag:* `{buy_tag}`\n" "*Sell Reason:* `{sell_reason}`\n" "*Duration:* `{duration} ({duration_min:.1f} min)`\n" "*Amount:* `{amount:.8f}`\n" @@ -852,7 +861,7 @@ class Telegram(RPCHandler): stat_line = ( f"{i+1}.\t {trade['pair']}\t" f"{round_coin_value(trade['profit_abs'], self._config['stake_currency'])} " - f"({trade['profit']:.2f}%) " + f"({trade['profit_pct']:.2f}%) " f"({trade['count']})\n") if len(output + stat_line) >= MAX_TELEGRAM_MESSAGE_LENGTH: @@ -867,6 +876,111 @@ class Telegram(RPCHandler): except RPCException as e: self._send_msg(str(e)) + @authorized_only + def _buy_tag_performance(self, update: Update, context: CallbackContext) -> None: + """ + Handler for /buys PAIR . + Shows a performance statistic from finished trades + :param bot: telegram bot + :param update: message update + :return: None + """ + try: + pair = None + if context.args and isinstance(context.args[0], str): + pair = context.args[0] + + trades = self._rpc._rpc_buy_tag_performance(pair) + output = "Buy Tag Performance:\n" + for i, trade in enumerate(trades): + stat_line = ( + f"{i+1}.\t {trade['buy_tag']}\t" + f"{round_coin_value(trade['profit_abs'], self._config['stake_currency'])} " + f"({trade['profit_pct']:.2f}%) " + f"({trade['count']})\n") + + if len(output + stat_line) >= MAX_TELEGRAM_MESSAGE_LENGTH: + self._send_msg(output, parse_mode=ParseMode.HTML) + output = stat_line + else: + output += stat_line + + self._send_msg(output, parse_mode=ParseMode.HTML, + reload_able=True, callback_path="update_buy_tag_performance", + query=update.callback_query) + except RPCException as e: + self._send_msg(str(e)) + + @authorized_only + def _sell_reason_performance(self, update: Update, context: CallbackContext) -> None: + """ + Handler for /sells. + Shows a performance statistic from finished trades + :param bot: telegram bot + :param update: message update + :return: None + """ + try: + pair = None + if context.args and isinstance(context.args[0], str): + pair = context.args[0] + + trades = self._rpc._rpc_sell_reason_performance(pair) + output = "Sell Reason Performance:\n" + for i, trade in enumerate(trades): + stat_line = ( + f"{i+1}.\t {trade['sell_reason']}\t" + f"{round_coin_value(trade['profit_abs'], self._config['stake_currency'])} " + f"({trade['profit_pct']:.2f}%) " + f"({trade['count']})\n") + + if len(output + stat_line) >= MAX_TELEGRAM_MESSAGE_LENGTH: + self._send_msg(output, parse_mode=ParseMode.HTML) + output = stat_line + else: + output += stat_line + + self._send_msg(output, parse_mode=ParseMode.HTML, + reload_able=True, callback_path="update_sell_reason_performance", + query=update.callback_query) + except RPCException as e: + self._send_msg(str(e)) + + @authorized_only + def _mix_tag_performance(self, update: Update, context: CallbackContext) -> None: + """ + Handler for /mix_tags. + Shows a performance statistic from finished trades + :param bot: telegram bot + :param update: message update + :return: None + """ + try: + pair = None + if context.args and isinstance(context.args[0], str): + pair = context.args[0] + + trades = self._rpc._rpc_mix_tag_performance(pair) + output = "Mix Tag Performance:\n" + for i, trade in enumerate(trades): + stat_line = ( + f"{i+1}.\t {trade['mix_tag']}\t" + f"{round_coin_value(trade['profit_abs'], self._config['stake_currency'])} " + f"({trade['profit']:.2f}%) " + f"({trade['count']})\n") + + if len(output + stat_line) >= MAX_TELEGRAM_MESSAGE_LENGTH: + self._send_msg(output, parse_mode=ParseMode.HTML) + output = stat_line + else: + output += stat_line + + self._send_msg(output, parse_mode=ParseMode.HTML, + reload_able=True, callback_path="update_mix_tag_performance", + query=update.callback_query) + except RPCException as e: + self._send_msg(str(e)) + @authorized_only def _count(self, update: Update, context: CallbackContext) -> None: """ @@ -1043,6 +1157,9 @@ class Telegram(RPCHandler): " *table :* `will display trades in a table`\n" " `pending buy orders are marked with an asterisk (*)`\n" " `pending sell orders are marked with a double asterisk (**)`\n" + "*/buys :* `Shows the buy_tag performance`\n" + "*/sells :* `Shows the sell reason performance`\n" + "*/mix_tags :* `Shows combined buy tag + sell reason performance`\n" "*/trades [limit]:* `Lists last closed trades (limited to 10 by default)`\n" "*/profit []:* `Lists cumulative profit from all finished trades, " "over the last n days`\n" diff --git a/freqtrade/strategy/interface.py b/freqtrade/strategy/interface.py index 0bbfc8906..d11097ed2 100644 --- a/freqtrade/strategy/interface.py +++ b/freqtrade/strategy/interface.py @@ -509,6 +509,7 @@ class IStrategy(ABC, HyperStrategyMixin): dataframe['buy'] = 0 dataframe['sell'] = 0 dataframe['buy_tag'] = None + dataframe['exit_tag'] = None # Other Defs in strategy that want to be called every loop here # twitter_sell = self.watch_twitter_feed(dataframe, metadata) @@ -586,7 +587,7 @@ class IStrategy(ABC, HyperStrategyMixin): pair: str, timeframe: str, dataframe: DataFrame - ) -> Tuple[bool, bool, Optional[str]]: + ) -> Tuple[bool, bool, Optional[str], Optional[str]]: """ Calculates current signal based based on the buy / sell columns of the dataframe. Used by Bot to get the signal to buy or sell @@ -597,7 +598,7 @@ class IStrategy(ABC, HyperStrategyMixin): """ if not isinstance(dataframe, DataFrame) or dataframe.empty: logger.warning(f'Empty candle (OHLCV) data for pair {pair}') - return False, False, None + return False, False, None, None latest_date = dataframe['date'].max() latest = dataframe.loc[dataframe['date'] == latest_date].iloc[-1] @@ -612,7 +613,7 @@ class IStrategy(ABC, HyperStrategyMixin): 'Outdated history for pair %s. Last tick is %s minutes old', pair, int((arrow.utcnow() - latest_date).total_seconds() // 60) ) - return False, False, None + return False, False, None, None buy = latest[SignalType.BUY.value] == 1 @@ -621,6 +622,7 @@ class IStrategy(ABC, HyperStrategyMixin): sell = latest[SignalType.SELL.value] == 1 buy_tag = latest.get(SignalTagType.BUY_TAG.value, None) + exit_tag = latest.get(SignalTagType.EXIT_TAG.value, None) logger.debug('trigger: %s (pair=%s) buy=%s sell=%s', latest['date'], pair, str(buy), str(sell)) @@ -629,8 +631,8 @@ class IStrategy(ABC, HyperStrategyMixin): current_time=datetime.now(timezone.utc), timeframe_seconds=timeframe_seconds, buy=buy): - return False, sell, buy_tag - return buy, sell, buy_tag + return False, sell, buy_tag, exit_tag + return buy, sell, buy_tag, exit_tag def ignore_expired_candle(self, latest_date: datetime, current_time: datetime, timeframe_seconds: int, buy: bool): diff --git a/tests/conftest.py b/tests/conftest.py index b35a220df..698c464ed 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -186,7 +186,7 @@ def get_patched_worker(mocker, config) -> Worker: return Worker(args=None, config=config) -def patch_get_signal(freqtrade: FreqtradeBot, value=(True, False, None)) -> None: +def patch_get_signal(freqtrade: FreqtradeBot, value=(True, False, None, None)) -> None: """ :param mocker: mocker to patch IStrategy class :param value: which value IStrategy.get_signal() must return diff --git a/tests/conftest_trades.py b/tests/conftest_trades.py index 024803be0..4496df37d 100644 --- a/tests/conftest_trades.py +++ b/tests/conftest_trades.py @@ -89,6 +89,7 @@ def mock_trade_2(fee): open_order_id='dry_run_sell_12345', strategy='StrategyTestV2', timeframe=5, + buy_tag='TEST1', sell_reason='sell_signal', open_date=datetime.now(tz=timezone.utc) - timedelta(minutes=20), close_date=datetime.now(tz=timezone.utc) - timedelta(minutes=2), @@ -241,6 +242,7 @@ def mock_trade_5(fee): open_rate=0.123, exchange='binance', strategy='SampleStrategy', + buy_tag='TEST1', stoploss_order_id='prod_stoploss_3455', timeframe=5, ) @@ -295,6 +297,7 @@ def mock_trade_6(fee): open_rate=0.15, exchange='binance', strategy='SampleStrategy', + buy_tag='TEST2', open_order_id="prod_sell_6", timeframe=5, ) diff --git a/tests/optimize/__init__.py b/tests/optimize/__init__.py index 8a2be39a1..68088d2d5 100644 --- a/tests/optimize/__init__.py +++ b/tests/optimize/__init__.py @@ -54,6 +54,8 @@ def _build_backtest_dataframe(data): frame[column] = frame[column].astype('float64') if 'buy_tag' not in columns: frame['buy_tag'] = None + if 'exit_tag' not in columns: + frame['exit_tag'] = None # Ensure all candles make kindof sense assert all(frame['low'] <= frame['close']) diff --git a/tests/optimize/test_backtesting.py b/tests/optimize/test_backtesting.py index b5fa44d01..ab7aa74a1 100644 --- a/tests/optimize/test_backtesting.py +++ b/tests/optimize/test_backtesting.py @@ -567,6 +567,7 @@ def test_backtest__get_sell_trade_entry(default_conf, fee, mocker) -> None: 195, # Low 201.5, # High '', # Buy Signal Name + '', # Exit Signal Name ] trade = backtesting._enter_trade(pair, row=row) @@ -581,26 +582,27 @@ def test_backtest__get_sell_trade_entry(default_conf, fee, mocker) -> None: 195, # Low 210.5, # High '', # Buy Signal Name + '', # Exit Signal Name ] row_detail = pd.DataFrame( [ [ pd.Timestamp(year=2020, month=1, day=1, hour=5, minute=0, tzinfo=timezone.utc), - 1, 200, 199, 0, 197, 200.1, '', + 1, 200, 199, 0, 197, 200.1, '', '', ], [ pd.Timestamp(year=2020, month=1, day=1, hour=5, minute=1, tzinfo=timezone.utc), - 0, 199, 199.5, 0, 199, 199.7, '', + 0, 199, 199.5, 0, 199, 199.7, '', '', ], [ pd.Timestamp(year=2020, month=1, day=1, hour=5, minute=2, tzinfo=timezone.utc), - 0, 199.5, 200.5, 0, 199, 200.8, '', + 0, 199.5, 200.5, 0, 199, 200.8, '', '', ], [ pd.Timestamp(year=2020, month=1, day=1, hour=5, minute=3, tzinfo=timezone.utc), - 0, 200.5, 210.5, 0, 193, 210.5, '', # ROI sell (?) + 0, 200.5, 210.5, 0, 193, 210.5, '', '', # ROI sell (?) ], [ pd.Timestamp(year=2020, month=1, day=1, hour=5, minute=4, tzinfo=timezone.utc), - 0, 200, 199, 0, 193, 200.1, '', + 0, 200, 199, 0, 193, 200.1, '', '', ], - ], columns=["date", "buy", "open", "close", "sell", "low", "high", "buy_tag"] + ], columns=["date", "buy", "open", "close", "sell", "low", "high", "buy_tag", "exit_tag"] ) # No data available. @@ -614,7 +616,7 @@ def test_backtest__get_sell_trade_entry(default_conf, fee, mocker) -> None: assert isinstance(trade, LocalTrade) # Assign empty ... no result. backtesting.detail_data[pair] = pd.DataFrame( - [], columns=["date", "buy", "open", "close", "sell", "low", "high", "buy_tag"]) + [], columns=["date", "buy", "open", "close", "sell", "low", "high", "buy_tag", "exit_tag"]) res = backtesting._get_sell_trade_entry(trade, row) assert res is None @@ -678,7 +680,7 @@ def test_backtest_one(default_conf, fee, mocker, testdatadir) -> None: 'min_rate': [0.10370188, 0.10300000000000001], 'max_rate': [0.10501, 0.1038888], 'is_open': [False, False], - 'buy_tag': [None, None], + 'buy_tag': [None, None] }) pd.testing.assert_frame_equal(results, expected) data_pair = processed[pair] diff --git a/tests/rpc/test_rpc.py b/tests/rpc/test_rpc.py index f8c923958..945217b8a 100644 --- a/tests/rpc/test_rpc.py +++ b/tests/rpc/test_rpc.py @@ -825,8 +825,226 @@ def test_performance_handle(default_conf, ticker, limit_buy_order, fee, assert len(res) == 1 assert res[0]['pair'] == 'ETH/BTC' assert res[0]['count'] == 1 + assert prec_satoshi(res[0]['profit_pct'], 6.2) + + +def test_buy_tag_performance_handle(default_conf, ticker, limit_buy_order, fee, + limit_sell_order, mocker) -> None: + mocker.patch('freqtrade.rpc.telegram.Telegram', MagicMock()) + mocker.patch.multiple( + 'freqtrade.exchange.Exchange', + get_balances=MagicMock(return_value=ticker), + fetch_ticker=ticker, + get_fee=fee, + ) + + freqtradebot = get_patched_freqtradebot(mocker, default_conf) + patch_get_signal(freqtradebot) + rpc = RPC(freqtradebot) + + # Create some test data + freqtradebot.enter_positions() + trade = Trade.query.first() + assert trade + + # Simulate fulfilled LIMIT_BUY order for trade + trade.update(limit_buy_order) + + # Simulate fulfilled LIMIT_SELL order for trade + trade.update(limit_sell_order) + + trade.close_date = datetime.utcnow() + trade.is_open = False + res = rpc._rpc_buy_tag_performance(None) + + assert len(res) == 1 + assert res[0]['buy_tag'] == 'Other' + assert res[0]['count'] == 1 + assert prec_satoshi(res[0]['profit_pct'], 6.2) + + trade.buy_tag = "TEST_TAG" + res = rpc._rpc_buy_tag_performance(None) + + assert len(res) == 1 + assert res[0]['buy_tag'] == 'TEST_TAG' + assert res[0]['count'] == 1 + assert prec_satoshi(res[0]['profit_pct'], 6.2) + + +def test_buy_tag_performance_handle_2(mocker, default_conf, markets, fee): + mocker.patch('freqtrade.rpc.telegram.Telegram', MagicMock()) + mocker.patch.multiple( + 'freqtrade.exchange.Exchange', + markets=PropertyMock(return_value=markets) + ) + + freqtradebot = get_patched_freqtradebot(mocker, default_conf) + create_mock_trades(fee) + rpc = RPC(freqtradebot) + + res = rpc._rpc_buy_tag_performance(None) + + assert len(res) == 2 + assert res[0]['buy_tag'] == 'TEST1' + assert res[0]['count'] == 1 + assert prec_satoshi(res[0]['profit_pct'], 0.5) + assert res[1]['buy_tag'] == 'Other' + assert res[1]['count'] == 1 + assert prec_satoshi(res[1]['profit_pct'], 1.0) + + # Test for a specific pair + res = rpc._rpc_buy_tag_performance('ETC/BTC') + assert len(res) == 1 + assert res[0]['count'] == 1 + assert res[0]['buy_tag'] == 'TEST1' + assert prec_satoshi(res[0]['profit_pct'], 0.5) + + +def test_sell_reason_performance_handle(default_conf, ticker, limit_buy_order, fee, + limit_sell_order, mocker) -> None: + mocker.patch('freqtrade.rpc.telegram.Telegram', MagicMock()) + mocker.patch.multiple( + 'freqtrade.exchange.Exchange', + get_balances=MagicMock(return_value=ticker), + fetch_ticker=ticker, + get_fee=fee, + ) + + freqtradebot = get_patched_freqtradebot(mocker, default_conf) + patch_get_signal(freqtradebot) + rpc = RPC(freqtradebot) + + # Create some test data + freqtradebot.enter_positions() + trade = Trade.query.first() + assert trade + + # Simulate fulfilled LIMIT_BUY order for trade + trade.update(limit_buy_order) + + # Simulate fulfilled LIMIT_SELL order for trade + trade.update(limit_sell_order) + + trade.close_date = datetime.utcnow() + trade.is_open = False + res = rpc._rpc_sell_reason_performance(None) + + assert len(res) == 1 + assert res[0]['sell_reason'] == 'Other' + assert res[0]['count'] == 1 + assert prec_satoshi(res[0]['profit_pct'], 6.2) + + trade.sell_reason = "TEST1" + res = rpc._rpc_sell_reason_performance(None) + + assert len(res) == 1 + assert res[0]['sell_reason'] == 'TEST1' + assert res[0]['count'] == 1 + assert prec_satoshi(res[0]['profit_pct'], 6.2) + + +def test_sell_reason_performance_handle_2(mocker, default_conf, markets, fee): + mocker.patch('freqtrade.rpc.telegram.Telegram', MagicMock()) + mocker.patch.multiple( + 'freqtrade.exchange.Exchange', + markets=PropertyMock(return_value=markets) + ) + + freqtradebot = get_patched_freqtradebot(mocker, default_conf) + create_mock_trades(fee) + rpc = RPC(freqtradebot) + + res = rpc._rpc_sell_reason_performance(None) + + assert len(res) == 2 + assert res[0]['sell_reason'] == 'sell_signal' + assert res[0]['count'] == 1 + assert prec_satoshi(res[0]['profit_pct'], 0.5) + assert res[1]['sell_reason'] == 'roi' + assert res[1]['count'] == 1 + assert prec_satoshi(res[1]['profit_pct'], 1.0) + + # Test for a specific pair + res = rpc._rpc_sell_reason_performance('ETC/BTC') + assert len(res) == 1 + assert res[0]['count'] == 1 + assert res[0]['sell_reason'] == 'sell_signal' + 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: + mocker.patch('freqtrade.rpc.telegram.Telegram', MagicMock()) + mocker.patch.multiple( + 'freqtrade.exchange.Exchange', + get_balances=MagicMock(return_value=ticker), + fetch_ticker=ticker, + get_fee=fee, + ) + + freqtradebot = get_patched_freqtradebot(mocker, default_conf) + patch_get_signal(freqtradebot) + rpc = RPC(freqtradebot) + + # Create some test data + freqtradebot.enter_positions() + trade = Trade.query.first() + assert trade + + # Simulate fulfilled LIMIT_BUY order for trade + trade.update(limit_buy_order) + + # Simulate fulfilled LIMIT_SELL order for trade + trade.update(limit_sell_order) + + trade.close_date = datetime.utcnow() + trade.is_open = False + res = rpc._rpc_mix_tag_performance(None) + + assert len(res) == 1 + assert res[0]['mix_tag'] == 'Other Other' + assert res[0]['count'] == 1 assert prec_satoshi(res[0]['profit'], 6.2) + trade.buy_tag = "TESTBUY" + trade.sell_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'], 6.2) + + +def test_mix_tag_performance_handle_2(mocker, default_conf, markets, fee): + mocker.patch('freqtrade.rpc.telegram.Telegram', MagicMock()) + mocker.patch.multiple( + 'freqtrade.exchange.Exchange', + markets=PropertyMock(return_value=markets) + ) + + freqtradebot = get_patched_freqtradebot(mocker, default_conf) + create_mock_trades(fee) + rpc = RPC(freqtradebot) + + res = rpc._rpc_mix_tag_performance(None) + + assert len(res) == 2 + assert res[0]['mix_tag'] == 'TEST1 sell_signal' + assert res[0]['count'] == 1 + assert prec_satoshi(res[0]['profit'], 0.5) + assert res[1]['mix_tag'] == 'Other roi' + assert res[1]['count'] == 1 + assert prec_satoshi(res[1]['profit'], 1.0) + + # Test for a specific pair + res = rpc._rpc_mix_tag_performance('ETC/BTC') + + assert len(res) == 1 + assert res[0]['count'] == 1 + assert res[0]['mix_tag'] == 'TEST1 sell_signal' + assert prec_satoshi(res[0]['profit'], 0.5) + def test_rpc_count(mocker, default_conf, ticker, fee) -> None: mocker.patch('freqtrade.rpc.telegram.Telegram', MagicMock()) diff --git a/tests/rpc/test_rpc_apiserver.py b/tests/rpc/test_rpc_apiserver.py index 02ed26459..e0bbee861 100644 --- a/tests/rpc/test_rpc_apiserver.py +++ b/tests/rpc/test_rpc_apiserver.py @@ -812,8 +812,10 @@ def test_api_performance(botclient, fee): rc = client_get(client, f"{BASE_URI}/performance") assert_response(rc) assert len(rc.json()) == 2 - assert rc.json() == [{'count': 1, 'pair': 'LTC/ETH', 'profit': 7.61, 'profit_abs': 0.01872279}, - {'count': 1, 'pair': 'XRP/ETH', 'profit': -5.57, 'profit_abs': -0.1150375}] + assert rc.json() == [{'count': 1, 'pair': 'LTC/ETH', 'profit': 7.61, 'profit_pct': 7.61, + 'profit_ratio': 0.07609203, 'profit_abs': 0.01872279}, + {'count': 1, 'pair': 'XRP/ETH', 'profit': -5.57, 'profit_pct': -5.57, + 'profit_ratio': -0.05570419, 'profit_abs': -0.1150375}] def test_api_status(botclient, mocker, ticker, fee, markets): diff --git a/tests/rpc/test_rpc_telegram.py b/tests/rpc/test_rpc_telegram.py index 7dde7b803..5f49c8bf7 100644 --- a/tests/rpc/test_rpc_telegram.py +++ b/tests/rpc/test_rpc_telegram.py @@ -33,6 +33,7 @@ class DummyCls(Telegram): """ Dummy class for testing the Telegram @authorized_only decorator """ + def __init__(self, rpc: RPC, config) -> None: super().__init__(rpc, config) self.state = {'called': False} @@ -92,7 +93,8 @@ def test_telegram_init(default_conf, mocker, caplog) -> None: message_str = ("rpc.telegram is listening for following commands: [['status'], ['profit'], " "['balance'], ['start'], ['stop'], ['forcesell'], ['forcebuy'], ['trades'], " - "['delete'], ['performance'], ['stats'], ['daily'], ['count'], ['locks'], " + "['delete'], ['performance'], ['buys'], ['sells'], ['mix_tags'], " + "['stats'], ['daily'], ['count'], ['locks'], " "['unlock', 'delete_locks'], ['reload_config', 'reload_conf'], " "['show_config', 'show_conf'], ['stopbuy'], " "['whitelist'], ['blacklist'], ['logs'], ['edge'], ['help'], ['version']" @@ -713,6 +715,7 @@ def test_telegram_forcesell_handle(default_conf, update, ticker, fee, 'profit_ratio': 0.0629778, 'stake_currency': 'BTC', 'fiat_currency': 'USD', + 'buy_tag': ANY, 'sell_reason': SellType.FORCE_SELL.value, 'open_date': ANY, 'close_date': ANY, @@ -776,6 +779,7 @@ def test_telegram_forcesell_down_handle(default_conf, update, ticker, fee, 'profit_ratio': -0.05482878, 'stake_currency': 'BTC', 'fiat_currency': 'USD', + 'buy_tag': ANY, 'sell_reason': SellType.FORCE_SELL.value, 'open_date': ANY, 'close_date': ANY, @@ -829,6 +833,7 @@ def test_forcesell_all_handle(default_conf, update, ticker, fee, mocker) -> None 'profit_ratio': -0.00408133, 'stake_currency': 'BTC', 'fiat_currency': 'USD', + 'buy_tag': ANY, 'sell_reason': SellType.FORCE_SELL.value, 'open_date': ANY, 'close_date': ANY, @@ -974,6 +979,102 @@ def test_performance_handle(default_conf, update, ticker, fee, assert 'ETH/BTC\t0.00006217 BTC (6.20%) (1)' in msg_mock.call_args_list[0][0][0] +def test_buy_tag_performance_handle(default_conf, update, ticker, fee, + limit_buy_order, limit_sell_order, 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) + + # Create some test data + freqtradebot.enter_positions() + trade = Trade.query.first() + assert trade + + # Simulate fulfilled LIMIT_BUY order for trade + trade.update(limit_buy_order) + + trade.buy_tag = "TESTBUY" + # Simulate fulfilled LIMIT_SELL order for trade + trade.update(limit_sell_order) + + trade.close_date = datetime.utcnow() + trade.is_open = False + + telegram._buy_tag_performance(update=update, context=MagicMock()) + assert msg_mock.call_count == 1 + assert 'Buy 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] + + +def test_sell_reason_performance_handle(default_conf, update, ticker, fee, + limit_buy_order, limit_sell_order, 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) + + # Create some test data + freqtradebot.enter_positions() + trade = Trade.query.first() + assert trade + + # Simulate fulfilled LIMIT_BUY order for trade + trade.update(limit_buy_order) + + trade.sell_reason = 'TESTSELL' + # Simulate fulfilled LIMIT_SELL order for trade + trade.update(limit_sell_order) + + trade.close_date = datetime.utcnow() + trade.is_open = False + + telegram._sell_reason_performance(update=update, context=MagicMock()) + assert msg_mock.call_count == 1 + assert 'Sell 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] + + +def test_mix_tag_performance_handle(default_conf, update, ticker, fee, + limit_buy_order, limit_sell_order, 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) + + # Create some test data + freqtradebot.enter_positions() + trade = Trade.query.first() + assert trade + + # Simulate fulfilled LIMIT_BUY order for trade + trade.update(limit_buy_order) + + trade.buy_tag = "TESTBUY" + trade.sell_reason = "TESTSELL" + + # Simulate fulfilled LIMIT_SELL order for trade + trade.update(limit_sell_order) + + trade.close_date = datetime.utcnow() + trade.is_open = False + + telegram._mix_tag_performance(update=update, context=MagicMock()) + 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)' + in msg_mock.call_args_list[0][0][0]) + + def test_count_handle(default_conf, update, ticker, fee, mocker) -> None: mocker.patch.multiple( 'freqtrade.exchange.Exchange', @@ -997,9 +1098,9 @@ def test_count_handle(default_conf, update, ticker, fee, mocker) -> None: msg = ('
  current    max    total stake\n---------  -----  -------------\n'
            '        1      {}          {}
').format( - default_conf['max_open_trades'], - default_conf['stake_amount'] - ) + default_conf['max_open_trades'], + default_conf['stake_amount'] + ) assert msg in msg_mock.call_args_list[0][0][0] @@ -1382,6 +1483,7 @@ def test_send_msg_sell_notification(default_conf, mocker) -> None: 'profit_ratio': -0.57405275, 'stake_currency': 'ETH', 'fiat_currency': 'USD', + 'buy_tag': 'buy_signal1', 'sell_reason': SellType.STOP_LOSS.value, 'open_date': arrow.utcnow().shift(hours=-1), 'close_date': arrow.utcnow(), @@ -1389,6 +1491,7 @@ def test_send_msg_sell_notification(default_conf, mocker) -> None: assert msg_mock.call_args[0][0] \ == ('\N{WARNING SIGN} *Binance:* Selling KEY/ETH (#1)\n' '*Profit:* `-57.41% (loss: -0.05746268 ETH / -24.812 USD)`\n' + '*Buy Tag:* `buy_signal1`\n' '*Sell Reason:* `stop_loss`\n' '*Duration:* `1:00:00 (60.0 min)`\n' '*Amount:* `1333.33333333`\n' @@ -1412,6 +1515,7 @@ def test_send_msg_sell_notification(default_conf, mocker) -> None: 'profit_amount': -0.05746268, 'profit_ratio': -0.57405275, 'stake_currency': 'ETH', + 'buy_tag': 'buy_signal1', 'sell_reason': SellType.STOP_LOSS.value, 'open_date': arrow.utcnow().shift(days=-1, hours=-2, minutes=-30), 'close_date': arrow.utcnow(), @@ -1419,6 +1523,7 @@ def test_send_msg_sell_notification(default_conf, mocker) -> None: assert msg_mock.call_args[0][0] \ == ('\N{WARNING SIGN} *Binance:* Selling KEY/ETH (#1)\n' '*Profit:* `-57.41%`\n' + '*Buy Tag:* `buy_signal1`\n' '*Sell Reason:* `stop_loss`\n' '*Duration:* `1 day, 2:30:00 (1590.0 min)`\n' '*Amount:* `1333.33333333`\n' @@ -1483,6 +1588,7 @@ def test_send_msg_sell_fill_notification(default_conf, mocker) -> None: 'profit_ratio': -0.57405275, 'stake_currency': 'ETH', 'fiat_currency': 'USD', + 'buy_tag': 'buy_signal1', 'sell_reason': SellType.STOP_LOSS.value, 'open_date': arrow.utcnow().shift(hours=-1), 'close_date': arrow.utcnow(), @@ -1574,12 +1680,14 @@ def test_send_msg_sell_notification_no_fiat(default_conf, mocker) -> None: 'profit_ratio': -0.57405275, 'stake_currency': 'ETH', 'fiat_currency': 'USD', + 'buy_tag': 'buy_signal1', 'sell_reason': SellType.STOP_LOSS.value, 'open_date': arrow.utcnow().shift(hours=-2, minutes=-35, seconds=-3), 'close_date': arrow.utcnow(), }) assert msg_mock.call_args[0][0] == ('\N{WARNING SIGN} *Binance:* Selling KEY/ETH (#1)\n' '*Profit:* `-57.41%`\n' + '*Buy Tag:* `buy_signal1`\n' '*Sell Reason:* `stop_loss`\n' '*Duration:* `2:35:03 (155.1 min)`\n' '*Amount:* `1333.33333333`\n' diff --git a/tests/strategy/test_interface.py b/tests/strategy/test_interface.py index ebd950fd6..f57a9f34e 100644 --- a/tests/strategy/test_interface.py +++ b/tests/strategy/test_interface.py @@ -30,7 +30,7 @@ _STRATEGY = StrategyTestV2(config={}) _STRATEGY.dp = DataProvider({}, None, None) -def test_returns_latest_signal(mocker, default_conf, ohlcv_history): +def test_returns_latest_signal(ohlcv_history): ohlcv_history.loc[1, 'date'] = arrow.utcnow() # Take a copy to correctly modify the call mocked_history = ohlcv_history.copy() @@ -38,20 +38,39 @@ def test_returns_latest_signal(mocker, default_conf, ohlcv_history): mocked_history['buy'] = 0 mocked_history.loc[1, 'sell'] = 1 - assert _STRATEGY.get_signal('ETH/BTC', '5m', mocked_history) == (False, True, None) + assert _STRATEGY.get_signal('ETH/BTC', '5m', mocked_history) == (False, True, None, None) mocked_history.loc[1, 'sell'] = 0 mocked_history.loc[1, 'buy'] = 1 - assert _STRATEGY.get_signal('ETH/BTC', '5m', mocked_history) == (True, False, None) + assert _STRATEGY.get_signal('ETH/BTC', '5m', mocked_history) == (True, False, None, None) mocked_history.loc[1, 'sell'] = 0 mocked_history.loc[1, 'buy'] = 0 - assert _STRATEGY.get_signal('ETH/BTC', '5m', mocked_history) == (False, False, None) + assert _STRATEGY.get_signal('ETH/BTC', '5m', mocked_history) == (False, False, None, None) mocked_history.loc[1, 'sell'] = 0 mocked_history.loc[1, 'buy'] = 1 mocked_history.loc[1, 'buy_tag'] = 'buy_signal_01' - assert _STRATEGY.get_signal('ETH/BTC', '5m', mocked_history) == (True, False, 'buy_signal_01') + assert _STRATEGY.get_signal( + 'ETH/BTC', + '5m', + mocked_history) == ( + True, + False, + 'buy_signal_01', + None) + + mocked_history.loc[1, 'buy_tag'] = None + mocked_history.loc[1, 'exit_tag'] = 'sell_signal_01' + + assert _STRATEGY.get_signal( + 'ETH/BTC', + '5m', + mocked_history) == ( + True, + False, + None, + 'sell_signal_01') def test_analyze_pair_empty(default_conf, mocker, caplog, ohlcv_history): @@ -68,17 +87,24 @@ def test_analyze_pair_empty(default_conf, mocker, caplog, ohlcv_history): def test_get_signal_empty(default_conf, mocker, caplog): - assert (False, False, None) == _STRATEGY.get_signal( + assert (False, False, None, None) == _STRATEGY.get_signal( 'foo', default_conf['timeframe'], DataFrame() ) assert log_has('Empty candle (OHLCV) data for pair foo', caplog) caplog.clear() - assert (False, False, None) == _STRATEGY.get_signal('bar', default_conf['timeframe'], None) + assert ( + False, + False, + None, + None) == _STRATEGY.get_signal( + 'bar', + default_conf['timeframe'], + None) assert log_has('Empty candle (OHLCV) data for pair bar', caplog) caplog.clear() - assert (False, False, None) == _STRATEGY.get_signal( + assert (False, False, None, None) == _STRATEGY.get_signal( 'baz', default_conf['timeframe'], DataFrame([]) @@ -118,7 +144,7 @@ def test_get_signal_old_dataframe(default_conf, mocker, caplog, ohlcv_history): caplog.set_level(logging.INFO) mocker.patch.object(_STRATEGY, 'assert_df') - assert (False, False, None) == _STRATEGY.get_signal( + assert (False, False, None, None) == _STRATEGY.get_signal( 'xyz', default_conf['timeframe'], mocked_history @@ -140,7 +166,7 @@ def test_get_signal_no_sell_column(default_conf, mocker, caplog, ohlcv_history): caplog.set_level(logging.INFO) mocker.patch.object(_STRATEGY, 'assert_df') - assert (True, False, None) == _STRATEGY.get_signal( + assert (True, False, None, None) == _STRATEGY.get_signal( 'xyz', default_conf['timeframe'], mocked_history @@ -653,7 +679,7 @@ def test_strategy_safe_wrapper(value): ret = strategy_safe_wrapper(working_method, message='DeadBeef')(value) - assert type(ret) == type(value) + assert isinstance(ret, type(value)) assert ret == value diff --git a/tests/test_freqtradebot.py b/tests/test_freqtradebot.py index 838a158e0..0435dc3a2 100644 --- a/tests/test_freqtradebot.py +++ b/tests/test_freqtradebot.py @@ -236,7 +236,7 @@ def test_edge_overrides_stoploss(limit_buy_order_usdt, fee, caplog, mocker, # stoploss shoud be hit assert freqtrade.handle_trade(trade) is not ignore_strat_sl if not ignore_strat_sl: - assert log_has('Executing Sell for NEO/BTC. Reason: stop_loss', caplog) + assert log_has_re(r'Executing Sell for NEO/BTC. Reason: stop_loss.*', caplog) assert trade.sell_reason == SellType.STOP_LOSS.value @@ -450,7 +450,7 @@ def test_create_trade_no_signal(default_conf_usdt, fee, mocker) -> None: ) default_conf_usdt['stake_amount'] = 10 freqtrade = FreqtradeBot(default_conf_usdt) - patch_get_signal(freqtrade, value=(False, False, None)) + patch_get_signal(freqtrade, value=(False, False, None, None)) Trade.query = MagicMock() Trade.query.filter = MagicMock() @@ -677,7 +677,7 @@ def test_process_informative_pairs_added(default_conf_usdt, ticker_usdt, mocker) inf_pairs = MagicMock(return_value=[("BTC/ETH", '1m'), ("ETH/USDT", "1h")]) mocker.patch( 'freqtrade.strategy.interface.IStrategy.get_signal', - return_value=(False, False, '') + return_value=(False, False, '', '') ) mocker.patch('time.sleep', return_value=None) @@ -1808,7 +1808,7 @@ def test_handle_trade(default_conf_usdt, limit_buy_order_usdt, limit_sell_order_ assert trade.is_open is True freqtrade.wallets.update() - patch_get_signal(freqtrade, value=(False, True, None)) + patch_get_signal(freqtrade, value=(False, True, None, 'sell_signal1')) assert freqtrade.handle_trade(trade) is True assert trade.open_order_id == limit_sell_order_usdt['id'] @@ -1819,6 +1819,7 @@ def test_handle_trade(default_conf_usdt, limit_buy_order_usdt, limit_sell_order_ assert trade.close_profit == 0.09451372 assert trade.calc_profit() == 5.685 assert trade.close_date is not None + assert trade.sell_reason == 'sell_signal1' def test_handle_overlapping_signals(default_conf_usdt, ticker_usdt, limit_buy_order_usdt_open, @@ -1836,7 +1837,7 @@ def test_handle_overlapping_signals(default_conf_usdt, ticker_usdt, limit_buy_or ) freqtrade = FreqtradeBot(default_conf_usdt) - patch_get_signal(freqtrade, value=(True, True, None)) + patch_get_signal(freqtrade, value=(True, True, None, None)) freqtrade.strategy.min_roi_reached = MagicMock(return_value=False) freqtrade.enter_positions() @@ -1855,7 +1856,7 @@ def test_handle_overlapping_signals(default_conf_usdt, ticker_usdt, limit_buy_or assert trades[0].is_open is True # Buy and Sell are not triggering, so doing nothing ... - patch_get_signal(freqtrade, value=(False, False, None)) + patch_get_signal(freqtrade, value=(False, False, None, None)) assert freqtrade.handle_trade(trades[0]) is False trades = Trade.query.all() nb_trades = len(trades) @@ -1863,7 +1864,7 @@ def test_handle_overlapping_signals(default_conf_usdt, ticker_usdt, limit_buy_or assert trades[0].is_open is True # Buy and Sell are triggering, so doing nothing ... - patch_get_signal(freqtrade, value=(True, True, None)) + patch_get_signal(freqtrade, value=(True, True, None, None)) assert freqtrade.handle_trade(trades[0]) is False trades = Trade.query.all() nb_trades = len(trades) @@ -1871,7 +1872,7 @@ def test_handle_overlapping_signals(default_conf_usdt, ticker_usdt, limit_buy_or assert trades[0].is_open is True # Sell is triggering, guess what : we are Selling! - patch_get_signal(freqtrade, value=(False, True, None)) + patch_get_signal(freqtrade, value=(False, True, None, None)) trades = Trade.query.all() assert freqtrade.handle_trade(trades[0]) is True @@ -1905,7 +1906,7 @@ def test_handle_trade_roi(default_conf_usdt, ticker_usdt, limit_buy_order_usdt_o # we might just want to check if we are in a sell condition without # executing # if ROI is reached we must sell - patch_get_signal(freqtrade, value=(False, True, None)) + patch_get_signal(freqtrade, value=(False, True, None, None)) assert freqtrade.handle_trade(trade) assert log_has("ETH/USDT - Required profit reached. sell_type=SellType.ROI", caplog) @@ -1934,10 +1935,10 @@ def test_handle_trade_use_sell_signal(default_conf_usdt, ticker_usdt, limit_buy_ trade = Trade.query.first() trade.is_open = True - patch_get_signal(freqtrade, value=(False, False, None)) + patch_get_signal(freqtrade, value=(False, False, None, None)) assert not freqtrade.handle_trade(trade) - patch_get_signal(freqtrade, value=(False, True, None)) + patch_get_signal(freqtrade, value=(False, True, None, None)) assert freqtrade.handle_trade(trade) assert log_has("ETH/USDT - Sell signal received. sell_type=SellType.SELL_SIGNAL", caplog) @@ -2579,6 +2580,7 @@ def test_execute_trade_exit_up(default_conf_usdt, ticker_usdt, fee, ticker_usdt_ 'limit': 2.2, 'amount': 30.0, 'order_type': 'limit', + 'buy_tag': None, 'open_rate': 2.0, 'current_rate': 2.3, 'profit_amount': 5.685, @@ -2632,6 +2634,7 @@ def test_execute_trade_exit_down(default_conf_usdt, ticker_usdt, fee, ticker_usd 'limit': 2.01, 'amount': 30.0, 'order_type': 'limit', + 'buy_tag': None, 'open_rate': 2.0, 'current_rate': 2.0, 'profit_amount': -0.00075, @@ -2699,6 +2702,7 @@ def test_execute_trade_exit_custom_exit_price(default_conf_usdt, ticker_usdt, fe 'limit': 2.25, 'amount': 30.0, 'order_type': 'limit', + 'buy_tag': None, 'open_rate': 2.0, 'current_rate': 2.3, 'profit_amount': 7.18125, @@ -2758,6 +2762,7 @@ def test_execute_trade_exit_down_stoploss_on_exchange_dry_run( 'limit': 1.98, 'amount': 30.0, 'order_type': 'limit', + 'buy_tag': None, 'open_rate': 2.0, 'current_rate': 2.0, 'profit_amount': -0.8985, @@ -2975,6 +2980,7 @@ def test_execute_trade_exit_market_order(default_conf_usdt, ticker_usdt, fee, 'limit': 2.2, 'amount': 30.0, 'order_type': 'market', + 'buy_tag': None, 'open_rate': 2.0, 'current_rate': 2.3, 'profit_amount': 5.685, @@ -3068,7 +3074,7 @@ def test_sell_profit_only( trade = Trade.query.first() trade.update(limit_buy_order_usdt) freqtrade.wallets.update() - patch_get_signal(freqtrade, value=(False, True, None)) + patch_get_signal(freqtrade, value=(False, True, None, None)) assert freqtrade.handle_trade(trade) is handle_first if handle_second: @@ -3103,7 +3109,7 @@ def test_sell_not_enough_balance(default_conf_usdt, limit_buy_order_usdt, limit_ trade = Trade.query.first() amnt = trade.amount trade.update(limit_buy_order_usdt) - patch_get_signal(freqtrade, value=(False, True, None)) + patch_get_signal(freqtrade, value=(False, True, None, None)) mocker.patch('freqtrade.wallets.Wallets.get_free', MagicMock(return_value=trade.amount * 0.985)) assert freqtrade.handle_trade(trade) is True @@ -3212,11 +3218,11 @@ def test_ignore_roi_if_buy_signal(default_conf_usdt, limit_buy_order_usdt, trade = Trade.query.first() trade.update(limit_buy_order_usdt) freqtrade.wallets.update() - patch_get_signal(freqtrade, value=(True, True, None)) + patch_get_signal(freqtrade, value=(True, True, None, None)) assert freqtrade.handle_trade(trade) is False # Test if buy-signal is absent (should sell due to roi = true) - patch_get_signal(freqtrade, value=(False, True, None)) + patch_get_signal(freqtrade, value=(False, True, None, None)) assert freqtrade.handle_trade(trade) is True assert trade.sell_reason == SellType.ROI.value @@ -3402,11 +3408,11 @@ def test_disable_ignore_roi_if_buy_signal(default_conf_usdt, limit_buy_order_usd trade = Trade.query.first() trade.update(limit_buy_order_usdt) # Sell due to min_roi_reached - patch_get_signal(freqtrade, value=(True, True, None)) + patch_get_signal(freqtrade, value=(True, True, None, None)) assert freqtrade.handle_trade(trade) is True # Test if buy-signal is absent - patch_get_signal(freqtrade, value=(False, True, None)) + patch_get_signal(freqtrade, value=(False, True, None, None)) assert freqtrade.handle_trade(trade) is True assert trade.sell_reason == SellType.ROI.value @@ -3848,7 +3854,7 @@ def test_order_book_ask_strategy( freqtrade.wallets.update() assert trade.is_open is True - patch_get_signal(freqtrade, value=(False, True, None)) + patch_get_signal(freqtrade, value=(False, True, None, None)) assert freqtrade.handle_trade(trade) is True assert trade.close_rate_requested == order_book_l2.return_value['asks'][0][0] diff --git a/tests/test_persistence.py b/tests/test_persistence.py index d036b045e..719dc8263 100644 --- a/tests/test_persistence.py +++ b/tests/test_persistence.py @@ -1317,6 +1317,10 @@ def test_Trade_object_idem(): 'get_open_trades_without_assigned_fees', 'get_open_order_trades', 'get_trades', + 'get_sell_reason_performance', + 'get_buy_tag_performance', + 'get_mix_tag_performance', + ) # Parent (LocalTrade) should have the same attributes