Merge pull request #10 from Corax-CoLAB/palette-ux-improvements-7168255927171434342

🎨 Palette: UX Improvements for UI, Telegram, and CLI
pull/12809/head
Corax CoLAB 4 months ago committed by GitHub
commit 6adebc64a2
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194

@ -5,3 +5,11 @@
## 2026-01-29 - CLI Feedback
**Learning:** Silent long-running operations (like UI download) cause user uncertainty ("Did it hang?").
**Action:** Always implement a progress bar (e.g., via `rich`) for network operations > 2 seconds.
## 2026-01-30 - Dead-end Fallback UX
**Learning:** Static error pages (like "UI not installed") act as dead ends, forcing users to manually refresh. This breaks the flow during setup.
**Action:** Always provide a "Check Again" or "Retry" action on state-dependent error pages to keep the user in the flow.
## 2026-01-30 - Text-based Card UI
**Learning:** In constraint-heavy text interfaces (Telegram), dense Key:Value lists are hard to scan. Grouping related data (Header with ID, PnL with Emoji, Details below) mimics a "Card" UI pattern effectively.
**Action:** Use whitespace and grouping to create visual "cards" in text messages instead of flat lists.

@ -80,6 +80,16 @@ def download_and_install_ui(dest_folder: Path, dl_url: str, version: str):
with (dest_folder / ".uiversion").open("w") as f:
f.write(version)
from freqtrade.loggers.rich_console import get_rich_console
console = get_rich_console()
console.print()
console.print(f"[bold green]✅ FreqUI {version} installed successfully![/bold green]")
console.print(f"Installed to: [bold]{dest_folder}[/bold]")
console.print("\n[bold]Next steps:[/bold]")
console.print("1. Restart your bot to load the new UI.")
console.print("2. Access the dashboard in your browser.")
def get_ui_download_url(version: str | None, prerelease: bool) -> tuple[str, str]:
base_url = "https://api.github.com/repos/freqtrade/frequi/"

@ -39,7 +39,7 @@ def start_list_exchanges(args: dict[str, Any]) -> None:
available_exchanges = [e for e in available_exchanges if e["valid"] is not False]
title = f"Exchanges available for Freqtrade ({len(available_exchanges)} exchanges):"
show_fut_reasons = args.get("list_exchanges_futures_options", False)
table = Table(title=title)
table = Table(title=title, row_styles=["", "dim"])
table.add_column("Exchange Name")
table.add_column("Class Name")
@ -62,7 +62,7 @@ def start_list_exchanges(args: dict[str, Any]) -> None:
continue
name = Text(exchange["name"])
if exchange["supported"]:
name.append(" (Supported)", style="italic")
name.append(" ", style="italic")
name.stylize("green bold")
classname = Text(exchange["classname"])
if exchange["is_alias"]:

@ -44,8 +44,26 @@ class ExchangeWS:
def cleanup(self) -> None:
logger.debug("Cleanup called - stopping")
self._klines_watching.clear()
for task in self._background_tasks:
task.cancel()
# Cancel all tasks
for _ in range(3):
try:
tasks = list(self._background_tasks)
for task in tasks:
if hasattr(self, "_loop") and not self._loop.is_closed():
self._loop.call_soon_threadsafe(task.cancel)
break
except RuntimeError:
# Set changed size during iteration
continue
# Wait for tasks to cleanup
start = time.time()
while self._background_tasks:
time.sleep(0.01)
if time.time() - start > 5:
logger.warning("Timeout waiting for background tasks to cancel")
break
if hasattr(self, "_loop") and not self._loop.is_closed():
self.reset_connections()

@ -15,6 +15,21 @@
--code-bg: #2d2d2d;
--border-color: #333;
--success-color: #4caf50;
--btn-bg: #2d2d2d;
--btn-hover: #3d3d3d;
}
[data-theme="light"] {
--bg-color: #f5f5f5;
--card-bg: #ffffff;
--text-color: #333333;
--text-muted: #666666;
--accent-color: #2196f3;
--accent-hover: #1976d2;
--code-bg: #f0f0f0;
--border-color: #ddd;
--success-color: #43a047;
--btn-bg: #e0e0e0;
--btn-hover: #d0d0d0;
}
* {
box-sizing: border-box;
@ -44,7 +59,7 @@
h1 {
font-size: 2rem;
margin-bottom: 1rem;
color: #fff;
color: var(--text-color);
font-weight: 700;
}
.subtitle {
@ -69,7 +84,7 @@
display: block;
padding: 1.5rem;
padding-right: 4rem;
color: #fff;
color: var(--text-color);
overflow-x: auto;
}
.copy-btn {
@ -88,7 +103,7 @@
}
.copy-btn:hover {
background-color: rgba(255, 255, 255, 0.1);
color: #fff;
color: var(--text-color);
}
.copy-btn.copied {
color: var(--success-color);
@ -114,9 +129,75 @@
.icon {
margin-right: 0.4rem;
}
/* New Elements */
.theme-toggle {
position: absolute;
top: 1rem;
right: 1rem;
background: var(--btn-bg);
border: 1px solid var(--border-color);
color: var(--text-color);
padding: 0.5rem;
border-radius: 50%;
cursor: pointer;
width: 40px;
height: 40px;
display: flex;
align-items: center;
justify-content: center;
transition: all 0.2s;
}
.theme-toggle:hover {
background: var(--btn-hover);
transform: scale(1.05);
}
.theme-toggle .icon {
margin-right: 0;
font-size: 1.2rem;
}
.actions {
margin-top: 1.5rem;
}
.refresh-btn {
background-color: var(--accent-color);
color: #fff;
border: none;
padding: 0.8rem 1.5rem;
font-size: 1rem;
border-radius: 6px;
cursor: pointer;
font-weight: 600;
transition: background-color 0.2s, transform 0.1s;
display: inline-flex;
align-items: center;
gap: 0.5rem;
}
.refresh-btn:hover {
background-color: var(--accent-hover);
transform: translateY(-1px);
}
.refresh-btn:active {
transform: translateY(0);
}
.refresh-btn:disabled {
opacity: 0.7;
cursor: wait;
}
@keyframes spin {
0% { transform: rotate(0deg); }
100% { transform: rotate(360deg); }
}
</style>
</head>
<body>
<button class="theme-toggle" onclick="toggleTheme()" aria-label="Toggle dark mode">
<span class="icon">🌙</span>
</button>
<div class="main-container">
<h1>Freqtrade UI Not Installed</h1>
<p class="subtitle">The web interface files are missing from your installation.</p>
@ -130,6 +211,12 @@
<p>Once installed, refresh this page to access the dashboard.</p>
<div class="actions">
<button class="refresh-btn" onclick="refreshPage()">
<span class="icon">🔄</span> Check Again
</button>
</div>
<div class="links">
<a href="https://www.freqtrade.io/en/latest/" target="_blank" rel="noopener">
<span class="icon">📚</span>Documentation
@ -157,6 +244,37 @@
console.error('Failed to copy text: ', err);
});
}
function refreshPage() {
const btn = document.querySelector('.refresh-btn');
const icon = btn.querySelector('.icon');
btn.disabled = true;
icon.style.display = 'inline-block';
icon.style.animation = 'spin 1s linear infinite';
btn.innerHTML = '<span class="icon" style="display:inline-block; animation: spin 1s linear infinite">🔄</span> Checking...';
setTimeout(() => {
window.location.reload();
}, 500);
}
function toggleTheme() {
const body = document.body;
const currentTheme = body.getAttribute('data-theme');
const newTheme = currentTheme === 'light' ? 'dark' : 'light';
setTheme(newTheme);
}
function setTheme(theme) {
document.body.setAttribute('data-theme', theme);
localStorage.setItem('theme', theme);
const btn = document.querySelector('.theme-toggle .icon');
btn.innerText = theme === 'light' ? '☀️' : '🌙';
}
// Initialize theme
const savedTheme = localStorage.getItem('theme') || 'dark';
setTheme(savedTheme);
</script>
</body>
</html>

@ -744,116 +744,109 @@ class Telegram(RPCHandler):
else:
await self._status_msg(update, context)
async def _status_msg(self, update: Update, context: CallbackContext) -> None:
"""
handler for `/status` and `/status <id>`.
"""
# Check if there's at least one numerical ID provided.
# If so, try to get only these trades.
trade_ids = []
if context.args and len(context.args) > 0:
trade_ids = [int(i) for i in context.args if i.isnumeric()]
results = self._rpc._rpc_trade_status(trade_ids=trade_ids)
position_adjust = self._config.get("position_adjustment_enable", False)
max_entries = self._config.get("max_entry_position_adjustment", -1)
for r in results:
r["open_date_hum"] = dt_humanize_delta(r["open_date"])
def _get_status_msg_lines(
self, r: dict[str, Any], position_adjust: bool, max_entries: int
) -> list[str]:
r["open_date_hum"] = dt_humanize_delta(r["open_date"])
r["stake_amount_r"] = fmt_coin(r["stake_amount"], r["quote_currency"])
r["max_stake_amount_r"] = fmt_coin(
r["max_stake_amount"] or r["stake_amount"], r["quote_currency"]
)
r["profit_abs_r"] = fmt_coin(r["profit_abs"], r["quote_currency"])
r["realized_profit_r"] = fmt_coin(r["realized_profit"], r["quote_currency"])
r["total_profit_abs_r"] = fmt_coin(r["total_profit_abs"], r["quote_currency"])
profit_emoji = "🟢" if r["profit_ratio"] >= 0 else "🔴"
lines = [
f"*{r['pair']}* (#{r['trade_id']})",
f"{'🔴 `Short`' if r.get('is_short') else '🟢 `Long`'}"
+ (f" ` ({r['leverage']}x)`" if r.get("leverage") else "")
+ f" {profit_emoji} `{format_pct(r['profit_ratio'])}` `({r['profit_abs_r']})`",
f"*Amount:* `{r['amount']} ({r['stake_amount_r']})`"
+ (f" / `{r['max_stake_amount_r']}`" if position_adjust else ""),
f"*Open:* `{round_value(r['open_rate'], 8)}`",
f"*Current:* `{round_value(r['current_rate'], 8)}`"
if r["is_open"]
else f"*Close:* `{round_value(r['close_rate'], 8)}`",
]
r["stake_amount_r"] = fmt_coin(r["stake_amount"], r["quote_currency"])
r["max_stake_amount_r"] = fmt_coin(
r["max_stake_amount"] or r["stake_amount"], r["quote_currency"]
)
r["profit_abs_r"] = fmt_coin(r["profit_abs"], r["quote_currency"])
r["realized_profit_r"] = fmt_coin(r["realized_profit"], r["quote_currency"])
r["total_profit_abs_r"] = fmt_coin(r["total_profit_abs"], r["quote_currency"])
lines = [
f"*Trade ID:* `{r['trade_id']}`"
+ (f" `(since {r['open_date_hum']})`" if r["is_open"] else ""),
f"*Current Pair:* {r['pair']}",
(
f"*Direction:* {'🔴 `Short`' if r.get('is_short') else '🟢 `Long`'}"
+ (f" ` ({r['leverage']}x)`" if r.get("leverage") else "")
),
f"*Amount:* `{r['amount']} ({r['stake_amount_r']})`",
f"*Total invested:* `{r['max_stake_amount_r']}`" if position_adjust else "",
f"*Enter Tag:* `{r['enter_tag']}`" if r["enter_tag"] else "",
f"*Exit Reason:* `{r['exit_reason']}`" if r.get("exit_reason") else "",
]
if r["is_open"]:
lines.append(f"*Age:* `{r['open_date_hum']}`")
if position_adjust:
max_buy_str = f"/{max_entries + 1}" if (max_entries > 0) else ""
lines.extend(
[
f"*Number of Entries:* `{r['nr_of_successful_entries']}{max_buy_str}`",
f"*Number of Exits:* `{r['nr_of_successful_exits']}`",
]
)
if r["enter_tag"]:
lines.append(f"*Tag:* `{r['enter_tag']}`")
if r.get("exit_reason"):
lines.append(f"*Exit:* `{r['exit_reason']}`")
profit_emoji = "🟢" if r["profit_ratio"] >= 0 else "🔴"
if position_adjust:
max_buy_str = f"/{max_entries + 1}" if (max_entries > 0) else ""
lines.extend(
[
f"*Open Rate:* `{round_value(r['open_rate'], 8)}`",
f"*Close Rate:* `{round_value(r['close_rate'], 8)}`" if r["close_rate"] else "",
f"*Open Date:* `{r['open_date']}`",
f"*Close Date:* `{r['close_date']}`" if r["close_date"] else "",
(
f" \n*Current Rate:* `{round_value(r['current_rate'], 8)}`"
if r["is_open"]
else ""
),
("*Unrealized Profit:* " if r["is_open"] else "*Close Profit: *")
+ f"{profit_emoji} `{format_pct(r['profit_ratio'])}` `({r['profit_abs_r']})`",
f"*Entries:* `{r['nr_of_successful_entries']}{max_buy_str}`",
f"*Exits:* `{r['nr_of_successful_exits']}`",
]
)
if r["is_open"]:
if (
r.get("realized_profit") is not None
and r.get("realized_profit_ratio") is not None
):
lines.append(
f"*Realized Profit:* `{format_pct(r['realized_profit_ratio'])} "
f"({r['realized_profit_r']})`"
)
if r.get("total_profit_ratio") is not None:
lines.append(
f"*Total Profit:* `{format_pct(r['total_profit_ratio'])} "
f"({r['total_profit_abs_r']})`"
)
if r["is_open"]:
if r.get("realized_profit") is not None and r.get("realized_profit_ratio") is not None:
lines.append(
f"*Realized Profit:* `{format_pct(r['realized_profit_ratio'])} "
f"({r['realized_profit_r']})`"
)
if r.get("total_profit_ratio") is not None:
lines.append(
f"*Total Profit:* `{format_pct(r['total_profit_ratio'])} "
f"({r['total_profit_abs_r']})`"
)
# Append empty line to improve readability
lines.append(" ")
# Adding liquidation only if it is not None
if liquidation := r.get("liquidation_price"):
lines.append(f"*Liquidation:* `{round_value(liquidation, 8)}`")
if (
r["stop_loss_abs"] != r["initial_stop_loss_abs"]
and r["initial_stop_loss_ratio"] is not None
):
# Adding initial stoploss only if it is different from stoploss
lines.append(
f"*Initial Stoploss:* `{r['initial_stop_loss_abs']:.8f}` "
f"`({format_pct(r['initial_stop_loss_ratio'])})`"
)
# Append empty line to improve readability
lines.append(" ")
# Adding liquidation only if it is not None
if liquidation := r.get("liquidation_price"):
lines.append(f"*Liquidation:* `{round_value(liquidation, 8)}`")
# Adding stoploss and stoploss percentage only if it is not None
if (
r["stop_loss_abs"] != r["initial_stop_loss_abs"]
and r["initial_stop_loss_ratio"] is not None
):
# Adding initial stoploss only if it is different from stoploss
lines.append(
f"*Stoploss:* `{round_value(r['stop_loss_abs'], 8)}` "
+ (f"`({format_pct(r['stop_loss_ratio'])})`" if r["stop_loss_ratio"] else "")
f"*Initial Stoploss:* `{r['initial_stop_loss_abs']:.8f}` "
f"`({format_pct(r['initial_stop_loss_ratio'])})`"
)
# Adding stoploss and stoploss percentage only if it is not None
lines.append(
f"*Stoploss:* `{round_value(r['stop_loss_abs'], 8)}` "
+ (f"`({format_pct(r['stop_loss_ratio'])})`" if r["stop_loss_ratio"] else "")
)
lines.append(
f"*Stoploss distance:* `{round_value(r['stoploss_current_dist'], 8)}` "
f"`({format_pct(r['stoploss_current_dist_ratio'])})`"
)
if open_orders := r.get("open_orders"):
lines.append(
f"*Stoploss distance:* `{round_value(r['stoploss_current_dist'], 8)}` "
f"`({format_pct(r['stoploss_current_dist_ratio'])})`"
f"*Open Order:* `{open_orders}`"
+ (f"- `{r['exit_order_status']}`" if r["exit_order_status"] else "")
)
if open_orders := r.get("open_orders"):
lines.append(
f"*Open Order:* `{open_orders}`"
+ (f"- `{r['exit_order_status']}`" if r["exit_order_status"] else "")
)
return lines
async def _status_msg(self, update: Update, context: CallbackContext) -> None:
"""
handler for `/status` and `/status <id>`.
"""
# Check if there's at least one numerical ID provided.
# If so, try to get only these trades.
trade_ids = []
if context.args and len(context.args) > 0:
trade_ids = [int(i) for i in context.args if i.isnumeric()]
results = self._rpc._rpc_trade_status(trade_ids=trade_ids)
position_adjust = self._config.get("position_adjustment_enable", False)
max_entries = self._config.get("max_entry_position_adjustment", -1)
for r in results:
lines = self._get_status_msg_lines(r, position_adjust, max_entries)
await self.__send_status_msg(lines, r)
async def __send_status_msg(self, lines: list[str], r: dict[str, Any]) -> None:
@ -1914,61 +1907,61 @@ class Telegram(RPCHandler):
:return: None
"""
force_enter_text = (
"*/forcelong <pair> [<rate>]:* `Instantly buys the given pair. "
" /forcelong <pair> [<rate>] - Instantly buys the given pair. "
"Optionally takes a rate at which to buy "
"(only applies to limit orders).` \n"
"(only applies to limit orders). \n"
)
if self._rpc._freqtrade.trading_mode != TradingMode.SPOT:
force_enter_text += (
"*/forceshort <pair> [<rate>]:* `Instantly shorts the given pair. "
" /forceshort <pair> [<rate>] - Instantly shorts the given pair. "
"Optionally takes a rate at which to sell "
"(only applies to limit orders).` \n"
"(only applies to limit orders). \n"
)
message = (
"🤖 *Bot Control*\n"
"*/start:* `Starts the trader`\n"
"*/stop:* `Stops the trader`\n"
"*/pause:* `Pause new entries (keeps open trades)`\n"
"*/stopentry:* `Alias for /pause` \n"
"*/forceexit <id>|all:* `Instantly exits trade(s)`\n"
"*/fx <id>|all:* `Alias for /forceexit`\n"
" /start - Starts the trader\n"
" /stop - Stops the trader\n"
" /pause - Pause new entries (keeps open trades)\n"
" /stopentry - Alias for /pause \n"
" /forceexit <id>|all - Instantly exits trade(s)\n"
" /fx <id>|all - Alias for /forceexit\n"
f"{force_enter_text if self._config.get('force_entry_enable', False) else ''}"
"*/delete <id>:* `Delete trade from DB (no exchange action)`\n"
"*/reload_trade <id>:* `Reload trade from exchange`\n"
"*/cancel_open_order <id>:* `Cancel open orders`\n"
" /delete <id> - Delete trade from DB (no exchange action)\n"
" /reload_trade <id> - Reload trade from exchange\n"
" /cancel_open_order <id> - Cancel open orders\n"
"\n"
"📊 *Statistics*\n"
"*/status <id>|[table]:* `List open trades`\n"
"*/profit [<n>]:* `Cumulative profit (last n days)`\n"
"*/profit_long [<n>]:* `Long profit`\n"
"*/profit_short [<n>]:* `Short profit`\n"
"*/daily <n>:* `Daily profit`\n"
"*/weekly <n>:* `Weekly profit`\n"
"*/monthly <n>:* `Monthly profit`\n"
"*/trades [limit]:* `Recent closed trades`\n"
"*/performance:* `Performance by pair`\n"
"*/stats:* `Win/Loss stats and durations`\n"
"*/count:* `Active trade count`\n"
"*/entries <pair>:* `Entry tag performance`\n"
"*/exits <pair>:* `Exit reason performance`\n"
"*/mix_tags <pair>:* `Combined tag performance`\n"
" /status <id>|[table] - List open trades\n"
" /profit [<n>] - Cumulative profit (last n days)\n"
" /profit_long [<n>] - Long profit\n"
" /profit_short [<n>] - Short profit\n"
" /daily <n> - Daily profit\n"
" /weekly <n> - Weekly profit\n"
" /monthly <n> - Monthly profit\n"
" /trades [limit] - Recent closed trades\n"
" /performance - Performance by pair\n"
" /stats - Win/Loss stats and durations\n"
" /count - Active trade count\n"
" /entries <pair> - Entry tag performance\n"
" /exits <pair> - Exit reason performance\n"
" /mix_tags <pair> - Combined tag performance\n"
"\n"
"⚙️ *Configuration*\n"
"*/show_config:* `Show running config`\n"
"*/reload_config:* `Reload config file`\n"
"*/whitelist [sorted|baseonly]:* `Show whitelist`\n"
"*/blacklist [pair]:* `Show/add to blacklist`\n"
"*/bl_delete [pair]:* `Remove from blacklist`\n"
"*/marketdir [dir]:* `Set market direction`\n"
" /show_config - Show running config\n"
" /reload_config - Reload config file\n"
" /whitelist [sorted|baseonly] - Show whitelist\n"
" /blacklist [pair] - Show/add to blacklist\n"
" /bl_delete [pair] - Remove from blacklist\n"
" /marketdir [dir] - Set market direction\n"
"\n"
" *Info*\n" # noqa: RUF001
"*/balance:* `Show balances`\n"
"*/locks:* `Show active locks`\n"
"*/unlock <pair|id>:* `Unlock pair/id`\n"
"*/logs [limit]:* `Show recent logs`\n"
"*/health:* `Health check`\n"
"*/version:* `Show version`\n"
"*/help:* `Show this help`\n"
" /balance - Show balances\n"
" /locks - Show active locks\n"
" /unlock <pair|id> - Unlock pair/id\n"
" /logs [limit] - Show recent logs\n"
" /health - Health check\n"
" /version - Show version\n"
" /help - Show this help\n"
)
await self._send_msg(message, parse_mode=ParseMode.MARKDOWN)

@ -423,11 +423,10 @@ async def test_telegram_status_multi_entry(default_conf, update, mocker, fee) ->
await telegram._status(update=update, context=MagicMock())
assert msg_mock.call_count == 4
msg = msg_mock.call_args_list[3][0][0]
assert re.search(r"Number of Entries.*2", msg)
assert re.search(r"Entries.*2", msg)
# Exit order is still open, hence not a successful exit
assert re.search(r"Number of Exits.*0", msg)
assert re.search(r"Close Date:", msg) is None
assert re.search(r"Close Profit:", msg) is None
assert re.search(r"Exits.*0", msg)
assert re.search(r"Close:", msg) is None
@pytest.mark.usefixtures("init_persistence")
@ -448,8 +447,7 @@ async def test_telegram_status_closed_trade(default_conf, update, mocker, fee) -
await telegram._status(update=update, context=context)
assert msg_mock.call_count == 1
msg = msg_mock.call_args_list[0][0][0]
assert re.search(r"Close Date:", msg)
assert re.search(r"Close Profit:", msg)
assert re.search(r"Close:", msg)
async def test_order_handle(default_conf, update, ticker, fee, mocker) -> None:
@ -592,8 +590,7 @@ async def test_status_handle(default_conf, update, ticker, fee, mocker) -> None:
# and no line should be empty
lines = msg_mock.call_args_list[0][0][0].split("\n")
assert "" not in lines[:-1]
assert "Close Rate" not in "".join(lines)
assert "Close Profit" not in "".join(lines)
assert "Close:" not in "".join(lines)
assert msg_mock.call_count == 3
assert "ETH/BTC" in msg_mock.call_args_list[0][0][0]
@ -607,8 +604,7 @@ async def test_status_handle(default_conf, update, ticker, fee, mocker) -> None:
lines = msg_mock.call_args_list[0][0][0].split("\n")
assert "" not in lines[:-1]
assert "Close Rate" not in "".join(lines)
assert "Close Profit" not in "".join(lines)
assert "Close:" not in "".join(lines)
assert msg_mock.call_count == 2
assert "LTC/BTC" in msg_mock.call_args_list[0][0][0]
@ -624,8 +620,8 @@ async def test_status_handle(default_conf, update, ticker, fee, mocker) -> None:
msg1 = msg_mock.call_args_list[0][0][0]
assert "Close Rate" not in msg1
assert "Trade ID:* `2`" in msg1
assert "Close:" not in msg1
assert "*LTC/BTC* (#2)" in msg1
async def test_status_table_handle(default_conf, update, ticker, fee, mocker) -> None:
@ -2140,7 +2136,7 @@ async def test_help_handle(default_conf, update, mocker) -> None:
await telegram._help(update=update, context=MagicMock())
assert msg_mock.call_count == 1
assert "*/help:* `Show this help`" in msg_mock.call_args_list[0][0][0]
assert "/help - Show this help" in msg_mock.call_args_list[0][0][0]
async def test_version_handle(default_conf, update, mocker) -> None:

Loading…
Cancel
Save