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. - `/stopentry`: Stop entering new trades.
- `/status <trade_id>|[table]`: Lists all or specific open 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 [<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`). - `/forceexit <trade_id>|all`: Instantly exits the given trade (Ignoring `minimum_roi`).
- `/fx <trade_id>|all`: Alias to `/forceexit` - `/fx <trade_id>|all`: Alias to `/forceexit`
- `/performance`: Show performance of each finished trade grouped by pair - `/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. - `/help`: Show help message.
- `/version`: Show version. - `/version`: Show version.
## Development branches ## Development branches
The project is currently setup in two main branches: The project is currently setup in two main branches:

View File

@@ -2090,32 +2090,34 @@ class Trade(ModelBase, LocalTrade):
return resp return resp
@staticmethod @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. Get best pair with closed trade.
NOTE: Not supported in Backtesting. NOTE: Not supported in Backtesting.
:returns: Tuple containing (pair, profit_sum) :returns: Tuple containing (pair, profit_sum)
""" """
filters: list = [Trade.is_open.is_(False)] if not trade_filter:
if start_date: trade_filter = []
filters.append(Trade.close_date >= start_date) 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() best_pair = Trade.session.execute(pair_rates_query).first()
# returns pair, profit_ratio, abs_profit, count # returns pair, profit_ratio, abs_profit, count
return best_pair return best_pair
@staticmethod @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 Get Trade volume based on Orders
NOTE: Not supported in Backtesting. NOTE: Not supported in Backtesting.
:returns: Tuple containing (pair, profit_sum) :returns: Tuple containing (pair, profit_sum)
""" """
filters = [Order.status == "closed"] if not trade_filter:
if start_date: trade_filter = []
filters.append(Order.order_filled_date >= start_date) trade_filter.append(Order.status == "closed")
trading_volume = Trade.session.execute( 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() ).scalar_one()
return trading_volume or 0.0 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.exchange.exchange_utils import price_to_precision
from freqtrade.ft_types import AnnotationType from freqtrade.ft_types import AnnotationType
from freqtrade.loggers import bufferHandler 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.persistence.models import PairLock, custom_data_rpc_wrapper
from freqtrade.plugins.pairlist.pairlist_helpers import expand_pairlist from freqtrade.plugins.pairlist.pairlist_helpers import expand_pairlist
from freqtrade.rpc.fiat_convert import CryptoToFiatConverter from freqtrade.rpc.fiat_convert import CryptoToFiatConverter
@@ -502,20 +502,13 @@ class RPC:
durations = {"wins": wins_dur, "draws": draws_dur, "losses": losses_dur} durations = {"wins": wins_dur, "draws": draws_dur, "losses": losses_dur}
return {"exit_reasons": exit_reasons, "durations": durations} return {"exit_reasons": exit_reasons, "durations": durations}
def _rpc_trade_statistics( def _collect_trade_statistics_data(
self, stake_currency: str, fiat_display_currency: str, start_date: datetime | None = None self,
trades: Sequence["Trade"],
stake_currency: str,
fiat_display_currency: str,
) -> dict[str, Any]: ) -> dict[str, Any]:
"""Returns cumulative profit statistics""" """Iterate trades, calculate various statistics, and return intermediate results."""
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()
profit_all_coin = [] profit_all_coin = []
profit_all_ratio = [] profit_all_ratio = []
profit_closed_coin = [] profit_closed_coin = []
@@ -544,7 +537,7 @@ class RPC:
losing_trades += 1 losing_trades += 1
losing_profit += profit_abs losing_profit += profit_abs
else: else:
# Get current rate # Get current rate for open trades
if len(trade.select_filled_orders(trade.entry_side)) == 0: if len(trade.select_filled_orders(trade.entry_side)) == 0:
# Skip trades with no filled orders # Skip trades with no filled orders
continue continue
@@ -558,17 +551,74 @@ class RPC:
profit_abs = nan profit_abs = nan
else: else:
_profit = trade.calculate_profit(trade.close_rate or current_rate) _profit = trade.calculate_profit(trade.close_rate or current_rate)
profit_ratio = _profit.profit_ratio profit_ratio = _profit.profit_ratio
profit_abs = _profit.total_profit profit_abs = _profit.total_profit
profit_all_coin.append(profit_abs) profit_all_coin.append(profit_abs)
profit_all_ratio.append(profit_ratio) 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]) closed_trade_count = len([t for t in trades if not t.is_open])
best_pair = Trade.get_best_pair(start_date) best_pair_filters = [Trade.close_date > start_date]
trading_volume = Trade.get_trading_volume(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 # Prepare data to display
profit_closed_coin_sum = round(sum(profit_closed_coin), 8) profit_closed_coin_sum = round(sum(profit_closed_coin), 8)

View File

@@ -191,8 +191,8 @@ class Telegram(RPCHandler):
r"/mix_tags", r"/mix_tags",
r"/daily$", r"/daily$",
r"/daily \d+$", r"/daily \d+$",
r"/profit$", r"/profit([_ ]long|[_ ]short)?$",
r"/profit \d+", r"/profit([_ ]long|[_ ]short)? \d+$",
r"/stats$", r"/stats$",
r"/count$", r"/count$",
r"/locks$", r"/locks$",
@@ -305,13 +305,17 @@ class Telegram(RPCHandler):
CommandHandler("order", self._order), CommandHandler("order", self._order),
CommandHandler("list_custom_data", self._list_custom_data), CommandHandler("list_custom_data", self._list_custom_data),
CommandHandler("tg_info", self._tg_info), CommandHandler("tg_info", self._tg_info),
CommandHandler("profit_long", self._profit_long),
CommandHandler("profit_short", self._profit_short),
] ]
callbacks = [ callbacks = [
CallbackQueryHandler(self._status_table, pattern="update_status_table"), CallbackQueryHandler(self._status_table, pattern="update_status_table"),
CallbackQueryHandler(self._daily, pattern="update_daily"), CallbackQueryHandler(self._daily, pattern="update_daily"),
CallbackQueryHandler(self._weekly, pattern="update_weekly"), CallbackQueryHandler(self._weekly, pattern="update_weekly"),
CallbackQueryHandler(self._monthly, pattern="update_monthly"), 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._balance, pattern="update_balance"),
CallbackQueryHandler(self._performance, pattern="update_performance"), CallbackQueryHandler(self._performance, pattern="update_performance"),
CallbackQueryHandler( CallbackQueryHandler(
@@ -995,29 +999,25 @@ class Telegram(RPCHandler):
""" """
await self._timeunit_stats(update, context, "months") await self._timeunit_stats(update, context, "months")
@authorized_only def _format_profit_message(
async def _profit(self, update: Update, context: CallbackContext) -> None: self,
stats: dict,
stake_cur: str,
fiat_disp_cur: str,
timescale: int | None = None,
direction: str | None = None,
) -> str:
""" """
Handler for /profit. Format profit statistics message for telegram.
Returns a cumulative profit statistics.
:param bot: telegram bot :param stats: Trade statistics dictionary
:param update: message update :param stake_cur: Stake currency
:return: None :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"] # Extract common variables
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_coin = stats["profit_closed_coin"]
profit_closed_ratio_mean = stats["profit_closed_ratio_mean"] profit_closed_ratio_mean = stats["profit_closed_ratio_mean"]
profit_closed_percent = stats["profit_closed_percent"] profit_closed_percent = stats["profit_closed_percent"]
@@ -1037,66 +1037,153 @@ class Telegram(RPCHandler):
expectancy = stats["expectancy"] expectancy = stats["expectancy"]
expectancy_ratio = stats["expectancy_ratio"] 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: 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: else:
# Message to display markdown_msg = no_closed_msg
if stats["closed_trade_count"] > 0:
fiat_closed_trades = ( fiat_all_trades = (
f"∙ `{fmt_coin(profit_closed_fiat, fiat_disp_cur)}`\n" if fiat_disp_cur else "" f"∙ `{fmt_coin(profit_all_fiat, fiat_disp_cur)}`\n" if fiat_disp_cur else ""
) )
markdown_msg = ( markdown_msg += (
"*ROI:* Closed trades\n" f"{all_roi_label}\n"
f"∙ `{fmt_coin(profit_closed_coin, stake_cur)} " f"∙ `{fmt_coin(profit_all_coin, stake_cur)} "
f"({profit_closed_ratio_mean:.2%}) " f"({profit_all_ratio_mean:.2%}) "
f"({profit_closed_percent} \N{GREEK CAPITAL LETTER SIGMA}%)`\n" f"({profit_all_percent} \N{GREEK CAPITAL LETTER SIGMA}%)`\n"
f"{fiat_closed_trades}" f"{fiat_all_trades}"
) f"*Total Trade Count:* `{trade_count}`\n"
else: f"*Bot started:* `{stats['bot_start_date']}`\n"
markdown_msg = "`No closed trade` \n" f"*{'First Trade opened' if not timescale else 'Showing Profit since'}:* "
fiat_all_trades = ( f"`{first_trade_date}`\n"
f"∙ `{fmt_coin(profit_all_fiat, fiat_disp_cur)}`\n" if fiat_disp_cur else "" 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 += ( markdown_msg += (
f"*ROI:* All trades\n" f"\n*Avg. Duration:* `{avg_duration}`\n"
f"∙ `{fmt_coin(profit_all_coin, stake_cur)} " f"*Best Performing:* `{best_pair}: {best_pair_profit_abs} "
f"({profit_all_ratio_mean:.2%}) " f"({best_pair_profit_ratio:.2%})`\n"
f"({profit_all_percent} \N{GREEK CAPITAL LETTER SIGMA}%)`\n" f"*Trading volume:* `{fmt_coin(stats['trading_volume'], stake_cur)}`\n"
f"{fiat_all_trades}" f"*Profit factor:* `{stats['profit_factor']:.2f}`\n"
f"*Total Trade Count:* `{trade_count}`\n" f"*Max Drawdown:* `{stats['max_drawdown']:.2%} "
f"*Bot started:* `{stats['bot_start_date']}`\n" f"({fmt_coin(stats['max_drawdown_abs'], stake_cur)})`\n"
f"*{'First Trade opened' if not timescale else 'Showing Profit since'}:* " f" from `{stats['max_drawdown_start']} "
f"`{first_trade_date}`\n" f"({fmt_coin(stats['drawdown_high'], stake_cur)})`\n"
f"*Latest Trade opened:* `{latest_trade_date}`\n" f" to `{stats['max_drawdown_end']} "
f"*Win / Loss:* `{stats['winning_trades']} / {stats['losing_trades']}`\n" f"({fmt_coin(stats['drawdown_low'], stake_cur)})`\n"
f"*Winrate:* `{winrate:.2%}`\n" f"*Current Drawdown:* `{stats['current_drawdown']:.2%} "
f"*Expectancy (Ratio):* `{expectancy:.2f} ({expectancy_ratio:.2f})`" 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 += ( return markdown_msg
f"\n*Avg. Duration:* `{avg_duration}`\n"
f"*Best Performing:* `{best_pair}: {best_pair_profit_abs} " async def _profit_handler(
f"({best_pair_profit_ratio:.2%})`\n" self,
f"*Trading volume:* `{fmt_coin(stats['trading_volume'], stake_cur)}`\n" update: Update,
f"*Profit factor:* `{stats['profit_factor']:.2f}`\n" context: CallbackContext,
f"*Max Drawdown:* `{stats['max_drawdown']:.2%} " direction: str | None = None,
f"({fmt_coin(stats['max_drawdown_abs'], stake_cur)})`\n" ) -> None:
f" from `{stats['max_drawdown_start']} " """
f"({fmt_coin(stats['drawdown_high'], stake_cur)})`\n" Common handler for profit commands.
f" to `{stats['max_drawdown_end']} "
f"({fmt_coin(stats['drawdown_low'], stake_cur)})`\n" :param update: Telegram update
f"*Current Drawdown:* `{stats['current_drawdown']:.2%} " :param context: Callback context
f"({fmt_coin(stats['current_drawdown_abs'], stake_cur)})`\n" :param direction: Trade direction filter ('long', 'short', or None)
f" from `{stats['current_drawdown_start']} " :param callback_path: Callback path for message updates
f"({fmt_coin(stats['current_drawdown_high'], stake_cur)})`\n" """
) 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( await self._send_msg(
markdown_msg, markdown_msg,
reload_able=True, reload_able=True,
callback_path="update_profit", callback_path="update_profit" if not direction else f"update_profit_{direction}",
query=update.callback_query, 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 @authorized_only
async def _stats(self, update: Update, context: CallbackContext) -> None: 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" "*/trades [limit]:* `Lists last closed trades (limited to 10 by default)`\n"
"*/profit [<n>]:* `Lists cumulative profit from all finished trades, " "*/profit [<n>]:* `Lists cumulative profit from all finished trades, "
"over the last n days`\n" "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" "*/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" "*/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" "*/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'], " "['pause', 'stopbuy', 'stopentry'], ['whitelist'], ['blacklist'], "
"['bl_delete', 'blacklist_delete'], " "['bl_delete', 'blacklist_delete'], "
"['logs'], ['health'], ['help'], ['version'], ['marketdir'], " "['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) assert log_has(message_str, caplog)
@@ -943,7 +943,7 @@ async def test_telegram_profit_handle(
trade.is_open = False trade.is_open = False
Trade.commit() Trade.commit()
context.args = [3] context.args = ["3"]
await telegram._profit(update=update, context=context) await telegram._profit(update=update, context=context)
assert msg_mock.call_count == 1 assert msg_mock.call_count == 1
assert "*ROI:* Closed trades" in msg_mock.call_args_list[-1][0][0] 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] 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]) @pytest.mark.parametrize("is_short", [True, False])
async def test_telegram_stats(default_conf, update, ticker, fee, mocker, is_short) -> None: 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) mocker.patch("freqtrade.rpc.rpc.CryptoToFiatConverter._find_price", return_value=15000.0)