Merge pull request #11974 from qqqqqf-q/feat/telegram-profit-direction

feat(telegram): Add /profit long and /profit short commands
This commit is contained in:
Matthias
2025-07-18 06:54:21 +02:00
committed by GitHub
5 changed files with 336 additions and 104 deletions

View File

@@ -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 <trade_id>|[table]`: Lists all or specific open trades.
- `/profit [<n>]`: Lists cumulative profit from all finished trades, over the last n days.
- `/profit_long [<n>]`: Lists cumulative profit from all finished long trades, over the last n days.
- `/profit_short [<n>]`: Lists cumulative profit from all finished short trades, over the last n days.
- `/forceexit <trade_id>|all`: Instantly exits the given trade (Ignoring `minimum_roi`).
- `/fx <trade_id>|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:

View File

@@ -2090,32 +2090,34 @@ class Trade(ModelBase, LocalTrade):
return resp
@staticmethod
def get_best_pair(start_date: datetime | None = None):
def get_best_pair(trade_filter: list | None = None):
"""
Get best pair with closed trade.
NOTE: Not supported in Backtesting.
:returns: Tuple containing (pair, profit_sum)
"""
filters: list = [Trade.is_open.is_(False)]
if start_date:
filters.append(Trade.close_date >= start_date)
if not trade_filter:
trade_filter = []
trade_filter.append(Trade.is_open.is_(False))
pair_rates_query = Trade._generic_performance_query([Trade.pair], filters)
pair_rates_query = Trade._generic_performance_query([Trade.pair], trade_filter)
best_pair = Trade.session.execute(pair_rates_query).first()
# returns pair, profit_ratio, abs_profit, count
return best_pair
@staticmethod
def get_trading_volume(start_date: datetime | None = None) -> float:
def get_trading_volume(trade_filter: list | None = None) -> float:
"""
Get Trade volume based on Orders
NOTE: Not supported in Backtesting.
:returns: Tuple containing (pair, profit_sum)
"""
filters = [Order.status == "closed"]
if start_date:
filters.append(Order.order_filled_date >= start_date)
if not trade_filter:
trade_filter = []
trade_filter.append(Order.status == "closed")
trading_volume = Trade.session.execute(
select(func.sum(Order.cost).label("volume")).filter(*filters)
select(func.sum(Order.cost).label("volume"))
.join(Order._trade_live)
.filter(*trade_filter)
).scalar_one()
return trading_volume or 0.0

View File

@@ -34,7 +34,7 @@ from freqtrade.exchange import Exchange, timeframe_to_minutes, timeframe_to_msec
from freqtrade.exchange.exchange_utils import price_to_precision
from freqtrade.ft_types import AnnotationType
from freqtrade.loggers import bufferHandler
from freqtrade.persistence import CustomDataWrapper, KeyValueStore, PairLocks, Trade
from freqtrade.persistence import CustomDataWrapper, KeyValueStore, Order, PairLocks, Trade
from freqtrade.persistence.models import PairLock, custom_data_rpc_wrapper
from freqtrade.plugins.pairlist.pairlist_helpers import expand_pairlist
from freqtrade.rpc.fiat_convert import CryptoToFiatConverter
@@ -502,20 +502,13 @@ class RPC:
durations = {"wins": wins_dur, "draws": draws_dur, "losses": losses_dur}
return {"exit_reasons": exit_reasons, "durations": durations}
def _rpc_trade_statistics(
self, stake_currency: str, fiat_display_currency: str, start_date: datetime | None = None
def _collect_trade_statistics_data(
self,
trades: Sequence["Trade"],
stake_currency: str,
fiat_display_currency: str,
) -> dict[str, Any]:
"""Returns cumulative profit statistics"""
start_date = datetime.fromtimestamp(0) if start_date is None else start_date
trade_filter = (
Trade.is_open.is_(False) & (Trade.close_date >= start_date)
) | Trade.is_open.is_(True)
trades: Sequence[Trade] = Trade.session.scalars(
Trade.get_trades_query(trade_filter, include_orders=False).order_by(Trade.id)
).all()
"""Iterate trades, calculate various statistics, and return intermediate results."""
profit_all_coin = []
profit_all_ratio = []
profit_closed_coin = []
@@ -544,7 +537,7 @@ class RPC:
losing_trades += 1
losing_profit += profit_abs
else:
# Get current rate
# Get current rate for open trades
if len(trade.select_filled_orders(trade.entry_side)) == 0:
# Skip trades with no filled orders
continue
@@ -558,17 +551,74 @@ class RPC:
profit_abs = nan
else:
_profit = trade.calculate_profit(trade.close_rate or current_rate)
profit_ratio = _profit.profit_ratio
profit_abs = _profit.total_profit
profit_all_coin.append(profit_abs)
profit_all_ratio.append(profit_ratio)
return {
"profit_all_coin": profit_all_coin,
"profit_all_ratio": profit_all_ratio,
"profit_closed_coin": profit_closed_coin,
"profit_closed_ratio": profit_closed_ratio,
"durations": durations,
"winning_trades": winning_trades,
"losing_trades": losing_trades,
"winning_profit": winning_profit,
"losing_profit": losing_profit,
}
def _rpc_trade_statistics(
self,
stake_currency: str,
fiat_display_currency: str,
start_date: datetime | None = None,
direction: str | None = None,
) -> dict[str, Any]:
"""
Returns cumulative profit statistics, with optional direction filter (long/short)
"""
start_date = datetime.fromtimestamp(0) if start_date is None else start_date
trade_filter = (
Trade.is_open.is_(False) & (Trade.close_date >= start_date)
) | Trade.is_open.is_(True)
if direction == "long":
dir_filter = Trade.is_short.is_(False)
trade_filter = trade_filter & dir_filter
elif direction == "short":
dir_filter = Trade.is_short.is_(True)
trade_filter = trade_filter & dir_filter
trades: Sequence[Trade] = Trade.session.scalars(
Trade.get_trades_query(trade_filter, include_orders=False).order_by(Trade.id)
).all()
stats = self._collect_trade_statistics_data(trades, stake_currency, fiat_display_currency)
profit_all_coin = stats["profit_all_coin"]
profit_all_ratio = stats["profit_all_ratio"]
profit_closed_coin = stats["profit_closed_coin"]
profit_closed_ratio = stats["profit_closed_ratio"]
durations = stats["durations"]
winning_trades = stats["winning_trades"]
losing_trades = stats["losing_trades"]
winning_profit = stats["winning_profit"]
losing_profit = stats["losing_profit"]
closed_trade_count = len([t for t in trades if not t.is_open])
best_pair = Trade.get_best_pair(start_date)
trading_volume = Trade.get_trading_volume(start_date)
best_pair_filters = [Trade.close_date > start_date]
trading_volume_filters = [Order.order_filled_date >= start_date]
if direction:
best_pair_filters.append(dir_filter)
trading_volume_filters.append(dir_filter)
best_pair = Trade.get_best_pair(best_pair_filters)
trading_volume = Trade.get_trading_volume(trading_volume_filters)
# Prepare data to display
profit_closed_coin_sum = round(sum(profit_closed_coin), 8)

View File

@@ -191,8 +191,8 @@ class Telegram(RPCHandler):
r"/mix_tags",
r"/daily$",
r"/daily \d+$",
r"/profit$",
r"/profit \d+",
r"/profit([_ ]long|[_ ]short)?$",
r"/profit([_ ]long|[_ ]short)? \d+$",
r"/stats$",
r"/count$",
r"/locks$",
@@ -305,13 +305,17 @@ class Telegram(RPCHandler):
CommandHandler("order", self._order),
CommandHandler("list_custom_data", self._list_custom_data),
CommandHandler("tg_info", self._tg_info),
CommandHandler("profit_long", self._profit_long),
CommandHandler("profit_short", self._profit_short),
]
callbacks = [
CallbackQueryHandler(self._status_table, pattern="update_status_table"),
CallbackQueryHandler(self._daily, pattern="update_daily"),
CallbackQueryHandler(self._weekly, pattern="update_weekly"),
CallbackQueryHandler(self._monthly, pattern="update_monthly"),
CallbackQueryHandler(self._profit, pattern="update_profit"),
CallbackQueryHandler(self._profit_long, pattern="update_profit_long"),
CallbackQueryHandler(self._profit_short, pattern="update_profit_short"),
CallbackQueryHandler(self._profit, pattern=r"update_profit$"),
CallbackQueryHandler(self._balance, pattern="update_balance"),
CallbackQueryHandler(self._performance, pattern="update_performance"),
CallbackQueryHandler(
@@ -995,29 +999,25 @@ class Telegram(RPCHandler):
"""
await self._timeunit_stats(update, context, "months")
@authorized_only
async def _profit(self, update: Update, context: CallbackContext) -> None:
def _format_profit_message(
self,
stats: dict,
stake_cur: str,
fiat_disp_cur: str,
timescale: int | None = None,
direction: str | None = None,
) -> str:
"""
Handler for /profit.
Returns a cumulative profit statistics.
:param bot: telegram bot
:param update: message update
:return: None
Format profit statistics message for telegram.
:param stats: Trade statistics dictionary
:param stake_cur: Stake currency
:param fiat_disp_cur: Fiat display currency
:param timescale: Optional timescale filter
:param direction: Optional direction filter ('long', 'short', or None for all)
:return: Formatted markdown message
"""
stake_cur = self._config["stake_currency"]
fiat_disp_cur = self._config.get("fiat_display_currency", "")
start_date = datetime.fromtimestamp(0)
timescale = None
try:
if context.args:
timescale = int(context.args[0]) - 1
today_start = datetime.combine(date.today(), datetime.min.time())
start_date = today_start - timedelta(days=timescale)
except (TypeError, ValueError, IndexError):
pass
stats = self._rpc._rpc_trade_statistics(stake_cur, fiat_disp_cur, start_date)
# Extract common variables
profit_closed_coin = stats["profit_closed_coin"]
profit_closed_ratio_mean = stats["profit_closed_ratio_mean"]
profit_closed_percent = stats["profit_closed_percent"]
@@ -1037,66 +1037,153 @@ class Telegram(RPCHandler):
expectancy = stats["expectancy"]
expectancy_ratio = stats["expectancy_ratio"]
# Direction-specific labels
direction_label = f" {direction}" if direction else ""
no_trades_msg = (
f"No{direction_label} trades yet.\n*Bot started:* `{stats['bot_start_date']}`"
)
no_closed_msg = f"`No closed{direction_label} trade` \n"
closed_roi_label = f"*ROI:* Closed{direction_label} trades"
all_roi_label = f"*ROI:* All{direction_label} trades"
if stats["trade_count"] == 0:
markdown_msg = f"No trades yet.\n*Bot started:* `{stats['bot_start_date']}`"
return no_trades_msg
# Build message
if stats["closed_trade_count"] > 0:
fiat_closed_trades = (
f"∙ `{fmt_coin(profit_closed_fiat, fiat_disp_cur)}`\n" if fiat_disp_cur else ""
)
markdown_msg = (
f"{closed_roi_label}\n"
f"∙ `{fmt_coin(profit_closed_coin, stake_cur)} "
f"({profit_closed_ratio_mean:.2%}) "
f"({profit_closed_percent} \N{GREEK CAPITAL LETTER SIGMA}%)`\n"
f"{fiat_closed_trades}"
)
else:
# Message to display
if stats["closed_trade_count"] > 0:
fiat_closed_trades = (
f"∙ `{fmt_coin(profit_closed_fiat, fiat_disp_cur)}`\n" if fiat_disp_cur else ""
)
markdown_msg = (
"*ROI:* Closed trades\n"
f"∙ `{fmt_coin(profit_closed_coin, stake_cur)} "
f"({profit_closed_ratio_mean:.2%}) "
f"({profit_closed_percent} \N{GREEK CAPITAL LETTER SIGMA}%)`\n"
f"{fiat_closed_trades}"
)
else:
markdown_msg = "`No closed trade` \n"
fiat_all_trades = (
f"∙ `{fmt_coin(profit_all_fiat, fiat_disp_cur)}`\n" if fiat_disp_cur else ""
)
markdown_msg = no_closed_msg
fiat_all_trades = (
f"∙ `{fmt_coin(profit_all_fiat, fiat_disp_cur)}`\n" if fiat_disp_cur else ""
)
markdown_msg += (
f"{all_roi_label}\n"
f"∙ `{fmt_coin(profit_all_coin, stake_cur)} "
f"({profit_all_ratio_mean:.2%}) "
f"({profit_all_percent} \N{GREEK CAPITAL LETTER SIGMA}%)`\n"
f"{fiat_all_trades}"
f"*Total Trade Count:* `{trade_count}`\n"
f"*Bot started:* `{stats['bot_start_date']}`\n"
f"*{'First Trade opened' if not timescale else 'Showing Profit since'}:* "
f"`{first_trade_date}`\n"
f"*Latest Trade opened:* `{latest_trade_date}`\n"
f"*Win / Loss:* `{stats['winning_trades']} / {stats['losing_trades']}`\n"
f"*Winrate:* `{winrate:.2%}`\n"
f"*Expectancy (Ratio):* `{expectancy:.2f} ({expectancy_ratio:.2f})`"
)
if stats["closed_trade_count"] > 0:
markdown_msg += (
f"*ROI:* All trades\n"
f"∙ `{fmt_coin(profit_all_coin, stake_cur)} "
f"({profit_all_ratio_mean:.2%}) "
f"({profit_all_percent} \N{GREEK CAPITAL LETTER SIGMA}%)`\n"
f"{fiat_all_trades}"
f"*Total Trade Count:* `{trade_count}`\n"
f"*Bot started:* `{stats['bot_start_date']}`\n"
f"*{'First Trade opened' if not timescale else 'Showing Profit since'}:* "
f"`{first_trade_date}`\n"
f"*Latest Trade opened:* `{latest_trade_date}`\n"
f"*Win / Loss:* `{stats['winning_trades']} / {stats['losing_trades']}`\n"
f"*Winrate:* `{winrate:.2%}`\n"
f"*Expectancy (Ratio):* `{expectancy:.2f} ({expectancy_ratio:.2f})`"
f"\n*Avg. Duration:* `{avg_duration}`\n"
f"*Best Performing:* `{best_pair}: {best_pair_profit_abs} "
f"({best_pair_profit_ratio:.2%})`\n"
f"*Trading volume:* `{fmt_coin(stats['trading_volume'], stake_cur)}`\n"
f"*Profit factor:* `{stats['profit_factor']:.2f}`\n"
f"*Max Drawdown:* `{stats['max_drawdown']:.2%} "
f"({fmt_coin(stats['max_drawdown_abs'], stake_cur)})`\n"
f" from `{stats['max_drawdown_start']} "
f"({fmt_coin(stats['drawdown_high'], stake_cur)})`\n"
f" to `{stats['max_drawdown_end']} "
f"({fmt_coin(stats['drawdown_low'], stake_cur)})`\n"
f"*Current Drawdown:* `{stats['current_drawdown']:.2%} "
f"({fmt_coin(stats['current_drawdown_abs'], stake_cur)})`\n"
f" from `{stats['current_drawdown_start']} "
f"({fmt_coin(stats['current_drawdown_high'], stake_cur)})`\n"
)
if stats["closed_trade_count"] > 0:
markdown_msg += (
f"\n*Avg. Duration:* `{avg_duration}`\n"
f"*Best Performing:* `{best_pair}: {best_pair_profit_abs} "
f"({best_pair_profit_ratio:.2%})`\n"
f"*Trading volume:* `{fmt_coin(stats['trading_volume'], stake_cur)}`\n"
f"*Profit factor:* `{stats['profit_factor']:.2f}`\n"
f"*Max Drawdown:* `{stats['max_drawdown']:.2%} "
f"({fmt_coin(stats['max_drawdown_abs'], stake_cur)})`\n"
f" from `{stats['max_drawdown_start']} "
f"({fmt_coin(stats['drawdown_high'], stake_cur)})`\n"
f" to `{stats['max_drawdown_end']} "
f"({fmt_coin(stats['drawdown_low'], stake_cur)})`\n"
f"*Current Drawdown:* `{stats['current_drawdown']:.2%} "
f"({fmt_coin(stats['current_drawdown_abs'], stake_cur)})`\n"
f" from `{stats['current_drawdown_start']} "
f"({fmt_coin(stats['current_drawdown_high'], stake_cur)})`\n"
)
return markdown_msg
async def _profit_handler(
self,
update: Update,
context: CallbackContext,
direction: str | None = None,
) -> None:
"""
Common handler for profit commands.
:param update: Telegram update
:param context: Callback context
:param direction: Trade direction filter ('long', 'short', or None)
:param callback_path: Callback path for message updates
"""
stake_cur = self._config["stake_currency"]
fiat_disp_cur = self._config.get("fiat_display_currency", "")
start_date = datetime.fromtimestamp(0)
timescale = None
try:
if context.args:
if not direction:
arg = context.args[0].lower()
if arg in ("short", "long"):
direction = arg
context.args.pop(0) # Remove direction from args
timescale = int(context.args[0]) - 1
today_start = datetime.combine(date.today(), datetime.min.time())
start_date = today_start - timedelta(days=timescale)
except (TypeError, ValueError, IndexError):
pass
# Get stats with optional direction filter
stats_kwargs = {
"stake_currency": stake_cur,
"fiat_display_currency": fiat_disp_cur,
"start_date": start_date,
}
if direction:
stats_kwargs["direction"] = direction
stats = self._rpc._rpc_trade_statistics(**stats_kwargs)
markdown_msg = self._format_profit_message(
stats, stake_cur, fiat_disp_cur, timescale, direction
)
await self._send_msg(
markdown_msg,
reload_able=True,
callback_path="update_profit",
callback_path="update_profit" if not direction else f"update_profit_{direction}",
query=update.callback_query,
)
@authorized_only
async def _profit(self, update: Update, context: CallbackContext) -> None:
"""
Handler for /profit.
Returns a cumulative profit statistics.
:param bot: telegram bot
:param update: message update
:return: None
"""
await self._profit_handler(update, context)
@authorized_only
async def _profit_long(self, update: Update, context: CallbackContext) -> None:
"""
Handler for /profit_long.
Returns cumulative profit statistics for long trades.
"""
await self._profit_handler(update, context, direction="long")
@authorized_only
async def _profit_short(self, update: Update, context: CallbackContext) -> None:
"""
Handler for /profit_short.
Returns cumulative profit statistics for short trades.
"""
await self._profit_handler(update, context, direction="short")
@authorized_only
async def _stats(self, update: Update, context: CallbackContext) -> None:
"""
@@ -1869,6 +1956,10 @@ class Telegram(RPCHandler):
"*/trades [limit]:* `Lists last closed trades (limited to 10 by default)`\n"
"*/profit [<n>]:* `Lists cumulative profit from all finished trades, "
"over the last n days`\n"
"*/profit_long [<n>]:* `Lists cumulative profit from all finished long trades, "
"over the last n days`\n"
"*/profit_short [<n>]:* `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 <n>:* `Shows profit or loss per day, over the last n days`\n"
"*/weekly <n>:* `Shows statistics per week, over the last n weeks`\n"

View File

@@ -171,7 +171,7 @@ def test_telegram_init(default_conf, mocker, caplog) -> None:
"['pause', 'stopbuy', 'stopentry'], ['whitelist'], ['blacklist'], "
"['bl_delete', 'blacklist_delete'], "
"['logs'], ['health'], ['help'], ['version'], ['marketdir'], "
"['order'], ['list_custom_data'], ['tg_info']]"
"['order'], ['list_custom_data'], ['tg_info'], ['profit_long'], ['profit_short']]"
)
assert log_has(message_str, caplog)
@@ -943,7 +943,7 @@ async def test_telegram_profit_handle(
trade.is_open = False
Trade.commit()
context.args = [3]
context.args = ["3"]
await telegram._profit(update=update, context=context)
assert msg_mock.call_count == 1
assert "*ROI:* Closed trades" in msg_mock.call_args_list[-1][0][0]
@@ -967,6 +967,92 @@ async def test_telegram_profit_handle(
assert "*Trading volume:* `126 USDT`" in msg_mock.call_args_list[-1][0][0]
@pytest.mark.asyncio
async def test_telegram_profit_long_short_handle(
default_conf_usdt, update, ticker_usdt, fee, mocker
):
"""
Test the /profit_long and /profit_short commands to ensure the output content
is consistent with /profit, covering both no trades and trades present cases.
"""
mocker.patch("freqtrade.rpc.rpc.CryptoToFiatConverter._find_price", return_value=1.1)
mocker.patch.multiple(EXMS, fetch_ticker=ticker_usdt, get_fee=fee)
telegram, freqtradebot, msg_mock = get_telegram_testobject(mocker, default_conf_usdt)
# When there are no trades
await telegram._profit_long(update=update, context=MagicMock())
assert msg_mock.call_count == 1
assert "No long trades yet." in msg_mock.call_args_list[0][0][0]
msg_mock.reset_mock()
# Test support with "/profit long"
context = MagicMock()
context.args = ["long"]
await telegram._profit(update=update, context=context)
assert msg_mock.call_count == 1
assert "No long trades yet." in msg_mock.call_args_list[0][0][0]
msg_mock.reset_mock()
await telegram._profit_short(update=update, context=MagicMock())
assert msg_mock.call_count == 1
assert "No short trades yet." in msg_mock.call_args_list[0][0][0]
msg_mock.reset_mock()
# Test support with "/profit short"
context = MagicMock()
context.args = ["short"]
await telegram._profit(update=update, context=context)
assert msg_mock.call_count == 1
assert "No short trades yet." in msg_mock.call_args_list[0][0][0]
msg_mock.reset_mock()
# When there are trades
create_mock_trades_usdt(fee)
# Keep only long trades
for t in Trade.get_trades_proxy():
t.is_short = False
Trade.commit()
await telegram._profit_long(update=update, context=MagicMock())
msg = msg_mock.call_args_list[0][0][0]
assert "*ROI:* Closed long trades" in msg
assert "*ROI:* All long trades" in msg
assert "*Total Trade Count:*" in msg
assert "*Winrate:*" in msg
assert "*Expectancy (Ratio):*" in msg
assert "*Best Performing:*" in msg
assert "*Profit factor:*" in msg
assert "*Max Drawdown:*" in msg
assert "*Current Drawdown:*" in msg
msg_mock.reset_mock()
# Keep only short trades
for t in Trade.get_trades_proxy():
t.is_short = True
Trade.commit()
await telegram._profit_short(update=update, context=MagicMock())
msg = msg_mock.call_args_list[0][0][0]
assert "*ROI:* Closed short trades" in msg
assert "*ROI:* All short trades" in msg
assert "*Total Trade Count:*" in msg
assert "*Winrate:*" in msg
assert "*Expectancy (Ratio):*" in msg
assert "*Best Performing:*" in msg
assert "*Profit factor:*" in msg
assert "*Max Drawdown:*" in msg
assert "*Current Drawdown:*" in msg
msg_mock.reset_mock()
# Test parameter passing
context = MagicMock()
context.args = ["2"]
await telegram._profit_long(update=update, context=context)
assert msg_mock.call_count == 1
await telegram._profit_short(update=update, context=context)
assert msg_mock.call_count == 2
@pytest.mark.parametrize("is_short", [True, False])
async def test_telegram_stats(default_conf, update, ticker, fee, mocker, is_short) -> None:
mocker.patch("freqtrade.rpc.rpc.CryptoToFiatConverter._find_price", return_value=15000.0)