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.
This commit is contained in:
+43
-33
@@ -502,20 +502,17 @@ 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 _process_trade_stats(
|
||||||
self, stake_currency: str, fiat_display_currency: str, start_date: datetime | None = None
|
self,
|
||||||
|
trades: Sequence[Trade],
|
||||||
|
stake_currency: str,
|
||||||
|
fiat_display_currency: str,
|
||||||
|
start_date: datetime,
|
||||||
) -> dict[str, Any]:
|
) -> dict[str, Any]:
|
||||||
"""Returns cumulative profit statistics"""
|
"""
|
||||||
|
Processes a list of trades and returns the statistics.
|
||||||
start_date = datetime.fromtimestamp(0) if start_date is None else start_date
|
Helper for _rpc_trade_statistics.
|
||||||
|
"""
|
||||||
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,9 +541,7 @@ class RPC:
|
|||||||
losing_trades += 1
|
losing_trades += 1
|
||||||
losing_profit += profit_abs
|
losing_profit += profit_abs
|
||||||
else:
|
else:
|
||||||
# Get current rate
|
|
||||||
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
|
|
||||||
continue
|
continue
|
||||||
try:
|
try:
|
||||||
current_rate = self._freqtrade.exchange.get_rate(
|
current_rate = self._freqtrade.exchange.get_rate(
|
||||||
@@ -558,7 +553,6 @@ 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
|
||||||
|
|
||||||
@@ -566,15 +560,11 @@ class RPC:
|
|||||||
profit_all_ratio.append(profit_ratio)
|
profit_all_ratio.append(profit_ratio)
|
||||||
|
|
||||||
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 = Trade.get_best_pair(start_date)
|
||||||
trading_volume = Trade.get_trading_volume(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_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_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 = (
|
profit_closed_fiat = (
|
||||||
self._fiat_converter.convert_amount(
|
self._fiat_converter.convert_amount(
|
||||||
profit_closed_coin_sum, stake_currency, fiat_display_currency
|
profit_closed_coin_sum, stake_currency, fiat_display_currency
|
||||||
@@ -582,22 +572,17 @@ class RPC:
|
|||||||
if self._fiat_converter
|
if self._fiat_converter
|
||||||
else 0
|
else 0
|
||||||
)
|
)
|
||||||
|
|
||||||
profit_all_coin_sum = round(sum(profit_all_coin), 8)
|
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_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()
|
starting_balance = self._freqtrade.wallets.get_starting_balance()
|
||||||
profit_closed_ratio_fromstart = 0.0
|
profit_closed_ratio_fromstart = 0.0
|
||||||
profit_all_ratio_fromstart = 0.0
|
profit_all_ratio_fromstart = 0.0
|
||||||
if starting_balance:
|
if starting_balance:
|
||||||
profit_closed_ratio_fromstart = profit_closed_coin_sum / starting_balance
|
profit_closed_ratio_fromstart = profit_closed_coin_sum / starting_balance
|
||||||
profit_all_ratio_fromstart = profit_all_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")
|
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
|
winrate = (winning_trades / closed_trade_count) if closed_trade_count > 0 else 0
|
||||||
|
|
||||||
trades_df = DataFrame(
|
trades_df = DataFrame(
|
||||||
[
|
[
|
||||||
{
|
{
|
||||||
@@ -609,9 +594,7 @@ class RPC:
|
|||||||
if not trade.is_open and trade.close_date
|
if not trade.is_open and trade.close_date
|
||||||
]
|
]
|
||||||
)
|
)
|
||||||
|
|
||||||
expectancy, expectancy_ratio = calculate_expectancy(trades_df)
|
expectancy, expectancy_ratio = calculate_expectancy(trades_df)
|
||||||
|
|
||||||
drawdown = DrawDownResult()
|
drawdown = DrawDownResult()
|
||||||
if len(trades_df) > 0:
|
if len(trades_df) > 0:
|
||||||
try:
|
try:
|
||||||
@@ -622,9 +605,7 @@ class RPC:
|
|||||||
starting_balance=starting_balance,
|
starting_balance=starting_balance,
|
||||||
)
|
)
|
||||||
except ValueError:
|
except ValueError:
|
||||||
# ValueError if no losing trade.
|
|
||||||
pass
|
pass
|
||||||
|
|
||||||
profit_all_fiat = (
|
profit_all_fiat = (
|
||||||
self._fiat_converter.convert_amount(
|
self._fiat_converter.convert_amount(
|
||||||
profit_all_coin_sum, stake_currency, fiat_display_currency
|
profit_all_coin_sum, stake_currency, fiat_display_currency
|
||||||
@@ -632,7 +613,6 @@ class RPC:
|
|||||||
if self._fiat_converter
|
if self._fiat_converter
|
||||||
else 0
|
else 0
|
||||||
)
|
)
|
||||||
|
|
||||||
first_date = trades[0].open_date_utc if trades else None
|
first_date = trades[0].open_date_utc if trades else None
|
||||||
last_date = trades[-1].open_date_utc if trades else None
|
last_date = trades[-1].open_date_utc if trades else None
|
||||||
num = float(len(durations) or 1)
|
num = float(len(durations) or 1)
|
||||||
@@ -664,7 +644,7 @@ class RPC:
|
|||||||
"latest_trade_timestamp": dt_ts_def(last_date, 0),
|
"latest_trade_timestamp": dt_ts_def(last_date, 0),
|
||||||
"avg_duration": str(timedelta(seconds=sum(durations) / num)).split(".")[0],
|
"avg_duration": str(timedelta(seconds=sum(durations) / num)).split(".")[0],
|
||||||
"best_pair": best_pair[0] if best_pair else "",
|
"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_ratio": best_pair[1] if best_pair else 0,
|
||||||
"best_pair_profit_abs": best_pair[2] if best_pair else 0,
|
"best_pair_profit_abs": best_pair[2] if best_pair else 0,
|
||||||
"winning_trades": winning_trades,
|
"winning_trades": winning_trades,
|
||||||
@@ -691,6 +671,36 @@ class RPC:
|
|||||||
"bot_start_date": format_date(bot_start),
|
"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(
|
def __balance_get_est_stake(
|
||||||
self, coin: str, stake_currency: str, amount: float, balance: Wallet
|
self, coin: str, stake_currency: str, amount: float, balance: Wallet
|
||||||
) -> tuple[float, float]:
|
) -> tuple[float, float]:
|
||||||
|
|||||||
+24
-12
@@ -1009,15 +1009,23 @@ class Telegram(RPCHandler):
|
|||||||
|
|
||||||
start_date = datetime.fromtimestamp(0)
|
start_date = datetime.fromtimestamp(0)
|
||||||
timescale = None
|
timescale = None
|
||||||
try:
|
direction: str | None = None
|
||||||
if context.args:
|
args = list(context.args) if context.args else []
|
||||||
timescale = int(context.args[0]) - 1
|
if args and isinstance(args[0], str) and args[0].lower() in ('long', 'short'):
|
||||||
today_start = datetime.combine(date.today(), datetime.min.time())
|
direction = args[0].lower()
|
||||||
start_date = today_start - timedelta(days=timescale)
|
args.pop(0)
|
||||||
except (TypeError, ValueError, IndexError):
|
if args:
|
||||||
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)
|
stats = self._rpc._rpc_trade_statistics(
|
||||||
|
stake_cur, fiat_disp_cur, start_date,direction=direction
|
||||||
|
)
|
||||||
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"]
|
||||||
@@ -1045,8 +1053,11 @@ class Telegram(RPCHandler):
|
|||||||
fiat_closed_trades = (
|
fiat_closed_trades = (
|
||||||
f"∙ `{fmt_coin(profit_closed_fiat, fiat_disp_cur)}`\n" if fiat_disp_cur else ""
|
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 = (
|
markdown_msg = (
|
||||||
"*ROI:* Closed trades\n"
|
f"*ROI ({direction_str}Trades):* Closed trades\n"
|
||||||
f"∙ `{fmt_coin(profit_closed_coin, stake_cur)} "
|
f"∙ `{fmt_coin(profit_closed_coin, stake_cur)} "
|
||||||
f"({profit_closed_ratio_mean:.2%}) "
|
f"({profit_closed_ratio_mean:.2%}) "
|
||||||
f"({profit_closed_percent} \N{GREEK CAPITAL LETTER SIGMA}%)`\n"
|
f"({profit_closed_percent} \N{GREEK CAPITAL LETTER SIGMA}%)`\n"
|
||||||
@@ -1057,8 +1068,9 @@ class Telegram(RPCHandler):
|
|||||||
fiat_all_trades = (
|
fiat_all_trades = (
|
||||||
f"∙ `{fmt_coin(profit_all_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 ""
|
||||||
)
|
)
|
||||||
|
direction_str_all = f"{direction.capitalize()} " if direction else ""
|
||||||
markdown_msg += (
|
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"∙ `{fmt_coin(profit_all_coin, stake_cur)} "
|
||||||
f"({profit_all_ratio_mean:.2%}) "
|
f"({profit_all_ratio_mean:.2%}) "
|
||||||
f"({profit_all_percent} \N{GREEK CAPITAL LETTER SIGMA}%)`\n"
|
f"({profit_all_percent} \N{GREEK CAPITAL LETTER SIGMA}%)`\n"
|
||||||
@@ -1867,8 +1879,8 @@ class Telegram(RPCHandler):
|
|||||||
"*/exits <pair|none>:* `Shows the exit reason performance`\n"
|
"*/exits <pair|none>:* `Shows the exit reason performance`\n"
|
||||||
"*/mix_tags <pair|none>:* `Shows combined entry tag + exit reason performance`\n"
|
"*/mix_tags <pair|none>:* `Shows combined entry tag + exit reason performance`\n"
|
||||||
"*/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 [long|short] [<n>]:* `Show profit from finished trades (last n days).`\n "
|
||||||
"over the last n days`\n"
|
"`Optional filter: long or short.`\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"
|
||||||
|
|||||||
@@ -921,7 +921,7 @@ async def test_telegram_profit_handle(
|
|||||||
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 "No closed trade" in msg_mock.call_args_list[-1][0][0]
|
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)
|
mocker.patch("freqtrade.wallets.Wallets.get_starting_balance", return_value=1000)
|
||||||
assert (
|
assert (
|
||||||
"∙ `0.298 USDT (0.50%) (0.03 \N{GREEK CAPITAL LETTER SIGMA}%)`"
|
"∙ `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]
|
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 (Trades):* Closed trades" in msg_mock.call_args_list[-1][0][0]
|
||||||
assert (
|
assert (
|
||||||
"∙ `5.685 USDT (9.45%) (0.57 \N{GREEK CAPITAL LETTER SIGMA}%)`"
|
"∙ `5.685 USDT (9.45%) (0.57 \N{GREEK CAPITAL LETTER SIGMA}%)`"
|
||||||
in msg_mock.call_args_list[-1][0][0]
|
in msg_mock.call_args_list[-1][0][0]
|
||||||
)
|
)
|
||||||
assert "∙ `6.253 USD`" 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 (
|
assert (
|
||||||
"∙ `5.685 USDT (9.45%) (0.57 \N{GREEK CAPITAL LETTER SIGMA}%)`"
|
"∙ `5.685 USDT (9.45%) (0.57 \N{GREEK CAPITAL LETTER SIGMA}%)`"
|
||||||
in msg_mock.call_args_list[-1][0][0]
|
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 "*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]
|
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])
|
@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:
|
||||||
|
|||||||
Reference in New Issue
Block a user