From 97f30cf13d6f9b53fd0b55b999dd3de0df60810e Mon Sep 17 00:00:00 2001 From: qqqqqf <1684773595@qq.com> Date: Sat, 12 Jul 2025 08:41:39 +0800 Subject: [PATCH 01/11] feat(telegram): Add /profit long and /profit short commands This commit enhances the /profit Telegram command to allow filtering by trade direction. - The `_profit` handler in `telegram.py` now parses 'long'/'short' arguments and passes the direction to the RPC layer. - The `_rpc_trade_statistics` method in `rpc.py` is updated to filter trades based on the provided direction. It has also been refactored for lower complexity. - The `/help` command documentation is updated to reflect the new functionality. - Corresponding unit tests in `test_rpc_telegram.py` are updated and extended to cover the new cases. --- freqtrade/rpc/rpc.py | 76 +++++++++++++++++++--------------- freqtrade/rpc/telegram.py | 36 ++++++++++------ tests/rpc/test_rpc_telegram.py | 19 +++++++-- 3 files changed, 83 insertions(+), 48 deletions(-) diff --git a/freqtrade/rpc/rpc.py b/freqtrade/rpc/rpc.py index 5d8ffc141..2909a719c 100644 --- a/freqtrade/rpc/rpc.py +++ b/freqtrade/rpc/rpc.py @@ -502,20 +502,17 @@ 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 _process_trade_stats( + self, + trades: Sequence[Trade], + stake_currency: str, + fiat_display_currency: str, + start_date: datetime, ) -> 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() - + """ + Processes a list of trades and returns the statistics. + Helper for _rpc_trade_statistics. + """ profit_all_coin = [] profit_all_ratio = [] profit_closed_coin = [] @@ -544,9 +541,7 @@ class RPC: losing_trades += 1 losing_profit += profit_abs else: - # Get current rate if len(trade.select_filled_orders(trade.entry_side)) == 0: - # Skip trades with no filled orders continue try: current_rate = self._freqtrade.exchange.get_rate( @@ -558,7 +553,6 @@ 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 @@ -566,15 +560,11 @@ class RPC: profit_all_ratio.append(profit_ratio) 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) - - # Prepare data to display profit_closed_coin_sum = round(sum(profit_closed_coin), 8) - profit_closed_ratio_mean = float(mean(profit_closed_ratio) if profit_closed_ratio else 0.0) profit_closed_ratio_sum = sum(profit_closed_ratio) if profit_closed_ratio else 0.0 - + profit_closed_ratio_mean = float(mean(profit_closed_ratio) if profit_closed_ratio else 0.0) profit_closed_fiat = ( self._fiat_converter.convert_amount( profit_closed_coin_sum, stake_currency, fiat_display_currency @@ -582,22 +572,17 @@ class RPC: if self._fiat_converter else 0 ) - profit_all_coin_sum = round(sum(profit_all_coin), 8) - profit_all_ratio_mean = float(mean(profit_all_ratio) if profit_all_ratio else 0.0) - # Doing the sum is not right - overall profit needs to be based on initial capital profit_all_ratio_sum = sum(profit_all_ratio) if profit_all_ratio else 0.0 + profit_all_ratio_mean = float(mean(profit_all_ratio) if profit_all_ratio else 0.0) starting_balance = self._freqtrade.wallets.get_starting_balance() profit_closed_ratio_fromstart = 0.0 profit_all_ratio_fromstart = 0.0 if starting_balance: profit_closed_ratio_fromstart = profit_closed_coin_sum / starting_balance profit_all_ratio_fromstart = profit_all_coin_sum / starting_balance - profit_factor = winning_profit / abs(losing_profit) if losing_profit else float("inf") - winrate = (winning_trades / closed_trade_count) if closed_trade_count > 0 else 0 - trades_df = DataFrame( [ { @@ -609,9 +594,7 @@ class RPC: if not trade.is_open and trade.close_date ] ) - expectancy, expectancy_ratio = calculate_expectancy(trades_df) - drawdown = DrawDownResult() if len(trades_df) > 0: try: @@ -622,9 +605,7 @@ class RPC: starting_balance=starting_balance, ) except ValueError: - # ValueError if no losing trade. pass - profit_all_fiat = ( self._fiat_converter.convert_amount( profit_all_coin_sum, stake_currency, fiat_display_currency @@ -632,7 +613,6 @@ class RPC: if self._fiat_converter else 0 ) - first_date = trades[0].open_date_utc if trades else None last_date = trades[-1].open_date_utc if trades else None num = float(len(durations) or 1) @@ -664,7 +644,7 @@ class RPC: "latest_trade_timestamp": dt_ts_def(last_date, 0), "avg_duration": str(timedelta(seconds=sum(durations) / num)).split(".")[0], "best_pair": best_pair[0] if best_pair else "", - "best_rate": round(best_pair[1] * 100, 2) if best_pair else 0, # Deprecated + "best_rate": round(best_pair[1] * 100, 2) if best_pair else 0, "best_pair_profit_ratio": best_pair[1] if best_pair else 0, "best_pair_profit_abs": best_pair[2] if best_pair else 0, "winning_trades": winning_trades, @@ -691,6 +671,36 @@ class RPC: "bot_start_date": format_date(bot_start), } + + + 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""" + start_date_filter = datetime.fromtimestamp(0) if start_date is None else start_date + + trade_filter = ( + Trade.is_open.is_(False) & (Trade.close_date >= start_date_filter) + ) | Trade.is_open.is_(True) + + if direction: + if direction == 'long': + trade_filter &= Trade.is_short.is_(False) + elif direction == 'short': + trade_filter &= Trade.is_short.is_(True) + + trades: Sequence[Trade] = Trade.session.scalars( + Trade.get_trades_query(trade_filter, include_orders=False).order_by(Trade.id) + ).all() + + return self._process_trade_stats( + trades, stake_currency, fiat_display_currency, start_date_filter + ) + def __balance_get_est_stake( self, coin: str, stake_currency: str, amount: float, balance: Wallet ) -> tuple[float, float]: diff --git a/freqtrade/rpc/telegram.py b/freqtrade/rpc/telegram.py index 7b3967193..189fe0205 100644 --- a/freqtrade/rpc/telegram.py +++ b/freqtrade/rpc/telegram.py @@ -1009,15 +1009,23 @@ class Telegram(RPCHandler): 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 + direction: str | None = None + args = list(context.args) if context.args else [] + if args and isinstance(args[0], str) and args[0].lower() in ('long', 'short'): + direction = args[0].lower() + args.pop(0) + if args: + 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) + stats = self._rpc._rpc_trade_statistics( + stake_cur, fiat_disp_cur, start_date,direction=direction + ) profit_closed_coin = stats["profit_closed_coin"] profit_closed_ratio_mean = stats["profit_closed_ratio_mean"] profit_closed_percent = stats["profit_closed_percent"] @@ -1045,8 +1053,11 @@ class Telegram(RPCHandler): fiat_closed_trades = ( f"∙ `{fmt_coin(profit_closed_fiat, fiat_disp_cur)}`\n" if fiat_disp_cur else "" ) + + direction_str = f"{direction.capitalize()} " if direction else "" + markdown_msg = ( - "*ROI:* Closed trades\n" + f"*ROI ({direction_str}Trades):* 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" @@ -1057,8 +1068,9 @@ class Telegram(RPCHandler): fiat_all_trades = ( f"∙ `{fmt_coin(profit_all_fiat, fiat_disp_cur)}`\n" if fiat_disp_cur else "" ) + direction_str_all = f"{direction.capitalize()} " if direction else "" markdown_msg += ( - f"*ROI:* All trades\n" + f"*ROI ({direction_str_all}Trades):* 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" @@ -1867,8 +1879,8 @@ class Telegram(RPCHandler): "*/exits :* `Shows the exit reason performance`\n" "*/mix_tags :* `Shows combined entry tag + exit 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" + "*/profit [long|short] []:* `Show profit from finished trades (last n days).`\n " + "`Optional filter: long or short.`\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 460352fff..79d8713ac 100644 --- a/tests/rpc/test_rpc_telegram.py +++ b/tests/rpc/test_rpc_telegram.py @@ -921,7 +921,7 @@ async def test_telegram_profit_handle( await telegram._profit(update=update, context=context) 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] + assert "*ROI (Trades):* All trades" in msg_mock.call_args_list[-1][0][0] mocker.patch("freqtrade.wallets.Wallets.get_starting_balance", return_value=1000) assert ( "∙ `0.298 USDT (0.50%) (0.03 \N{GREEK CAPITAL LETTER SIGMA}%)`" @@ -946,13 +946,13 @@ async def test_telegram_profit_handle( 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] + assert "*ROI (Trades):* Closed trades" in msg_mock.call_args_list[-1][0][0] assert ( "∙ `5.685 USDT (9.45%) (0.57 \N{GREEK CAPITAL LETTER SIGMA}%)`" 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 "*ROI (Trades):* All trades" in msg_mock.call_args_list[-1][0][0] assert ( "∙ `5.685 USDT (9.45%) (0.57 \N{GREEK CAPITAL LETTER SIGMA}%)`" in msg_mock.call_args_list[-1][0][0] @@ -966,6 +966,19 @@ async def test_telegram_profit_handle( assert "*Expectancy (Ratio):*" in msg_mock.call_args_list[-1][0][0] assert "*Trading volume:* `126 USDT`" in msg_mock.call_args_list[-1][0][0] + msg_mock.reset_mock() + # Test /profit long + context.args = ["long"] + await telegram._profit(update=update, context=context) + assert msg_mock.call_count == 1 + assert "*ROI (Long Trades):* All trades" in msg_mock.call_args_list[-1][0][0] + + msg_mock.reset_mock() + # Test /profit short + context.args = ["short"] + await telegram._profit(update=update, context=context) + assert msg_mock.call_count == 1 + assert "No trades yet." in msg_mock.call_args_list[-1][0][0] @pytest.mark.parametrize("is_short", [True, False]) async def test_telegram_stats(default_conf, update, ticker, fee, mocker, is_short) -> None: From 19b57ad87e8e17a07d44f1795f837e325cff3a72 Mon Sep 17 00:00:00 2001 From: qqqqqf <104579013+qqqqqf-q@users.noreply.github.com> Date: Tue, 15 Jul 2025 19:15:04 +0800 Subject: [PATCH 02/11] Add /profit long and /profit short commands#2 # Added `/profit_long` and `/profit_short` Commands Users can now use commands like: - `/profit_long []` - `/profit_short []` - `/profit []` --- ## Key Changes Implemented ### `freqtrade/rpc/telegram.py`: - The `_profit` command handler has been updated to robustly parse `long` or `short` as optional arguments. - **Translation:** The `_profit` command handler has been improved to reliably interpret `long` or `short` as optional parameters. - The determined direction is passed to the RPC layer. - **Translation:** The direction determined (either `long` or `short`) is passed to the RPC layer. - The `/help` command documentation is updated. - **Translation:** The documentation for the `/help` command has been updated accordingly. --- ### `freqtrade/rpc/rpc.py`: - The `_rpc_trade_statistics` method now accepts a direction parameter. - **Translation:** The `_rpc_trade_statistics` method has been updated to accept a `direction` parameter. - The method has been refactored into a main function and a `_process_trade_stats` helper function to reduce complexity and improve readability. - **Translation:** The method has been refactored into a main function and a helper function, `_process_trade_stats`, to reduce complexity and improve readability. - The database query filter is dynamically modified to include a condition on `Trade.is_short` when a direction is provided. - **Translation:** The database query filter dynamically adjusts to include a condition on `Trade.is_short` when a direction is specified. --- ### `tests/rpc/test_rpc_telegram.py`: - Existing tests for `_profit` have been updated to match the new message format. - **Translation:** Existing tests for the `_profit` function have been updated to match the new message format. - New test cases have been added to specifically validate the `long` and `short` filtering functionality. - **Translation:** New test cases have been added to specifically validate the filtering functionality for `long` and `short` trades. --- ## Testing - All local `pytest` tests pass successfully. - **Translation:** All local `pytest` tests have passed successfully. - All `ruff` linter checks pass. - **Translation:** All `ruff` code checks have passed. - As I do not have a full local deployment, I am relying on the CI pipeline for final validation. - **Translation:** Since I don't have a complete local deployment, I am relying on the CI pipeline for final validation. --- This time, only a little AI was used :) Except for the translation. --- README.md | 3 + freqtrade/rpc/rpc.py | 112 +++++++++------ freqtrade/rpc/telegram.py | 245 +++++++++++++++++++++++++++++---- tests/rpc/test_rpc_telegram.py | 94 ++++++++++--- 4 files changed, 373 insertions(+), 81 deletions(-) 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/rpc/rpc.py b/freqtrade/rpc/rpc.py index 2909a719c..b928b1ae7 100644 --- a/freqtrade/rpc/rpc.py +++ b/freqtrade/rpc/rpc.py @@ -502,17 +502,13 @@ class RPC: durations = {"wins": wins_dur, "draws": draws_dur, "losses": losses_dur} return {"exit_reasons": exit_reasons, "durations": durations} - def _process_trade_stats( + def _collect_trade_statistics_data( self, - trades: Sequence[Trade], + trades: Sequence['Trade'], stake_currency: str, fiat_display_currency: str, - start_date: datetime, ) -> dict[str, Any]: - """ - Processes a list of trades and returns the statistics. - Helper for _rpc_trade_statistics. - """ + """Iterate trades, calculate various statistics, and return intermediate results.""" profit_all_coin = [] profit_all_ratio = [] profit_closed_coin = [] @@ -541,7 +537,9 @@ class RPC: losing_trades += 1 losing_profit += profit_abs else: + # Get current rate for open trades if len(trade.select_filled_orders(trade.entry_side)) == 0: + # Skip trades with no filled orders continue try: current_rate = self._freqtrade.exchange.get_rate( @@ -559,12 +557,66 @@ class RPC: 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: + if direction == "long": + trade_filter = trade_filter & Trade.is_short.is_(False) + elif direction == "short": + trade_filter = trade_filter & Trade.is_short.is_(True) + + 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) + + # Prepare data to display profit_closed_coin_sum = round(sum(profit_closed_coin), 8) - profit_closed_ratio_sum = sum(profit_closed_ratio) if profit_closed_ratio else 0.0 profit_closed_ratio_mean = float(mean(profit_closed_ratio) if profit_closed_ratio else 0.0) + profit_closed_ratio_sum = sum(profit_closed_ratio) if profit_closed_ratio else 0.0 + profit_closed_fiat = ( self._fiat_converter.convert_amount( profit_closed_coin_sum, stake_currency, fiat_display_currency @@ -572,17 +624,22 @@ class RPC: if self._fiat_converter else 0 ) + profit_all_coin_sum = round(sum(profit_all_coin), 8) - profit_all_ratio_sum = sum(profit_all_ratio) if profit_all_ratio else 0.0 profit_all_ratio_mean = float(mean(profit_all_ratio) if profit_all_ratio else 0.0) + # Doing the sum is not right - overall profit needs to be based on initial capital + profit_all_ratio_sum = sum(profit_all_ratio) if profit_all_ratio else 0.0 starting_balance = self._freqtrade.wallets.get_starting_balance() profit_closed_ratio_fromstart = 0.0 profit_all_ratio_fromstart = 0.0 if starting_balance: profit_closed_ratio_fromstart = profit_closed_coin_sum / starting_balance profit_all_ratio_fromstart = profit_all_coin_sum / starting_balance + profit_factor = winning_profit / abs(losing_profit) if losing_profit else float("inf") + winrate = (winning_trades / closed_trade_count) if closed_trade_count > 0 else 0 + trades_df = DataFrame( [ { @@ -594,7 +651,9 @@ class RPC: if not trade.is_open and trade.close_date ] ) + expectancy, expectancy_ratio = calculate_expectancy(trades_df) + drawdown = DrawDownResult() if len(trades_df) > 0: try: @@ -605,7 +664,9 @@ class RPC: starting_balance=starting_balance, ) except ValueError: + # ValueError if no losing trade. pass + profit_all_fiat = ( self._fiat_converter.convert_amount( profit_all_coin_sum, stake_currency, fiat_display_currency @@ -613,6 +674,7 @@ class RPC: if self._fiat_converter else 0 ) + first_date = trades[0].open_date_utc if trades else None last_date = trades[-1].open_date_utc if trades else None num = float(len(durations) or 1) @@ -644,7 +706,7 @@ class RPC: "latest_trade_timestamp": dt_ts_def(last_date, 0), "avg_duration": str(timedelta(seconds=sum(durations) / num)).split(".")[0], "best_pair": best_pair[0] if best_pair else "", - "best_rate": round(best_pair[1] * 100, 2) if best_pair else 0, + "best_rate": round(best_pair[1] * 100, 2) if best_pair else 0, # Deprecated "best_pair_profit_ratio": best_pair[1] if best_pair else 0, "best_pair_profit_abs": best_pair[2] if best_pair else 0, "winning_trades": winning_trades, @@ -671,36 +733,6 @@ class RPC: "bot_start_date": format_date(bot_start), } - - - 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""" - start_date_filter = datetime.fromtimestamp(0) if start_date is None else start_date - - trade_filter = ( - Trade.is_open.is_(False) & (Trade.close_date >= start_date_filter) - ) | Trade.is_open.is_(True) - - if direction: - if direction == 'long': - trade_filter &= Trade.is_short.is_(False) - elif direction == 'short': - trade_filter &= Trade.is_short.is_(True) - - trades: Sequence[Trade] = Trade.session.scalars( - Trade.get_trades_query(trade_filter, include_orders=False).order_by(Trade.id) - ).all() - - return self._process_trade_stats( - trades, stake_currency, fiat_display_currency, start_date_filter - ) - def __balance_get_est_stake( self, coin: str, stake_currency: str, amount: float, balance: Wallet ) -> tuple[float, float]: diff --git a/freqtrade/rpc/telegram.py b/freqtrade/rpc/telegram.py index 189fe0205..d94fe2851 100644 --- a/freqtrade/rpc/telegram.py +++ b/freqtrade/rpc/telegram.py @@ -305,6 +305,8 @@ 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"), @@ -1009,23 +1011,15 @@ class Telegram(RPCHandler): start_date = datetime.fromtimestamp(0) timescale = None - direction: str | None = None - args = list(context.args) if context.args else [] - if args and isinstance(args[0], str) and args[0].lower() in ('long', 'short'): - direction = args[0].lower() - args.pop(0) - if args: - 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 + 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,direction=direction - ) + stats = self._rpc._rpc_trade_statistics(stake_cur, fiat_disp_cur, start_date) profit_closed_coin = stats["profit_closed_coin"] profit_closed_ratio_mean = stats["profit_closed_ratio_mean"] profit_closed_percent = stats["profit_closed_percent"] @@ -1053,11 +1047,8 @@ class Telegram(RPCHandler): fiat_closed_trades = ( f"∙ `{fmt_coin(profit_closed_fiat, fiat_disp_cur)}`\n" if fiat_disp_cur else "" ) - - direction_str = f"{direction.capitalize()} " if direction else "" - markdown_msg = ( - f"*ROI ({direction_str}Trades):* Closed trades\n" + "*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" @@ -1068,9 +1059,8 @@ class Telegram(RPCHandler): fiat_all_trades = ( f"∙ `{fmt_coin(profit_all_fiat, fiat_disp_cur)}`\n" if fiat_disp_cur else "" ) - direction_str_all = f"{direction.capitalize()} " if direction else "" markdown_msg += ( - f"*ROI ({direction_str_all}Trades):* All trades\n" + 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" @@ -1109,6 +1099,208 @@ class Telegram(RPCHandler): query=update.callback_query, ) + @authorized_only + async def _profit_long(self, update: Update, context: CallbackContext) -> None: + """ + Handler for /profit_long. + Returns cumulative profit statistics for long trades. + """ + 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, + direction="long" + ) + + profit_closed_coin = stats["profit_closed_coin"] + profit_closed_ratio_mean = stats["profit_closed_ratio_mean"] + profit_closed_percent = stats["profit_closed_percent"] + profit_closed_fiat = stats["profit_closed_fiat"] + profit_all_coin = stats["profit_all_coin"] + profit_all_ratio_mean = stats["profit_all_ratio_mean"] + profit_all_percent = stats["profit_all_percent"] + profit_all_fiat = stats["profit_all_fiat"] + trade_count = stats["trade_count"] + first_trade_date = f"{stats['first_trade_humanized']} ({stats['first_trade_date']})" + latest_trade_date = f"{stats['latest_trade_humanized']} ({stats['latest_trade_date']})" + avg_duration = stats["avg_duration"] + best_pair = stats["best_pair"] + best_pair_profit_ratio = stats["best_pair_profit_ratio"] + best_pair_profit_abs = fmt_coin(stats["best_pair_profit_abs"], stake_cur) + winrate = stats["winrate"] + expectancy = stats["expectancy"] + expectancy_ratio = stats["expectancy_ratio"] + if stats["trade_count"] == 0: + markdown_msg = f"No long trades yet.\n*Bot started:* `{stats['bot_start_date']}`" + else: + 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 long trades*\n" + f"∙ `{fmt_coin(profit_closed_coin, stake_cur)} " + f"({profit_closed_ratio_mean:.2%}) " + f"({profit_closed_percent} \u03A3%)`\n" + f"{fiat_closed_trades}" + ) + else: + markdown_msg = "`No closed long trade` \n" + fiat_all_trades = ( + f"∙ `{fmt_coin(profit_all_fiat, fiat_disp_cur)}`\n" if fiat_disp_cur else "" + ) + markdown_msg += ( + f"*ROI: All long trades\n" + f"∙ `{fmt_coin(profit_all_coin, stake_cur)} " + f"({profit_all_ratio_mean:.2%}) " + f"({profit_all_percent} \u03A3%)`\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"\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" + ) + await self._send_msg( + markdown_msg, + reload_able=True, + callback_path="update_profit_long", + query=update.callback_query, + ) + + @authorized_only + async def _profit_short(self, update: Update, context: CallbackContext) -> None: + """ + Handler for /profit_short. + Returns cumulative profit statistics for short trades. + """ + 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, + direction="short" + ) + + profit_closed_coin = stats["profit_closed_coin"] + profit_closed_ratio_mean = stats["profit_closed_ratio_mean"] + profit_closed_percent = stats["profit_closed_percent"] + profit_closed_fiat = stats["profit_closed_fiat"] + profit_all_coin = stats["profit_all_coin"] + profit_all_ratio_mean = stats["profit_all_ratio_mean"] + profit_all_percent = stats["profit_all_percent"] + profit_all_fiat = stats["profit_all_fiat"] + trade_count = stats["trade_count"] + first_trade_date = f"{stats['first_trade_humanized']} ({stats['first_trade_date']})" + latest_trade_date = f"{stats['latest_trade_humanized']} ({stats['latest_trade_date']})" + avg_duration = stats["avg_duration"] + best_pair = stats["best_pair"] + best_pair_profit_ratio = stats["best_pair_profit_ratio"] + best_pair_profit_abs = fmt_coin(stats["best_pair_profit_abs"], stake_cur) + winrate = stats["winrate"] + expectancy = stats["expectancy"] + expectancy_ratio = stats["expectancy_ratio"] + if stats["trade_count"] == 0: + markdown_msg = f"No short trades yet.\n*Bot started:* `{stats['bot_start_date']}`" + else: + 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 short trades*\n" + f"∙ `{fmt_coin(profit_closed_coin, stake_cur)} " + f"({profit_closed_ratio_mean:.2%}) " + f"({profit_closed_percent} \u03A3%)`\n" + f"{fiat_closed_trades}" + ) + else: + markdown_msg = "`No closed short trade` \n" + fiat_all_trades = ( + f"∙ `{fmt_coin(profit_all_fiat, fiat_disp_cur)}`\n" if fiat_disp_cur else "" + ) + markdown_msg += ( + f"*ROI: All short trades\n" + f"∙ `{fmt_coin(profit_all_coin, stake_cur)} " + f"({profit_all_ratio_mean:.2%}) " + f"({profit_all_percent} \u03A3%)`\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"\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" + ) + await self._send_msg( + markdown_msg, + reload_able=True, + callback_path="update_profit_short", + query=update.callback_query, + ) + @authorized_only async def _stats(self, update: Update, context: CallbackContext) -> None: """ @@ -1879,8 +2071,12 @@ class Telegram(RPCHandler): "*/exits :* `Shows the exit reason performance`\n" "*/mix_tags :* `Shows combined entry tag + exit reason performance`\n" "*/trades [limit]:* `Lists last closed trades (limited to 10 by default)`\n" - "*/profit [long|short] []:* `Show profit from finished trades (last n days).`\n " - "`Optional filter: long or short.`\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" @@ -2187,3 +2383,4 @@ class Telegram(RPCHandler): ) except TelegramError as telegram_err: logger.warning("TelegramError: %s! Giving up on that message.", telegram_err.message) + diff --git a/tests/rpc/test_rpc_telegram.py b/tests/rpc/test_rpc_telegram.py index 79d8713ac..531b3046d 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) @@ -921,7 +921,7 @@ async def test_telegram_profit_handle( await telegram._profit(update=update, context=context) assert msg_mock.call_count == 1 assert "No closed trade" in msg_mock.call_args_list[-1][0][0] - assert "*ROI (Trades):* All trades" 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=1000) assert ( "∙ `0.298 USDT (0.50%) (0.03 \N{GREEK CAPITAL LETTER SIGMA}%)`" @@ -946,13 +946,13 @@ async def test_telegram_profit_handle( context.args = [3] await telegram._profit(update=update, context=context) assert msg_mock.call_count == 1 - assert "*ROI (Trades):* Closed trades" in msg_mock.call_args_list[-1][0][0] + assert "*ROI:* Closed trades" in msg_mock.call_args_list[-1][0][0] assert ( "∙ `5.685 USDT (9.45%) (0.57 \N{GREEK CAPITAL LETTER SIGMA}%)`" in msg_mock.call_args_list[-1][0][0] ) assert "∙ `6.253 USD`" in msg_mock.call_args_list[-1][0][0] - assert "*ROI (Trades):* All trades" in msg_mock.call_args_list[-1][0][0] + assert "*ROI:* All trades" in msg_mock.call_args_list[-1][0][0] assert ( "∙ `5.685 USDT (9.45%) (0.57 \N{GREEK CAPITAL LETTER SIGMA}%)`" in msg_mock.call_args_list[-1][0][0] @@ -966,19 +966,6 @@ async def test_telegram_profit_handle( assert "*Expectancy (Ratio):*" in msg_mock.call_args_list[-1][0][0] assert "*Trading volume:* `126 USDT`" in msg_mock.call_args_list[-1][0][0] - msg_mock.reset_mock() - # Test /profit long - context.args = ["long"] - await telegram._profit(update=update, context=context) - assert msg_mock.call_count == 1 - assert "*ROI (Long Trades):* All trades" in msg_mock.call_args_list[-1][0][0] - - msg_mock.reset_mock() - # Test /profit short - context.args = ["short"] - await telegram._profit(update=update, context=context) - assert msg_mock.call_count == 1 - assert "No trades yet." in msg_mock.call_args_list[-1][0][0] @pytest.mark.parametrize("is_short", [True, False]) async def test_telegram_stats(default_conf, update, ticker, fee, mocker, is_short) -> None: @@ -2995,3 +2982,76 @@ async def test__tg_info(default_conf_usdt, mocker, update): content = context.bot.send_message.call_args[1]["text"] assert "Freqtrade Bot Info:\n" in content assert '"chat_id": "1235"' in content + + +@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() + 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() + + # 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 From c92c64bac2086337792ced4f56c45bc05d1d57c9 Mon Sep 17 00:00:00 2001 From: qqqqqf <104579013+qqqqqf-q@users.noreply.github.com> Date: Wed, 16 Jul 2025 11:43:51 +0800 Subject: [PATCH 03/11] Modify the duplicate functions. Modify the original three duplicate functions (_profit_short, _profit_long, _profit), and add _profit_handler and _format_profit_message. Refactor telegram.py and rpc.py. Sorry for the duplicate functions yesterday, I was a bit rushed. Both pytest and ruff have passed. --- freqtrade/rpc/rpc.py | 8 +- freqtrade/rpc/telegram.py | 440 ++++++++++++++------------------------ 2 files changed, 165 insertions(+), 283 deletions(-) diff --git a/freqtrade/rpc/rpc.py b/freqtrade/rpc/rpc.py index b928b1ae7..66007f6b1 100644 --- a/freqtrade/rpc/rpc.py +++ b/freqtrade/rpc/rpc.py @@ -504,7 +504,7 @@ class RPC: def _collect_trade_statistics_data( self, - trades: Sequence['Trade'], + trades: Sequence["Trade"], stake_currency: str, fiat_display_currency: str, ) -> dict[str, Any]: @@ -574,7 +574,7 @@ class RPC: stake_currency: str, fiat_display_currency: str, start_date: datetime | None = None, - direction: str | None = None + direction: str | None = None, ) -> dict[str, Any]: """ Returns cumulative profit statistics, with optional direction filter (long/short) @@ -582,8 +582,8 @@ class RPC: 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) - ) + Trade.is_open.is_(False) & (Trade.close_date >= start_date) + ) | Trade.is_open.is_(True) if direction: if direction == "long": diff --git a/freqtrade/rpc/telegram.py b/freqtrade/rpc/telegram.py index d94fe2851..1ce608bf0 100644 --- a/freqtrade/rpc/telegram.py +++ b/freqtrade/rpc/telegram.py @@ -997,6 +997,162 @@ class Telegram(RPCHandler): """ await self._timeunit_stats(update, context, "months") + def _format_profit_message( + self, + stats: dict, + stake_cur: str, + fiat_disp_cur: str, + timescale: int | None = None, + direction: str | None = None, + ) -> str: + """ + 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 + """ + # 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"] + profit_closed_fiat = stats["profit_closed_fiat"] + profit_all_coin = stats["profit_all_coin"] + profit_all_ratio_mean = stats["profit_all_ratio_mean"] + profit_all_percent = stats["profit_all_percent"] + profit_all_fiat = stats["profit_all_fiat"] + trade_count = stats["trade_count"] + first_trade_date = f"{stats['first_trade_humanized']} ({stats['first_trade_date']})" + latest_trade_date = f"{stats['latest_trade_humanized']} ({stats['latest_trade_date']})" + avg_duration = stats["avg_duration"] + best_pair = stats["best_pair"] + best_pair_profit_ratio = stats["best_pair_profit_ratio"] + best_pair_profit_abs = fmt_coin(stats["best_pair_profit_abs"], stake_cur) + winrate = stats["winrate"] + 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*" if direction else "*ROI:* Closed trades" + ) + all_roi_label = f"*ROI: All{direction_label} trades" if direction else "*ROI:* All trades" + + if stats["trade_count"] == 0: + 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: + 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"\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, + callback_path: str = "update_profit", + ) -> 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: + 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=callback_path, + query=update.callback_query, + ) + @authorized_only async def _profit(self, update: Update, context: CallbackContext) -> None: """ @@ -1006,98 +1162,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", "") - - 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) - profit_closed_coin = stats["profit_closed_coin"] - profit_closed_ratio_mean = stats["profit_closed_ratio_mean"] - profit_closed_percent = stats["profit_closed_percent"] - profit_closed_fiat = stats["profit_closed_fiat"] - profit_all_coin = stats["profit_all_coin"] - profit_all_ratio_mean = stats["profit_all_ratio_mean"] - profit_all_percent = stats["profit_all_percent"] - profit_all_fiat = stats["profit_all_fiat"] - trade_count = stats["trade_count"] - first_trade_date = f"{stats['first_trade_humanized']} ({stats['first_trade_date']})" - latest_trade_date = f"{stats['latest_trade_humanized']} ({stats['latest_trade_date']})" - avg_duration = stats["avg_duration"] - best_pair = stats["best_pair"] - best_pair_profit_ratio = stats["best_pair_profit_ratio"] - best_pair_profit_abs = fmt_coin(stats["best_pair_profit_abs"], stake_cur) - winrate = stats["winrate"] - expectancy = stats["expectancy"] - expectancy_ratio = stats["expectancy_ratio"] - - if stats["trade_count"] == 0: - markdown_msg = f"No trades yet.\n*Bot started:* `{stats['bot_start_date']}`" - 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 += ( - 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})`" - ) - 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" - ) - await self._send_msg( - markdown_msg, - reload_able=True, - callback_path="update_profit", - query=update.callback_query, - ) + await self._profit_handler(update, context) @authorized_only async def _profit_long(self, update: Update, context: CallbackContext) -> None: @@ -1105,99 +1170,8 @@ class Telegram(RPCHandler): Handler for /profit_long. Returns cumulative profit statistics for long trades. """ - 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, - direction="long" - ) - - profit_closed_coin = stats["profit_closed_coin"] - profit_closed_ratio_mean = stats["profit_closed_ratio_mean"] - profit_closed_percent = stats["profit_closed_percent"] - profit_closed_fiat = stats["profit_closed_fiat"] - profit_all_coin = stats["profit_all_coin"] - profit_all_ratio_mean = stats["profit_all_ratio_mean"] - profit_all_percent = stats["profit_all_percent"] - profit_all_fiat = stats["profit_all_fiat"] - trade_count = stats["trade_count"] - first_trade_date = f"{stats['first_trade_humanized']} ({stats['first_trade_date']})" - latest_trade_date = f"{stats['latest_trade_humanized']} ({stats['latest_trade_date']})" - avg_duration = stats["avg_duration"] - best_pair = stats["best_pair"] - best_pair_profit_ratio = stats["best_pair_profit_ratio"] - best_pair_profit_abs = fmt_coin(stats["best_pair_profit_abs"], stake_cur) - winrate = stats["winrate"] - expectancy = stats["expectancy"] - expectancy_ratio = stats["expectancy_ratio"] - if stats["trade_count"] == 0: - markdown_msg = f"No long trades yet.\n*Bot started:* `{stats['bot_start_date']}`" - else: - 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 long trades*\n" - f"∙ `{fmt_coin(profit_closed_coin, stake_cur)} " - f"({profit_closed_ratio_mean:.2%}) " - f"({profit_closed_percent} \u03A3%)`\n" - f"{fiat_closed_trades}" - ) - else: - markdown_msg = "`No closed long trade` \n" - fiat_all_trades = ( - f"∙ `{fmt_coin(profit_all_fiat, fiat_disp_cur)}`\n" if fiat_disp_cur else "" - ) - markdown_msg += ( - f"*ROI: All long trades\n" - f"∙ `{fmt_coin(profit_all_coin, stake_cur)} " - f"({profit_all_ratio_mean:.2%}) " - f"({profit_all_percent} \u03A3%)`\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"\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" - ) - await self._send_msg( - markdown_msg, - reload_able=True, - callback_path="update_profit_long", - query=update.callback_query, + await self._profit_handler( + update, context, direction="long", callback_path="update_profit_long" ) @authorized_only @@ -1206,99 +1180,8 @@ class Telegram(RPCHandler): Handler for /profit_short. Returns cumulative profit statistics for short trades. """ - 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, - direction="short" - ) - - profit_closed_coin = stats["profit_closed_coin"] - profit_closed_ratio_mean = stats["profit_closed_ratio_mean"] - profit_closed_percent = stats["profit_closed_percent"] - profit_closed_fiat = stats["profit_closed_fiat"] - profit_all_coin = stats["profit_all_coin"] - profit_all_ratio_mean = stats["profit_all_ratio_mean"] - profit_all_percent = stats["profit_all_percent"] - profit_all_fiat = stats["profit_all_fiat"] - trade_count = stats["trade_count"] - first_trade_date = f"{stats['first_trade_humanized']} ({stats['first_trade_date']})" - latest_trade_date = f"{stats['latest_trade_humanized']} ({stats['latest_trade_date']})" - avg_duration = stats["avg_duration"] - best_pair = stats["best_pair"] - best_pair_profit_ratio = stats["best_pair_profit_ratio"] - best_pair_profit_abs = fmt_coin(stats["best_pair_profit_abs"], stake_cur) - winrate = stats["winrate"] - expectancy = stats["expectancy"] - expectancy_ratio = stats["expectancy_ratio"] - if stats["trade_count"] == 0: - markdown_msg = f"No short trades yet.\n*Bot started:* `{stats['bot_start_date']}`" - else: - 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 short trades*\n" - f"∙ `{fmt_coin(profit_closed_coin, stake_cur)} " - f"({profit_closed_ratio_mean:.2%}) " - f"({profit_closed_percent} \u03A3%)`\n" - f"{fiat_closed_trades}" - ) - else: - markdown_msg = "`No closed short trade` \n" - fiat_all_trades = ( - f"∙ `{fmt_coin(profit_all_fiat, fiat_disp_cur)}`\n" if fiat_disp_cur else "" - ) - markdown_msg += ( - f"*ROI: All short trades\n" - f"∙ `{fmt_coin(profit_all_coin, stake_cur)} " - f"({profit_all_ratio_mean:.2%}) " - f"({profit_all_percent} \u03A3%)`\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"\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" - ) - await self._send_msg( - markdown_msg, - reload_able=True, - callback_path="update_profit_short", - query=update.callback_query, + await self._profit_handler( + update, context, direction="short", callback_path="update_profit_short" ) @authorized_only @@ -2383,4 +2266,3 @@ class Telegram(RPCHandler): ) except TelegramError as telegram_err: logger.warning("TelegramError: %s! Giving up on that message.", telegram_err.message) - From 7c4c7897115c4ecb1a6a3879eff7664dc4aa3fb4 Mon Sep 17 00:00:00 2001 From: Matthias Date: Thu, 17 Jul 2025 07:10:32 +0200 Subject: [PATCH 04/11] chore: fix message formatting issue --- freqtrade/rpc/telegram.py | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/freqtrade/rpc/telegram.py b/freqtrade/rpc/telegram.py index 1ce608bf0..ddab6a11f 100644 --- a/freqtrade/rpc/telegram.py +++ b/freqtrade/rpc/telegram.py @@ -313,7 +313,9 @@ class Telegram(RPCHandler): 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( @@ -1041,10 +1043,8 @@ class Telegram(RPCHandler): 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*" if direction else "*ROI:* Closed trades" - ) - all_roi_label = f"*ROI: All{direction_label} trades" if direction else "*ROI:* All trades" + closed_roi_label = f"*ROI:* Closed{direction_label} trades" + all_roi_label = f"*ROI:* All{direction_label} trades" if stats["trade_count"] == 0: return no_trades_msg @@ -1162,7 +1162,7 @@ class Telegram(RPCHandler): :param update: message update :return: None """ - await self._profit_handler(update, context) + await self._profit_handler(update, context, callback_path="update_profit") @authorized_only async def _profit_long(self, update: Update, context: CallbackContext) -> None: From 78124cd02582856270749a24bd30d0ca0211d6f1 Mon Sep 17 00:00:00 2001 From: Matthias Date: Thu, 17 Jul 2025 07:20:15 +0200 Subject: [PATCH 05/11] feat: support `/profit long`, too --- freqtrade/rpc/telegram.py | 9 +++++++-- tests/rpc/test_rpc_telegram.py | 17 +++++++++++++++++ 2 files changed, 24 insertions(+), 2 deletions(-) diff --git a/freqtrade/rpc/telegram.py b/freqtrade/rpc/telegram.py index ddab6a11f..137d075b5 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$", @@ -1126,6 +1126,11 @@ class Telegram(RPCHandler): 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) diff --git a/tests/rpc/test_rpc_telegram.py b/tests/rpc/test_rpc_telegram.py index 531b3046d..3aa802f3d 100644 --- a/tests/rpc/test_rpc_telegram.py +++ b/tests/rpc/test_rpc_telegram.py @@ -3006,11 +3006,28 @@ async def test_telegram_profit_long_short_handle( 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) From b79b5b6c3267bc5add5b047ea6832d7ebdcd75ed Mon Sep 17 00:00:00 2001 From: Matthias Date: Thu, 17 Jul 2025 07:21:03 +0200 Subject: [PATCH 06/11] refactor: move profit test next to each other --- tests/rpc/test_rpc_telegram.py | 176 ++++++++++++++++----------------- 1 file changed, 86 insertions(+), 90 deletions(-) diff --git a/tests/rpc/test_rpc_telegram.py b/tests/rpc/test_rpc_telegram.py index 3aa802f3d..7935d7622 100644 --- a/tests/rpc/test_rpc_telegram.py +++ b/tests/rpc/test_rpc_telegram.py @@ -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) @@ -2982,93 +3068,3 @@ async def test__tg_info(default_conf_usdt, mocker, update): content = context.bot.send_message.call_args[1]["text"] assert "Freqtrade Bot Info:\n" in content assert '"chat_id": "1235"' in content - - -@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 From 2b05a496711d7708c9fce5d59a449a10ee5c7b1e Mon Sep 17 00:00:00 2001 From: Matthias Date: Thu, 17 Jul 2025 07:26:45 +0200 Subject: [PATCH 07/11] test: Update telegram /profit tests --- tests/rpc/test_rpc_telegram.py | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/tests/rpc/test_rpc_telegram.py b/tests/rpc/test_rpc_telegram.py index 7935d7622..2fb501390 100644 --- a/tests/rpc/test_rpc_telegram.py +++ b/tests/rpc/test_rpc_telegram.py @@ -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] @@ -1016,8 +1016,8 @@ async def test_telegram_profit_long_short_handle( 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 "*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 @@ -1033,8 +1033,8 @@ async def test_telegram_profit_long_short_handle( 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 "*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 From a5ac8a95a726543de96b177254f6efb4049e63ab Mon Sep 17 00:00:00 2001 From: Matthias Date: Thu, 17 Jul 2025 20:23:59 +0200 Subject: [PATCH 08/11] feat: update get_trading_volume interface to allow filtering for more props --- freqtrade/persistence/trade_model.py | 12 +++++++----- freqtrade/rpc/rpc.py | 19 ++++++++++++------- 2 files changed, 19 insertions(+), 12 deletions(-) diff --git a/freqtrade/persistence/trade_model.py b/freqtrade/persistence/trade_model.py index 51ec4abef..4f3390117 100644 --- a/freqtrade/persistence/trade_model.py +++ b/freqtrade/persistence/trade_model.py @@ -2106,16 +2106,18 @@ class Trade(ModelBase, LocalTrade): 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 66007f6b1..532aa67ca 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 @@ -585,11 +585,14 @@ class RPC: Trade.is_open.is_(False) & (Trade.close_date >= start_date) ) | Trade.is_open.is_(True) - if direction: - if direction == "long": - trade_filter = trade_filter & Trade.is_short.is_(False) - elif direction == "short": - trade_filter = trade_filter & Trade.is_short.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 + else: + dir_filter = True trades: Sequence[Trade] = Trade.session.scalars( Trade.get_trades_query(trade_filter, include_orders=False).order_by(Trade.id) @@ -610,7 +613,9 @@ class RPC: 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) + trading_volume = Trade.get_trading_volume( + [Order.order_filled_date >= start_date, dir_filter] + ) # Prepare data to display profit_closed_coin_sum = round(sum(profit_closed_coin), 8) From 31522c681e696dfa69758d26390ef47460264e0b Mon Sep 17 00:00:00 2001 From: Matthias Date: Thu, 17 Jul 2025 20:27:53 +0200 Subject: [PATCH 09/11] feat: update get_best_pair to allow better filtering --- freqtrade/persistence/trade_model.py | 10 +++++----- freqtrade/rpc/rpc.py | 2 +- 2 files changed, 6 insertions(+), 6 deletions(-) diff --git a/freqtrade/persistence/trade_model.py b/freqtrade/persistence/trade_model.py index 4f3390117..ee1f9d9ff 100644 --- a/freqtrade/persistence/trade_model.py +++ b/freqtrade/persistence/trade_model.py @@ -2090,17 +2090,17 @@ 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 diff --git a/freqtrade/rpc/rpc.py b/freqtrade/rpc/rpc.py index 532aa67ca..e32bd811b 100644 --- a/freqtrade/rpc/rpc.py +++ b/freqtrade/rpc/rpc.py @@ -612,7 +612,7 @@ class RPC: closed_trade_count = len([t for t in trades if not t.is_open]) - best_pair = Trade.get_best_pair(start_date) + best_pair = Trade.get_best_pair([Trade.close_date > start_date, dir_filter]) trading_volume = Trade.get_trading_volume( [Order.order_filled_date >= start_date, dir_filter] ) From 978f9c804b805ccf8080e4b7f7a3fb333338648a Mon Sep 17 00:00:00 2001 From: Matthias Date: Thu, 17 Jul 2025 20:59:04 +0200 Subject: [PATCH 10/11] chore: improved code structure and types --- freqtrade/rpc/rpc.py | 15 +++++++++------ 1 file changed, 9 insertions(+), 6 deletions(-) diff --git a/freqtrade/rpc/rpc.py b/freqtrade/rpc/rpc.py index e32bd811b..868b10d7d 100644 --- a/freqtrade/rpc/rpc.py +++ b/freqtrade/rpc/rpc.py @@ -591,8 +591,6 @@ class RPC: elif direction == "short": dir_filter = Trade.is_short.is_(True) trade_filter = trade_filter & dir_filter - else: - dir_filter = True trades: Sequence[Trade] = Trade.session.scalars( Trade.get_trades_query(trade_filter, include_orders=False).order_by(Trade.id) @@ -612,10 +610,15 @@ class RPC: closed_trade_count = len([t for t in trades if not t.is_open]) - best_pair = Trade.get_best_pair([Trade.close_date > start_date, dir_filter]) - trading_volume = Trade.get_trading_volume( - [Order.order_filled_date >= start_date, dir_filter] - ) + 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) From d710c85cdaeedbe933e578af8ede64d6bbc3d0e3 Mon Sep 17 00:00:00 2001 From: Matthias Date: Thu, 17 Jul 2025 21:02:46 +0200 Subject: [PATCH 11/11] chore: simplify profit-callback logic --- freqtrade/rpc/telegram.py | 13 ++++--------- 1 file changed, 4 insertions(+), 9 deletions(-) diff --git a/freqtrade/rpc/telegram.py b/freqtrade/rpc/telegram.py index 137d075b5..b93e3ec25 100644 --- a/freqtrade/rpc/telegram.py +++ b/freqtrade/rpc/telegram.py @@ -1109,7 +1109,6 @@ class Telegram(RPCHandler): update: Update, context: CallbackContext, direction: str | None = None, - callback_path: str = "update_profit", ) -> None: """ Common handler for profit commands. @@ -1154,7 +1153,7 @@ class Telegram(RPCHandler): await self._send_msg( markdown_msg, reload_able=True, - callback_path=callback_path, + callback_path="update_profit" if not direction else f"update_profit_{direction}", query=update.callback_query, ) @@ -1167,7 +1166,7 @@ class Telegram(RPCHandler): :param update: message update :return: None """ - await self._profit_handler(update, context, callback_path="update_profit") + await self._profit_handler(update, context) @authorized_only async def _profit_long(self, update: Update, context: CallbackContext) -> None: @@ -1175,9 +1174,7 @@ class Telegram(RPCHandler): Handler for /profit_long. Returns cumulative profit statistics for long trades. """ - await self._profit_handler( - update, context, direction="long", callback_path="update_profit_long" - ) + await self._profit_handler(update, context, direction="long") @authorized_only async def _profit_short(self, update: Update, context: CallbackContext) -> None: @@ -1185,9 +1182,7 @@ class Telegram(RPCHandler): Handler for /profit_short. Returns cumulative profit statistics for short trades. """ - await self._profit_handler( - update, context, direction="short", callback_path="update_profit_short" - ) + await self._profit_handler(update, context, direction="short") @authorized_only async def _stats(self, update: Update, context: CallbackContext) -> None: