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] 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: