diff --git a/README.md b/README.md index 7fa99d54b..ce45d376a 100644 --- a/README.md +++ b/README.md @@ -146,6 +146,8 @@ Telegram is not mandatory. However, this is a great way to control your bot. Mor - `/stopentry`: Stop entering new trades. - `/status |[table]`: Lists all or specific open trades. - `/profit []`: Lists cumulative profit from all finished trades, over the last n days. +- `/profit_long []`: Lists cumulative profit from all finished long trades, over the last n days. +- `/profit_short []`: Lists cumulative profit from all finished short trades, over the last n days. - `/forceexit |all`: Instantly exits the given trade (Ignoring `minimum_roi`). - `/fx |all`: Alias to `/forceexit` - `/performance`: Show performance of each finished trade grouped by pair @@ -154,6 +156,7 @@ Telegram is not mandatory. However, this is a great way to control your bot. Mor - `/help`: Show help message. - `/version`: Show version. + ## Development branches The project is currently setup in two main branches: diff --git a/freqtrade/persistence/trade_model.py b/freqtrade/persistence/trade_model.py index 51ec4abef..ee1f9d9ff 100644 --- a/freqtrade/persistence/trade_model.py +++ b/freqtrade/persistence/trade_model.py @@ -2090,32 +2090,34 @@ class Trade(ModelBase, LocalTrade): return resp @staticmethod - def get_best_pair(start_date: datetime | None = None): + def get_best_pair(trade_filter: list | None = None): """ Get best pair with closed trade. NOTE: Not supported in Backtesting. :returns: Tuple containing (pair, profit_sum) """ - filters: list = [Trade.is_open.is_(False)] - if start_date: - filters.append(Trade.close_date >= start_date) + if not trade_filter: + trade_filter = [] + trade_filter.append(Trade.is_open.is_(False)) - pair_rates_query = Trade._generic_performance_query([Trade.pair], filters) + pair_rates_query = Trade._generic_performance_query([Trade.pair], trade_filter) best_pair = Trade.session.execute(pair_rates_query).first() # returns pair, profit_ratio, abs_profit, count return best_pair @staticmethod - def get_trading_volume(start_date: datetime | None = None) -> float: + def get_trading_volume(trade_filter: list | None = None) -> float: """ Get Trade volume based on Orders NOTE: Not supported in Backtesting. :returns: Tuple containing (pair, profit_sum) """ - filters = [Order.status == "closed"] - if start_date: - filters.append(Order.order_filled_date >= start_date) + if not trade_filter: + trade_filter = [] + trade_filter.append(Order.status == "closed") trading_volume = Trade.session.execute( - select(func.sum(Order.cost).label("volume")).filter(*filters) + select(func.sum(Order.cost).label("volume")) + .join(Order._trade_live) + .filter(*trade_filter) ).scalar_one() return trading_volume or 0.0 diff --git a/freqtrade/rpc/rpc.py b/freqtrade/rpc/rpc.py index 5d8ffc141..868b10d7d 100644 --- a/freqtrade/rpc/rpc.py +++ b/freqtrade/rpc/rpc.py @@ -34,7 +34,7 @@ from freqtrade.exchange import Exchange, timeframe_to_minutes, timeframe_to_msec from freqtrade.exchange.exchange_utils import price_to_precision from freqtrade.ft_types import AnnotationType from freqtrade.loggers import bufferHandler -from freqtrade.persistence import CustomDataWrapper, KeyValueStore, PairLocks, Trade +from freqtrade.persistence import CustomDataWrapper, KeyValueStore, Order, PairLocks, Trade from freqtrade.persistence.models import PairLock, custom_data_rpc_wrapper from freqtrade.plugins.pairlist.pairlist_helpers import expand_pairlist from freqtrade.rpc.fiat_convert import CryptoToFiatConverter @@ -502,20 +502,13 @@ class RPC: durations = {"wins": wins_dur, "draws": draws_dur, "losses": losses_dur} return {"exit_reasons": exit_reasons, "durations": durations} - def _rpc_trade_statistics( - self, stake_currency: str, fiat_display_currency: str, start_date: datetime | None = None + def _collect_trade_statistics_data( + self, + trades: Sequence["Trade"], + stake_currency: str, + fiat_display_currency: str, ) -> dict[str, Any]: - """Returns cumulative profit statistics""" - - start_date = datetime.fromtimestamp(0) if start_date is None else start_date - - trade_filter = ( - Trade.is_open.is_(False) & (Trade.close_date >= start_date) - ) | Trade.is_open.is_(True) - trades: Sequence[Trade] = Trade.session.scalars( - Trade.get_trades_query(trade_filter, include_orders=False).order_by(Trade.id) - ).all() - + """Iterate trades, calculate various statistics, and return intermediate results.""" profit_all_coin = [] profit_all_ratio = [] profit_closed_coin = [] @@ -544,7 +537,7 @@ class RPC: losing_trades += 1 losing_profit += profit_abs else: - # Get current rate + # Get current rate for open trades if len(trade.select_filled_orders(trade.entry_side)) == 0: # Skip trades with no filled orders continue @@ -558,17 +551,74 @@ class RPC: profit_abs = nan else: _profit = trade.calculate_profit(trade.close_rate or current_rate) - profit_ratio = _profit.profit_ratio profit_abs = _profit.total_profit profit_all_coin.append(profit_abs) profit_all_ratio.append(profit_ratio) + return { + "profit_all_coin": profit_all_coin, + "profit_all_ratio": profit_all_ratio, + "profit_closed_coin": profit_closed_coin, + "profit_closed_ratio": profit_closed_ratio, + "durations": durations, + "winning_trades": winning_trades, + "losing_trades": losing_trades, + "winning_profit": winning_profit, + "losing_profit": losing_profit, + } + + def _rpc_trade_statistics( + self, + stake_currency: str, + fiat_display_currency: str, + start_date: datetime | None = None, + direction: str | None = None, + ) -> dict[str, Any]: + """ + Returns cumulative profit statistics, with optional direction filter (long/short) + """ + start_date = datetime.fromtimestamp(0) if start_date is None else start_date + + trade_filter = ( + Trade.is_open.is_(False) & (Trade.close_date >= start_date) + ) | Trade.is_open.is_(True) + + if direction == "long": + dir_filter = Trade.is_short.is_(False) + trade_filter = trade_filter & dir_filter + elif direction == "short": + dir_filter = Trade.is_short.is_(True) + trade_filter = trade_filter & dir_filter + + trades: Sequence[Trade] = Trade.session.scalars( + Trade.get_trades_query(trade_filter, include_orders=False).order_by(Trade.id) + ).all() + + stats = self._collect_trade_statistics_data(trades, stake_currency, fiat_display_currency) + + profit_all_coin = stats["profit_all_coin"] + profit_all_ratio = stats["profit_all_ratio"] + profit_closed_coin = stats["profit_closed_coin"] + profit_closed_ratio = stats["profit_closed_ratio"] + durations = stats["durations"] + winning_trades = stats["winning_trades"] + losing_trades = stats["losing_trades"] + winning_profit = stats["winning_profit"] + losing_profit = stats["losing_profit"] + closed_trade_count = len([t for t in trades if not t.is_open]) - best_pair = Trade.get_best_pair(start_date) - trading_volume = Trade.get_trading_volume(start_date) + best_pair_filters = [Trade.close_date > start_date] + trading_volume_filters = [Order.order_filled_date >= start_date] + + if direction: + best_pair_filters.append(dir_filter) + trading_volume_filters.append(dir_filter) + + best_pair = Trade.get_best_pair(best_pair_filters) + trading_volume = Trade.get_trading_volume(trading_volume_filters) # Prepare data to display profit_closed_coin_sum = round(sum(profit_closed_coin), 8) diff --git a/freqtrade/rpc/telegram.py b/freqtrade/rpc/telegram.py index 7b3967193..b93e3ec25 100644 --- a/freqtrade/rpc/telegram.py +++ b/freqtrade/rpc/telegram.py @@ -191,8 +191,8 @@ class Telegram(RPCHandler): r"/mix_tags", r"/daily$", r"/daily \d+$", - r"/profit$", - r"/profit \d+", + r"/profit([_ ]long|[_ ]short)?$", + r"/profit([_ ]long|[_ ]short)? \d+$", r"/stats$", r"/count$", r"/locks$", @@ -305,13 +305,17 @@ class Telegram(RPCHandler): CommandHandler("order", self._order), CommandHandler("list_custom_data", self._list_custom_data), CommandHandler("tg_info", self._tg_info), + CommandHandler("profit_long", self._profit_long), + CommandHandler("profit_short", self._profit_short), ] callbacks = [ CallbackQueryHandler(self._status_table, pattern="update_status_table"), CallbackQueryHandler(self._daily, pattern="update_daily"), CallbackQueryHandler(self._weekly, pattern="update_weekly"), CallbackQueryHandler(self._monthly, pattern="update_monthly"), - CallbackQueryHandler(self._profit, pattern="update_profit"), + CallbackQueryHandler(self._profit_long, pattern="update_profit_long"), + CallbackQueryHandler(self._profit_short, pattern="update_profit_short"), + CallbackQueryHandler(self._profit, pattern=r"update_profit$"), CallbackQueryHandler(self._balance, pattern="update_balance"), CallbackQueryHandler(self._performance, pattern="update_performance"), CallbackQueryHandler( @@ -995,29 +999,25 @@ class Telegram(RPCHandler): """ await self._timeunit_stats(update, context, "months") - @authorized_only - async def _profit(self, update: Update, context: CallbackContext) -> None: + def _format_profit_message( + self, + stats: dict, + stake_cur: str, + fiat_disp_cur: str, + timescale: int | None = None, + direction: str | None = None, + ) -> str: """ - Handler for /profit. - Returns a cumulative profit statistics. - :param bot: telegram bot - :param update: message update - :return: None + Format profit statistics message for telegram. + + :param stats: Trade statistics dictionary + :param stake_cur: Stake currency + :param fiat_disp_cur: Fiat display currency + :param timescale: Optional timescale filter + :param direction: Optional direction filter ('long', 'short', or None for all) + :return: Formatted markdown message """ - stake_cur = self._config["stake_currency"] - fiat_disp_cur = self._config.get("fiat_display_currency", "") - - start_date = datetime.fromtimestamp(0) - timescale = None - try: - if context.args: - timescale = int(context.args[0]) - 1 - today_start = datetime.combine(date.today(), datetime.min.time()) - start_date = today_start - timedelta(days=timescale) - except (TypeError, ValueError, IndexError): - pass - - stats = self._rpc._rpc_trade_statistics(stake_cur, fiat_disp_cur, start_date) + # Extract common variables profit_closed_coin = stats["profit_closed_coin"] profit_closed_ratio_mean = stats["profit_closed_ratio_mean"] profit_closed_percent = stats["profit_closed_percent"] @@ -1037,66 +1037,153 @@ class Telegram(RPCHandler): expectancy = stats["expectancy"] expectancy_ratio = stats["expectancy_ratio"] + # Direction-specific labels + direction_label = f" {direction}" if direction else "" + no_trades_msg = ( + f"No{direction_label} trades yet.\n*Bot started:* `{stats['bot_start_date']}`" + ) + no_closed_msg = f"`No closed{direction_label} trade` \n" + closed_roi_label = f"*ROI:* Closed{direction_label} trades" + all_roi_label = f"*ROI:* All{direction_label} trades" + if stats["trade_count"] == 0: - markdown_msg = f"No trades yet.\n*Bot started:* `{stats['bot_start_date']}`" + return no_trades_msg + + # Build message + if stats["closed_trade_count"] > 0: + fiat_closed_trades = ( + f"∙ `{fmt_coin(profit_closed_fiat, fiat_disp_cur)}`\n" if fiat_disp_cur else "" + ) + markdown_msg = ( + f"{closed_roi_label}\n" + f"∙ `{fmt_coin(profit_closed_coin, stake_cur)} " + f"({profit_closed_ratio_mean:.2%}) " + f"({profit_closed_percent} \N{GREEK CAPITAL LETTER SIGMA}%)`\n" + f"{fiat_closed_trades}" + ) else: - # Message to display - if stats["closed_trade_count"] > 0: - fiat_closed_trades = ( - f"∙ `{fmt_coin(profit_closed_fiat, fiat_disp_cur)}`\n" if fiat_disp_cur else "" - ) - markdown_msg = ( - "*ROI:* Closed trades\n" - f"∙ `{fmt_coin(profit_closed_coin, stake_cur)} " - f"({profit_closed_ratio_mean:.2%}) " - f"({profit_closed_percent} \N{GREEK CAPITAL LETTER SIGMA}%)`\n" - f"{fiat_closed_trades}" - ) - else: - markdown_msg = "`No closed trade` \n" - fiat_all_trades = ( - f"∙ `{fmt_coin(profit_all_fiat, fiat_disp_cur)}`\n" if fiat_disp_cur else "" - ) + markdown_msg = no_closed_msg + + fiat_all_trades = ( + f"∙ `{fmt_coin(profit_all_fiat, fiat_disp_cur)}`\n" if fiat_disp_cur else "" + ) + markdown_msg += ( + f"{all_roi_label}\n" + f"∙ `{fmt_coin(profit_all_coin, stake_cur)} " + f"({profit_all_ratio_mean:.2%}) " + f"({profit_all_percent} \N{GREEK CAPITAL LETTER SIGMA}%)`\n" + f"{fiat_all_trades}" + f"*Total Trade Count:* `{trade_count}`\n" + f"*Bot started:* `{stats['bot_start_date']}`\n" + f"*{'First Trade opened' if not timescale else 'Showing Profit since'}:* " + f"`{first_trade_date}`\n" + f"*Latest Trade opened:* `{latest_trade_date}`\n" + f"*Win / Loss:* `{stats['winning_trades']} / {stats['losing_trades']}`\n" + f"*Winrate:* `{winrate:.2%}`\n" + f"*Expectancy (Ratio):* `{expectancy:.2f} ({expectancy_ratio:.2f})`" + ) + + if stats["closed_trade_count"] > 0: markdown_msg += ( - f"*ROI:* All trades\n" - f"∙ `{fmt_coin(profit_all_coin, stake_cur)} " - f"({profit_all_ratio_mean:.2%}) " - f"({profit_all_percent} \N{GREEK CAPITAL LETTER SIGMA}%)`\n" - f"{fiat_all_trades}" - f"*Total Trade Count:* `{trade_count}`\n" - f"*Bot started:* `{stats['bot_start_date']}`\n" - f"*{'First Trade opened' if not timescale else 'Showing Profit since'}:* " - f"`{first_trade_date}`\n" - f"*Latest Trade opened:* `{latest_trade_date}`\n" - f"*Win / Loss:* `{stats['winning_trades']} / {stats['losing_trades']}`\n" - f"*Winrate:* `{winrate:.2%}`\n" - f"*Expectancy (Ratio):* `{expectancy:.2f} ({expectancy_ratio:.2f})`" + f"\n*Avg. Duration:* `{avg_duration}`\n" + f"*Best Performing:* `{best_pair}: {best_pair_profit_abs} " + f"({best_pair_profit_ratio:.2%})`\n" + f"*Trading volume:* `{fmt_coin(stats['trading_volume'], stake_cur)}`\n" + f"*Profit factor:* `{stats['profit_factor']:.2f}`\n" + f"*Max Drawdown:* `{stats['max_drawdown']:.2%} " + f"({fmt_coin(stats['max_drawdown_abs'], stake_cur)})`\n" + f" from `{stats['max_drawdown_start']} " + f"({fmt_coin(stats['drawdown_high'], stake_cur)})`\n" + f" to `{stats['max_drawdown_end']} " + f"({fmt_coin(stats['drawdown_low'], stake_cur)})`\n" + f"*Current Drawdown:* `{stats['current_drawdown']:.2%} " + f"({fmt_coin(stats['current_drawdown_abs'], stake_cur)})`\n" + f" from `{stats['current_drawdown_start']} " + f"({fmt_coin(stats['current_drawdown_high'], stake_cur)})`\n" ) - if stats["closed_trade_count"] > 0: - markdown_msg += ( - f"\n*Avg. Duration:* `{avg_duration}`\n" - f"*Best Performing:* `{best_pair}: {best_pair_profit_abs} " - f"({best_pair_profit_ratio:.2%})`\n" - f"*Trading volume:* `{fmt_coin(stats['trading_volume'], stake_cur)}`\n" - f"*Profit factor:* `{stats['profit_factor']:.2f}`\n" - f"*Max Drawdown:* `{stats['max_drawdown']:.2%} " - f"({fmt_coin(stats['max_drawdown_abs'], stake_cur)})`\n" - f" from `{stats['max_drawdown_start']} " - f"({fmt_coin(stats['drawdown_high'], stake_cur)})`\n" - f" to `{stats['max_drawdown_end']} " - f"({fmt_coin(stats['drawdown_low'], stake_cur)})`\n" - f"*Current Drawdown:* `{stats['current_drawdown']:.2%} " - f"({fmt_coin(stats['current_drawdown_abs'], stake_cur)})`\n" - f" from `{stats['current_drawdown_start']} " - f"({fmt_coin(stats['current_drawdown_high'], stake_cur)})`\n" - ) + + return markdown_msg + + async def _profit_handler( + self, + update: Update, + context: CallbackContext, + direction: str | None = None, + ) -> None: + """ + Common handler for profit commands. + + :param update: Telegram update + :param context: Callback context + :param direction: Trade direction filter ('long', 'short', or None) + :param callback_path: Callback path for message updates + """ + stake_cur = self._config["stake_currency"] + fiat_disp_cur = self._config.get("fiat_display_currency", "") + + start_date = datetime.fromtimestamp(0) + timescale = None + try: + if context.args: + if not direction: + arg = context.args[0].lower() + if arg in ("short", "long"): + direction = arg + context.args.pop(0) # Remove direction from args + timescale = int(context.args[0]) - 1 + today_start = datetime.combine(date.today(), datetime.min.time()) + start_date = today_start - timedelta(days=timescale) + except (TypeError, ValueError, IndexError): + pass + + # Get stats with optional direction filter + stats_kwargs = { + "stake_currency": stake_cur, + "fiat_display_currency": fiat_disp_cur, + "start_date": start_date, + } + if direction: + stats_kwargs["direction"] = direction + + stats = self._rpc._rpc_trade_statistics(**stats_kwargs) + markdown_msg = self._format_profit_message( + stats, stake_cur, fiat_disp_cur, timescale, direction + ) + await self._send_msg( markdown_msg, reload_able=True, - callback_path="update_profit", + callback_path="update_profit" if not direction else f"update_profit_{direction}", query=update.callback_query, ) + @authorized_only + async def _profit(self, update: Update, context: CallbackContext) -> None: + """ + Handler for /profit. + Returns a cumulative profit statistics. + :param bot: telegram bot + :param update: message update + :return: None + """ + await self._profit_handler(update, context) + + @authorized_only + async def _profit_long(self, update: Update, context: CallbackContext) -> None: + """ + Handler for /profit_long. + Returns cumulative profit statistics for long trades. + """ + await self._profit_handler(update, context, direction="long") + + @authorized_only + async def _profit_short(self, update: Update, context: CallbackContext) -> None: + """ + Handler for /profit_short. + Returns cumulative profit statistics for short trades. + """ + await self._profit_handler(update, context, direction="short") + @authorized_only async def _stats(self, update: Update, context: CallbackContext) -> None: """ @@ -1869,6 +1956,10 @@ class Telegram(RPCHandler): "*/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" + "*/profit_long []:* `Lists cumulative profit from all finished long trades, " + "over the last n days`\n" + "*/profit_short []:* `Lists cumulative profit from all finished short trades, " + "over the last n days`\n" "*/performance:* `Show performance of each finished trade grouped by pair`\n" "*/daily :* `Shows profit or loss per day, over the last n days`\n" "*/weekly :* `Shows statistics per week, over the last n weeks`\n" diff --git a/tests/rpc/test_rpc_telegram.py b/tests/rpc/test_rpc_telegram.py index d50d5b012..e2540e885 100644 --- a/tests/rpc/test_rpc_telegram.py +++ b/tests/rpc/test_rpc_telegram.py @@ -171,7 +171,7 @@ def test_telegram_init(default_conf, mocker, caplog) -> None: "['pause', 'stopbuy', 'stopentry'], ['whitelist'], ['blacklist'], " "['bl_delete', 'blacklist_delete'], " "['logs'], ['health'], ['help'], ['version'], ['marketdir'], " - "['order'], ['list_custom_data'], ['tg_info']]" + "['order'], ['list_custom_data'], ['tg_info'], ['profit_long'], ['profit_short']]" ) assert log_has(message_str, caplog) @@ -943,7 +943,7 @@ async def test_telegram_profit_handle( trade.is_open = False Trade.commit() - context.args = [3] + context.args = ["3"] await 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] @@ -967,6 +967,92 @@ async def test_telegram_profit_handle( assert "*Trading volume:* `126 USDT`" in msg_mock.call_args_list[-1][0][0] +@pytest.mark.asyncio +async def test_telegram_profit_long_short_handle( + default_conf_usdt, update, ticker_usdt, fee, mocker +): + """ + Test the /profit_long and /profit_short commands to ensure the output content + is consistent with /profit, covering both no trades and trades present cases. + """ + + mocker.patch("freqtrade.rpc.rpc.CryptoToFiatConverter._find_price", return_value=1.1) + mocker.patch.multiple(EXMS, fetch_ticker=ticker_usdt, get_fee=fee) + telegram, freqtradebot, msg_mock = get_telegram_testobject(mocker, default_conf_usdt) + + # When there are no trades + await telegram._profit_long(update=update, context=MagicMock()) + assert msg_mock.call_count == 1 + assert "No long trades yet." in msg_mock.call_args_list[0][0][0] + msg_mock.reset_mock() + + # Test support with "/profit long" + context = MagicMock() + context.args = ["long"] + await telegram._profit(update=update, context=context) + assert msg_mock.call_count == 1 + assert "No long trades yet." in msg_mock.call_args_list[0][0][0] + msg_mock.reset_mock() + + await telegram._profit_short(update=update, context=MagicMock()) + assert msg_mock.call_count == 1 + assert "No short trades yet." in msg_mock.call_args_list[0][0][0] + msg_mock.reset_mock() + + # Test support with "/profit short" + context = MagicMock() + context.args = ["short"] + await telegram._profit(update=update, context=context) + assert msg_mock.call_count == 1 + assert "No short trades yet." in msg_mock.call_args_list[0][0][0] + msg_mock.reset_mock() + + # When there are trades + create_mock_trades_usdt(fee) + + # Keep only long trades + for t in Trade.get_trades_proxy(): + t.is_short = False + Trade.commit() + await telegram._profit_long(update=update, context=MagicMock()) + msg = msg_mock.call_args_list[0][0][0] + assert "*ROI:* Closed long trades" in msg + assert "*ROI:* All long trades" in msg + assert "*Total Trade Count:*" in msg + assert "*Winrate:*" in msg + assert "*Expectancy (Ratio):*" in msg + assert "*Best Performing:*" in msg + assert "*Profit factor:*" in msg + assert "*Max Drawdown:*" in msg + assert "*Current Drawdown:*" in msg + msg_mock.reset_mock() + + # Keep only short trades + for t in Trade.get_trades_proxy(): + t.is_short = True + Trade.commit() + await telegram._profit_short(update=update, context=MagicMock()) + msg = msg_mock.call_args_list[0][0][0] + assert "*ROI:* Closed short trades" in msg + assert "*ROI:* All short trades" in msg + assert "*Total Trade Count:*" in msg + assert "*Winrate:*" in msg + assert "*Expectancy (Ratio):*" in msg + assert "*Best Performing:*" in msg + assert "*Profit factor:*" in msg + assert "*Max Drawdown:*" in msg + assert "*Current Drawdown:*" in msg + msg_mock.reset_mock() + + # Test parameter passing + context = MagicMock() + context.args = ["2"] + await telegram._profit_long(update=update, context=context) + assert msg_mock.call_count == 1 + await telegram._profit_short(update=update, context=context) + assert msg_mock.call_count == 2 + + @pytest.mark.parametrize("is_short", [True, False]) async def test_telegram_stats(default_conf, update, ticker, fee, mocker, is_short) -> None: mocker.patch("freqtrade.rpc.rpc.CryptoToFiatConverter._find_price", return_value=15000.0)